From 0b5fc9914c6d384eef3a54fffe691d233b9b3275 Mon Sep 17 00:00:00 2001 From: Forgejo Actions Date: Fri, 27 Feb 2026 00:02:53 +0000 Subject: [PATCH 01/20] chore: bump version to 0.0.48 [skip ci] --- package-lock.json | 4 ++-- package.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index c2c2a47..f657203 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "chattz-electron", - "version": "0.0.47", + "version": "0.0.48", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "chattz-electron", - "version": "0.0.47", + "version": "0.0.48", "dependencies": { "electron-updater": "^6.8.3" }, diff --git a/package.json b/package.json index 4f274d3..9949c75 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "chattz-electron", - "version": "0.0.47", + "version": "0.0.48", "description": "Electron frontend for Chattz", "author": "Pavel Flegr ", "homepage": "https://discord.flegr.me", @@ -56,4 +56,4 @@ "dependencies": { "electron-updater": "^6.8.3" } -} \ No newline at end of file +} From 3735162f41a98c5a721082c63051a78d31426618 Mon Sep 17 00:00:00 2001 From: pavel Date: Fri, 27 Feb 2026 09:55:48 +0100 Subject: [PATCH 02/20] refactor --- desktop/app.js | 1947 +----------------------------------- desktop/index.html | 4 +- static/app.js | 1932 +----------------------------------- static/index.html | 2 +- static/shared/app-core.js | 1984 +++++++++++++++++++++++++++++++++++++ 5 files changed, 1989 insertions(+), 3880 deletions(-) create mode 100644 static/shared/app-core.js diff --git a/desktop/app.js b/desktop/app.js index bff5420..e0b9fa7 100644 --- a/desktop/app.js +++ b/desktop/app.js @@ -1,1946 +1 @@ -const state = { - me: null, - guilds: [], - channels: [], - dmConversations: [], - members: [], - voicePresence: new Map(), - selectedGuildId: null, - selectedTextChannelId: null, - selectedDmUserId: null, - selectedDmDisplayName: null, - selectedVoiceChannelId: null, - voice: { - ws: null, - joinedChannelId: null, - localStream: null, - rawStream: null, - videoStream: null, - screenStream: null, - peerConnections: new Map(), - videoSenders: new Map(), - screenSenders: new Map(), - muted: false, - sharingVideo: false, - sharingScreen: false, - deepFilterProcessor: null, - deepFilterModule: null, - viewMode: 'chat', // 'chat' or 'video' - iceServers: [{ urls: "stun:stun.l.google.com:19302" }], - peerGainNodes: new Map(), // userId -> GainNode - visibleVolumeSliders: new Set(), // userIds whose sliders are visible - }, - voicePresencePollId: null, - onlineUsers: new Set(), - idleUsers: new Set(), - userVolumes: new Map(), // userId -> volume (0.0 to 2.0) -}; - -// --- Desktop Backend Configuration --- -let API_BASE_URL = ''; // Will be initialized via IPC - -async function initializeConfig() { - try { - const config = await window.electronAPI.getConfig(); - API_BASE_URL = config.backendUrl; - console.log(`Backend initialized: ${API_BASE_URL}`); - - // Start the app now that config is ready - init(); - } catch (err) { - console.error("Failed to fetch config via IPC", err); - // Fallback to URL search params if IPC fails (unlikely in desktop app) - const urlParams = new URLSearchParams(window.location.search); - API_BASE_URL = urlParams.get('backend') || ''; - } -} - -// Start config fetch immediately -initializeConfig(); - -function getWsUrl(path) { - let urlStr; - if (!API_BASE_URL) { - const proto = location.protocol === "https:" ? "wss" : "ws"; - urlStr = `${proto}://${location.host}${path}`; - } else { - const url = new URL(API_BASE_URL); - const proto = url.protocol === "https:" ? "wss" : "ws"; - const normalizedPath = path.startsWith('/') ? path : `/${path}`; - urlStr = `${proto}://${url.host}${normalizedPath}`; - } - - const token = localStorage.getItem("chattz_token"); - if (token) { - urlStr += (urlStr.includes('?') ? '&' : '?') + `token=${token}`; - } - return urlStr; -} - - - - - - -const el = { - authScreen: document.getElementById("auth-screen"), - loginBtn: document.getElementById("login-btn"), - main: document.getElementById("main"), - status: document.getElementById("status"), - - // Guilds - guildList: document.getElementById("guild-list"), - addGuildBtn: document.getElementById("add-guild-btn"), - guildTitle: document.getElementById("guild-title"), - createInviteBtn: document.getElementById("create-invite-btn"), - inviteCopiedBadge: document.getElementById("invite-copied-badge"), - - // Channels - channelList: document.getElementById("channel-list"), - voiceChannelList: document.getElementById("voice-channel-list"), - addTextBtn: document.getElementById("add-text-btn"), - addVoiceBtn: document.getElementById("add-voice-btn"), - dmList: document.getElementById("dm-list"), - channelTitle: document.getElementById("channel-title"), - - // Messages - messageList: document.getElementById("message-list"), - messageForm: document.getElementById("message-form"), - messageBody: document.getElementById("message-body"), - - // User Panel - userName: document.getElementById("user-name"), - userAvatar: document.getElementById("user-avatar"), - logoutBtn: document.getElementById("logout-btn"), - updateNotifier: document.getElementById("update-notifier"), - updateDownloadBtn: document.getElementById("update-download-btn"), - updateInstallBtn: document.getElementById("update-install-btn"), - - // Voice Connection - voiceConnection: document.getElementById("voice-connection"), - vcChannelName: document.getElementById("vc-channel-name"), - voiceVideoBtn: document.getElementById("voice-video-btn"), - voiceScreenBtn: document.getElementById("voice-screen-btn"), - voiceMuteBtn: document.getElementById("voice-mute-btn"), - voiceLeaveBtn: document.getElementById("voice-leave-btn"), - videoGrid: document.getElementById("video-grid"), - messageForm: document.getElementById("message-form"), - messageInputWrapper: document.querySelector(".chat-input-wrapper"), - - // Members - memberList: document.getElementById("member-list"), - - // Modals - modalContainer: document.getElementById("modal-container"), - guildForm: document.getElementById("guild-form"), - guildName: document.getElementById("guild-name"), - modalCancel: document.getElementById("modal-cancel"), - - channelModal: document.getElementById("channel-modal"), - channelForm: document.getElementById("channel-form"), - channelName: document.getElementById("channel-name"), - channelModalCancel: document.getElementById("channel-modal-cancel"), - - // Sound Board - soundboard: document.getElementById('soundboard'), - soundboardGrid: document.getElementById('soundboard-grid'), - addSoundBtn: document.getElementById('add-sound-btn'), - soundModal: document.getElementById('sound-modal'), - soundForm: document.getElementById('sound-form'), - soundName: document.getElementById('sound-name'), - soundIcon: document.getElementById('sound-icon'), - soundFile: document.getElementById('sound-file'), - soundModalCancel: document.getElementById('sound-modal-cancel'), - soundSubmitBtn: document.getElementById('sound-submit-btn'), - - // Mobile - mobileMenuBtn: document.getElementById("mobile-menu-btn"), - mobileMembersBtn: document.getElementById("mobile-members-btn"), - overlay: document.getElementById("overlay"), - utilitySidebar: document.getElementById("utility-sidebar"), - - // GIF Picker - gifBtn: document.getElementById("gif-btn"), - gifPicker: document.getElementById("gif-picker"), - gifPickerClose: document.getElementById("gif-picker-close"), - gifSearchInput: document.getElementById("gif-search-input"), - gifResults: document.getElementById("gif-results"), -}; - -// --- API Helpers --- - -async function api(path, options = {}) { - const fullUrl = API_BASE_URL ? (path.startsWith('http') ? path : `${API_BASE_URL}${path}`) : path; - - const headers = { - "content-type": "application/json", - ...(options.headers || {}), - }; - - const token = localStorage.getItem("chattz_token"); - if (token) { - headers["Authorization"] = `Bearer ${token}`; - } - - let res = await fetch(fullUrl, { - ...options, - headers, - }); - - // Handle 401 Unauthorized via Refresh Token - if (res.status === 401 && !path.includes('/auth/refresh')) { - const refreshToken = localStorage.getItem("chattz_refresh_token"); - if (refreshToken) { - try { - const refreshUrl = API_BASE_URL ? `${API_BASE_URL}/auth/refresh` : '/auth/refresh'; - const refreshRes = await fetch(refreshUrl, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ refresh_token: refreshToken }) - }); - - if (refreshRes.ok) { - const newTokens = await refreshRes.json(); - localStorage.setItem("chattz_token", newTokens.access_token); - localStorage.setItem("chattz_refresh_token", newTokens.refresh_token); - - // Retry original request with new token - headers["Authorization"] = `Bearer ${newTokens.access_token}`; - res = await fetch(fullUrl, { ...options, headers }); - } else { - // Both tokens invalid/expired, wipe out to force clear state - throw new Error("Refresh token expired or invalid"); - } - } catch (err) { - localStorage.removeItem("chattz_token"); - localStorage.removeItem("chattz_refresh_token"); - location.href = API_BASE_URL ? `${API_BASE_URL}/auth/login` : "/auth/login"; - throw new Error("Session expired, please log in again"); - } - } - } - - if (!res.ok) { - let detail = "request failed"; - try { - const body = await res.json(); - detail = body.error || detail; - } catch { } - throw new Error(`${res.status}: ${detail}`); - } - - if (res.status === 204) return null; - return res.json(); -} - -// --- Utils --- - -function shortName(name) { - if (!name || typeof name !== 'string') return "?"; - const trimmed = name.trim(); - if (!trimmed) return "?"; - const words = trimmed.split(/\s+/).filter(w => w.length > 0).slice(0, 2); - if (words.length === 0) return "?"; - if (words.length === 1) return words[0].substring(0, 2).toUpperCase(); - return words.map((w) => w[0]?.toUpperCase() || "").join(""); -} - -function escapeHtml(s) { - if (s === null || s === undefined) return ""; - return String(s) - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); -} - -function formatMessageBody(body) { - const escaped = escapeHtml(body); - // Detect GIF URLs (simple regex for demo) - const urlRegex = /(https?:\/\/[^\s]+(?:\.gif|\/giphy\.gif)[^\s]*)/gi; - if (urlRegex.test(body)) { - return escaped.replace(urlRegex, (url) => { - return `
`; - }); - } - return escaped; -} - -function formatDate(isoString) { - const d = new Date(isoString); - const now = new Date(); - const isToday = d.toDateString() === now.toDateString(); - - if (isToday) { - return `Today at ${d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`; - } - return d.toLocaleDateString(); -} - -let audioCtx = null; - -// Global interaction listener to unlock AudioContext (autoplay policy) -window.addEventListener('click', () => { - if (audioCtx && audioCtx.state === 'suspended') { - audioCtx.resume(); - } -}, { once: true }); - -function playSound(type) { - try { - if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)(); - if (audioCtx.state === 'suspended') audioCtx.resume(); - - const sounds = { - message: { freq: 880, type: 'sine', duration: 0.1, volume: 0.1 }, - dm: { freq: [660, 880], type: 'sine', duration: 0.15, volume: 0.1 }, - join: { freq: [440, 880], type: 'sine', duration: 0.2, volume: 0.1 }, - leave: { freq: [880, 440], type: 'sine', duration: 0.2, volume: 0.1 }, - 'peer-join': { freq: [660, 990], type: 'sine', duration: 0.15, volume: 0.05 }, - 'peer-leave': { freq: [990, 660], type: 'sine', duration: 0.15, volume: 0.05 }, - }; - - const s = sounds[type]; - if (!s) return; - - const osc = audioCtx.createOscillator(); - const gain = audioCtx.createGain(); - - osc.type = s.type; - if (Array.isArray(s.freq)) { - osc.frequency.setValueAtTime(s.freq[0], audioCtx.currentTime); - osc.frequency.exponentialRampToValueAtTime(s.freq[1], audioCtx.currentTime + s.duration); - } else { - osc.frequency.setValueAtTime(s.freq, audioCtx.currentTime); - } - - gain.gain.setValueAtTime(s.volume, audioCtx.currentTime); - gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + s.duration); - - osc.connect(gain); - gain.connect(audioCtx.destination); - - osc.start(); - osc.stop(audioCtx.currentTime + s.duration); - } catch (err) { - console.warn("playSound failed", err); - } -} -const LAST_GUILD_STORAGE_KEY = "chattz:lastGuildId"; - -// --- Renderers --- - -function renderGuilds() { - el.guildList.innerHTML = ""; - - for (const guild of state.guilds) { - const btn = document.createElement("button"); - btn.className = `guild-pill ${state.selectedGuildId === guild.id ? "active" : ""}`; - btn.title = guild.name; - btn.textContent = shortName(guild.name); - btn.onclick = async () => { - state.selectedGuildId = guild.id; - state.selectedTextChannelId = null; - state.selectedVoiceChannelId = null; - state.selectedDmUserId = null; - state.selectedDmDisplayName = null; - localStorage.setItem(LAST_GUILD_STORAGE_KEY, guild.id); - renderGuilds(); - renderDMs(); - await loadChannels(); - await loadGuildMembers(); - await refreshVoicePresence(); - startVoicePresencePolling(); - renderMessages([]); - updateHeaderLabels(); - }; - el.guildList.appendChild(btn); - } -} - -function renderChannels() { - el.channelList.innerHTML = ""; - el.voiceChannelList.innerHTML = ""; - - for (const channel of state.channels) { - const row = document.createElement("button"); - row.className = `channel-row ${(channel.kind === 'text' && state.selectedTextChannelId === channel.id) || - (channel.kind === 'voice' && state.selectedVoiceChannelId === channel.id) ? "active" : "" - }`; - - const iconName = channel.kind === 'text' ? 'hash' : 'volume-2'; - row.innerHTML = ` ${escapeHtml(channel.name)}`; - - row.onclick = async () => { - if (channel.kind === 'text') { - state.selectedDmUserId = null; - state.selectedDmDisplayName = null; - state.selectedTextChannelId = channel.id; - state.voice.viewMode = 'chat'; - renderChannels(); - renderDMs(); - updateView(); - updateHeaderLabels(); - const messages = await api(`/channels/${channel.id}/messages?limit=100`); - renderMessages(messages); - } else { - state.selectedVoiceChannelId = channel.id; - state.voice.viewMode = 'video'; - renderChannels(); - renderDMs(); - updateView(); - updateHeaderLabels(); - try { - await joinVoice(); - } catch (err) { - console.error("joinVoice failed from channel click", err); - alert(`Voice join failed: ${err?.message || err}`); - } - } - if (window.innerWidth <= 768) closeMobileMenus(); - }; - - if (channel.kind === 'text') { - el.channelList.appendChild(row); - } else { - el.voiceChannelList.appendChild(row); - - const participants = state.voicePresence.get(channel.id) || []; - if (participants.length > 0) { - const pList = document.createElement("div"); - pList.className = "voice-row-members channel-list"; - pList.style.paddingLeft = "24px"; - for (const p of participants) { - const pRow = document.createElement("div"); - pRow.className = `channel-row ${p.is_speaking ? 'voice-speaking' : ''}`; - pRow.style.padding = "2px 8px"; - pRow.style.flexWrap = "wrap"; - - let sliderHtml = ''; - if (p.user_id !== state.me.id) { - const vol = state.userVolumes.get(p.user_id) ?? 1.0; - const isVisible = state.voice.visibleVolumeSliders.has(p.user_id); - sliderHtml = ` -
- - - ${Math.round(vol * 100)}% -
- `; - } - - pRow.innerHTML = ` -
-
${shortName(p.display_name)}
- ${escapeHtml(p.display_name)} - ${p.is_muted ? '' : ''} -
- ${sliderHtml} - `; - - const slider = pRow.querySelector('.volume-slider'); - if (slider) { - slider.addEventListener('input', (e) => { - const val = parseFloat(e.target.value); - state.userVolumes.set(p.user_id, val); - pRow.querySelector('.vol-pct').textContent = `${Math.round(val * 100)}%`; - - const gainNode = state.voice.peerGainNodes.get(p.user_id); - if (gainNode) { - gainNode.gain.setTargetAtTime(val, state.voice.audioContext.currentTime, 0.05); - } - }); - // Stop propagation to prevent joining channel again when clicking slider - slider.addEventListener('click', (e) => e.stopPropagation()); - } - - const toggleVolumeSlider = (e) => { - if (p.user_id === state.me.id) return; - e.preventDefault(); - e.stopPropagation(); - if (state.voice.visibleVolumeSliders.has(p.user_id)) { - state.voice.visibleVolumeSliders.delete(p.user_id); - } else { - state.voice.visibleVolumeSliders.add(p.user_id); - } - renderChannels(); - }; - - // Electron can swallow contextmenu events on some platforms; use - // right-button mousedown as a reliable fallback for slider toggle. - pRow.addEventListener('contextmenu', toggleVolumeSlider); - pRow.addEventListener('mousedown', (e) => { - if (e.button === 2) toggleVolumeSlider(e); - }); - - pList.appendChild(pRow); - } - el.voiceChannelList.appendChild(pList); - } - } - } - lucide.createIcons(); -} - -function renderDMs() { - el.dmList.innerHTML = ""; - for (const dm of state.dmConversations) { - const isOnline = state.onlineUsers.has(dm.user_id); - const isIdle = state.idleUsers.has(dm.user_id); - const row = document.createElement("button"); - row.className = `channel-row ${state.selectedDmUserId === dm.user_id ? "active" : ""}`; - row.innerHTML = ` -
- -
-
- ${escapeHtml(dm.display_name)} - `; - row.onclick = async () => { - state.selectedDmUserId = dm.user_id; - state.selectedDmDisplayName = dm.display_name; - state.selectedTextChannelId = null; - state.selectedVoiceChannelId = null; - state.voice.viewMode = 'chat'; - renderChannels(); - renderDMs(); - updateView(); - updateHeaderLabels(); - const messages = await api(`/dms/${dm.user_id}/messages?limit=100`); - renderMessages(messages); - }; - el.dmList.appendChild(row); - } - - lucide.createIcons(); -} - -function renderMessages(messages) { - el.messageList.innerHTML = ""; - let lastAuthorId = null; - let lastTime = null; - - for (const m of messages.slice().reverse()) { - const row = document.createElement("div"); - const mDate = new Date(m.created_at); - const isGrouped = lastAuthorId === m.author_user_id && - lastTime && (mDate - lastTime < 300000); // 5 minutes - - row.className = `msg ${isGrouped ? "msg-grouped" : ""}`; - - const displayName = m.author_display_name || "Unknown User"; - - if (isGrouped) { - row.innerHTML = `
${formatMessageBody(m.body)}
`; - } else { - row.innerHTML = ` -
${shortName(displayName)}
-
-
- ${escapeHtml(displayName)} - ${formatDate(m.created_at)} -
-
${formatMessageBody(m.body)}
-
- `; - } - el.messageList.appendChild(row); - lastAuthorId = m.author_user_id; - lastTime = mDate; - } - - if (messages.length > 0) { - state.lastMessageId = messages[0].id; - } - - el.messageList.scrollTop = el.messageList.scrollHeight; -} - -function renderMembers() { - el.memberList.innerHTML = ""; - for (const m of state.members) { - const isOnline = state.onlineUsers.has(m.id); - const isIdle = state.idleUsers.has(m.id); - const row = document.createElement("div"); - row.className = "member-row"; - row.innerHTML = ` -
- ${shortName(m.display_name)} -
-
-
${escapeHtml(m.display_name)}
- `; - row.style.cursor = "pointer"; - row.onclick = async () => { - if (state.me && m.id === state.me.id) return; - state.selectedDmUserId = m.id; - state.selectedDmDisplayName = m.display_name; - state.selectedTextChannelId = null; - state.selectedVoiceChannelId = null; - state.voice.viewMode = 'chat'; - renderChannels(); - renderDMs(); - updateView(); - updateHeaderLabels(); - const messages = await api(`/dms/${m.id}/messages?limit=100`); - renderMessages(messages); - }; - el.memberList.appendChild(row); - } -} - -function updateHeaderLabels() { - const guild = state.guilds.find((g) => g.id === state.selectedGuildId); - el.guildTitle.textContent = guild ? guild.name : "No server selected"; - - if (state.selectedDmUserId) { - const dmFromConversations = state.dmConversations.find((u) => u.user_id === state.selectedDmUserId); - const dmFromMembers = state.members.find((m) => m.id === state.selectedDmUserId); - const displayName = dmFromConversations?.display_name || dmFromMembers?.display_name || state.selectedDmDisplayName; - const dmName = displayName ? `@${displayName}` : "Direct Message"; - el.channelTitle.textContent = dmName; - el.messageBody.placeholder = displayName - ? `Message @${displayName}` - : "Message"; - return; - } - - if (state.voice.viewMode === 'video' && state.selectedVoiceChannelId) { - const channel = state.channels.find((c) => c.id === state.selectedVoiceChannelId); - el.channelTitle.textContent = channel ? channel.name : "Voice Arena"; - return; - } - - const channel = state.channels.find((c) => c.id === state.selectedTextChannelId); - el.channelTitle.textContent = channel ? channel.name : "Select a channel"; - el.messageBody.placeholder = channel ? `Message #${channel.name}` : "Select a channel"; -} - -async function createInviteLink() { - if (!state.selectedGuildId) { - alert("Select a server first."); - return; - } - try { - const invite = await api(`/guilds/${state.selectedGuildId}/invites`, { - method: "POST", - body: JSON.stringify({ max_uses: 50, expires_in_hours: 24 }), - }); - // Use the backend URL if available, otherwise fallback to current origin - const base = API_BASE_URL ? new URL(API_BASE_URL).origin : location.origin; - const link = `${base}/?invite=${encodeURIComponent(invite.code)}`; - - // Use Electron's native clipboard API if available (defined in preload.js) - if (window.electronAPI && window.electronAPI.copyToClipboard) { - try { - await window.electronAPI.copyToClipboard(link); - showCopiedBadge(); - return; - } catch (e) { - console.error("Electron clipboard write failed:", e); - } - } - - // Fallback to navigator.clipboard - try { - await navigator.clipboard.writeText(link); - showCopiedBadge(); - } catch (err) { - console.error("Navigator clipboard write failed:", err); - prompt("Copy invite link:", link); - } - } catch (err) { - alert(err.message); - } -} - -function showCopiedBadge() { - el.inviteCopiedBadge.classList.remove("hidden"); - setTimeout(() => { - el.inviteCopiedBadge.classList.add("hidden"); - }, 2000); -} - -// --- Logic --- - -async function loadGuilds() { - state.guilds = await api("/guilds"); - renderGuilds(); -} - -async function loadDMConversations() { - state.dmConversations = await api("/dms"); - renderDMs(); -} - -async function loadGuildMembers() { - if (!state.selectedGuildId) { - state.members = []; - renderMembers(); - return; - } - state.members = await api(`/guilds/${state.selectedGuildId}/members`); - renderMembers(); -} - -async function loadChannels() { - if (!state.selectedGuildId) { - state.channels = []; - renderChannels(); - return; - } - - state.channels = await api(`/guilds/${state.selectedGuildId}/channels`); - renderChannels(); -} - -async function refreshVoicePresence() { - if (!state.selectedGuildId) { - state.voicePresence.clear(); - renderChannels(); - return; - } - - try { - const res = await api(`/guilds/${state.selectedGuildId}/voice-presence`); - state.voicePresence.clear(); - for (const entry of res.channels || []) { - state.voicePresence.set(entry.channel_id, entry.participants || []); - } - renderChannels(); - } catch (err) { - console.warn("voice presence failed", err); - } -} - -function startVoicePresencePolling() { - if (state.voicePresencePollId) clearInterval(state.voicePresencePollId); - state.voicePresencePollId = setInterval(() => { - refreshVoicePresence().catch(() => { }); - }, 3000); -} - -function initChatWs() { - if (state.chatWs) state.chatWs.close(); - const wsUrl = getWsUrl('/ws'); - const ws = new WebSocket(wsUrl); - state.chatWs = ws; - - ws.onmessage = (event) => { - const msg = JSON.parse(event.data); - const authorId = msg.message?.author_user_id; - const isFromMe = authorId && state.me && authorId === state.me.id; - - if (msg.type === "message_created") { - if (!isFromMe) playSound('message'); - if (state.selectedTextChannelId === msg.channel_id) { - api(`/channels/${state.selectedTextChannelId}/messages?limit=100`).then(renderMessages); - } - } else if (msg.type === "dm_created") { - if (!isFromMe) playSound('dm'); - if (state.selectedDmUserId === msg.other_user_id) { - api(`/dms/${state.selectedDmUserId}/messages?limit=100`).then(renderMessages); - playSound('dm'); - loadDMConversations().then(renderDMs); - } else { - loadDMConversations().then(renderDMs); - } - } else if (msg.type === "user_presence") { - if (msg.online) { - state.onlineUsers.add(msg.user_id); - if (msg.idle) { - state.idleUsers.add(msg.user_id); - } else { - state.idleUsers.delete(msg.user_id); - } - } else { - state.onlineUsers.delete(msg.user_id); - state.idleUsers.delete(msg.user_id); - } - renderMembers(); - renderDMs(); - } - }; - - ws.onclose = () => { - console.log("Chat WS closed, reconnecting..."); - setTimeout(initChatWs, 3000); - }; -} - -// --- Voice --- - -function getVoiceWsUrl(channelId) { - return getWsUrl(`/channels/${channelId}/voice/ws`); -} - -function shouldInitiateOffer(peerId) { - if (!state.me || !state.me.id) return false; - return state.me.id > peerId; -} - -function stopAndClearAudioPipeline() { - if (state.voice.deepFilterProcessor) { - state.voice.deepFilterProcessor.destroy(); - state.voice.deepFilterProcessor = null; - } - if (state.voice.localStream) { - for (const track of state.voice.localStream.getTracks()) track.stop(); - } - if (state.voice.rawStream && state.voice.rawStream !== state.voice.localStream) { - for (const track of state.voice.rawStream.getTracks()) track.stop(); - } - if (state.voice.audioContext) { - state.voice.audioContext.close().catch(() => { }); - } - state.voice.localStream = null; - state.voice.rawStream = null; - state.voice.audioContext = null; -} - -function stopAndClearVideoPipeline() { - if (state.voice.videoStream) { - for (const track of state.voice.videoStream.getTracks()) track.stop(); - } - state.voice.videoStream = null; - state.voice.sharingVideo = false; - document.getElementById(`video-${state.me.id}-camera`)?.parentElement?.remove(); -} - -function stopAndClearScreenPipeline() { - if (state.voice.screenStream) { - for (const track of state.voice.screenStream.getTracks()) track.stop(); - } - state.voice.screenStream = null; - state.voice.sharingScreen = false; - document.getElementById(`video-${state.me.id}-screen`)?.parentElement?.remove(); -} - -function updateVideoGridVisibility() { - // Persistence logic: we always update the grid visibility based on participants - // however the actual container visibility in the UI is managed by updateView() -} - -function updateView() { - if (state.voice.viewMode === 'video') { - el.videoGrid.classList.remove("hidden"); - el.messageList.classList.add("hidden"); - el.messageInputWrapper.classList.add("hidden"); - } else { - el.videoGrid.classList.add("hidden"); - el.messageList.classList.remove("hidden"); - el.messageInputWrapper.classList.remove("hidden"); - } -} - -function renderVideo(peerId, displayName, stream, source) { - const videoId = `video-${peerId}-${source}`; - let videoEl = document.getElementById(videoId); - if (!videoEl) { - const container = document.createElement("div"); - container.className = "video-item"; - const label = source === 'screen' ? `${escapeHtml(displayName)}'s Screen` : escapeHtml(displayName); - container.innerHTML = ` - -
${label}
- `; - container.onclick = () => { - const isFullscreen = container.classList.contains('fullscreen'); - // Reset all - document.querySelectorAll('.video-item').forEach(el => el.classList.remove('fullscreen')); - - if (isFullscreen) { - el.videoGrid.classList.remove('has-fullscreen'); - } else { - container.classList.add('fullscreen'); - el.videoGrid.classList.add('has-fullscreen'); - } - }; - - el.videoGrid.appendChild(container); - videoEl = container.querySelector("video"); - } - videoEl.srcObject = stream; -} - -async function buildAudioPipeline(rawStream) { - const ctx = new AudioContext(); - state.voice.audioContext = ctx; - const source = ctx.createMediaStreamSource(rawStream); - let processedSource = source; - - try { - const deepFilter = await getDeepFilterModule(); - const processor = new deepFilter.DeepFilterNet3Core({ - sampleRate: 48000, - noiseReductionLevel: 70, - assetConfig: { - cdnUrl: resolveDeepFilterAssetsBaseUrl(), - }, - }); - await processor.initialize(); - const workletNode = await processor.createAudioWorkletNode(ctx); - processor.setNoiseSuppressionEnabled(true); - source.connect(workletNode); - processedSource = workletNode; - state.voice.deepFilterProcessor = processor; - console.info('DeepFilterNet3 enabled'); - } catch (err) { - console.warn('DeepFilterNet3 unavailable, falling back to raw mic audio', err); - state.voice.deepFilterProcessor = null; - } - - // Metering/Speaking detection with adaptive threshold and hysteresis. - const analyser = ctx.createAnalyser(); - analyser.fftSize = 1024; - processedSource.connect(analyser); - - const supportsFloatTimeDomain = typeof analyser.getFloatTimeDomainData === 'function'; - const timeData = supportsFloatTimeDomain ? new Float32Array(analyser.fftSize) : new Uint8Array(analyser.fftSize); - let localIsSpeaking = false; - let speechFrames = 0; - let silenceFrames = 0; - let noiseFloor = 0.004; - let levelEma = 0; - - const checkVolume = () => { - if (!state.voice.audioContext || state.voice.audioContext.state === 'closed') return; - - try { - if (supportsFloatTimeDomain) { - analyser.getFloatTimeDomainData(timeData); - } else { - analyser.getByteTimeDomainData(timeData); - } - } catch (err) { - setTimeout(checkVolume, 60); - return; - } - - let sumSquares = 0; - let peak = 0; - for (let i = 0; i < timeData.length; i++) { - const v = supportsFloatTimeDomain - ? timeData[i] - : (timeData[i] - 128) / 128; - sumSquares += v * v; - const abs = Math.abs(v); - if (abs > peak) peak = abs; - } - - const rms = Math.sqrt(sumSquares / timeData.length); - const level = Math.max(rms, peak * 0.5); - levelEma = levelEma * 0.75 + level * 0.25; - - if (!localIsSpeaking) { - // Learn room noise slowly while idle. - noiseFloor = noiseFloor * 0.98 + levelEma * 0.02; - } else { - // Do not let noise floor jump up while speaking. - noiseFloor = Math.min(noiseFloor, levelEma); - } - - const startThreshold = Math.max(noiseFloor * 2.2, 0.010); - const stopThreshold = Math.max(noiseFloor * 1.5, 0.006); - - if (levelEma > startThreshold) { - speechFrames = Math.min(speechFrames + 1, 8); - silenceFrames = 0; - } else if (levelEma < stopThreshold) { - silenceFrames = Math.min(silenceFrames + 1, 8); - speechFrames = Math.max(speechFrames - 1, 0); - } - - const newSpeakingState = localIsSpeaking ? silenceFrames < 3 : speechFrames >= 2; - - if (newSpeakingState !== localIsSpeaking) { - localIsSpeaking = newSpeakingState; - if (state.voice.ws && state.voice.ws.readyState === WebSocket.OPEN) { - state.voice.ws.send(JSON.stringify({ type: 'set_speaking_status', is_speaking: localIsSpeaking })); - } - // Local UI update - state.voicePresence.get(state.selectedVoiceChannelId)?.forEach(p => { - if (p.user_id === state.me.id) p.is_speaking = localIsSpeaking; - }); - renderChannels(); - const myVideo = document.getElementById(`video-${state.me.id}-camera`) || document.getElementById(`video-${state.me.id}-screen`); - if (myVideo) { - myVideo.parentElement.classList.toggle('speaking', localIsSpeaking); - } - } - setTimeout(checkVolume, 60); - }; - checkVolume(); - - const destination = ctx.createMediaStreamDestination(); - processedSource.connect(destination); - return destination.stream; -} - -function resolveDeepFilterModuleUrl() { - if (location.protocol === 'file:') { - return new URL('./vendor/deepfilternet3-noise-filter.esm.js', location.href).toString(); - } - return '/static/vendor/deepfilternet3-noise-filter.esm.js'; -} - -function resolveDeepFilterAssetsBaseUrl() { - if (location.protocol === 'file:') { - return new URL('./vendor/deepfilternet3', location.href).toString(); - } - return '/static/vendor/deepfilternet3'; -} - -async function getDeepFilterModule() { - if (state.voice.deepFilterModule) return state.voice.deepFilterModule; - const moduleUrl = resolveDeepFilterModuleUrl(); - state.voice.deepFilterModule = await import(moduleUrl); - return state.voice.deepFilterModule; -} - -async function createLocalVoiceStream() { - const constraints = { - audio: { - channelCount: 1, - sampleRate: 48000, - echoCancellation: true, - noiseSuppression: false, - autoGainControl: false, - }, - video: false, - }; - const rawStream = await navigator.mediaDevices.getUserMedia(constraints); - let localStream = rawStream; - try { - localStream = await buildAudioPipeline(rawStream); - } catch (err) { - console.warn('voice audio pipeline failed, using raw mic stream', err); - } - state.voice.rawStream = rawStream; - state.voice.localStream = localStream; - if (state.voice.muted) { - state.voice.localStream.getAudioTracks().forEach((t) => { - t.enabled = false; - }); - } -} - -function ensurePeerConnection(peerId) { - if (state.voice.peerConnections.has(peerId)) { - return state.voice.peerConnections.get(peerId); - } - - const pc = new RTCPeerConnection({ - iceServers: state.voice.iceServers - }); - - pc.makingOffer = false; - pc.ignoreOffer = false; - pc.polite = !shouldInitiateOffer(peerId); - - // 1. Audio - if (state.voice.localStream) { - state.voice.localStream.getAudioTracks().forEach(track => { - pc.addTrack(track, state.voice.localStream); - }); - } else { - pc.addTransceiver('audio', { direction: 'recvonly' }); - } - - // 2. Camera Video - if (state.voice.videoStream) { - state.voice.videoStream.getVideoTracks().forEach(track => { - const sender = pc.addTrack(track, state.voice.videoStream); - state.voice.videoSenders.set(peerId, sender); - }); - } - - // 3. Screen Video - if (state.voice.screenStream) { - state.voice.screenStream.getVideoTracks().forEach(track => { - const sender = pc.addTrack(track, state.voice.screenStream); - state.voice.screenSenders.set(peerId, sender); - }); - } - - pc.onicecandidate = (event) => { - if (!event.candidate || !state.voice.ws) return; - state.voice.ws.send(JSON.stringify({ - type: "signal", - to_user_id: peerId, - kind: "ice", - data: event.candidate, - })); - }; - - pc.oniceconnectionstatechange = async () => { - const iceState = pc.iceConnectionState; - console.debug(`voice ice state (${peerId}):`, iceState); - if (iceState !== "failed" || pc.restartingIce) return; - pc.restartingIce = true; - try { - const offer = await pc.createOffer({ iceRestart: true }); - await pc.setLocalDescription(offer); - if (state.voice.ws && state.voice.ws.readyState === WebSocket.OPEN) { - state.voice.ws.send(JSON.stringify({ - type: "signal", - to_user_id: peerId, - kind: "offer", - data: pc.localDescription, - })); - } - } catch (err) { - console.warn("ICE restart failed", err); - } finally { - pc.restartingIce = false; - } - }; - - pc.ontrack = (event) => { - const stream = event.streams[0]; - if (event.track.kind === "audio") { - let audio = document.getElementById(`audio-${peerId}`); - if (!audio) { - audio = document.createElement("audio"); - audio.id = `audio-${peerId}`; - audio.autoplay = true; - audio.playsInline = true; - document.body.appendChild(audio); - } - audio.muted = false; - audio.srcObject = new MediaStream([event.track]); - const tryPlay = () => { - const playPromise = audio.play(); - if (playPromise && typeof playPromise.catch === "function") { - playPromise.catch((err) => { - console.warn(`remote audio playback blocked for ${peerId}`, err); - }); - } - }; - audio.onloadedmetadata = tryPlay; - audio.oncanplay = tryPlay; - tryPlay(); - - // Web Audio Pipeline for Volume Boosting - if (!state.voice.audioContext) { - state.voice.audioContext = new (window.AudioContext || window.webkitAudioContext)(); - } - const ctx = state.voice.audioContext; - if (ctx.state === 'suspended') ctx.resume(); - - const sourceNode = ctx.createMediaStreamSource(audio.srcObject); - const gainNode = ctx.createGain(); - - const currentVolume = state.userVolumes.get(peerId) ?? 1.0; - gainNode.gain.setValueAtTime(currentVolume, ctx.currentTime); - - sourceNode.connect(gainNode); - gainNode.connect(ctx.destination); - - state.voice.peerGainNodes.set(peerId, gainNode); - - // Mute the original element as we play through Web Audio destination - audio.volume = 0; - audio.muted = true; - } else if (event.track.kind === "video") { - const peer = state.members.find(m => m.id === peerId) || { display_name: "Unknown" }; - renderVideo(peerId, peer.display_name, stream, event.track.id); - - event.track.onmute = () => { - document.getElementById(`video-${peerId}-${event.track.id}`)?.parentElement?.remove(); - }; - event.track.onended = () => { - document.getElementById(`video-${peerId}-${event.track.id}`)?.parentElement?.remove(); - }; - } - }; - - pc.onnegotiationneeded = async () => { - try { - pc.makingOffer = true; - const offer = await pc.createOffer(); - await pc.setLocalDescription(offer); - state.voice.ws.send(JSON.stringify({ - type: "signal", - to_user_id: peerId, - kind: "offer", - data: pc.localDescription, - })); - } catch (err) { - console.error("negotiation failed", err); - } finally { - pc.makingOffer = false; - } - }; - - state.voice.peerConnections.set(peerId, pc); - return pc; -} - -async function attachLocalAudioToPeerConnections() { - if (!state.voice.localStream) return; - const localTrack = state.voice.localStream.getAudioTracks()[0]; - if (!localTrack) return; - - await Promise.all( - Array.from(state.voice.peerConnections.values()).map(async (pc) => { - const audioTransceiver = pc.getTransceivers().find((t) => t.receiver?.track?.kind === 'audio'); - if (audioTransceiver?.sender) { - await audioTransceiver.sender.replaceTrack(localTrack); - if (audioTransceiver.direction === 'recvonly') { - audioTransceiver.direction = 'sendrecv'; - } - } else { - pc.addTrack(localTrack, state.voice.localStream); - } - }) - ); -} - -async function handleSignal(fromPeerId, kind, data) { - const pc = ensurePeerConnection(fromPeerId); - - try { - if (kind === "offer") { - const offerCollision = pc.makingOffer || pc.signalingState !== "stable"; - pc.ignoreOffer = !pc.polite && offerCollision; - if (pc.ignoreOffer) return; - - await pc.setRemoteDescription(new RTCSessionDescription(data)); - const answer = await pc.createAnswer(); - await pc.setLocalDescription(answer); - state.voice.ws.send(JSON.stringify({ - type: "signal", - to_user_id: fromPeerId, - kind: "answer", - data: pc.localDescription, - })); - } else if (kind === "answer") { - await pc.setRemoteDescription(new RTCSessionDescription(data)); - } else if (kind === "ice") { - try { - await pc.addIceCandidate(data ? new RTCIceCandidate(data) : null); - } catch (err) { - if (!pc.ignoreOffer) { - console.warn("failed to add ice candidate", err); - } - } - } - } catch (err) { - console.error("handleSignal failed", err); - } -} - -async function joinVoice() { - if (!state.selectedVoiceChannelId) { - alert('No voice channel selected.'); - return; - } - if (state.voice.joinedChannelId === state.selectedVoiceChannelId && - state.voice.ws && - state.voice.ws.readyState === WebSocket.OPEN) { - return; - } - - await leaveVoice(); - - el.voiceConnection.classList.remove('hidden'); - el.vcChannelName.textContent = 'Connecting...'; - - const ws = new WebSocket(getVoiceWsUrl(state.selectedVoiceChannelId)); - const currentWs = ws; - let wsOpened = false; - let micUnavailable = false; - - state.voice.ws = ws; - state.voice.joinedChannelId = state.selectedVoiceChannelId; - - // Start mic setup in parallel so channel join is not blocked by device init. - createLocalVoiceStream() - .then(async () => { - if (state.voice.ws !== currentWs) { - stopAndClearAudioPipeline(); - return; - } - try { - await attachLocalAudioToPeerConnections(); - } catch (err) { - console.warn('failed to attach local audio to existing peer connections', err); - } - }) - .catch((err) => { - micUnavailable = true; - console.warn('failed to initialize local voice stream, joining as listen-only', err); - state.voice.localStream = null; - state.voice.rawStream = null; - if (state.voice.audioContext) { - state.voice.audioContext.close().catch(() => { }); - state.voice.audioContext = null; - } - }); - - const connectTimer = setTimeout(() => { - if (!wsOpened && state.voice.ws === currentWs) { - console.error('voice websocket connect timeout'); - try { currentWs.close(); } catch (_) { } - el.vcChannelName.textContent = 'Connection timeout'; - alert('Could not connect to voice channel (timeout). Please try again.'); - } - }, 5000); - - ws.onopen = () => { - if (state.voice.ws !== currentWs) return; - wsOpened = true; - clearTimeout(connectTimer); - const channel = state.channels.find(c => c.id === state.selectedVoiceChannelId); - el.vcChannelName.textContent = channel ? channel.name : "Voice"; - if (micUnavailable) { - console.warn('Voice joined in listen-only mode because microphone is unavailable.'); - } - el.voiceConnection.classList.remove("hidden"); - el.soundboard.classList.remove("hidden"); - playSound('join'); - refreshVoicePresence().catch(() => { }); - loadSounds().catch(() => { }); - }; - - ws.onmessage = async (event) => { - if (state.voice.ws !== currentWs) return; - const msg = JSON.parse(event.data); - if (msg.type === "peers") { - for (const peer of msg.peers) { - ensurePeerConnection(peer.user_id); - } - } else if (msg.type === "peer_joined") { - playSound('peer-join'); - ensurePeerConnection(msg.user_id); - } else if (msg.type === "peer_left") { - playSound('peer-leave'); - const pc = state.voice.peerConnections.get(msg.user_id); - if (pc) { - pc.close(); - state.voice.peerConnections.delete(msg.user_id); - } - document.getElementById(`audio-${msg.user_id}`)?.remove(); - } else if (msg.type === "signal") { - await handleSignal(msg.from_user_id, msg.kind, msg.data); - } else if (msg.type === "video_status_changed") { - if (!msg.is_sharing_video) { - document.getElementById(`video-${msg.user_id}-camera`)?.parentElement?.remove(); - } - } else if (msg.type === "screen_status_changed") { - if (!msg.is_sharing_screen) { - document.getElementById(`video-${msg.user_id}-screen`)?.parentElement?.remove(); - } - } else if (msg.type === "speaking_status_changed") { - state.voicePresence.get(state.selectedVoiceChannelId)?.forEach(p => { - if (p.user_id === msg.user_id) p.is_speaking = msg.is_speaking; - }); - renderChannels(); - const videoEl = document.getElementById(`video-${msg.user_id}-camera`) || document.getElementById(`video-${msg.user_id}-screen`); - if (videoEl) { - videoEl.parentElement.classList.toggle('speaking', msg.is_speaking); - } - } else if (msg.type === "mute_status_changed") { - state.voicePresence.get(state.selectedVoiceChannelId)?.forEach(p => { - if (p.user_id === msg.user_id) p.is_muted = msg.is_muted; - }); - renderChannels(); - } else if (msg.type === "play_sound") { - const audio = new Audio(msg.sound_url); - audio.play().catch(console.error); - } - refreshVoicePresence().catch(() => { }); - }; - - ws.onerror = (event) => { - if (state.voice.ws !== currentWs) return; - console.error('voice websocket error', event); - el.vcChannelName.textContent = 'Connection failed'; - }; - - ws.onclose = (event) => { - if (state.voice.ws !== currentWs) return; - clearTimeout(connectTimer); - if (!wsOpened) { - console.error('voice websocket closed before open', event.code, event.reason); - const code = event && typeof event.code === 'number' ? event.code : 'unknown'; - el.vcChannelName.textContent = `Connection failed (${code})`; - alert(`Voice connection failed (code ${code}).`); - } - if (wsOpened) { - el.voiceConnection.classList.add("hidden"); - } - el.soundboard.classList.add("hidden"); - for (const pc of state.voice.peerConnections.values()) pc.close(); - state.voice.peerConnections.clear(); - state.voice.videoSenders.clear(); - state.voice.screenSenders.clear(); - stopAndClearAudioPipeline(); - state.voice.joinedChannelId = null; - state.voice.ws = null; - playSound('leave'); - refreshVoicePresence().catch(() => { }); - }; -} - -async function leaveVoice() { - if (state.voice.ws) state.voice.ws.close(); -} - -function toggleMute() { - if (!state.voice.localStream) return; - state.voice.muted = !state.voice.muted; - state.voice.localStream.getAudioTracks().forEach((t) => { - t.enabled = !state.voice.muted; - }); - if (state.voice.ws && state.voice.ws.readyState === WebSocket.OPEN) { - state.voice.ws.send(JSON.stringify({ type: "set_mute_status", is_muted: state.voice.muted })); - } - el.voiceMuteBtn.innerHTML = state.voice.muted ? '' : ''; - el.voiceMuteBtn.style.color = state.voice.muted ? 'var(--danger)' : 'var(--text-muted)'; - lucide.createIcons(); -} - -async function toggleVideo() { - if (state.voice.sharingVideo) { - stopAndClearVideoPipeline(); - if (state.voice.ws) { - state.voice.ws.send(JSON.stringify({ type: "set_video_status", is_sharing_video: false })); - } - for (const [peerId, pc] of state.voice.peerConnections) { - const sender = state.voice.videoSenders.get(peerId); - if (sender) { - pc.removeTrack(sender); - state.voice.videoSenders.delete(peerId); - } - } - } else { - try { - const stream = await navigator.mediaDevices.getUserMedia({ video: true }); - state.voice.videoStream = stream; - state.voice.sharingVideo = true; - renderVideo(state.me.id, state.me.display_name, stream, 'camera'); - - if (state.voice.ws) { - state.voice.ws.send(JSON.stringify({ type: "set_video_status", is_sharing_video: true })); - } - - for (const [peerId, pc] of state.voice.peerConnections) { - stream.getVideoTracks().forEach(track => { - const sender = pc.addTrack(track, stream); - state.voice.videoSenders.set(peerId, sender); - }); - } - } catch (err) { - console.error("camera denied", err); - alert("Could not access camera."); - return; - } - } - - el.voiceVideoBtn.innerHTML = state.voice.sharingVideo ? '' : ''; - el.voiceVideoBtn.style.color = state.voice.sharingVideo ? 'var(--green)' : 'var(--text-muted)'; - lucide.createIcons(); -} - -async function toggleScreenShare() { - if (state.voice.sharingScreen) { - stopAndClearScreenPipeline(); - if (state.voice.ws) { - state.voice.ws.send(JSON.stringify({ type: "set_screen_status", is_sharing_screen: false })); - } - for (const [peerId, pc] of state.voice.peerConnections) { - const sender = state.voice.screenSenders.get(peerId); - if (sender) { - pc.removeTrack(sender); - state.voice.screenSenders.delete(peerId); - } - } - } else { - try { - const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true }); - state.voice.screenStream = stream; - state.voice.sharingScreen = true; - renderVideo(state.me.id, state.me.display_name, stream, 'screen'); - - // Stop sharing if user clicks "Stop sharing" in browser UI - stream.getVideoTracks()[0].onended = () => { - if (state.voice.sharingScreen) toggleScreenShare(); - }; - - if (state.voice.ws) { - state.voice.ws.send(JSON.stringify({ type: "set_screen_status", is_sharing_screen: true })); - } - - for (const [peerId, pc] of state.voice.peerConnections) { - stream.getVideoTracks().forEach(track => { - const sender = pc.addTrack(track, stream); - state.voice.screenSenders.set(peerId, sender); - }); - } - } catch (err) { - console.error("screen share denied", err); - return; - } - } - - el.voiceScreenBtn.innerHTML = state.voice.sharingScreen ? '' : ''; - el.voiceScreenBtn.style.color = state.voice.sharingScreen ? 'var(--green)' : 'var(--text-muted)'; - lucide.createIcons(); -} - -function toggleWatchVideo() { - state.voice.watchingVideo = !state.voice.watchingVideo; - updateVideoGridVisibility(); - - el.voiceWatchBtn.innerHTML = state.voice.watchingVideo ? '' : ''; - el.voiceWatchBtn.style.color = state.voice.watchingVideo ? 'var(--green)' : 'var(--danger)'; - el.voiceWatchBtn.title = state.voice.watchingVideo ? 'Stop Watching' : 'Start Watching'; - lucide.createIcons(); -} - -// --- Sound Board --- - -async function loadSounds() { - if (!state.selectedGuildId) return; - try { - const sounds = await api(`/guilds/${state.selectedGuildId}/sounds`); - renderSounds(sounds); - } catch (err) { - console.error("failed to load sounds", err); - } -} - -function renderSounds(sounds) { - el.soundboardGrid.innerHTML = ''; - sounds.forEach(sound => { - const item = document.createElement('div'); - item.className = 'sound-item'; - - // Only show delete button if user is creator or owner - const canDelete = state.me && (state.me.id === sound.created_by_user_id || (state.guilds.find(g => g.id === state.selectedGuildId)?.owner_user_id === state.me.id)); - - item.innerHTML = ` -
${sound.icon}
-
${sound.name}
- ${canDelete ? `` : ''} - `; - - item.onclick = (e) => { - if (e.target.closest('.sound-delete')) { - e.stopPropagation(); - if (confirm(`Are you sure you want to delete "${sound.name}"?`)) { - deleteSound(sound.id); - } - return; - } - playRemoteSound(sound.file_path); - }; - - el.soundboardGrid.appendChild(item); - }); - lucide.createIcons(); -} - -async function deleteSound(soundId) { - if (!state.selectedGuildId) return; - try { - const url = API_BASE_URL ? `${API_BASE_URL}/guilds/${state.selectedGuildId}/sounds/${soundId}` : `/guilds/${state.selectedGuildId}/sounds/${soundId}`; - const response = await fetch(url, { - method: 'DELETE' - }); - if (!response.ok) { - const err = await response.json(); - throw new Error(err.error || 'Delete failed'); - } - await loadSounds(); - } catch (err) { - alert(err.message); - } -} - -function playRemoteSound(url) { - const fullUrl = API_BASE_URL && !url.startsWith('http') ? `${API_BASE_URL}${url}` : url; - if (state.voice.ws && state.voice.ws.readyState === WebSocket.OPEN) { - state.voice.ws.send(JSON.stringify({ type: 'play_sound', sound_url: fullUrl })); - } - // Also play locally immediately - const audio = new Audio(fullUrl); - audio.play().catch(console.error); -} - -// --- Mobile Logic --- - -function toggleMobileMenu() { - const isOpen = el.main.classList.contains("menu-open"); - if (isOpen) { - el.main.classList.remove("menu-open"); - el.overlay.classList.add("hidden"); - } else { - el.main.classList.add("menu-open"); - el.utilitySidebar.classList.remove("active"); // close members if open - el.overlay.classList.remove("hidden"); - } -} - -function toggleMobileMembers() { - const isOpen = el.utilitySidebar.classList.contains("active"); - if (isOpen) { - el.utilitySidebar.classList.remove("active"); - el.overlay.classList.add("hidden"); - } else { - el.utilitySidebar.classList.add("active"); - el.main.classList.remove("menu-open"); // close menu if open - el.overlay.classList.remove("hidden"); - } -} - -function closeMobileMenus() { - el.main.classList.remove("menu-open"); - el.utilitySidebar.classList.remove("active"); - el.overlay.classList.add("hidden"); -} - -// --- Initialization --- - -let inactivityTimer = null; -let isCurrentlyIdle = false; - -function resetInactivityTimer() { - if (isCurrentlyIdle) { - isCurrentlyIdle = false; - if (state.chatWs && state.chatWs.readyState === WebSocket.OPEN) { - state.chatWs.send(JSON.stringify({ type: "set_idle_status", is_idle: false })); - } - } - - if (inactivityTimer) clearTimeout(inactivityTimer); - inactivityTimer = setTimeout(() => { - isCurrentlyIdle = true; - if (state.chatWs && state.chatWs.readyState === WebSocket.OPEN) { - state.chatWs.send(JSON.stringify({ type: "set_idle_status", is_idle: true })); - } - }, 5 * 60 * 1000); // 5 minutes -} - -document.addEventListener('mousemove', resetInactivityTimer); -document.addEventListener('keydown', resetInactivityTimer); -document.addEventListener('click', resetInactivityTimer); - -async function init() { - lucide.createIcons(); - - // 1. Handle tokens in URL (from successful login redirects) - try { - const searchParams = new URLSearchParams(location.search); - const jwtToken = searchParams.get("token"); - const refreshToken = searchParams.get("refresh_token"); - - if (jwtToken || refreshToken) { - if (jwtToken) localStorage.setItem("chattz_token", jwtToken); - if (refreshToken) localStorage.setItem("chattz_refresh_token", refreshToken); - - searchParams.delete("token"); - searchParams.delete("refresh_token"); - const nextQuery = searchParams.toString(); - const nextUrl = `${location.pathname}${nextQuery ? `?${nextQuery}` : ""}`; - history.replaceState(null, "", nextUrl); - } - } catch (e) { - console.error("Failed to parse token from URL:", e); - } - - // 2. Global Button Handlers - el.loginBtn.onclick = () => { - const loginPath = "/auth/login"; - location.href = API_BASE_URL ? `${API_BASE_URL}${loginPath}` : loginPath; - }; - - el.logoutBtn.onclick = async () => { - await leaveVoice(); - localStorage.removeItem("chattz_token"); - localStorage.removeItem("chattz_refresh_token"); - try { await api("/auth/logout", { method: "POST" }); } catch (e) { } - location.reload(); - }; - - el.addGuildBtn.onclick = () => { el.modalContainer.classList.remove("hidden"); }; - el.createInviteBtn.onclick = createInviteLink; - el.modalCancel.onclick = () => { el.modalContainer.classList.add("hidden"); }; - - el.addTextBtn.onclick = () => { - if (!state.selectedGuildId) { alert("Create or select a server first."); return; } - document.querySelector('input[name="channel-kind"][value="text"]').checked = true; - el.channelModal.classList.remove("hidden"); - }; - el.addVoiceBtn.onclick = () => { - if (!state.selectedGuildId) { alert("Create or select a server first."); return; } - document.querySelector('input[name="channel-kind"][value="voice"]').checked = true; - el.channelModal.classList.remove("hidden"); - }; - el.channelModalCancel.onclick = () => { el.channelModal.classList.add("hidden"); }; - - // Mobile Listeners - if (el.mobileMenuBtn) el.mobileMenuBtn.onclick = toggleMobileMenu; - if (el.mobileMembersBtn) el.mobileMembersBtn.onclick = toggleMobileMembers; - if (el.overlay) el.overlay.onclick = closeMobileMenus; - - // 3. Form Submissions - el.guildForm.onsubmit = async (e) => { - e.preventDefault(); - try { - const guild = await api("/guilds", { method: "POST", body: JSON.stringify({ name: el.guildName.value }) }); - el.guildName.value = ""; - el.modalContainer.classList.add("hidden"); - state.guilds.push(guild); - state.selectedGuildId = guild.id; - state.selectedTextChannelId = null; - state.selectedVoiceChannelId = null; - state.selectedDmUserId = null; - state.selectedDmDisplayName = null; - localStorage.setItem(LAST_GUILD_STORAGE_KEY, guild.id); - renderGuilds(); - await loadChannels(); - await loadGuildMembers(); - await refreshVoicePresence(); - renderMessages([]); - updateHeaderLabels(); - } catch (err) { alert(err.message); } - }; - - el.channelForm.onsubmit = async (e) => { - e.preventDefault(); - if (!state.selectedGuildId) { alert("Select a server first."); return; } - const kindInput = document.querySelector('input[name="channel-kind"]:checked'); - const kind = kindInput ? kindInput.value : "text"; - const channelName = el.channelName.value.trim(); - if (!channelName) { alert("Channel name is required."); return; } - try { - const created = await api("/channels", { - method: "POST", - body: JSON.stringify({ guild_id: state.selectedGuildId, name: channelName, kind: kind }), - }); - el.channelName.value = ""; - el.channelModal.classList.add("hidden"); - await loadChannels(); - if (created.kind === "text") { - state.selectedTextChannelId = created.id; - state.selectedVoiceChannelId = null; - state.selectedDmUserId = null; - state.selectedDmDisplayName = null; - renderChannels(); - renderDMs(); - updateHeaderLabels(); - const messages = await api(`/channels/${created.id}/messages?limit=100`); - renderMessages(messages); - } else { - state.selectedVoiceChannelId = created.id; - state.selectedTextChannelId = null; - state.selectedDmUserId = null; - state.selectedDmDisplayName = null; - renderChannels(); - renderDMs(); - updateHeaderLabels(); - await joinVoice(); - } - } catch (err) { alert(err.message); } - }; - - el.messageForm.onsubmit = async (e) => { - e.preventDefault(); - const body = el.messageBody.value.trim(); - if (!body) return; - try { - if (state.selectedTextChannelId) { - await api(`/channels/${state.selectedTextChannelId}/messages`, { method: "POST", body: JSON.stringify({ body }) }); - } else if (state.selectedDmUserId) { - await api(`/dms/${state.selectedDmUserId}/messages`, { method: "POST", body: JSON.stringify({ body }) }); - } else return; - el.messageBody.value = ""; - const messages = state.selectedTextChannelId - ? await api(`/channels/${state.selectedTextChannelId}/messages?limit=100`) - : await api(`/dms/${state.selectedDmUserId}/messages?limit=100`); - renderMessages(messages); - await loadDMConversations(); - renderDMs(); - } catch (err) { alert(err.message); } - }; - - // 4. Voice/Soundboard Controls - el.voiceVideoBtn.onclick = toggleVideo; - el.voiceScreenBtn.onclick = toggleScreenShare; - el.voiceMuteBtn.onclick = toggleMute; - el.voiceLeaveBtn.onclick = leaveVoice; - - el.addSoundBtn.onclick = () => { el.soundModal.classList.remove("hidden"); }; - el.soundModalCancel.onclick = () => { el.soundModal.classList.add("hidden"); }; - - el.soundForm.onsubmit = async (e) => { - e.preventDefault(); - if (!state.selectedGuildId) return; - const formData = new FormData(); - formData.append('name', el.soundName.value); - formData.append('icon', el.soundIcon.value); - formData.append('file', el.soundFile.files[0]); - el.soundSubmitBtn.disabled = true; - el.soundSubmitBtn.textContent = 'Uploading...'; - try { - const url = API_BASE_URL ? `${API_BASE_URL}/guilds/${state.selectedGuildId}/sounds` : `/guilds/${state.selectedGuildId}/sounds`; - const response = await fetch(url, { method: 'POST', body: formData }); - if (!response.ok) { const err = await response.json(); throw new Error(err.error || 'Upload failed'); } - el.soundModal.classList.add("hidden"); - el.soundForm.reset(); - await loadSounds(); - } catch (err) { alert(err.message); } finally { - el.soundSubmitBtn.disabled = false; - el.soundSubmitBtn.textContent = 'Add Sound'; - } - }; - - // 5. GIF Picker - const TENOR_API_KEY = "exTiFGKJ0CzESIHzVQWy3pRO8I1MAdpRomg95DBSu2sg6e7YcHgThMI4giGAx8D0"; - const TENOR_CLIENT_KEY = "pavel-discord"; - - el.gifBtn.onclick = () => { el.gifPicker.classList.remove("hidden"); searchGifs(""); }; - el.gifPickerClose.onclick = () => { el.gifPicker.classList.add("hidden"); }; - - let gifSearchTimeout = null; - el.gifSearchInput.oninput = () => { - clearTimeout(gifSearchTimeout); - gifSearchTimeout = setTimeout(() => { searchGifs(el.gifSearchInput.value); }, 500); - }; - - async function searchGifs(query) { - el.gifResults.innerHTML = '
Searching...
'; - const baseUrl = "https://api.klipy.com/v2"; - const endpoint = query - ? `${baseUrl}/search?q=${encodeURIComponent(query)}&key=${TENOR_API_KEY}&client_key=${TENOR_CLIENT_KEY}&limit=20` - : `${baseUrl}/featured?key=${TENOR_API_KEY}&client_key=${TENOR_CLIENT_KEY}&limit=20`; - try { - const res = await fetch(endpoint); - const data = await res.json(); - renderGifs(data.results); - } catch (err) { el.gifResults.innerHTML = '
Error loading GIFs
'; } - } - - function renderGifs(gifs) { - el.gifResults.innerHTML = ""; - if (!gifs || gifs.length === 0) { el.gifResults.innerHTML = '
No GIFs found
'; return; } - gifs.forEach(gif => { - const previewUrl = gif.media_formats.tinygif.url; - const fullUrl = gif.media_formats.gif.url; - const item = document.createElement("div"); - item.className = "gif-item"; - const img = document.createElement("img"); - img.src = previewUrl; - img.loading = "lazy"; - img.onclick = () => { - el.messageBody.value = fullUrl; - el.gifPicker.classList.add("hidden"); - el.messageForm.dispatchEvent(new Event('submit')); - }; - item.appendChild(img); - el.gifResults.appendChild(item); - }); - } - - // 6. Initial Data Loading & App Start - try { - state.me = await api("/me"); - if (state.me && state.me.display_name) { - el.userName.textContent = state.me.display_name; - el.userAvatar.textContent = shortName(state.me.display_name); - } - el.authScreen.classList.add("hidden"); - el.main.classList.remove("hidden"); - - // Check for invite code in URL - const params = new URLSearchParams(location.search); - const inviteCode = params.get("invite"); - if (inviteCode) { - try { - const joinedGuild = await api(`/invites/${encodeURIComponent(inviteCode)}/join`, { method: "POST" }); - localStorage.setItem(LAST_GUILD_STORAGE_KEY, joinedGuild.id); - } catch (err) { alert(`Failed to join invite: ${err.message}`); } finally { - params.delete("invite"); - const nextQuery = params.toString(); - const nextUrl = `${location.pathname}${nextQuery ? `?${nextQuery}` : ""}`; - history.replaceState(null, "", nextUrl); - } - } - - state.guilds = await api("/guilds"); - renderGuilds(); - await loadDMConversations(); - - try { - const online = await api("/presence"); - state.onlineUsers = new Set(online); - } catch (err) { console.warn("presence sync failed", err); } - - if (state.guilds.length > 0) { - const lastGuildId = localStorage.getItem(LAST_GUILD_STORAGE_KEY); - const guild = state.guilds.find((g) => g.id === lastGuildId) || state.guilds[0]; - state.selectedGuildId = guild.id; - localStorage.setItem(LAST_GUILD_STORAGE_KEY, guild.id); - renderGuilds(); - await loadChannels(); - await loadGuildMembers(); - await refreshVoicePresence(); - updateHeaderLabels(); - } else { - state.members = []; - renderMembers(); - updateHeaderLabels(); - } - - initChatWs(); - startVoicePresencePolling(); - initUpdater(); - lucide.createIcons(); - } catch (err) { - console.error("init failed", err); - el.authScreen.classList.remove("hidden"); - el.main.classList.add("hidden"); - } -} - -function initUpdater() { - if (!window.electronAPI) return; - - window.electronAPI.onUpdateAvailable((info) => { - console.log("Update available:", info.version); - el.updateNotifier.classList.remove("hidden"); - el.updateDownloadBtn.classList.remove("hidden"); - el.updateInstallBtn.classList.add("hidden"); - }); - - window.electronAPI.onUpdateDownloaded((info) => { - console.log("Update downloaded:", info.version); - el.updateNotifier.classList.remove("hidden"); - el.updateDownloadBtn.classList.add("hidden"); - el.updateInstallBtn.classList.remove("hidden"); - el.updateInstallBtn.title = `Install Update ${info.version}`; - }); - - window.electronAPI.onUpdateError((err) => { - console.error("Update error:", err); - // Optionally hide indicator on error - // el.updateNotifier.classList.add("hidden"); - }); - - el.updateDownloadBtn.onclick = () => { - el.updateDownloadBtn.disabled = true; - el.updateDownloadBtn.style.opacity = "0.5"; - window.electronAPI.downloadUpdate(); - }; - - el.updateInstallBtn.onclick = () => { - window.electronAPI.quitAndInstall(); - }; -} - -// Config fetcher will trigger init() -initializeConfig(); +import "../static/shared/app-core.js"; diff --git a/desktop/index.html b/desktop/index.html index ac9fa8d..07f8f7d 100644 --- a/desktop/index.html +++ b/desktop/index.html @@ -265,7 +265,7 @@ - + - \ No newline at end of file + diff --git a/static/app.js b/static/app.js index d6b264f..2794dc8 100644 --- a/static/app.js +++ b/static/app.js @@ -1,1931 +1 @@ -const state = { - me: null, - guilds: [], - channels: [], - dmConversations: [], - members: [], - voicePresence: new Map(), - selectedGuildId: null, - selectedTextChannelId: null, - selectedDmUserId: null, - selectedDmDisplayName: null, - selectedVoiceChannelId: null, - voice: { - ws: null, - joinedChannelId: null, - localStream: null, - rawStream: null, - videoStream: null, - screenStream: null, - peerConnections: new Map(), - videoSenders: new Map(), - screenSenders: new Map(), - muted: false, - sharingVideo: false, - sharingScreen: false, - deepFilterProcessor: null, - deepFilterModule: null, - viewMode: 'chat', // 'chat' or 'video' - iceServers: [{ urls: "stun:stun.l.google.com:19302" }], - peerGainNodes: new Map(), // userId -> GainNode - visibleVolumeSliders: new Set(), // userIds whose sliders are currently visible - }, - voicePresencePollId: null, - chatWs: null, - lastMessageId: null, - onlineUsers: new Set(), - idleUsers: new Set(), - userVolumes: new Map(), // userId -> volume (0.0 to 2.0) -}; - - - -const el = { - authScreen: document.getElementById("auth-screen"), - loginBtn: document.getElementById("login-btn"), - main: document.getElementById("main"), - status: document.getElementById("status"), - - // Guilds - guildList: document.getElementById("guild-list"), - addGuildBtn: document.getElementById("add-guild-btn"), - guildTitle: document.getElementById("guild-title"), - createInviteBtn: document.getElementById("create-invite-btn"), - inviteCopiedBadge: document.getElementById("invite-copied-badge"), - - // Channels - channelList: document.getElementById("channel-list"), - voiceChannelList: document.getElementById("voice-channel-list"), - addTextBtn: document.getElementById("add-text-btn"), - addVoiceBtn: document.getElementById("add-voice-btn"), - dmList: document.getElementById("dm-list"), - channelTitle: document.getElementById("channel-title"), - - // Messages - messageList: document.getElementById("message-list"), - messageForm: document.getElementById("message-form"), - messageBody: document.getElementById("message-body"), - - // User Panel - userName: document.getElementById("user-name"), - userAvatar: document.getElementById("user-avatar"), - logoutBtn: document.getElementById("logout-btn"), - - // Voice Connection - voiceConnection: document.getElementById("voice-connection"), - vcChannelName: document.getElementById("vc-channel-name"), - voiceVideoBtn: document.getElementById("voice-video-btn"), - voiceScreenBtn: document.getElementById("voice-screen-btn"), - voiceMuteBtn: document.getElementById("voice-mute-btn"), - voiceLeaveBtn: document.getElementById("voice-leave-btn"), - videoGrid: document.getElementById("video-grid"), - messageForm: document.getElementById("message-form"), - messageInputWrapper: document.querySelector(".chat-input-wrapper"), - - // Members - memberList: document.getElementById("member-list"), - - // Modals - modalContainer: document.getElementById("modal-container"), - guildForm: document.getElementById("guild-form"), - guildName: document.getElementById("guild-name"), - modalCancel: document.getElementById("modal-cancel"), - - channelModal: document.getElementById("channel-modal"), - channelForm: document.getElementById("channel-form"), - channelName: document.getElementById("channel-name"), - channelModalCancel: document.getElementById("channel-modal-cancel"), - - // Sound Board - soundboard: document.getElementById('soundboard'), - soundboardGrid: document.getElementById('soundboard-grid'), - addSoundBtn: document.getElementById('add-sound-btn'), - soundModal: document.getElementById('sound-modal'), - soundForm: document.getElementById('sound-form'), - soundName: document.getElementById('sound-name'), - soundIcon: document.getElementById('sound-icon'), - soundFile: document.getElementById('sound-file'), - soundModalCancel: document.getElementById('sound-modal-cancel'), - soundSubmitBtn: document.getElementById('sound-submit-btn'), - - // Mobile - mobileMenuBtn: document.getElementById("mobile-menu-btn"), - mobileMembersBtn: document.getElementById("mobile-members-btn"), - overlay: document.getElementById("overlay"), - utilitySidebar: document.getElementById("utility-sidebar"), - - // GIF Picker - gifBtn: document.getElementById("gif-btn"), - gifPicker: document.getElementById("gif-picker"), - gifPickerClose: document.getElementById("gif-picker-close"), - gifSearchInput: document.getElementById("gif-search-input"), - gifResults: document.getElementById("gif-results"), -}; - -// --- API Helpers --- - -async function api(path, options = {}) { - const headers = { - "content-type": "application/json", - ...(options.headers || {}), - }; - - const token = localStorage.getItem("chattz_token"); - if (token) { - headers["Authorization"] = `Bearer ${token}`; - } - - let res = await fetch(path, { - ...options, - headers, - }); - - // Handle 401 Unauthorized via Refresh Token - if (res.status === 401 && !path.includes('/auth/refresh')) { - const refreshToken = localStorage.getItem("chattz_refresh_token"); - if (refreshToken) { - try { - const refreshRes = await fetch('/auth/refresh', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ refresh_token: refreshToken }) - }); - - if (refreshRes.ok) { - const newTokens = await refreshRes.json(); - localStorage.setItem("chattz_token", newTokens.access_token); - localStorage.setItem("chattz_refresh_token", newTokens.refresh_token); - - // Retry original request with new token - headers["Authorization"] = `Bearer ${newTokens.access_token}`; - res = await fetch(path, { ...options, headers }); - } else { - // Both tokens invalid/expired - throw new Error("Refresh token expired or invalid"); - } - } catch (err) { - localStorage.removeItem("chattz_token"); - localStorage.removeItem("chattz_refresh_token"); - location.href = "/auth/login"; - throw new Error("Session expired, please log in again"); - } - } - } - - if (!res.ok) { - let detail = "request failed"; - try { - const body = await res.json(); - detail = body.error || detail; - } catch { } - throw new Error(`${res.status}: ${detail}`); - } - - if (res.status === 204) return null; - return res.json(); -} - -// --- Utils --- - -function shortName(name) { - if (!name || typeof name !== 'string') return "?"; - const trimmed = name.trim(); - if (!trimmed) return "?"; - const words = trimmed.split(/\s+/).filter(w => w.length > 0).slice(0, 2); - if (words.length === 0) return "?"; - if (words.length === 1) return words[0].substring(0, 2).toUpperCase(); - return words.map((w) => w[0]?.toUpperCase() || "").join(""); -} - -function escapeHtml(s) { - if (s === null || s === undefined) return ""; - return String(s) - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); -} - -function formatMessageBody(body) { - const escaped = escapeHtml(body); - // Detect GIF URLs (simple regex for demo) - const urlRegex = /(https?:\/\/[^\s]+(?:\.gif|\/giphy\.gif)[^\s]*)/gi; - if (urlRegex.test(body)) { - return escaped.replace(urlRegex, (url) => { - return `
`; - }); - } - return escaped; -} - -function formatDate(isoString) { - const d = new Date(isoString); - const now = new Date(); - const isToday = d.toDateString() === now.toDateString(); - - if (isToday) { - return `Today at ${d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`; - } - return d.toLocaleDateString(); -} - -let audioCtx = null; - -// Global interaction listener to unlock AudioContext (autoplay policy) -window.addEventListener('click', () => { - if (audioCtx && audioCtx.state === 'suspended') { - audioCtx.resume(); - } -}, { once: true }); - -function playSound(type) { - try { - if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)(); - if (audioCtx.state === 'suspended') audioCtx.resume(); - - const sounds = { - message: { freq: 880, type: 'sine', duration: 0.1, volume: 0.1 }, - dm: { freq: [660, 880], type: 'sine', duration: 0.15, volume: 0.1 }, - join: { freq: [440, 880], type: 'sine', duration: 0.2, volume: 0.1 }, - leave: { freq: [880, 440], type: 'sine', duration: 0.2, volume: 0.1 }, - 'peer-join': { freq: [660, 990], type: 'sine', duration: 0.15, volume: 0.05 }, - 'peer-leave': { freq: [990, 660], type: 'sine', duration: 0.15, volume: 0.05 }, - }; - - const s = sounds[type]; - if (!s) return; - - const osc = audioCtx.createOscillator(); - const gain = audioCtx.createGain(); - - osc.type = s.type; - if (Array.isArray(s.freq)) { - osc.frequency.setValueAtTime(s.freq[0], audioCtx.currentTime); - osc.frequency.exponentialRampToValueAtTime(s.freq[1], audioCtx.currentTime + s.duration); - } else { - osc.frequency.setValueAtTime(s.freq, audioCtx.currentTime); - } - - gain.gain.setValueAtTime(s.volume, audioCtx.currentTime); - gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + s.duration); - - osc.connect(gain); - gain.connect(audioCtx.destination); - - osc.start(); - osc.stop(audioCtx.currentTime + s.duration); - } catch (err) { - console.warn("playSound failed", err); - } -} -const LAST_GUILD_STORAGE_KEY = "chattz:lastGuildId"; - -// --- Renderers --- - -function renderGuilds() { - el.guildList.innerHTML = ""; - - for (const guild of state.guilds) { - const btn = document.createElement("button"); - btn.className = `guild-pill ${state.selectedGuildId === guild.id ? "active" : ""}`; - btn.title = guild.name; - btn.textContent = shortName(guild.name); - btn.onclick = async () => { - state.selectedGuildId = guild.id; - state.selectedTextChannelId = null; - state.selectedVoiceChannelId = null; - state.selectedDmUserId = null; - state.selectedDmDisplayName = null; - localStorage.setItem(LAST_GUILD_STORAGE_KEY, guild.id); - renderGuilds(); - renderDMs(); - await loadChannels(); - await loadGuildMembers(); - await refreshVoicePresence(); - startVoicePresencePolling(); - renderMessages([]); - updateHeaderLabels(); - }; - el.guildList.appendChild(btn); - } -} - -function renderChannels() { - el.channelList.innerHTML = ""; - el.voiceChannelList.innerHTML = ""; - - for (const channel of state.channels) { - const row = document.createElement("button"); - row.className = `channel-row ${(channel.kind === 'text' && state.selectedTextChannelId === channel.id) || - (channel.kind === 'voice' && state.selectedVoiceChannelId === channel.id) ? "active" : "" - }`; - - const iconName = channel.kind === 'text' ? 'hash' : 'volume-2'; - row.innerHTML = ` ${escapeHtml(channel.name)}`; - - row.onclick = async () => { - if (channel.kind === 'text') { - state.selectedDmUserId = null; - state.selectedDmDisplayName = null; - state.selectedTextChannelId = channel.id; - state.voice.viewMode = 'chat'; - renderChannels(); - renderDMs(); - updateView(); - updateHeaderLabels(); - const messages = await api(`/channels/${channel.id}/messages?limit=100`); - renderMessages(messages); - } else { - state.selectedVoiceChannelId = channel.id; - state.voice.viewMode = 'video'; - renderChannels(); - renderDMs(); - updateView(); - updateHeaderLabels(); - try { - await joinVoice(); - } catch (err) { - console.error("joinVoice failed from channel click", err); - alert(`Voice join failed: ${err?.message || err}`); - } - } - if (window.innerWidth <= 768) closeMobileMenus(); - }; - - if (channel.kind === 'text') { - el.channelList.appendChild(row); - } else { - el.voiceChannelList.appendChild(row); - - const participants = state.voicePresence.get(channel.id) || []; - if (participants.length > 0) { - const pList = document.createElement("div"); - pList.className = "voice-row-members channel-list"; - pList.style.paddingLeft = "24px"; - for (const p of participants) { - const pRow = document.createElement("div"); - pRow.className = `channel-row ${p.is_speaking ? 'voice-speaking' : ''}`; - pRow.style.padding = "2px 8px"; - pRow.style.flexWrap = "wrap"; - - let sliderHtml = ''; - if (p.user_id !== state.me.id) { - const vol = state.userVolumes.get(p.user_id) ?? 1.0; - const isVisible = state.voice.visibleVolumeSliders.has(p.user_id); - sliderHtml = ` -
- - - ${Math.round(vol * 100)}% -
- `; - } - - pRow.innerHTML = ` -
-
${shortName(p.display_name)}
- ${escapeHtml(p.display_name)} - ${p.is_muted ? '' : ''} -
- ${sliderHtml} - `; - - const slider = pRow.querySelector('.volume-slider'); - if (slider) { - slider.addEventListener('input', (e) => { - const val = parseFloat(e.target.value); - state.userVolumes.set(p.user_id, val); - pRow.querySelector('.vol-pct').textContent = `${Math.round(val * 100)}%`; - - const gainNode = state.voice.peerGainNodes.get(p.user_id); - if (gainNode) { - gainNode.gain.setTargetAtTime(val, state.voice.audioContext.currentTime, 0.05); - } - }); - // Stop propagation to prevent joining channel again when clicking slider - slider.addEventListener('click', (e) => e.stopPropagation()); - } - - const toggleVolumeSlider = (e) => { - if (p.user_id === state.me.id) return; - e.preventDefault(); - e.stopPropagation(); - if (state.voice.visibleVolumeSliders.has(p.user_id)) { - state.voice.visibleVolumeSliders.delete(p.user_id); - } else { - state.voice.visibleVolumeSliders.add(p.user_id); - } - renderChannels(); - }; - - pRow.addEventListener('contextmenu', toggleVolumeSlider); - pRow.addEventListener('mousedown', (e) => { - if (e.button === 2) toggleVolumeSlider(e); - }); - - pList.appendChild(pRow); - } - el.voiceChannelList.appendChild(pList); - } - } - } - lucide.createIcons(); -} - -function renderDMs() { - el.dmList.innerHTML = ""; - for (const dm of state.dmConversations) { - const isOnline = state.onlineUsers.has(dm.user_id); - const isIdle = state.idleUsers.has(dm.user_id); - const row = document.createElement("button"); - row.className = `channel-row ${state.selectedDmUserId === dm.user_id ? "active" : ""}`; - row.innerHTML = ` -
- -
-
- ${escapeHtml(dm.display_name)} - `; - row.onclick = async () => { - state.selectedDmUserId = dm.user_id; - state.selectedDmDisplayName = dm.display_name; - state.selectedTextChannelId = null; - state.selectedVoiceChannelId = null; - state.voice.viewMode = 'chat'; - renderChannels(); - renderDMs(); - updateView(); - updateHeaderLabels(); - const messages = await api(`/dms/${dm.user_id}/messages?limit=100`); - renderMessages(messages); - }; - el.dmList.appendChild(row); - } - - lucide.createIcons(); -} - -function renderMessages(messages) { - el.messageList.innerHTML = ""; - let lastAuthorId = null; - let lastTime = null; - - for (const m of messages.slice().reverse()) { - const row = document.createElement("div"); - const mDate = new Date(m.created_at); - const isGrouped = lastAuthorId === m.author_user_id && - lastTime && (mDate - lastTime < 300000); // 5 minutes - - row.className = `msg ${isGrouped ? "msg-grouped" : ""}`; - - const displayName = m.author_display_name || "Unknown User"; - - if (isGrouped) { - row.innerHTML = `
${formatMessageBody(m.body)}
`; - } else { - row.innerHTML = ` -
${shortName(displayName)}
-
-
- ${escapeHtml(displayName)} - ${formatDate(m.created_at)} -
-
${formatMessageBody(m.body)}
-
- `; - } - el.messageList.appendChild(row); - lastAuthorId = m.author_user_id; - lastTime = mDate; - } - - if (messages.length > 0) { - state.lastMessageId = messages[0].id; - } - - el.messageList.scrollTop = el.messageList.scrollHeight; -} - -function renderMembers() { - el.memberList.innerHTML = ""; - for (const m of state.members) { - const isOnline = state.onlineUsers.has(m.id); - const isIdle = state.idleUsers.has(m.id); - const row = document.createElement("div"); - row.className = "member-row"; - row.innerHTML = ` -
- ${shortName(m.display_name)} -
-
-
${escapeHtml(m.display_name)}
- `; - row.style.cursor = "pointer"; - row.onclick = async () => { - if (state.me && m.id === state.me.id) return; - state.selectedDmUserId = m.id; - state.selectedDmDisplayName = m.display_name; - state.selectedTextChannelId = null; - state.selectedVoiceChannelId = null; - state.voice.viewMode = 'chat'; - renderChannels(); - renderDMs(); - updateView(); - updateHeaderLabels(); - const messages = await api(`/dms/${m.id}/messages?limit=100`); - renderMessages(messages); - }; - el.memberList.appendChild(row); - } -} - -function updateHeaderLabels() { - const guild = state.guilds.find((g) => g.id === state.selectedGuildId); - el.guildTitle.textContent = guild ? guild.name : "No server selected"; - - if (state.selectedDmUserId) { - const dmFromConversations = state.dmConversations.find((u) => u.user_id === state.selectedDmUserId); - const dmFromMembers = state.members.find((m) => m.id === state.selectedDmUserId); - const displayName = dmFromConversations?.display_name || dmFromMembers?.display_name || state.selectedDmDisplayName; - const dmName = displayName ? `@${displayName}` : "Direct Message"; - el.channelTitle.textContent = dmName; - el.messageBody.placeholder = displayName - ? `Message @${displayName}` - : "Message"; - return; - } - - if (state.voice.viewMode === 'video' && state.selectedVoiceChannelId) { - const channel = state.channels.find((c) => c.id === state.selectedVoiceChannelId); - el.channelTitle.textContent = channel ? channel.name : "Voice Arena"; - return; - } - - const channel = state.channels.find((c) => c.id === state.selectedTextChannelId); - el.channelTitle.textContent = channel ? channel.name : "Select a channel"; - el.messageBody.placeholder = channel ? `Message #${channel.name}` : "Select a channel"; -} - -async function createInviteLink() { - if (!state.selectedGuildId) { - alert("Select a server first."); - return; - } - try { - const invite = await api(`/guilds/${state.selectedGuildId}/invites`, { - method: "POST", - body: JSON.stringify({ max_uses: 50, expires_in_hours: 24 }), - }); - const link = `${location.origin}/?invite=${encodeURIComponent(invite.code)}`; - try { - await navigator.clipboard.writeText(link); - showCopiedBadge(); - } catch { - prompt("Copy invite link:", link); - } - } catch (err) { - alert(err.message); - } -} - -function showCopiedBadge() { - el.inviteCopiedBadge.classList.remove("hidden"); - setTimeout(() => { - el.inviteCopiedBadge.classList.add("hidden"); - }, 2000); -} - -// --- Logic --- - -async function loadGuilds() { - state.guilds = await api("/guilds"); - renderGuilds(); -} - -async function loadDMConversations() { - state.dmConversations = await api("/dms"); - renderDMs(); -} - -async function loadGuildMembers() { - if (!state.selectedGuildId) { - state.members = []; - renderMembers(); - return; - } - state.members = await api(`/guilds/${state.selectedGuildId}/members`); - renderMembers(); -} - -async function loadChannels() { - if (!state.selectedGuildId) { - state.channels = []; - renderChannels(); - return; - } - - state.channels = await api(`/guilds/${state.selectedGuildId}/channels`); - renderChannels(); -} - -async function refreshVoicePresence() { - if (!state.selectedGuildId) { - state.voicePresence.clear(); - renderChannels(); - return; - } - - try { - const res = await api(`/guilds/${state.selectedGuildId}/voice-presence`); - state.voicePresence.clear(); - for (const entry of res.channels || []) { - state.voicePresence.set(entry.channel_id, entry.participants || []); - } - renderChannels(); - } catch (err) { - console.warn("voice presence failed", err); - } -} - -function startVoicePresencePolling() { - if (state.voicePresencePollId) clearInterval(state.voicePresencePollId); - state.voicePresencePollId = setInterval(() => { - refreshVoicePresence().catch(() => { }); - }, 3000); -} - -function initChatWs() { - if (state.chatWs) state.chatWs.close(); - const protocol = location.protocol === "https:" ? "wss:" : "ws:"; - let wsUrl = `${protocol}//${location.host}/ws`; - const token = localStorage.getItem("chattz_token"); - if (token) { - wsUrl += (wsUrl.includes('?') ? '&' : '?') + `token=${token}`; - } - const ws = new WebSocket(wsUrl); - state.chatWs = ws; - - ws.onmessage = (event) => { - const msg = JSON.parse(event.data); - const authorId = msg.message?.author_user_id; - const isFromMe = authorId && state.me && authorId === state.me.id; - - if (msg.type === "message_created") { - if (!isFromMe) playSound('message'); - if (state.selectedTextChannelId === msg.channel_id) { - api(`/channels/${state.selectedTextChannelId}/messages?limit=100`).then(renderMessages); - } - } else if (msg.type === "dm_created") { - if (!isFromMe) playSound('dm'); - if (state.selectedDmUserId === msg.other_user_id) { - api(`/dms/${state.selectedDmUserId}/messages?limit=100`).then(renderMessages); - playSound('dm'); - loadDMConversations().then(renderDMs); - } else { - loadDMConversations().then(renderDMs); - } - } else if (msg.type === "user_presence") { - if (msg.online) { - state.onlineUsers.add(msg.user_id); - if (msg.idle) { - state.idleUsers.add(msg.user_id); - } else { - state.idleUsers.delete(msg.user_id); - } - } else { - state.onlineUsers.delete(msg.user_id); - state.idleUsers.delete(msg.user_id); - } - renderMembers(); - renderDMs(); - } - }; - - ws.onclose = () => { - console.log("Chat WS closed, refreshing page for resilience..."); - // Automatically refresh on connection loss instead of just background reconnecting - location.reload(); - }; -} - -// --- Voice --- - -function getVoiceWsUrl(channelId) { - const proto = location.protocol === "https:" ? "wss" : "ws"; - let urlStr = `${proto}://${location.host}/channels/${channelId}/voice/ws`; - const token = localStorage.getItem("chattz_token"); - if (token) { - urlStr += (urlStr.includes('?') ? '&' : '?') + `token=${token}`; - } - return urlStr; -} - -function shouldInitiateOffer(peerId) { - if (!state.me || !state.me.id) return false; - return state.me.id > peerId; -} - -function stopAndClearAudioPipeline() { - if (state.voice.deepFilterProcessor) { - state.voice.deepFilterProcessor.destroy(); - state.voice.deepFilterProcessor = null; - } - if (state.voice.localStream) { - for (const track of state.voice.localStream.getTracks()) track.stop(); - } - if (state.voice.rawStream && state.voice.rawStream !== state.voice.localStream) { - for (const track of state.voice.rawStream.getTracks()) track.stop(); - } - if (state.voice.audioContext) { - state.voice.audioContext.close().catch(() => { }); - } - state.voice.localStream = null; - state.voice.rawStream = null; - state.voice.audioContext = null; -} - -function stopAndClearVideoPipeline() { - if (state.voice.videoStream) { - for (const track of state.voice.videoStream.getTracks()) track.stop(); - } - state.voice.videoStream = null; - state.voice.sharingVideo = false; - document.getElementById(`video-${state.me.id}-camera`)?.parentElement?.remove(); -} - -function stopAndClearScreenPipeline() { - if (state.voice.screenStream) { - for (const track of state.voice.screenStream.getTracks()) track.stop(); - } - state.voice.screenStream = null; - state.voice.sharingScreen = false; - document.getElementById(`video-${state.me.id}-screen`)?.parentElement?.remove(); -} - -function updateVideoGridVisibility() { - // Persistence logic: we always update the grid visibility based on participants - // however the actual container visibility in the UI is managed by updateView() -} - -function updateView() { - if (state.voice.viewMode === 'video') { - el.videoGrid.classList.remove("hidden"); - el.messageList.classList.add("hidden"); - el.messageInputWrapper.classList.add("hidden"); - } else { - el.videoGrid.classList.add("hidden"); - el.messageList.classList.remove("hidden"); - el.messageInputWrapper.classList.remove("hidden"); - } -} - -function renderVideo(peerId, displayName, stream, source) { - const videoId = `video-${peerId}-${source}`; - let videoEl = document.getElementById(videoId); - if (!videoEl) { - const container = document.createElement("div"); - container.className = "video-item"; - const label = source === 'screen' ? `${escapeHtml(displayName)}'s Screen` : escapeHtml(displayName); - container.innerHTML = ` - -
${label}
- `; - container.onclick = () => { - const isFullscreen = container.classList.contains('fullscreen'); - // Reset all - document.querySelectorAll('.video-item').forEach(el => el.classList.remove('fullscreen')); - - if (isFullscreen) { - el.videoGrid.classList.remove('has-fullscreen'); - } else { - container.classList.add('fullscreen'); - el.videoGrid.classList.add('has-fullscreen'); - } - }; - - el.videoGrid.appendChild(container); - videoEl = container.querySelector("video"); - } - videoEl.srcObject = stream; -} - -async function buildAudioPipeline(rawStream) { - const ctx = new AudioContext(); - state.voice.audioContext = ctx; - const source = ctx.createMediaStreamSource(rawStream); - let processedSource = source; - - try { - const deepFilter = await getDeepFilterModule(); - const processor = new deepFilter.DeepFilterNet3Core({ - sampleRate: 48000, - noiseReductionLevel: 70, - assetConfig: { - cdnUrl: resolveDeepFilterAssetsBaseUrl(), - }, - }); - await processor.initialize(); - const workletNode = await processor.createAudioWorkletNode(ctx); - processor.setNoiseSuppressionEnabled(true); - source.connect(workletNode); - processedSource = workletNode; - state.voice.deepFilterProcessor = processor; - console.info('DeepFilterNet3 enabled'); - } catch (err) { - console.warn('DeepFilterNet3 unavailable, falling back to raw mic audio', err); - state.voice.deepFilterProcessor = null; - } - - // Metering/Speaking detection with adaptive threshold and hysteresis. - const analyser = ctx.createAnalyser(); - analyser.fftSize = 1024; - processedSource.connect(analyser); - - const supportsFloatTimeDomain = typeof analyser.getFloatTimeDomainData === 'function'; - const timeData = supportsFloatTimeDomain ? new Float32Array(analyser.fftSize) : new Uint8Array(analyser.fftSize); - let localIsSpeaking = false; - let speechFrames = 0; - let silenceFrames = 0; - let noiseFloor = 0.004; - let levelEma = 0; - - const checkVolume = () => { - if (!state.voice.audioContext || state.voice.audioContext.state === 'closed') return; - - try { - if (supportsFloatTimeDomain) { - analyser.getFloatTimeDomainData(timeData); - } else { - analyser.getByteTimeDomainData(timeData); - } - } catch (err) { - setTimeout(checkVolume, 60); - return; - } - - let sumSquares = 0; - let peak = 0; - for (let i = 0; i < timeData.length; i++) { - const v = supportsFloatTimeDomain ? timeData[i] : (timeData[i] - 128) / 128; - sumSquares += v * v; - const abs = Math.abs(v); - if (abs > peak) peak = abs; - } - - const rms = Math.sqrt(sumSquares / timeData.length); - const level = Math.max(rms, peak * 0.5); - levelEma = levelEma * 0.75 + level * 0.25; - - if (!localIsSpeaking) { - noiseFloor = noiseFloor * 0.98 + levelEma * 0.02; - } else { - noiseFloor = Math.min(noiseFloor, levelEma); - } - - const startThreshold = Math.max(noiseFloor * 2.2, 0.010); - const stopThreshold = Math.max(noiseFloor * 1.5, 0.006); - - if (levelEma > startThreshold) { - speechFrames = Math.min(speechFrames + 1, 8); - silenceFrames = 0; - } else if (levelEma < stopThreshold) { - silenceFrames = Math.min(silenceFrames + 1, 8); - speechFrames = Math.max(speechFrames - 1, 0); - } - - const newSpeakingState = localIsSpeaking ? silenceFrames < 3 : speechFrames >= 2; - if (newSpeakingState !== localIsSpeaking) { - localIsSpeaking = newSpeakingState; - if (state.voice.ws && state.voice.ws.readyState === WebSocket.OPEN) { - state.voice.ws.send(JSON.stringify({ type: 'set_speaking_status', is_speaking: localIsSpeaking })); - } - state.voicePresence.get(state.selectedVoiceChannelId)?.forEach(p => { - if (p.user_id === state.me.id) p.is_speaking = localIsSpeaking; - }); - renderChannels(); - const myVideo = document.getElementById(`video-${state.me.id}-camera`) || document.getElementById(`video-${state.me.id}-screen`); - if (myVideo) myVideo.parentElement.classList.toggle('speaking', localIsSpeaking); - } - setTimeout(checkVolume, 60); - }; - checkVolume(); - - const destination = ctx.createMediaStreamDestination(); - processedSource.connect(destination); - return destination.stream; -} - -function resolveDeepFilterModuleUrl() { - if (location.protocol === 'file:') { - return new URL('./vendor/deepfilternet3-noise-filter.esm.js', location.href).toString(); - } - return '/static/vendor/deepfilternet3-noise-filter.esm.js'; -} - -function resolveDeepFilterAssetsBaseUrl() { - if (location.protocol === 'file:') { - return new URL('./vendor/deepfilternet3', location.href).toString(); - } - return '/static/vendor/deepfilternet3'; -} - -async function getDeepFilterModule() { - if (state.voice.deepFilterModule) return state.voice.deepFilterModule; - const moduleUrl = resolveDeepFilterModuleUrl(); - state.voice.deepFilterModule = await import(moduleUrl); - return state.voice.deepFilterModule; -} - -async function createLocalVoiceStream() { - const constraints = { - audio: { - channelCount: 1, - sampleRate: 48000, - echoCancellation: true, - noiseSuppression: false, - autoGainControl: false, - }, - video: false, - }; - const rawStream = await navigator.mediaDevices.getUserMedia(constraints); - let localStream = rawStream; - try { - localStream = await buildAudioPipeline(rawStream); - } catch (err) { - console.warn('voice audio pipeline failed, using raw mic stream', err); - } - state.voice.rawStream = rawStream; - state.voice.localStream = localStream; - if (state.voice.muted) { - state.voice.localStream.getAudioTracks().forEach((t) => { - t.enabled = false; - }); - } -} - -function ensurePeerConnection(peerId) { - if (state.voice.peerConnections.has(peerId)) { - return state.voice.peerConnections.get(peerId); - } - - const pc = new RTCPeerConnection({ - iceServers: state.voice.iceServers - }); - - pc.makingOffer = false; - pc.ignoreOffer = false; - pc.polite = !shouldInitiateOffer(peerId); - - // 1. Audio - if (state.voice.localStream) { - state.voice.localStream.getAudioTracks().forEach(track => { - pc.addTrack(track, state.voice.localStream); - }); - } else { - pc.addTransceiver('audio', { direction: 'recvonly' }); - } - - // 2. Camera Video - if (state.voice.videoStream) { - state.voice.videoStream.getVideoTracks().forEach(track => { - const sender = pc.addTrack(track, state.voice.videoStream); - state.voice.videoSenders.set(peerId, sender); - }); - } - - // 3. Screen Video - if (state.voice.screenStream) { - state.voice.screenStream.getVideoTracks().forEach(track => { - const sender = pc.addTrack(track, state.voice.screenStream); - state.voice.screenSenders.set(peerId, sender); - }); - } - - pc.onicecandidate = (event) => { - if (!event.candidate || !state.voice.ws) return; - state.voice.ws.send(JSON.stringify({ - type: "signal", - to_user_id: peerId, - kind: "ice", - data: event.candidate, - })); - }; - - pc.oniceconnectionstatechange = async () => { - const iceState = pc.iceConnectionState; - console.debug(`voice ice state (${peerId}):`, iceState); - if (iceState !== "failed" || pc.restartingIce) return; - pc.restartingIce = true; - try { - const offer = await pc.createOffer({ iceRestart: true }); - await pc.setLocalDescription(offer); - if (state.voice.ws && state.voice.ws.readyState === WebSocket.OPEN) { - state.voice.ws.send(JSON.stringify({ - type: "signal", - to_user_id: peerId, - kind: "offer", - data: pc.localDescription, - })); - } - } catch (err) { - console.warn("ICE restart failed", err); - } finally { - pc.restartingIce = false; - } - }; - - pc.ontrack = (event) => { - const stream = event.streams[0]; - if (event.track.kind === "audio") { - let audio = document.getElementById(`audio-${peerId}`); - if (!audio) { - audio = document.createElement("audio"); - audio.id = `audio-${peerId}`; - audio.autoplay = true; - audio.playsInline = true; - document.body.appendChild(audio); - } - audio.muted = false; - audio.srcObject = new MediaStream([event.track]); - const tryPlay = () => { - const playPromise = audio.play(); - if (playPromise && typeof playPromise.catch === "function") { - playPromise.catch((err) => { - console.warn(`remote audio playback blocked for ${peerId}`, err); - }); - } - }; - audio.onloadedmetadata = tryPlay; - audio.oncanplay = tryPlay; - tryPlay(); - - // Web Audio Pipeline for Volume Boosting - if (!state.voice.audioContext) { - state.voice.audioContext = new (window.AudioContext || window.webkitAudioContext)(); - } - const ctx = state.voice.audioContext; - if (ctx.state === 'suspended') ctx.resume(); - - const sourceNode = ctx.createMediaStreamSource(audio.srcObject); - const gainNode = ctx.createGain(); - - const currentVolume = state.userVolumes.get(peerId) ?? 1.0; - gainNode.gain.setValueAtTime(currentVolume, ctx.currentTime); - - sourceNode.connect(gainNode); - gainNode.connect(ctx.destination); - - state.voice.peerGainNodes.set(peerId, gainNode); - - // Mute the original element as we play through Web Audio destination - audio.volume = 0; - audio.muted = true; - } else if (event.track.kind === "video") { - const peer = state.members.find(m => m.id === peerId) || { display_name: "Unknown" }; - renderVideo(peerId, peer.display_name, stream, event.track.id); - - event.track.onmute = () => { - document.getElementById(`video-${peerId}-${event.track.id}`)?.parentElement?.remove(); - }; - event.track.onended = () => { - document.getElementById(`video-${peerId}-${event.track.id}`)?.parentElement?.remove(); - }; - } - }; - - pc.onnegotiationneeded = async () => { - try { - pc.makingOffer = true; - const offer = await pc.createOffer(); - await pc.setLocalDescription(offer); - state.voice.ws.send(JSON.stringify({ - type: "signal", - to_user_id: peerId, - kind: "offer", - data: pc.localDescription, - })); - } catch (err) { - console.error("negotiation failed", err); - } finally { - pc.makingOffer = false; - } - }; - - state.voice.peerConnections.set(peerId, pc); - return pc; -} - -async function attachLocalAudioToPeerConnections() { - if (!state.voice.localStream) return; - const localTrack = state.voice.localStream.getAudioTracks()[0]; - if (!localTrack) return; - - await Promise.all( - Array.from(state.voice.peerConnections.values()).map(async (pc) => { - const audioTransceiver = pc.getTransceivers().find((t) => t.receiver?.track?.kind === 'audio'); - if (audioTransceiver?.sender) { - await audioTransceiver.sender.replaceTrack(localTrack); - if (audioTransceiver.direction === 'recvonly') { - audioTransceiver.direction = 'sendrecv'; - } - } else { - pc.addTrack(localTrack, state.voice.localStream); - } - }) - ); -} - -async function handleSignal(fromPeerId, kind, data) { - const pc = ensurePeerConnection(fromPeerId); - - try { - if (kind === "offer") { - const offerCollision = pc.makingOffer || pc.signalingState !== "stable"; - pc.ignoreOffer = !pc.polite && offerCollision; - if (pc.ignoreOffer) return; - - await pc.setRemoteDescription(new RTCSessionDescription(data)); - const answer = await pc.createAnswer(); - await pc.setLocalDescription(answer); - state.voice.ws.send(JSON.stringify({ - type: "signal", - to_user_id: fromPeerId, - kind: "answer", - data: pc.localDescription, - })); - } else if (kind === "answer") { - await pc.setRemoteDescription(new RTCSessionDescription(data)); - } else if (kind === "ice") { - try { - await pc.addIceCandidate(data ? new RTCIceCandidate(data) : null); - } catch (err) { - if (!pc.ignoreOffer) { - console.warn("failed to add ice candidate", err); - } - } - } - } catch (err) { - console.error("handleSignal failed", err); - } -} - -async function joinVoice() { - if (!state.selectedVoiceChannelId) { - alert('No voice channel selected.'); - return; - } - if (state.voice.joinedChannelId === state.selectedVoiceChannelId && - state.voice.ws && - state.voice.ws.readyState === WebSocket.OPEN) { - return; - } - - await leaveVoice(); - - el.voiceConnection.classList.remove('hidden'); - el.vcChannelName.textContent = 'Connecting...'; - - const ws = new WebSocket(getVoiceWsUrl(state.selectedVoiceChannelId)); - const currentWs = ws; - let wsOpened = false; - let micUnavailable = false; - - state.voice.ws = ws; - state.voice.joinedChannelId = state.selectedVoiceChannelId; - localStorage.setItem("active_voice_channel", state.selectedVoiceChannelId); - - // Start mic setup in parallel so channel join is not blocked by device init. - createLocalVoiceStream() - .then(async () => { - if (state.voice.ws !== currentWs) { - stopAndClearAudioPipeline(); - return; - } - try { - await attachLocalAudioToPeerConnections(); - } catch (err) { - console.warn('failed to attach local audio to existing peer connections', err); - } - }) - .catch((err) => { - micUnavailable = true; - console.warn('failed to initialize local voice stream, joining as listen-only', err); - state.voice.localStream = null; - state.voice.rawStream = null; - if (state.voice.audioContext) { - state.voice.audioContext.close().catch(() => { }); - state.voice.audioContext = null; - } - }); - - const connectTimer = setTimeout(() => { - if (!wsOpened && state.voice.ws === currentWs) { - console.error('voice websocket connect timeout'); - try { currentWs.close(); } catch (_) { } - el.vcChannelName.textContent = 'Connection timeout'; - alert('Could not connect to voice channel (timeout). Please try again.'); - } - }, 5000); - - ws.onopen = () => { - if (state.voice.ws !== currentWs) return; - wsOpened = true; - clearTimeout(connectTimer); - const channel = state.channels.find(c => c.id === state.selectedVoiceChannelId); - el.vcChannelName.textContent = channel ? channel.name : "Voice"; - if (micUnavailable) { - console.warn('Voice joined in listen-only mode because microphone is unavailable.'); - } - el.voiceConnection.classList.remove("hidden"); - el.soundboard.classList.remove("hidden"); - playSound('join'); - refreshVoicePresence().catch(() => { }); - loadSounds().catch(() => { }); - }; - - ws.onmessage = async (event) => { - if (state.voice.ws !== currentWs) return; - const msg = JSON.parse(event.data); - if (msg.type === "peers") { - for (const peer of msg.peers) { - ensurePeerConnection(peer.user_id); - } - } else if (msg.type === "peer_joined") { - playSound('peer-join'); - ensurePeerConnection(msg.user_id); - } else if (msg.type === "peer_left") { - playSound('peer-leave'); - const pc = state.voice.peerConnections.get(msg.user_id); - if (pc) { - pc.close(); - state.voice.peerConnections.delete(msg.user_id); - } - document.getElementById(`audio-${msg.user_id}`)?.remove(); - } else if (msg.type === "signal") { - await handleSignal(msg.from_user_id, msg.kind, msg.data); - } else if (msg.type === "video_status_changed") { - if (!msg.is_sharing_video) { - document.getElementById(`video-${msg.user_id}-camera`)?.parentElement?.remove(); - } - } else if (msg.type === "screen_status_changed") { - if (!msg.is_sharing_screen) { - document.getElementById(`video-${msg.user_id}-screen`)?.parentElement?.remove(); - } - } else if (msg.type === "speaking_status_changed") { - state.voicePresence.get(state.selectedVoiceChannelId)?.forEach(p => { - if (p.user_id === msg.user_id) p.is_speaking = msg.is_speaking; - }); - renderChannels(); - const videoEl = document.getElementById(`video-${msg.user_id}-camera`) || document.getElementById(`video-${msg.user_id}-screen`); - if (videoEl) { - videoEl.parentElement.classList.toggle('speaking', msg.is_speaking); - } - } else if (msg.type === "mute_status_changed") { - state.voicePresence.get(state.selectedVoiceChannelId)?.forEach(p => { - if (p.user_id === msg.user_id) p.is_muted = msg.is_muted; - }); - renderChannels(); - } else if (msg.type === "play_sound") { - const audio = new Audio(msg.sound_url); - audio.play().catch(console.error); - } - refreshVoicePresence().catch(() => { }); - }; - - ws.onerror = (event) => { - if (state.voice.ws !== currentWs) return; - console.error('voice websocket error', event); - el.vcChannelName.textContent = 'Connection failed'; - }; - - ws.onclose = (event) => { - if (state.voice.ws !== currentWs) return; - clearTimeout(connectTimer); - if (!wsOpened) { - console.error('voice websocket closed before open', event.code, event.reason); - const code = event && typeof event.code === 'number' ? event.code : 'unknown'; - el.vcChannelName.textContent = `Connection failed (${code})`; - alert(`Voice connection failed (code ${code}).`); - } - if (wsOpened) { - el.voiceConnection.classList.add("hidden"); - } - el.soundboard.classList.add("hidden"); - for (const pc of state.voice.peerConnections.values()) pc.close(); - state.voice.peerConnections.clear(); - state.voice.videoSenders.clear(); - state.voice.screenSenders.clear(); - stopAndClearAudioPipeline(); - state.voice.joinedChannelId = null; - state.voice.ws = null; - playSound('leave'); - refreshVoicePresence().catch(() => { }); - }; -} - -async function leaveVoice() { - localStorage.removeItem("active_voice_channel"); - if (state.voice.ws) state.voice.ws.close(); -} - -function toggleMute() { - if (!state.voice.localStream) return; - state.voice.muted = !state.voice.muted; - state.voice.localStream.getAudioTracks().forEach((t) => { - t.enabled = !state.voice.muted; - }); - if (state.voice.ws && state.voice.ws.readyState === WebSocket.OPEN) { - state.voice.ws.send(JSON.stringify({ type: "set_mute_status", is_muted: state.voice.muted })); - } - el.voiceMuteBtn.innerHTML = state.voice.muted ? '' : ''; - el.voiceMuteBtn.style.color = state.voice.muted ? 'var(--danger)' : 'var(--text-muted)'; - lucide.createIcons(); -} - -async function toggleVideo() { - if (state.voice.sharingVideo) { - stopAndClearVideoPipeline(); - if (state.voice.ws) { - state.voice.ws.send(JSON.stringify({ type: "set_video_status", is_sharing_video: false })); - } - for (const [peerId, pc] of state.voice.peerConnections) { - const sender = state.voice.videoSenders.get(peerId); - if (sender) { - pc.removeTrack(sender); - state.voice.videoSenders.delete(peerId); - } - } - } else { - try { - const stream = await navigator.mediaDevices.getUserMedia({ video: true }); - state.voice.videoStream = stream; - state.voice.sharingVideo = true; - renderVideo(state.me.id, state.me.display_name, stream, 'camera'); - - if (state.voice.ws) { - state.voice.ws.send(JSON.stringify({ type: "set_video_status", is_sharing_video: true })); - } - - for (const [peerId, pc] of state.voice.peerConnections) { - stream.getVideoTracks().forEach(track => { - const sender = pc.addTrack(track, stream); - state.voice.videoSenders.set(peerId, sender); - }); - } - } catch (err) { - console.error("camera denied", err); - alert("Could not access camera."); - return; - } - } - - el.voiceVideoBtn.innerHTML = state.voice.sharingVideo ? '' : ''; - el.voiceVideoBtn.style.color = state.voice.sharingVideo ? 'var(--green)' : 'var(--text-muted)'; - lucide.createIcons(); -} - -async function toggleScreenShare() { - if (state.voice.sharingScreen) { - stopAndClearScreenPipeline(); - if (state.voice.ws) { - state.voice.ws.send(JSON.stringify({ type: "set_screen_status", is_sharing_screen: false })); - } - for (const [peerId, pc] of state.voice.peerConnections) { - const sender = state.voice.screenSenders.get(peerId); - if (sender) { - pc.removeTrack(sender); - state.voice.screenSenders.delete(peerId); - } - } - } else { - try { - const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true }); - state.voice.screenStream = stream; - state.voice.sharingScreen = true; - renderVideo(state.me.id, state.me.display_name, stream, 'screen'); - - // Stop sharing if user clicks "Stop sharing" in browser UI - stream.getVideoTracks()[0].onended = () => { - if (state.voice.sharingScreen) toggleScreenShare(); - }; - - if (state.voice.ws) { - state.voice.ws.send(JSON.stringify({ type: "set_screen_status", is_sharing_screen: true })); - } - - for (const [peerId, pc] of state.voice.peerConnections) { - stream.getVideoTracks().forEach(track => { - const sender = pc.addTrack(track, stream); - state.voice.screenSenders.set(peerId, sender); - }); - } - } catch (err) { - console.error("screen share denied", err); - return; - } - } - - el.voiceScreenBtn.innerHTML = state.voice.sharingScreen ? '' : ''; - el.voiceScreenBtn.style.color = state.voice.sharingScreen ? 'var(--green)' : 'var(--text-muted)'; - lucide.createIcons(); -} - -function toggleWatchVideo() { - state.voice.watchingVideo = !state.voice.watchingVideo; - updateVideoGridVisibility(); - - el.voiceWatchBtn.innerHTML = state.voice.watchingVideo ? '' : ''; - el.voiceWatchBtn.style.color = state.voice.watchingVideo ? 'var(--green)' : 'var(--danger)'; - el.voiceWatchBtn.title = state.voice.watchingVideo ? 'Stop Watching' : 'Start Watching'; - lucide.createIcons(); -} - -// --- Sound Board --- - -async function loadSounds() { - if (!state.selectedGuildId) return; - try { - const sounds = await api(`/guilds/${state.selectedGuildId}/sounds`); - renderSounds(sounds); - } catch (err) { - console.error("failed to load sounds", err); - } -} - -function renderSounds(sounds) { - el.soundboardGrid.innerHTML = ''; - sounds.forEach(sound => { - const item = document.createElement('div'); - item.className = 'sound-item'; - - // Only show delete button if user is creator or owner - const canDelete = state.me && (state.me.id === sound.created_by_user_id || (state.guilds.find(g => g.id === state.selectedGuildId)?.owner_user_id === state.me.id)); - - item.innerHTML = ` -
${sound.icon}
-
${sound.name}
- ${canDelete ? `` : ''} - `; - - item.onclick = (e) => { - if (e.target.closest('.sound-delete')) { - e.stopPropagation(); - if (confirm(`Are you sure you want to delete "${sound.name}"?`)) { - deleteSound(sound.id); - } - return; - } - playRemoteSound(sound.file_path); - }; - - el.soundboardGrid.appendChild(item); - }); - lucide.createIcons(); -} - -async function deleteSound(soundId) { - if (!state.selectedGuildId) return; - try { - const response = await fetch(`/guilds/${state.selectedGuildId}/sounds/${soundId}`, { - method: 'DELETE' - }); - if (!response.ok) { - const err = await response.json(); - throw new Error(err.error || 'Delete failed'); - } - await loadSounds(); - } catch (err) { - alert(err.message); - } -} - -function playRemoteSound(url) { - if (state.voice.ws && state.voice.ws.readyState === WebSocket.OPEN) { - state.voice.ws.send(JSON.stringify({ type: 'play_sound', sound_url: url })); - } - // Also play locally immediately - const audio = new Audio(url); - audio.play().catch(console.error); -} - -// --- Mobile Logic --- - -function toggleMobileMenu() { - const isOpen = el.main.classList.contains("menu-open"); - if (isOpen) { - el.main.classList.remove("menu-open"); - el.overlay.classList.add("hidden"); - } else { - el.main.classList.add("menu-open"); - el.utilitySidebar.classList.remove("active"); // close members if open - el.overlay.classList.remove("hidden"); - } -} - -function toggleMobileMembers() { - const isOpen = el.utilitySidebar.classList.contains("active"); - if (isOpen) { - el.utilitySidebar.classList.remove("active"); - el.overlay.classList.add("hidden"); - } else { - el.utilitySidebar.classList.add("active"); - el.main.classList.remove("menu-open"); // close menu if open - el.overlay.classList.remove("hidden"); - } -} - -function closeMobileMenus() { - el.main.classList.remove("menu-open"); - el.utilitySidebar.classList.remove("active"); - el.overlay.classList.add("hidden"); -} - -// --- Initialization --- - -let inactivityTimer = null; -let isCurrentlyIdle = false; - -function resetInactivityTimer() { - if (isCurrentlyIdle) { - isCurrentlyIdle = false; - if (state.chatWs && state.chatWs.readyState === WebSocket.OPEN) { - state.chatWs.send(JSON.stringify({ type: "set_idle_status", is_idle: false })); - } - } - - if (inactivityTimer) clearTimeout(inactivityTimer); - inactivityTimer = setTimeout(() => { - isCurrentlyIdle = true; - if (state.chatWs && state.chatWs.readyState === WebSocket.OPEN) { - state.chatWs.send(JSON.stringify({ type: "set_idle_status", is_idle: true })); - } - }, 5 * 60 * 1000); // 5 minutes -} - -document.addEventListener('mousemove', resetInactivityTimer); -document.addEventListener('keydown', resetInactivityTimer); -document.addEventListener('click', resetInactivityTimer); - -async function init() { - lucide.createIcons(); - - try { - const searchParams = new URLSearchParams(location.search); - const jwtToken = searchParams.get("token"); - const refreshToken = searchParams.get("refresh_token"); - - if (jwtToken || refreshToken) { - if (jwtToken) localStorage.setItem("chattz_token", jwtToken); - if (refreshToken) localStorage.setItem("chattz_refresh_token", refreshToken); - - searchParams.delete("token"); - searchParams.delete("refresh_token"); - const nextQuery = searchParams.toString(); - const nextUrl = `${location.pathname}${nextQuery ? `?${nextQuery}` : ""}`; - history.replaceState(null, "", nextUrl); - } - } catch (e) { - console.error("Failed to parse token from URL:", e); - } - - el.loginBtn.onclick = () => { location.href = "/auth/login"; }; - - el.logoutBtn.onclick = async () => { - await leaveVoice(); - localStorage.removeItem("chattz_token"); - localStorage.removeItem("chattz_refresh_token"); - try { await api("/auth/logout", { method: "POST" }); } catch (e) { } - location.reload(); - }; - - el.addGuildBtn.onclick = () => { el.modalContainer.classList.remove("hidden"); }; - el.createInviteBtn.onclick = createInviteLink; - el.modalCancel.onclick = () => { el.modalContainer.classList.add("hidden"); }; - - el.addTextBtn.onclick = () => { - if (!state.selectedGuildId) { - alert("Create or select a server first."); - return; - } - document.querySelector('input[name="channel-kind"][value="text"]').checked = true; - el.channelModal.classList.remove("hidden"); - }; - el.addVoiceBtn.onclick = () => { - if (!state.selectedGuildId) { - alert("Create or select a server first."); - return; - } - document.querySelector('input[name="channel-kind"][value="voice"]').checked = true; - el.channelModal.classList.remove("hidden"); - }; - el.channelModalCancel.onclick = () => { el.channelModal.classList.add("hidden"); }; - - // Mobile Listeners - if (el.mobileMenuBtn) el.mobileMenuBtn.onclick = toggleMobileMenu; - if (el.mobileMembersBtn) el.mobileMembersBtn.onclick = toggleMobileMembers; - if (el.overlay) el.overlay.onclick = closeMobileMenus; - - el.guildForm.onsubmit = async (e) => { - e.preventDefault(); - try { - const guild = await api("/guilds", { - method: "POST", - body: JSON.stringify({ name: el.guildName.value }), - }); - el.guildName.value = ""; - el.modalContainer.classList.add("hidden"); - state.guilds.push(guild); - state.selectedGuildId = guild.id; - state.selectedTextChannelId = null; - state.selectedVoiceChannelId = null; - state.selectedDmUserId = null; - state.selectedDmDisplayName = null; - localStorage.setItem(LAST_GUILD_STORAGE_KEY, guild.id); - renderGuilds(); - await loadChannels(); - await loadGuildMembers(); - await refreshVoicePresence(); - renderMessages([]); - updateHeaderLabels(); - } catch (err) { alert(err.message); } - }; - - el.channelForm.onsubmit = async (e) => { - e.preventDefault(); - if (!state.selectedGuildId) { - alert("Select a server first."); - return; - } - const kindInput = document.querySelector('input[name="channel-kind"]:checked'); - const kind = kindInput ? kindInput.value : "text"; - const channelName = el.channelName.value.trim(); - if (!channelName) { - alert("Channel name is required."); - return; - } - try { - const created = await api("/channels", { - method: "POST", - body: JSON.stringify({ - guild_id: state.selectedGuildId, - name: channelName, - kind: kind, - }), - }); - el.channelName.value = ""; - el.channelModal.classList.add("hidden"); - await loadChannels(); - if (created.kind === "text") { - state.selectedTextChannelId = created.id; - state.selectedVoiceChannelId = null; - state.selectedDmUserId = null; - state.selectedDmDisplayName = null; - renderChannels(); - renderDMs(); - updateHeaderLabels(); - const messages = await api(`/channels/${created.id}/messages?limit=100`); - renderMessages(messages); - } else { - state.selectedVoiceChannelId = created.id; - state.selectedTextChannelId = null; - state.selectedDmUserId = null; - state.selectedDmDisplayName = null; - renderChannels(); - renderDMs(); - updateHeaderLabels(); - } - } catch (err) { alert(err.message); } - }; - - el.messageForm.onsubmit = async (e) => { - e.preventDefault(); - const body = el.messageBody.value.trim(); - if (!body) return; - - try { - if (state.selectedTextChannelId) { - await api(`/channels/${state.selectedTextChannelId}/messages`, { - method: "POST", - body: JSON.stringify({ body }), - }); - } else if (state.selectedDmUserId) { - await api(`/dms/${state.selectedDmUserId}/messages`, { - method: "POST", - body: JSON.stringify({ body }), - }); - } else { - return; - } - el.messageBody.value = ""; - const messages = state.selectedTextChannelId - ? await api(`/channels/${state.selectedTextChannelId}/messages?limit=100`) - : await api(`/dms/${state.selectedDmUserId}/messages?limit=100`); - renderMessages(messages); - await loadDMConversations(); - renderDMs(); - } catch (err) { alert(err.message); } - }; - - el.voiceVideoBtn.onclick = toggleVideo; - el.voiceScreenBtn.onclick = toggleScreenShare; - el.voiceMuteBtn.onclick = toggleMute; - el.voiceLeaveBtn.onclick = leaveVoice; - - el.addSoundBtn.onclick = () => { - el.soundModal.classList.remove("hidden"); - }; - el.soundModalCancel.onclick = () => { - el.soundModal.classList.add("hidden"); - }; - - el.soundForm.onsubmit = async (e) => { - e.preventDefault(); - if (!state.selectedGuildId) return; - - const formData = new FormData(); - formData.append('name', el.soundName.value); - formData.append('icon', el.soundIcon.value); - formData.append('file', el.soundFile.files[0]); - - el.soundSubmitBtn.disabled = true; - el.soundSubmitBtn.textContent = 'Uploading...'; - - try { - // Need to use fetch directly because api() might not handle FormData correctly depending on implementation - const response = await fetch(`/guilds/${state.selectedGuildId}/sounds`, { - method: 'POST', - body: formData - }); - if (!response.ok) { - const err = await response.json(); - throw new Error(err.error || 'Upload failed'); - } - el.soundModal.classList.add("hidden"); - el.soundForm.reset(); - await loadSounds(); - } catch (err) { - alert(err.message); - } finally { - el.soundSubmitBtn.disabled = false; - el.soundSubmitBtn.textContent = 'Add Sound'; - } - }; - - // --- GIF Picker Logic --- - const TENOR_API_KEY = "exTiFGKJ0CzESIHzVQWy3pRO8I1MAdpRomg95DBSu2sg6e7YcHgThMI4giGAx8D0"; // Placeholder / Public key if available, otherwise "YOUR_API_KEY" - const TENOR_CLIENT_KEY = "pavel-discord"; - - el.gifBtn.onclick = () => { - el.gifPicker.classList.remove("hidden"); - searchGifs(""); // Initial featured search - }; - - el.gifPickerClose.onclick = () => { - el.gifPicker.classList.add("hidden"); - }; - - let searchTimeout = null; - el.gifSearchInput.oninput = () => { - clearTimeout(searchTimeout); - searchTimeout = setTimeout(() => { - searchGifs(el.gifSearchInput.value); - }, 500); - }; - - async function searchGifs(query) { - el.gifResults.innerHTML = '
Searching...
'; - const baseUrl = "https://api.klipy.com/v2"; - const endpoint = query - ? `${baseUrl}/search?q=${encodeURIComponent(query)}&key=${TENOR_API_KEY}&client_key=${TENOR_CLIENT_KEY}&limit=20` - : `${baseUrl}/featured?key=${TENOR_API_KEY}&client_key=${TENOR_CLIENT_KEY}&limit=20`; - - try { - const res = await fetch(endpoint); - const data = await res.json(); - renderGifs(data.results); - } catch (err) { - el.gifResults.innerHTML = '
Error loading GIFs
'; - } - } - - function renderGifs(gifs) { - el.gifResults.innerHTML = ""; - if (!gifs || gifs.length === 0) { - el.gifResults.innerHTML = '
No GIFs found
'; - return; - } - - gifs.forEach(gif => { - // Use tinygif for preview, standard gif for sending - const previewUrl = gif.media_formats.tinygif.url; - const fullUrl = gif.media_formats.gif.url; - - const item = document.createElement("div"); - item.className = "gif-item"; - item.innerHTML = ``; - item.onclick = () => { - sendGif(fullUrl); - el.gifPicker.classList.add("hidden"); - el.gifSearchInput.value = ""; - }; - el.gifResults.appendChild(item); - }); - } - - async function sendGif(url) { - try { - if (state.selectedTextChannelId) { - await api(`/channels/${state.selectedTextChannelId}/messages`, { - method: "POST", - body: JSON.stringify({ body: url }), - }); - } else if (state.selectedDmUserId) { - await api(`/dms/${state.selectedDmUserId}/messages`, { - method: "POST", - body: JSON.stringify({ body: url }), - }); - } - const messages = state.selectedTextChannelId - ? await api(`/channels/${state.selectedTextChannelId}/messages?limit=100`) - : await api(`/dms/${state.selectedDmUserId}/messages?limit=100`); - renderMessages(messages); - } catch (err) { - console.error("Failed to send GIF", err); - } - } - - try { - state.me = await api("/me"); - if (state.me && state.me.display_name) { - el.userName.textContent = state.me.display_name; - el.userAvatar.textContent = shortName(state.me.display_name); - } - el.authScreen.classList.add("hidden"); - el.main.classList.remove("hidden"); - - const params = new URLSearchParams(location.search); - const inviteCode = params.get("invite"); - if (inviteCode) { - try { - const joinedGuild = await api(`/invites/${encodeURIComponent(inviteCode)}/join`, { - method: "POST", - }); - localStorage.setItem(LAST_GUILD_STORAGE_KEY, joinedGuild.id); - } catch (err) { - alert(`Failed to join invite: ${err.message}`); - } finally { - params.delete("invite"); - const nextQuery = params.toString(); - const nextUrl = `${location.pathname}${nextQuery ? `?${nextQuery}` : ""}`; - history.replaceState(null, "", nextUrl); - } - } - - await loadGuilds(); - await loadDMConversations(); - - try { - const online = await api("/presence"); - state.onlineUsers = new Set(online); - } catch (err) { console.warn("presence sync failed", err); } - - if (state.guilds.length > 0) { - const lastGuildId = localStorage.getItem(LAST_GUILD_STORAGE_KEY); - const guild = state.guilds.find((g) => g.id === lastGuildId) || state.guilds[0]; - state.selectedGuildId = guild.id; - localStorage.setItem(LAST_GUILD_STORAGE_KEY, guild.id); - renderGuilds(); - await loadChannels(); - await loadGuildMembers(); - await refreshVoicePresence(); - updateHeaderLabels(); - } else { - state.members = []; - renderMembers(); - updateHeaderLabels(); - } - - initChatWs(); - startVoicePresencePolling(); - - // Auto-reconnect to voice if previously joined - const savedVoiceChannel = localStorage.getItem("active_voice_channel"); - if (savedVoiceChannel) { - console.log("Auto-reconnecting to voice channel:", savedVoiceChannel); - state.selectedVoiceChannelId = savedVoiceChannel; - // Wait for components to be ready - setTimeout(() => { - joinVoice().catch(err => console.error("Auto-reconnect failed", err)); - }, 800); - } - - lucide.createIcons(); - } catch (err) { - console.error("init failed", err); - el.authScreen.classList.remove("hidden"); - el.main.classList.add("hidden"); - } -} - -init(); +import "./shared/app-core.js"; diff --git a/static/index.html b/static/index.html index fb34084..5aef5f7 100644 --- a/static/index.html +++ b/static/index.html @@ -265,7 +265,7 @@ - + diff --git a/static/shared/app-core.js b/static/shared/app-core.js new file mode 100644 index 0000000..2b5fa49 --- /dev/null +++ b/static/shared/app-core.js @@ -0,0 +1,1984 @@ +const state = { + me: null, + guilds: [], + channels: [], + dmConversations: [], + members: [], + voicePresence: new Map(), + selectedGuildId: null, + selectedTextChannelId: null, + selectedDmUserId: null, + selectedDmDisplayName: null, + selectedVoiceChannelId: null, + voice: { + ws: null, + joinedChannelId: null, + localStream: null, + rawStream: null, + videoStream: null, + screenStream: null, + peerConnections: new Map(), + videoSenders: new Map(), + screenSenders: new Map(), + muted: false, + sharingVideo: false, + sharingScreen: false, + deepFilterProcessor: null, + deepFilterModule: null, + viewMode: 'chat', // 'chat' or 'video' + iceServers: [{ urls: "stun:stun.l.google.com:19302" }], + peerGainNodes: new Map(), // userId -> GainNode + visibleVolumeSliders: new Set(), // userIds whose sliders are visible + }, + voicePresencePollId: null, + chatWs: null, + lastMessageId: null, + onlineUsers: new Set(), + idleUsers: new Set(), + userVolumes: new Map(), // userId -> volume (0.0 to 2.0) +}; + +// --- Desktop Backend Configuration --- +let API_BASE_URL = ''; // Will be initialized via IPC + +async function initializeConfig() { + try { + if (window.electronAPI?.getConfig) { + const config = await window.electronAPI.getConfig(); + API_BASE_URL = config.backendUrl; + console.log(`Backend initialized: ${API_BASE_URL}`); + } else { + API_BASE_URL = ''; + } + } catch (err) { + console.error("Failed to fetch config via IPC", err); + // Fallback to URL search params if IPC fails (unlikely in desktop app) + const urlParams = new URLSearchParams(window.location.search); + API_BASE_URL = urlParams.get('backend') || ''; + } finally { + // Start the app once config resolution has completed. + init(); + } +} + +function getWsUrl(path) { + let urlStr; + if (!API_BASE_URL) { + const proto = location.protocol === "https:" ? "wss" : "ws"; + urlStr = `${proto}://${location.host}${path}`; + } else { + const url = new URL(API_BASE_URL); + const proto = url.protocol === "https:" ? "wss" : "ws"; + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + urlStr = `${proto}://${url.host}${normalizedPath}`; + } + + const token = localStorage.getItem("chattz_token"); + if (token) { + urlStr += (urlStr.includes('?') ? '&' : '?') + `token=${token}`; + } + return urlStr; +} + + + + + + +const el = { + authScreen: document.getElementById("auth-screen"), + loginBtn: document.getElementById("login-btn"), + main: document.getElementById("main"), + status: document.getElementById("status"), + + // Guilds + guildList: document.getElementById("guild-list"), + addGuildBtn: document.getElementById("add-guild-btn"), + guildTitle: document.getElementById("guild-title"), + createInviteBtn: document.getElementById("create-invite-btn"), + inviteCopiedBadge: document.getElementById("invite-copied-badge"), + + // Channels + channelList: document.getElementById("channel-list"), + voiceChannelList: document.getElementById("voice-channel-list"), + addTextBtn: document.getElementById("add-text-btn"), + addVoiceBtn: document.getElementById("add-voice-btn"), + dmList: document.getElementById("dm-list"), + channelTitle: document.getElementById("channel-title"), + + // Messages + messageList: document.getElementById("message-list"), + messageForm: document.getElementById("message-form"), + messageBody: document.getElementById("message-body"), + + // User Panel + userName: document.getElementById("user-name"), + userAvatar: document.getElementById("user-avatar"), + logoutBtn: document.getElementById("logout-btn"), + updateNotifier: document.getElementById("update-notifier"), + updateDownloadBtn: document.getElementById("update-download-btn"), + updateInstallBtn: document.getElementById("update-install-btn"), + + // Voice Connection + voiceConnection: document.getElementById("voice-connection"), + vcChannelName: document.getElementById("vc-channel-name"), + voiceVideoBtn: document.getElementById("voice-video-btn"), + voiceScreenBtn: document.getElementById("voice-screen-btn"), + voiceMuteBtn: document.getElementById("voice-mute-btn"), + voiceLeaveBtn: document.getElementById("voice-leave-btn"), + videoGrid: document.getElementById("video-grid"), + messageForm: document.getElementById("message-form"), + messageInputWrapper: document.querySelector(".chat-input-wrapper"), + + // Members + memberList: document.getElementById("member-list"), + + // Modals + modalContainer: document.getElementById("modal-container"), + guildForm: document.getElementById("guild-form"), + guildName: document.getElementById("guild-name"), + modalCancel: document.getElementById("modal-cancel"), + + channelModal: document.getElementById("channel-modal"), + channelForm: document.getElementById("channel-form"), + channelName: document.getElementById("channel-name"), + channelModalCancel: document.getElementById("channel-modal-cancel"), + + // Sound Board + soundboard: document.getElementById('soundboard'), + soundboardGrid: document.getElementById('soundboard-grid'), + addSoundBtn: document.getElementById('add-sound-btn'), + soundModal: document.getElementById('sound-modal'), + soundForm: document.getElementById('sound-form'), + soundName: document.getElementById('sound-name'), + soundIcon: document.getElementById('sound-icon'), + soundFile: document.getElementById('sound-file'), + soundModalCancel: document.getElementById('sound-modal-cancel'), + soundSubmitBtn: document.getElementById('sound-submit-btn'), + + // Mobile + mobileMenuBtn: document.getElementById("mobile-menu-btn"), + mobileMembersBtn: document.getElementById("mobile-members-btn"), + overlay: document.getElementById("overlay"), + utilitySidebar: document.getElementById("utility-sidebar"), + + // GIF Picker + gifBtn: document.getElementById("gif-btn"), + gifPicker: document.getElementById("gif-picker"), + gifPickerClose: document.getElementById("gif-picker-close"), + gifSearchInput: document.getElementById("gif-search-input"), + gifResults: document.getElementById("gif-results"), +}; + +// --- API Helpers --- + +async function api(path, options = {}) { + const fullUrl = API_BASE_URL ? (path.startsWith('http') ? path : `${API_BASE_URL}${path}`) : path; + + const headers = { + "content-type": "application/json", + ...(options.headers || {}), + }; + + const token = localStorage.getItem("chattz_token"); + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + + let res = await fetch(fullUrl, { + ...options, + headers, + }); + + // Handle 401 Unauthorized via Refresh Token + if (res.status === 401 && !path.includes('/auth/refresh')) { + const refreshToken = localStorage.getItem("chattz_refresh_token"); + if (refreshToken) { + try { + const refreshUrl = API_BASE_URL ? `${API_BASE_URL}/auth/refresh` : '/auth/refresh'; + const refreshRes = await fetch(refreshUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ refresh_token: refreshToken }) + }); + + if (refreshRes.ok) { + const newTokens = await refreshRes.json(); + localStorage.setItem("chattz_token", newTokens.access_token); + localStorage.setItem("chattz_refresh_token", newTokens.refresh_token); + + // Retry original request with new token + headers["Authorization"] = `Bearer ${newTokens.access_token}`; + res = await fetch(fullUrl, { ...options, headers }); + } else { + // Both tokens invalid/expired, wipe out to force clear state + throw new Error("Refresh token expired or invalid"); + } + } catch (err) { + localStorage.removeItem("chattz_token"); + localStorage.removeItem("chattz_refresh_token"); + location.href = API_BASE_URL ? `${API_BASE_URL}/auth/login` : "/auth/login"; + throw new Error("Session expired, please log in again"); + } + } + } + + if (!res.ok) { + let detail = "request failed"; + try { + const body = await res.json(); + detail = body.error || detail; + } catch { } + throw new Error(`${res.status}: ${detail}`); + } + + if (res.status === 204) return null; + return res.json(); +} + +// --- Utils --- + +function shortName(name) { + if (!name || typeof name !== 'string') return "?"; + const trimmed = name.trim(); + if (!trimmed) return "?"; + const words = trimmed.split(/\s+/).filter(w => w.length > 0).slice(0, 2); + if (words.length === 0) return "?"; + if (words.length === 1) return words[0].substring(0, 2).toUpperCase(); + return words.map((w) => w[0]?.toUpperCase() || "").join(""); +} + +function escapeHtml(s) { + if (s === null || s === undefined) return ""; + return String(s) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function formatMessageBody(body) { + const escaped = escapeHtml(body); + // Detect GIF URLs (simple regex for demo) + const urlRegex = /(https?:\/\/[^\s]+(?:\.gif|\/giphy\.gif)[^\s]*)/gi; + if (urlRegex.test(body)) { + return escaped.replace(urlRegex, (url) => { + return `
`; + }); + } + return escaped; +} + +function formatDate(isoString) { + const d = new Date(isoString); + const now = new Date(); + const isToday = d.toDateString() === now.toDateString(); + + if (isToday) { + return `Today at ${d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`; + } + return d.toLocaleDateString(); +} + +let audioCtx = null; + +// Global interaction listener to unlock AudioContext (autoplay policy) +window.addEventListener('click', () => { + if (audioCtx && audioCtx.state === 'suspended') { + audioCtx.resume(); + } +}, { once: true }); + +function playSound(type) { + try { + if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)(); + if (audioCtx.state === 'suspended') audioCtx.resume(); + + const sounds = { + message: { freq: 880, type: 'sine', duration: 0.1, volume: 0.1 }, + dm: { freq: [660, 880], type: 'sine', duration: 0.15, volume: 0.1 }, + join: { freq: [440, 880], type: 'sine', duration: 0.2, volume: 0.1 }, + leave: { freq: [880, 440], type: 'sine', duration: 0.2, volume: 0.1 }, + 'peer-join': { freq: [660, 990], type: 'sine', duration: 0.15, volume: 0.05 }, + 'peer-leave': { freq: [990, 660], type: 'sine', duration: 0.15, volume: 0.05 }, + }; + + const s = sounds[type]; + if (!s) return; + + const osc = audioCtx.createOscillator(); + const gain = audioCtx.createGain(); + + osc.type = s.type; + if (Array.isArray(s.freq)) { + osc.frequency.setValueAtTime(s.freq[0], audioCtx.currentTime); + osc.frequency.exponentialRampToValueAtTime(s.freq[1], audioCtx.currentTime + s.duration); + } else { + osc.frequency.setValueAtTime(s.freq, audioCtx.currentTime); + } + + gain.gain.setValueAtTime(s.volume, audioCtx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + s.duration); + + osc.connect(gain); + gain.connect(audioCtx.destination); + + osc.start(); + osc.stop(audioCtx.currentTime + s.duration); + } catch (err) { + console.warn("playSound failed", err); + } +} +const LAST_GUILD_STORAGE_KEY = "chattz:lastGuildId"; + +// --- Renderers --- + +function renderGuilds() { + el.guildList.innerHTML = ""; + + for (const guild of state.guilds) { + const btn = document.createElement("button"); + btn.className = `guild-pill ${state.selectedGuildId === guild.id ? "active" : ""}`; + btn.title = guild.name; + btn.textContent = shortName(guild.name); + btn.onclick = async () => { + state.selectedGuildId = guild.id; + state.selectedTextChannelId = null; + state.selectedVoiceChannelId = null; + state.selectedDmUserId = null; + state.selectedDmDisplayName = null; + localStorage.setItem(LAST_GUILD_STORAGE_KEY, guild.id); + renderGuilds(); + renderDMs(); + await loadChannels(); + await loadGuildMembers(); + await refreshVoicePresence(); + startVoicePresencePolling(); + renderMessages([]); + updateHeaderLabels(); + }; + el.guildList.appendChild(btn); + } +} + +function renderChannels() { + el.channelList.innerHTML = ""; + el.voiceChannelList.innerHTML = ""; + + for (const channel of state.channels) { + const row = document.createElement("button"); + row.className = `channel-row ${(channel.kind === 'text' && state.selectedTextChannelId === channel.id) || + (channel.kind === 'voice' && state.selectedVoiceChannelId === channel.id) ? "active" : "" + }`; + + const iconName = channel.kind === 'text' ? 'hash' : 'volume-2'; + row.innerHTML = ` ${escapeHtml(channel.name)}`; + + row.onclick = async () => { + if (channel.kind === 'text') { + state.selectedDmUserId = null; + state.selectedDmDisplayName = null; + state.selectedTextChannelId = channel.id; + state.voice.viewMode = 'chat'; + renderChannels(); + renderDMs(); + updateView(); + updateHeaderLabels(); + const messages = await api(`/channels/${channel.id}/messages?limit=100`); + renderMessages(messages); + } else { + state.selectedVoiceChannelId = channel.id; + state.voice.viewMode = 'video'; + renderChannels(); + renderDMs(); + updateView(); + updateHeaderLabels(); + try { + await joinVoice(); + } catch (err) { + console.error("joinVoice failed from channel click", err); + alert(`Voice join failed: ${err?.message || err}`); + } + } + if (window.innerWidth <= 768) closeMobileMenus(); + }; + + if (channel.kind === 'text') { + el.channelList.appendChild(row); + } else { + el.voiceChannelList.appendChild(row); + + const participants = state.voicePresence.get(channel.id) || []; + if (participants.length > 0) { + const pList = document.createElement("div"); + pList.className = "voice-row-members channel-list"; + pList.style.paddingLeft = "24px"; + for (const p of participants) { + const pRow = document.createElement("div"); + pRow.className = `channel-row ${p.is_speaking ? 'voice-speaking' : ''}`; + pRow.style.padding = "2px 8px"; + pRow.style.flexWrap = "wrap"; + + let sliderHtml = ''; + if (p.user_id !== state.me.id) { + const vol = state.userVolumes.get(p.user_id) ?? 1.0; + const isVisible = state.voice.visibleVolumeSliders.has(p.user_id); + sliderHtml = ` +
+ + + ${Math.round(vol * 100)}% +
+ `; + } + + pRow.innerHTML = ` +
+
${shortName(p.display_name)}
+ ${escapeHtml(p.display_name)} + ${p.is_muted ? '' : ''} +
+ ${sliderHtml} + `; + + const slider = pRow.querySelector('.volume-slider'); + if (slider) { + slider.addEventListener('input', (e) => { + const val = parseFloat(e.target.value); + state.userVolumes.set(p.user_id, val); + pRow.querySelector('.vol-pct').textContent = `${Math.round(val * 100)}%`; + + const gainNode = state.voice.peerGainNodes.get(p.user_id); + if (gainNode) { + gainNode.gain.setTargetAtTime(val, state.voice.audioContext.currentTime, 0.05); + } + }); + // Stop propagation to prevent joining channel again when clicking slider + slider.addEventListener('click', (e) => e.stopPropagation()); + } + + const toggleVolumeSlider = (e) => { + if (p.user_id === state.me.id) return; + e.preventDefault(); + e.stopPropagation(); + if (state.voice.visibleVolumeSliders.has(p.user_id)) { + state.voice.visibleVolumeSliders.delete(p.user_id); + } else { + state.voice.visibleVolumeSliders.add(p.user_id); + } + renderChannels(); + }; + + // Electron can swallow contextmenu events on some platforms; use + // right-button mousedown as a reliable fallback for slider toggle. + pRow.addEventListener('contextmenu', toggleVolumeSlider); + pRow.addEventListener('mousedown', (e) => { + if (e.button === 2) toggleVolumeSlider(e); + }); + + pList.appendChild(pRow); + } + el.voiceChannelList.appendChild(pList); + } + } + } + lucide.createIcons(); +} + +function renderDMs() { + el.dmList.innerHTML = ""; + for (const dm of state.dmConversations) { + const isOnline = state.onlineUsers.has(dm.user_id); + const isIdle = state.idleUsers.has(dm.user_id); + const row = document.createElement("button"); + row.className = `channel-row ${state.selectedDmUserId === dm.user_id ? "active" : ""}`; + row.innerHTML = ` +
+ +
+
+ ${escapeHtml(dm.display_name)} + `; + row.onclick = async () => { + state.selectedDmUserId = dm.user_id; + state.selectedDmDisplayName = dm.display_name; + state.selectedTextChannelId = null; + state.selectedVoiceChannelId = null; + state.voice.viewMode = 'chat'; + renderChannels(); + renderDMs(); + updateView(); + updateHeaderLabels(); + const messages = await api(`/dms/${dm.user_id}/messages?limit=100`); + renderMessages(messages); + }; + el.dmList.appendChild(row); + } + + lucide.createIcons(); +} + +function renderMessages(messages) { + el.messageList.innerHTML = ""; + let lastAuthorId = null; + let lastTime = null; + + for (const m of messages.slice().reverse()) { + const row = document.createElement("div"); + const mDate = new Date(m.created_at); + const isGrouped = lastAuthorId === m.author_user_id && + lastTime && (mDate - lastTime < 300000); // 5 minutes + + row.className = `msg ${isGrouped ? "msg-grouped" : ""}`; + + const displayName = m.author_display_name || "Unknown User"; + + if (isGrouped) { + row.innerHTML = `
${formatMessageBody(m.body)}
`; + } else { + row.innerHTML = ` +
${shortName(displayName)}
+
+
+ ${escapeHtml(displayName)} + ${formatDate(m.created_at)} +
+
${formatMessageBody(m.body)}
+
+ `; + } + el.messageList.appendChild(row); + lastAuthorId = m.author_user_id; + lastTime = mDate; + } + + if (messages.length > 0) { + state.lastMessageId = messages[0].id; + } + + el.messageList.scrollTop = el.messageList.scrollHeight; +} + +function renderMembers() { + el.memberList.innerHTML = ""; + for (const m of state.members) { + const isOnline = state.onlineUsers.has(m.id); + const isIdle = state.idleUsers.has(m.id); + const row = document.createElement("div"); + row.className = "member-row"; + row.innerHTML = ` +
+ ${shortName(m.display_name)} +
+
+
${escapeHtml(m.display_name)}
+ `; + row.style.cursor = "pointer"; + row.onclick = async () => { + if (state.me && m.id === state.me.id) return; + state.selectedDmUserId = m.id; + state.selectedDmDisplayName = m.display_name; + state.selectedTextChannelId = null; + state.selectedVoiceChannelId = null; + state.voice.viewMode = 'chat'; + renderChannels(); + renderDMs(); + updateView(); + updateHeaderLabels(); + const messages = await api(`/dms/${m.id}/messages?limit=100`); + renderMessages(messages); + }; + el.memberList.appendChild(row); + } +} + +function updateHeaderLabels() { + const guild = state.guilds.find((g) => g.id === state.selectedGuildId); + el.guildTitle.textContent = guild ? guild.name : "No server selected"; + + if (state.selectedDmUserId) { + const dmFromConversations = state.dmConversations.find((u) => u.user_id === state.selectedDmUserId); + const dmFromMembers = state.members.find((m) => m.id === state.selectedDmUserId); + const displayName = dmFromConversations?.display_name || dmFromMembers?.display_name || state.selectedDmDisplayName; + const dmName = displayName ? `@${displayName}` : "Direct Message"; + el.channelTitle.textContent = dmName; + el.messageBody.placeholder = displayName + ? `Message @${displayName}` + : "Message"; + return; + } + + if (state.voice.viewMode === 'video' && state.selectedVoiceChannelId) { + const channel = state.channels.find((c) => c.id === state.selectedVoiceChannelId); + el.channelTitle.textContent = channel ? channel.name : "Voice Arena"; + return; + } + + const channel = state.channels.find((c) => c.id === state.selectedTextChannelId); + el.channelTitle.textContent = channel ? channel.name : "Select a channel"; + el.messageBody.placeholder = channel ? `Message #${channel.name}` : "Select a channel"; +} + +async function createInviteLink() { + if (!state.selectedGuildId) { + alert("Select a server first."); + return; + } + try { + const invite = await api(`/guilds/${state.selectedGuildId}/invites`, { + method: "POST", + body: JSON.stringify({ max_uses: 50, expires_in_hours: 24 }), + }); + // Use the backend URL if available, otherwise fallback to current origin + const base = API_BASE_URL ? new URL(API_BASE_URL).origin : location.origin; + const link = `${base}/?invite=${encodeURIComponent(invite.code)}`; + + // Use Electron's native clipboard API if available (defined in preload.js) + if (window.electronAPI && window.electronAPI.copyToClipboard) { + try { + await window.electronAPI.copyToClipboard(link); + showCopiedBadge(); + return; + } catch (e) { + console.error("Electron clipboard write failed:", e); + } + } + + // Fallback to navigator.clipboard + try { + await navigator.clipboard.writeText(link); + showCopiedBadge(); + } catch (err) { + console.error("Navigator clipboard write failed:", err); + prompt("Copy invite link:", link); + } + } catch (err) { + alert(err.message); + } +} + +function showCopiedBadge() { + el.inviteCopiedBadge.classList.remove("hidden"); + setTimeout(() => { + el.inviteCopiedBadge.classList.add("hidden"); + }, 2000); +} + +// --- Logic --- + +async function loadGuilds() { + state.guilds = await api("/guilds"); + renderGuilds(); +} + +async function loadDMConversations() { + state.dmConversations = await api("/dms"); + renderDMs(); +} + +async function loadGuildMembers() { + if (!state.selectedGuildId) { + state.members = []; + renderMembers(); + return; + } + state.members = await api(`/guilds/${state.selectedGuildId}/members`); + renderMembers(); +} + +async function loadChannels() { + if (!state.selectedGuildId) { + state.channels = []; + renderChannels(); + return; + } + + state.channels = await api(`/guilds/${state.selectedGuildId}/channels`); + renderChannels(); +} + +async function refreshVoicePresence() { + if (!state.selectedGuildId) { + state.voicePresence.clear(); + renderChannels(); + return; + } + + try { + const res = await api(`/guilds/${state.selectedGuildId}/voice-presence`); + state.voicePresence.clear(); + for (const entry of res.channels || []) { + state.voicePresence.set(entry.channel_id, entry.participants || []); + } + renderChannels(); + } catch (err) { + console.warn("voice presence failed", err); + } +} + +function startVoicePresencePolling() { + if (state.voicePresencePollId) clearInterval(state.voicePresencePollId); + state.voicePresencePollId = setInterval(() => { + refreshVoicePresence().catch(() => { }); + }, 3000); +} + +function initChatWs() { + if (state.chatWs) state.chatWs.close(); + const wsUrl = getWsUrl('/ws'); + const ws = new WebSocket(wsUrl); + state.chatWs = ws; + + ws.onmessage = (event) => { + const msg = JSON.parse(event.data); + const authorId = msg.message?.author_user_id; + const isFromMe = authorId && state.me && authorId === state.me.id; + + if (msg.type === "message_created") { + if (!isFromMe) playSound('message'); + if (state.selectedTextChannelId === msg.channel_id) { + api(`/channels/${state.selectedTextChannelId}/messages?limit=100`).then(renderMessages); + } + } else if (msg.type === "dm_created") { + if (!isFromMe) playSound('dm'); + if (state.selectedDmUserId === msg.other_user_id) { + api(`/dms/${state.selectedDmUserId}/messages?limit=100`).then(renderMessages); + playSound('dm'); + loadDMConversations().then(renderDMs); + } else { + loadDMConversations().then(renderDMs); + } + } else if (msg.type === "user_presence") { + if (msg.online) { + state.onlineUsers.add(msg.user_id); + if (msg.idle) { + state.idleUsers.add(msg.user_id); + } else { + state.idleUsers.delete(msg.user_id); + } + } else { + state.onlineUsers.delete(msg.user_id); + state.idleUsers.delete(msg.user_id); + } + renderMembers(); + renderDMs(); + } + }; + + ws.onclose = () => { + console.log("Chat WS closed, reconnecting..."); + setTimeout(initChatWs, 3000); + }; +} + +// --- Voice --- + +function getVoiceWsUrl(channelId) { + return getWsUrl(`/channels/${channelId}/voice/ws`); +} + +function shouldInitiateOffer(peerId) { + if (!state.me || !state.me.id) return false; + return state.me.id > peerId; +} + +function stopAndClearAudioPipeline() { + if (state.voice.deepFilterProcessor) { + state.voice.deepFilterProcessor.destroy(); + state.voice.deepFilterProcessor = null; + } + if (state.voice.localStream) { + for (const track of state.voice.localStream.getTracks()) track.stop(); + } + if (state.voice.rawStream && state.voice.rawStream !== state.voice.localStream) { + for (const track of state.voice.rawStream.getTracks()) track.stop(); + } + if (state.voice.audioContext) { + state.voice.audioContext.close().catch(() => { }); + } + state.voice.localStream = null; + state.voice.rawStream = null; + state.voice.audioContext = null; +} + +function stopAndClearVideoPipeline() { + if (state.voice.videoStream) { + for (const track of state.voice.videoStream.getTracks()) track.stop(); + } + state.voice.videoStream = null; + state.voice.sharingVideo = false; + document.getElementById(`video-${state.me.id}-camera`)?.parentElement?.remove(); +} + +function stopAndClearScreenPipeline() { + if (state.voice.screenStream) { + for (const track of state.voice.screenStream.getTracks()) track.stop(); + } + state.voice.screenStream = null; + state.voice.sharingScreen = false; + document.getElementById(`video-${state.me.id}-screen`)?.parentElement?.remove(); +} + +function updateVideoGridVisibility() { + // Persistence logic: we always update the grid visibility based on participants + // however the actual container visibility in the UI is managed by updateView() +} + +function updateView() { + if (state.voice.viewMode === 'video') { + el.videoGrid.classList.remove("hidden"); + el.messageList.classList.add("hidden"); + el.messageInputWrapper.classList.add("hidden"); + } else { + el.videoGrid.classList.add("hidden"); + el.messageList.classList.remove("hidden"); + el.messageInputWrapper.classList.remove("hidden"); + } +} + +function renderVideo(peerId, displayName, stream, source) { + const videoId = `video-${peerId}-${source}`; + let videoEl = document.getElementById(videoId); + if (!videoEl) { + const container = document.createElement("div"); + container.className = "video-item"; + const label = source === 'screen' ? `${escapeHtml(displayName)}'s Screen` : escapeHtml(displayName); + container.innerHTML = ` + +
${label}
+ `; + container.onclick = () => { + const isFullscreen = container.classList.contains('fullscreen'); + // Reset all + document.querySelectorAll('.video-item').forEach(el => el.classList.remove('fullscreen')); + + if (isFullscreen) { + el.videoGrid.classList.remove('has-fullscreen'); + } else { + container.classList.add('fullscreen'); + el.videoGrid.classList.add('has-fullscreen'); + } + }; + + el.videoGrid.appendChild(container); + videoEl = container.querySelector("video"); + } + videoEl.srcObject = stream; +} + +async function buildAudioPipeline(rawStream) { + const ctx = new AudioContext(); + state.voice.audioContext = ctx; + const source = ctx.createMediaStreamSource(rawStream); + let processedSource = source; + + try { + const deepFilter = await getDeepFilterModule(); + const processor = new deepFilter.DeepFilterNet3Core({ + sampleRate: 48000, + noiseReductionLevel: 70, + assetConfig: { + cdnUrl: resolveDeepFilterAssetsBaseUrl(), + }, + }); + await processor.initialize(); + const workletNode = await processor.createAudioWorkletNode(ctx); + processor.setNoiseSuppressionEnabled(true); + source.connect(workletNode); + processedSource = workletNode; + state.voice.deepFilterProcessor = processor; + console.info('DeepFilterNet3 enabled'); + } catch (err) { + console.warn('DeepFilterNet3 unavailable, falling back to raw mic audio', err); + state.voice.deepFilterProcessor = null; + } + + // Metering/Speaking detection with adaptive threshold and hysteresis. + const analyser = ctx.createAnalyser(); + analyser.fftSize = 1024; + processedSource.connect(analyser); + + const supportsFloatTimeDomain = typeof analyser.getFloatTimeDomainData === 'function'; + const timeData = supportsFloatTimeDomain ? new Float32Array(analyser.fftSize) : new Uint8Array(analyser.fftSize); + let localIsSpeaking = false; + let speechFrames = 0; + let silenceFrames = 0; + let noiseFloor = 0.004; + let levelEma = 0; + + const checkVolume = () => { + if (!state.voice.audioContext || state.voice.audioContext.state === 'closed') return; + + try { + if (supportsFloatTimeDomain) { + analyser.getFloatTimeDomainData(timeData); + } else { + analyser.getByteTimeDomainData(timeData); + } + } catch (err) { + setTimeout(checkVolume, 60); + return; + } + + let sumSquares = 0; + let peak = 0; + for (let i = 0; i < timeData.length; i++) { + const v = supportsFloatTimeDomain + ? timeData[i] + : (timeData[i] - 128) / 128; + sumSquares += v * v; + const abs = Math.abs(v); + if (abs > peak) peak = abs; + } + + const rms = Math.sqrt(sumSquares / timeData.length); + const level = Math.max(rms, peak * 0.5); + levelEma = levelEma * 0.75 + level * 0.25; + + if (!localIsSpeaking) { + // Learn room noise slowly while idle. + noiseFloor = noiseFloor * 0.98 + levelEma * 0.02; + } else { + // Do not let noise floor jump up while speaking. + noiseFloor = Math.min(noiseFloor, levelEma); + } + + const startThreshold = Math.max(noiseFloor * 2.2, 0.010); + const stopThreshold = Math.max(noiseFloor * 1.5, 0.006); + + if (levelEma > startThreshold) { + speechFrames = Math.min(speechFrames + 1, 8); + silenceFrames = 0; + } else if (levelEma < stopThreshold) { + silenceFrames = Math.min(silenceFrames + 1, 8); + speechFrames = Math.max(speechFrames - 1, 0); + } + + const newSpeakingState = localIsSpeaking ? silenceFrames < 3 : speechFrames >= 2; + + if (newSpeakingState !== localIsSpeaking) { + localIsSpeaking = newSpeakingState; + if (state.voice.ws && state.voice.ws.readyState === WebSocket.OPEN) { + state.voice.ws.send(JSON.stringify({ type: 'set_speaking_status', is_speaking: localIsSpeaking })); + } + // Local UI update + state.voicePresence.get(state.selectedVoiceChannelId)?.forEach(p => { + if (p.user_id === state.me.id) p.is_speaking = localIsSpeaking; + }); + renderChannels(); + const myVideo = document.getElementById(`video-${state.me.id}-camera`) || document.getElementById(`video-${state.me.id}-screen`); + if (myVideo) { + myVideo.parentElement.classList.toggle('speaking', localIsSpeaking); + } + } + setTimeout(checkVolume, 60); + }; + checkVolume(); + + const destination = ctx.createMediaStreamDestination(); + processedSource.connect(destination); + return destination.stream; +} + +function resolveDeepFilterModuleUrl() { + if (location.protocol === 'file:') { + return new URL('./vendor/deepfilternet3-noise-filter.esm.js', location.href).toString(); + } + return '/static/vendor/deepfilternet3-noise-filter.esm.js'; +} + +function resolveDeepFilterAssetsBaseUrl() { + if (location.protocol === 'file:') { + return new URL('./vendor/deepfilternet3', location.href).toString(); + } + return '/static/vendor/deepfilternet3'; +} + +async function getDeepFilterModule() { + if (state.voice.deepFilterModule) return state.voice.deepFilterModule; + const moduleUrl = resolveDeepFilterModuleUrl(); + state.voice.deepFilterModule = await import(moduleUrl); + return state.voice.deepFilterModule; +} + +async function createLocalVoiceStream() { + const constraints = { + audio: { + channelCount: 1, + sampleRate: 48000, + echoCancellation: true, + noiseSuppression: false, + autoGainControl: false, + }, + video: false, + }; + const rawStream = await navigator.mediaDevices.getUserMedia(constraints); + let localStream = rawStream; + try { + localStream = await buildAudioPipeline(rawStream); + } catch (err) { + console.warn('voice audio pipeline failed, using raw mic stream', err); + } + state.voice.rawStream = rawStream; + state.voice.localStream = localStream; + if (state.voice.muted) { + state.voice.localStream.getAudioTracks().forEach((t) => { + t.enabled = false; + }); + } +} + +function ensurePeerConnection(peerId) { + if (state.voice.peerConnections.has(peerId)) { + return state.voice.peerConnections.get(peerId); + } + + const pc = new RTCPeerConnection({ + iceServers: state.voice.iceServers + }); + + pc.makingOffer = false; + pc.ignoreOffer = false; + pc.polite = !shouldInitiateOffer(peerId); + + // 1. Audio + if (state.voice.localStream) { + state.voice.localStream.getAudioTracks().forEach(track => { + pc.addTrack(track, state.voice.localStream); + }); + } else { + pc.addTransceiver('audio', { direction: 'recvonly' }); + } + + // 2. Camera Video + if (state.voice.videoStream) { + state.voice.videoStream.getVideoTracks().forEach(track => { + const sender = pc.addTrack(track, state.voice.videoStream); + state.voice.videoSenders.set(peerId, sender); + }); + } + + // 3. Screen Video + if (state.voice.screenStream) { + state.voice.screenStream.getVideoTracks().forEach(track => { + const sender = pc.addTrack(track, state.voice.screenStream); + state.voice.screenSenders.set(peerId, sender); + }); + } + + pc.onicecandidate = (event) => { + if (!event.candidate || !state.voice.ws) return; + state.voice.ws.send(JSON.stringify({ + type: "signal", + to_user_id: peerId, + kind: "ice", + data: event.candidate, + })); + }; + + pc.oniceconnectionstatechange = async () => { + const iceState = pc.iceConnectionState; + console.debug(`voice ice state (${peerId}):`, iceState); + if (iceState !== "failed" || pc.restartingIce) return; + pc.restartingIce = true; + try { + const offer = await pc.createOffer({ iceRestart: true }); + await pc.setLocalDescription(offer); + if (state.voice.ws && state.voice.ws.readyState === WebSocket.OPEN) { + state.voice.ws.send(JSON.stringify({ + type: "signal", + to_user_id: peerId, + kind: "offer", + data: pc.localDescription, + })); + } + } catch (err) { + console.warn("ICE restart failed", err); + } finally { + pc.restartingIce = false; + } + }; + + pc.ontrack = (event) => { + const stream = event.streams[0]; + if (event.track.kind === "audio") { + let audio = document.getElementById(`audio-${peerId}`); + if (!audio) { + audio = document.createElement("audio"); + audio.id = `audio-${peerId}`; + audio.autoplay = true; + audio.playsInline = true; + document.body.appendChild(audio); + } + audio.muted = false; + audio.srcObject = new MediaStream([event.track]); + const tryPlay = () => { + const playPromise = audio.play(); + if (playPromise && typeof playPromise.catch === "function") { + playPromise.catch((err) => { + console.warn(`remote audio playback blocked for ${peerId}`, err); + }); + } + }; + audio.onloadedmetadata = tryPlay; + audio.oncanplay = tryPlay; + tryPlay(); + + // Web Audio Pipeline for Volume Boosting + if (!state.voice.audioContext) { + state.voice.audioContext = new (window.AudioContext || window.webkitAudioContext)(); + } + const ctx = state.voice.audioContext; + if (ctx.state === 'suspended') ctx.resume(); + + const sourceNode = ctx.createMediaStreamSource(audio.srcObject); + const gainNode = ctx.createGain(); + + const currentVolume = state.userVolumes.get(peerId) ?? 1.0; + gainNode.gain.setValueAtTime(currentVolume, ctx.currentTime); + + sourceNode.connect(gainNode); + gainNode.connect(ctx.destination); + + state.voice.peerGainNodes.set(peerId, gainNode); + + // Mute the original element as we play through Web Audio destination + audio.volume = 0; + audio.muted = true; + } else if (event.track.kind === "video") { + const peer = state.members.find(m => m.id === peerId) || { display_name: "Unknown" }; + renderVideo(peerId, peer.display_name, stream, event.track.id); + + event.track.onmute = () => { + document.getElementById(`video-${peerId}-${event.track.id}`)?.parentElement?.remove(); + }; + event.track.onended = () => { + document.getElementById(`video-${peerId}-${event.track.id}`)?.parentElement?.remove(); + }; + } + }; + + pc.onnegotiationneeded = async () => { + try { + pc.makingOffer = true; + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + state.voice.ws.send(JSON.stringify({ + type: "signal", + to_user_id: peerId, + kind: "offer", + data: pc.localDescription, + })); + } catch (err) { + console.error("negotiation failed", err); + } finally { + pc.makingOffer = false; + } + }; + + state.voice.peerConnections.set(peerId, pc); + return pc; +} + +async function attachLocalAudioToPeerConnections() { + if (!state.voice.localStream) return; + const localTrack = state.voice.localStream.getAudioTracks()[0]; + if (!localTrack) return; + + await Promise.all( + Array.from(state.voice.peerConnections.values()).map(async (pc) => { + const audioTransceiver = pc.getTransceivers().find((t) => t.receiver?.track?.kind === 'audio'); + if (audioTransceiver?.sender) { + await audioTransceiver.sender.replaceTrack(localTrack); + if (audioTransceiver.direction === 'recvonly') { + audioTransceiver.direction = 'sendrecv'; + } + } else { + pc.addTrack(localTrack, state.voice.localStream); + } + }) + ); +} + +async function handleSignal(fromPeerId, kind, data) { + const pc = ensurePeerConnection(fromPeerId); + + try { + if (kind === "offer") { + const offerCollision = pc.makingOffer || pc.signalingState !== "stable"; + pc.ignoreOffer = !pc.polite && offerCollision; + if (pc.ignoreOffer) return; + + await pc.setRemoteDescription(new RTCSessionDescription(data)); + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + state.voice.ws.send(JSON.stringify({ + type: "signal", + to_user_id: fromPeerId, + kind: "answer", + data: pc.localDescription, + })); + } else if (kind === "answer") { + await pc.setRemoteDescription(new RTCSessionDescription(data)); + } else if (kind === "ice") { + try { + await pc.addIceCandidate(data ? new RTCIceCandidate(data) : null); + } catch (err) { + if (!pc.ignoreOffer) { + console.warn("failed to add ice candidate", err); + } + } + } + } catch (err) { + console.error("handleSignal failed", err); + } +} + +async function joinVoice() { + if (!state.selectedVoiceChannelId) { + alert('No voice channel selected.'); + return; + } + if (state.voice.joinedChannelId === state.selectedVoiceChannelId && + state.voice.ws && + state.voice.ws.readyState === WebSocket.OPEN) { + return; + } + + await leaveVoice(); + + el.voiceConnection.classList.remove('hidden'); + el.vcChannelName.textContent = 'Connecting...'; + + const ws = new WebSocket(getVoiceWsUrl(state.selectedVoiceChannelId)); + const currentWs = ws; + let wsOpened = false; + let micUnavailable = false; + + state.voice.ws = ws; + state.voice.joinedChannelId = state.selectedVoiceChannelId; + localStorage.setItem("active_voice_channel", state.selectedVoiceChannelId); + + // Start mic setup in parallel so channel join is not blocked by device init. + createLocalVoiceStream() + .then(async () => { + if (state.voice.ws !== currentWs) { + stopAndClearAudioPipeline(); + return; + } + try { + await attachLocalAudioToPeerConnections(); + } catch (err) { + console.warn('failed to attach local audio to existing peer connections', err); + } + }) + .catch((err) => { + micUnavailable = true; + console.warn('failed to initialize local voice stream, joining as listen-only', err); + state.voice.localStream = null; + state.voice.rawStream = null; + if (state.voice.audioContext) { + state.voice.audioContext.close().catch(() => { }); + state.voice.audioContext = null; + } + }); + + const connectTimer = setTimeout(() => { + if (!wsOpened && state.voice.ws === currentWs) { + console.error('voice websocket connect timeout'); + try { currentWs.close(); } catch (_) { } + el.vcChannelName.textContent = 'Connection timeout'; + alert('Could not connect to voice channel (timeout). Please try again.'); + } + }, 5000); + + ws.onopen = () => { + if (state.voice.ws !== currentWs) return; + wsOpened = true; + clearTimeout(connectTimer); + const channel = state.channels.find(c => c.id === state.selectedVoiceChannelId); + el.vcChannelName.textContent = channel ? channel.name : "Voice"; + if (micUnavailable) { + console.warn('Voice joined in listen-only mode because microphone is unavailable.'); + } + el.voiceConnection.classList.remove("hidden"); + el.soundboard.classList.remove("hidden"); + playSound('join'); + refreshVoicePresence().catch(() => { }); + loadSounds().catch(() => { }); + }; + + ws.onmessage = async (event) => { + if (state.voice.ws !== currentWs) return; + const msg = JSON.parse(event.data); + if (msg.type === "peers") { + for (const peer of msg.peers) { + ensurePeerConnection(peer.user_id); + } + } else if (msg.type === "peer_joined") { + playSound('peer-join'); + ensurePeerConnection(msg.user_id); + } else if (msg.type === "peer_left") { + playSound('peer-leave'); + const pc = state.voice.peerConnections.get(msg.user_id); + if (pc) { + pc.close(); + state.voice.peerConnections.delete(msg.user_id); + } + document.getElementById(`audio-${msg.user_id}`)?.remove(); + } else if (msg.type === "signal") { + await handleSignal(msg.from_user_id, msg.kind, msg.data); + } else if (msg.type === "video_status_changed") { + if (!msg.is_sharing_video) { + document.getElementById(`video-${msg.user_id}-camera`)?.parentElement?.remove(); + } + } else if (msg.type === "screen_status_changed") { + if (!msg.is_sharing_screen) { + document.getElementById(`video-${msg.user_id}-screen`)?.parentElement?.remove(); + } + } else if (msg.type === "speaking_status_changed") { + state.voicePresence.get(state.selectedVoiceChannelId)?.forEach(p => { + if (p.user_id === msg.user_id) p.is_speaking = msg.is_speaking; + }); + renderChannels(); + const videoEl = document.getElementById(`video-${msg.user_id}-camera`) || document.getElementById(`video-${msg.user_id}-screen`); + if (videoEl) { + videoEl.parentElement.classList.toggle('speaking', msg.is_speaking); + } + } else if (msg.type === "mute_status_changed") { + state.voicePresence.get(state.selectedVoiceChannelId)?.forEach(p => { + if (p.user_id === msg.user_id) p.is_muted = msg.is_muted; + }); + renderChannels(); + } else if (msg.type === "play_sound") { + const audio = new Audio(msg.sound_url); + audio.play().catch(console.error); + } + refreshVoicePresence().catch(() => { }); + }; + + ws.onerror = (event) => { + if (state.voice.ws !== currentWs) return; + console.error('voice websocket error', event); + el.vcChannelName.textContent = 'Connection failed'; + }; + + ws.onclose = (event) => { + if (state.voice.ws !== currentWs) return; + clearTimeout(connectTimer); + if (!wsOpened) { + console.error('voice websocket closed before open', event.code, event.reason); + const code = event && typeof event.code === 'number' ? event.code : 'unknown'; + el.vcChannelName.textContent = `Connection failed (${code})`; + alert(`Voice connection failed (code ${code}).`); + } + if (wsOpened) { + el.voiceConnection.classList.add("hidden"); + } + el.soundboard.classList.add("hidden"); + for (const pc of state.voice.peerConnections.values()) pc.close(); + state.voice.peerConnections.clear(); + state.voice.videoSenders.clear(); + state.voice.screenSenders.clear(); + stopAndClearAudioPipeline(); + state.voice.joinedChannelId = null; + state.voice.ws = null; + playSound('leave'); + refreshVoicePresence().catch(() => { }); + }; +} + +async function leaveVoice() { + localStorage.removeItem("active_voice_channel"); + if (state.voice.ws) state.voice.ws.close(); +} + +function toggleMute() { + if (!state.voice.localStream) return; + state.voice.muted = !state.voice.muted; + state.voice.localStream.getAudioTracks().forEach((t) => { + t.enabled = !state.voice.muted; + }); + if (state.voice.ws && state.voice.ws.readyState === WebSocket.OPEN) { + state.voice.ws.send(JSON.stringify({ type: "set_mute_status", is_muted: state.voice.muted })); + } + el.voiceMuteBtn.innerHTML = state.voice.muted ? '' : ''; + el.voiceMuteBtn.style.color = state.voice.muted ? 'var(--danger)' : 'var(--text-muted)'; + lucide.createIcons(); +} + +async function toggleVideo() { + if (state.voice.sharingVideo) { + stopAndClearVideoPipeline(); + if (state.voice.ws) { + state.voice.ws.send(JSON.stringify({ type: "set_video_status", is_sharing_video: false })); + } + for (const [peerId, pc] of state.voice.peerConnections) { + const sender = state.voice.videoSenders.get(peerId); + if (sender) { + pc.removeTrack(sender); + state.voice.videoSenders.delete(peerId); + } + } + } else { + try { + const stream = await navigator.mediaDevices.getUserMedia({ video: true }); + state.voice.videoStream = stream; + state.voice.sharingVideo = true; + renderVideo(state.me.id, state.me.display_name, stream, 'camera'); + + if (state.voice.ws) { + state.voice.ws.send(JSON.stringify({ type: "set_video_status", is_sharing_video: true })); + } + + for (const [peerId, pc] of state.voice.peerConnections) { + stream.getVideoTracks().forEach(track => { + const sender = pc.addTrack(track, stream); + state.voice.videoSenders.set(peerId, sender); + }); + } + } catch (err) { + console.error("camera denied", err); + alert("Could not access camera."); + return; + } + } + + el.voiceVideoBtn.innerHTML = state.voice.sharingVideo ? '' : ''; + el.voiceVideoBtn.style.color = state.voice.sharingVideo ? 'var(--green)' : 'var(--text-muted)'; + lucide.createIcons(); +} + +async function toggleScreenShare() { + if (state.voice.sharingScreen) { + stopAndClearScreenPipeline(); + if (state.voice.ws) { + state.voice.ws.send(JSON.stringify({ type: "set_screen_status", is_sharing_screen: false })); + } + for (const [peerId, pc] of state.voice.peerConnections) { + const sender = state.voice.screenSenders.get(peerId); + if (sender) { + pc.removeTrack(sender); + state.voice.screenSenders.delete(peerId); + } + } + } else { + try { + const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true }); + state.voice.screenStream = stream; + state.voice.sharingScreen = true; + renderVideo(state.me.id, state.me.display_name, stream, 'screen'); + + // Stop sharing if user clicks "Stop sharing" in browser UI + stream.getVideoTracks()[0].onended = () => { + if (state.voice.sharingScreen) toggleScreenShare(); + }; + + if (state.voice.ws) { + state.voice.ws.send(JSON.stringify({ type: "set_screen_status", is_sharing_screen: true })); + } + + for (const [peerId, pc] of state.voice.peerConnections) { + stream.getVideoTracks().forEach(track => { + const sender = pc.addTrack(track, stream); + state.voice.screenSenders.set(peerId, sender); + }); + } + } catch (err) { + console.error("screen share denied", err); + return; + } + } + + el.voiceScreenBtn.innerHTML = state.voice.sharingScreen ? '' : ''; + el.voiceScreenBtn.style.color = state.voice.sharingScreen ? 'var(--green)' : 'var(--text-muted)'; + lucide.createIcons(); +} + +function toggleWatchVideo() { + state.voice.watchingVideo = !state.voice.watchingVideo; + updateVideoGridVisibility(); + + el.voiceWatchBtn.innerHTML = state.voice.watchingVideo ? '' : ''; + el.voiceWatchBtn.style.color = state.voice.watchingVideo ? 'var(--green)' : 'var(--danger)'; + el.voiceWatchBtn.title = state.voice.watchingVideo ? 'Stop Watching' : 'Start Watching'; + lucide.createIcons(); +} + +// --- Sound Board --- + +async function loadSounds() { + if (!state.selectedGuildId) return; + try { + const sounds = await api(`/guilds/${state.selectedGuildId}/sounds`); + renderSounds(sounds); + } catch (err) { + console.error("failed to load sounds", err); + } +} + +function renderSounds(sounds) { + el.soundboardGrid.innerHTML = ''; + sounds.forEach(sound => { + const item = document.createElement('div'); + item.className = 'sound-item'; + + // Only show delete button if user is creator or owner + const canDelete = state.me && (state.me.id === sound.created_by_user_id || (state.guilds.find(g => g.id === state.selectedGuildId)?.owner_user_id === state.me.id)); + + item.innerHTML = ` +
${sound.icon}
+
${sound.name}
+ ${canDelete ? `` : ''} + `; + + item.onclick = (e) => { + if (e.target.closest('.sound-delete')) { + e.stopPropagation(); + if (confirm(`Are you sure you want to delete "${sound.name}"?`)) { + deleteSound(sound.id); + } + return; + } + playRemoteSound(sound.file_path); + }; + + el.soundboardGrid.appendChild(item); + }); + lucide.createIcons(); +} + +async function deleteSound(soundId) { + if (!state.selectedGuildId) return; + try { + const 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.gifPicker.classList.add("hidden"); + el.gifSearchInput.value = ""; + sendGif(fullUrl); + }; + item.appendChild(img); + el.gifResults.appendChild(item); + }); + } + + async function sendGif(url) { + try { + if (state.selectedTextChannelId) { + await api(`/channels/${state.selectedTextChannelId}/messages`, { + method: "POST", + body: JSON.stringify({ body: url }), + }); + } else if (state.selectedDmUserId) { + await api(`/dms/${state.selectedDmUserId}/messages`, { + method: "POST", + body: JSON.stringify({ body: url }), + }); + } + const messages = state.selectedTextChannelId + ? await api(`/channels/${state.selectedTextChannelId}/messages?limit=100`) + : await api(`/dms/${state.selectedDmUserId}/messages?limit=100`); + renderMessages(messages); + } catch (err) { + console.error("Failed to send GIF", err); + } + } + + // 6. Initial Data Loading & App Start + try { + state.me = await api("/me"); + if (state.me && state.me.display_name) { + el.userName.textContent = state.me.display_name; + el.userAvatar.textContent = shortName(state.me.display_name); + } + el.authScreen.classList.add("hidden"); + el.main.classList.remove("hidden"); + + // Check for invite code in URL + const params = new URLSearchParams(location.search); + const inviteCode = params.get("invite"); + if (inviteCode) { + try { + const joinedGuild = await api(`/invites/${encodeURIComponent(inviteCode)}/join`, { method: "POST" }); + 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(); + + // Auto-reconnect to voice if previously joined + const savedVoiceChannel = localStorage.getItem("active_voice_channel"); + if (savedVoiceChannel) { + console.log("Auto-reconnecting to voice channel:", savedVoiceChannel); + state.selectedVoiceChannelId = savedVoiceChannel; + setTimeout(() => { + joinVoice().catch(err => console.error("Auto-reconnect failed", err)); + }, 800); + } + + 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 || !el.updateNotifier || !el.updateDownloadBtn || !el.updateInstallBtn) 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(); From 208ab8042ef6bb9b098a10da227586f89a55f4dd Mon Sep 17 00:00:00 2001 From: pavel Date: Fri, 27 Feb 2026 10:57:13 +0100 Subject: [PATCH 03/20] checkpoint --- desktop/preload.js | 6 ++ main.js | 137 ++++++++++++++++++++++++++++- static/shared/app-core.js | 179 +++++++++++++++++++++++++++++++++----- 3 files changed, 296 insertions(+), 26 deletions(-) diff --git a/desktop/preload.js b/desktop/preload.js index ae9d90e..dfcc665 100644 --- a/desktop/preload.js +++ b/desktop/preload.js @@ -3,9 +3,15 @@ const { contextBridge, ipcRenderer } = require('electron'); contextBridge.exposeInMainWorld('electronAPI', { copyToClipboard: (text) => ipcRenderer.invoke('clipboard-write', text), getConfig: () => ipcRenderer.invoke('get-config'), + storageGet: (key) => ipcRenderer.invoke('storage-get', key), + storageSet: (key, value) => ipcRenderer.invoke('storage-set', key, value), + storageRemove: (key) => ipcRenderer.invoke('storage-remove', key), + getUpdateState: () => ipcRenderer.invoke('get-update-state'), + checkForUpdatesNow: () => ipcRenderer.invoke('check-for-updates-now'), checkForUpdates: () => ipcRenderer.send('check-for-updates'), downloadUpdate: () => ipcRenderer.send('download-update'), quitAndInstall: () => ipcRenderer.send('quit-and-install'), + onUpdateState: (callback) => ipcRenderer.on('update-state', (event, state) => callback(state)), onUpdateAvailable: (callback) => ipcRenderer.on('update-available', (event, info) => callback(info)), onUpdateDownloaded: (callback) => ipcRenderer.on('update-downloaded', (event, info) => callback(info)), onUpdateError: (callback) => ipcRenderer.on('update-error', (event, error) => callback(error)) diff --git a/main.js b/main.js index 0f0878d..009d449 100644 --- a/main.js +++ b/main.js @@ -1,8 +1,82 @@ const { app, BrowserWindow, session, desktopCapturer, ipcMain, clipboard } = require('electron'); const { autoUpdater } = require('electron-updater'); const path = require('path'); +const fs = require('fs'); const url = require('url'); +const updateState = { + status: 'idle', // idle | checking | available | downloading | downloaded | not-available | error + info: null, + error: null, +}; + +function broadcastUpdateState() { + const wins = BrowserWindow.getAllWindows(); + for (const win of wins) { + if (!win.isDestroyed()) { + win.webContents.send('update-state', updateState); + } + } +} + +function getStorageFilePath() { + return path.join(app.getPath('userData'), 'renderer-storage.json'); +} + +function readPersistentStore() { + const filePath = getStorageFilePath(); + try { + if (!fs.existsSync(filePath)) return {}; + const raw = fs.readFileSync(filePath, 'utf8'); + const parsed = JSON.parse(raw); + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch (err) { + console.error('Failed to read persistent store', err); + return {}; + } +} + +function writePersistentStore(store) { + const filePath = getStorageFilePath(); + try { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(store), 'utf8'); + } catch (err) { + console.error('Failed to write persistent store', err); + } +} + +function isNewerVersionAvailable(info) { + const next = info && typeof info.version === 'string' ? info.version : ''; + return Boolean(next) && next !== app.getVersion(); +} + +async function runUpdateCheck(reason = 'manual') { + updateState.status = 'checking'; + updateState.error = null; + broadcastUpdateState(); + try { + const result = await autoUpdater.checkForUpdates(); + const info = result && result.updateInfo ? result.updateInfo : null; + if (isNewerVersionAvailable(info)) { + updateState.status = 'available'; + updateState.info = info; + updateState.error = null; + broadcastUpdateState(); + } else { + updateState.status = 'not-available'; + updateState.info = info; + updateState.error = null; + broadcastUpdateState(); + } + } catch (err) { + updateState.status = 'error'; + updateState.error = err && err.message ? err.message : String(err); + broadcastUpdateState(); + console.error(`Update check failed (${reason})`, err); + } +} + function createWindow() { // Create a persistent session for chattz to keep the user logged in const sess = session.fromPartition('persist:chattz'); @@ -34,6 +108,34 @@ function createWindow() { return true; }); + ipcMain.handle('storage-get', (event, key) => { + if (typeof key !== 'string' || key.length === 0) return null; + const store = readPersistentStore(); + return Object.prototype.hasOwnProperty.call(store, key) ? store[key] : null; + }); + + ipcMain.handle('storage-set', (event, key, value) => { + if (typeof key !== 'string' || key.length === 0) return false; + const store = readPersistentStore(); + store[key] = value; + writePersistentStore(store); + return true; + }); + + ipcMain.handle('storage-remove', (event, key) => { + if (typeof key !== 'string' || key.length === 0) return false; + const store = readPersistentStore(); + delete store[key]; + writePersistentStore(store); + return true; + }); + + ipcMain.handle('get-update-state', () => ({ ...updateState })); + ipcMain.handle('check-for-updates-now', async () => { + await runUpdateCheck('renderer-direct'); + return { ...updateState }; + }); + // Auto-approve media permissions (camera, microphone) sess.setPermissionCheckHandler((webContents, permission) => { if (permission === 'media' || permission === 'clipboard-read' || permission === 'clipboard-write') { @@ -112,6 +214,14 @@ function createWindow() { loadDesktopApp(); + // Ensure renderer receives latest updater state after any (re)load. + win.webContents.on('did-finish-load', () => { + broadcastUpdateState(); + // Login flow swaps pages; force a check after desktop UI reloads so + // renderer always gets fresh updater state. + void runUpdateCheck('did-finish-load'); + }); + // Uncomment to debug // win.webContents.openDevTools(); } @@ -127,27 +237,46 @@ app.whenReady().then(() => { autoUpdater.logger = console; autoUpdater.on('update-available', (info) => { + updateState.status = 'available'; + updateState.info = info; + updateState.error = null; + broadcastUpdateState(); const wins = BrowserWindow.getAllWindows(); if (wins.length > 0) wins[0].webContents.send('update-available', info); }); + autoUpdater.on('update-not-available', (info) => { + updateState.status = 'not-available'; + updateState.info = info || null; + updateState.error = null; + broadcastUpdateState(); + }); + autoUpdater.on('update-downloaded', (info) => { + updateState.status = 'downloaded'; + updateState.info = info; + updateState.error = null; + broadcastUpdateState(); const wins = BrowserWindow.getAllWindows(); if (wins.length > 0) wins[0].webContents.send('update-downloaded', info); }); autoUpdater.on('error', (err) => { + updateState.status = 'error'; + updateState.error = err.message; + broadcastUpdateState(); const wins = BrowserWindow.getAllWindows(); if (wins.length > 0) wins[0].webContents.send('update-error', err.message); }); ipcMain.on('check-for-updates', () => { - autoUpdater.checkForUpdatesAndNotify().catch(err => { - console.error("Manual update check failed", err); - }); + void runUpdateCheck('manual'); }); ipcMain.on('download-update', () => { + updateState.status = 'downloading'; + updateState.error = null; + broadcastUpdateState(); autoUpdater.downloadUpdate(); }); @@ -157,7 +286,7 @@ app.whenReady().then(() => { // Check once on startup setTimeout(() => { - autoUpdater.checkForUpdatesAndNotify().catch(() => { }); + void runUpdateCheck('startup'); }, 5000); app.on('activate', () => { diff --git a/static/shared/app-core.js b/static/shared/app-core.js index 2b5fa49..97d405d 100644 --- a/static/shared/app-core.js +++ b/static/shared/app-core.js @@ -40,6 +40,80 @@ const state = { // --- Desktop Backend Configuration --- let API_BASE_URL = ''; // Will be initialized via IPC +const LAST_GUILD_STORAGE_KEY = "chattz:lastGuildId"; +const PERSISTED_STORAGE_KEYS = [ + "chattz_token", + "chattz_refresh_token", + LAST_GUILD_STORAGE_KEY, + "active_voice_channel", +]; + +function storageGet(key) { + return localStorage.getItem(key); +} + +function storageSet(key, value) { + localStorage.setItem(key, value); + if (window.electronAPI?.storageSet) { + void window.electronAPI.storageSet(key, value).catch((err) => { + console.warn("Failed to persist storage key", key, err); + }); + } +} + +function storageRemove(key) { + localStorage.removeItem(key); + if (window.electronAPI?.storageRemove) { + void window.electronAPI.storageRemove(key).catch((err) => { + console.warn("Failed to remove persisted storage key", key, err); + }); + } +} + +async function storageSetCritical(key, value) { + localStorage.setItem(key, value); + if (window.electronAPI?.storageSet) { + try { + await window.electronAPI.storageSet(key, value); + } catch (err) { + console.warn("Failed to persist critical storage key", key, err); + } + } +} + +async function storageRemoveCritical(key) { + localStorage.removeItem(key); + if (window.electronAPI?.storageRemove) { + try { + await window.electronAPI.storageRemove(key); + } catch (err) { + console.warn("Failed to remove critical storage key", key, err); + } + } +} + +async function hydrateDesktopStorage() { + if (!window.electronAPI?.storageGet) return; + for (const key of PERSISTED_STORAGE_KEYS) { + const localVal = localStorage.getItem(key); + if (localVal !== null) { + try { + await window.electronAPI.storageSet(key, localVal); + } catch (err) { + console.warn("Failed to backfill storage key", key, err); + } + continue; + } + try { + const val = await window.electronAPI.storageGet(key); + if (typeof val === "string") { + localStorage.setItem(key, val); + } + } catch (err) { + console.warn("Failed to hydrate storage key", key, err); + } + } +} async function initializeConfig() { try { @@ -73,7 +147,7 @@ function getWsUrl(path) { urlStr = `${proto}://${url.host}${normalizedPath}`; } - const token = localStorage.getItem("chattz_token"); + const token = storageGet("chattz_token"); if (token) { urlStr += (urlStr.includes('?') ? '&' : '?') + `token=${token}`; } @@ -180,7 +254,7 @@ async function api(path, options = {}) { ...(options.headers || {}), }; - const token = localStorage.getItem("chattz_token"); + const token = storageGet("chattz_token"); if (token) { headers["Authorization"] = `Bearer ${token}`; } @@ -192,7 +266,7 @@ async function api(path, options = {}) { // Handle 401 Unauthorized via Refresh Token if (res.status === 401 && !path.includes('/auth/refresh')) { - const refreshToken = localStorage.getItem("chattz_refresh_token"); + const refreshToken = storageGet("chattz_refresh_token"); if (refreshToken) { try { const refreshUrl = API_BASE_URL ? `${API_BASE_URL}/auth/refresh` : '/auth/refresh'; @@ -204,8 +278,8 @@ async function api(path, options = {}) { if (refreshRes.ok) { const newTokens = await refreshRes.json(); - localStorage.setItem("chattz_token", newTokens.access_token); - localStorage.setItem("chattz_refresh_token", newTokens.refresh_token); + await storageSetCritical("chattz_token", newTokens.access_token); + await storageSetCritical("chattz_refresh_token", newTokens.refresh_token); // Retry original request with new token headers["Authorization"] = `Bearer ${newTokens.access_token}`; @@ -215,8 +289,8 @@ async function api(path, options = {}) { throw new Error("Refresh token expired or invalid"); } } catch (err) { - localStorage.removeItem("chattz_token"); - localStorage.removeItem("chattz_refresh_token"); + await storageRemoveCritical("chattz_token"); + await storageRemoveCritical("chattz_refresh_token"); location.href = API_BASE_URL ? `${API_BASE_URL}/auth/login` : "/auth/login"; throw new Error("Session expired, please log in again"); } @@ -330,8 +404,6 @@ function playSound(type) { console.warn("playSound failed", err); } } -const LAST_GUILD_STORAGE_KEY = "chattz:lastGuildId"; - // --- Renderers --- function renderGuilds() { @@ -348,7 +420,7 @@ function renderGuilds() { state.selectedVoiceChannelId = null; state.selectedDmUserId = null; state.selectedDmDisplayName = null; - localStorage.setItem(LAST_GUILD_STORAGE_KEY, guild.id); + storageSet(LAST_GUILD_STORAGE_KEY, guild.id); renderGuilds(); renderDMs(); await loadChannels(); @@ -1258,7 +1330,7 @@ async function joinVoice() { state.voice.ws = ws; state.voice.joinedChannelId = state.selectedVoiceChannelId; - localStorage.setItem("active_voice_channel", state.selectedVoiceChannelId); + storageSet("active_voice_channel", state.selectedVoiceChannelId); // Start mic setup in parallel so channel join is not blocked by device init. createLocalVoiceStream() @@ -1390,7 +1462,7 @@ async function joinVoice() { } async function leaveVoice() { - localStorage.removeItem("active_voice_channel"); + storageRemove("active_voice_channel"); if (state.voice.ws) state.voice.ws.close(); } @@ -1612,6 +1684,7 @@ function closeMobileMenus() { let inactivityTimer = null; let isCurrentlyIdle = false; +let updaterInitialized = false; function resetInactivityTimer() { if (isCurrentlyIdle) { @@ -1636,6 +1709,8 @@ document.addEventListener('click', resetInactivityTimer); async function init() { lucide.createIcons(); + await hydrateDesktopStorage(); + initUpdater(); // 1. Handle tokens in URL (from successful login redirects) try { @@ -1644,8 +1719,8 @@ async function init() { const refreshToken = searchParams.get("refresh_token"); if (jwtToken || refreshToken) { - if (jwtToken) localStorage.setItem("chattz_token", jwtToken); - if (refreshToken) localStorage.setItem("chattz_refresh_token", refreshToken); + if (jwtToken) await storageSetCritical("chattz_token", jwtToken); + if (refreshToken) await storageSetCritical("chattz_refresh_token", refreshToken); searchParams.delete("token"); searchParams.delete("refresh_token"); @@ -1665,8 +1740,8 @@ async function init() { el.logoutBtn.onclick = async () => { await leaveVoice(); - localStorage.removeItem("chattz_token"); - localStorage.removeItem("chattz_refresh_token"); + await storageRemoveCritical("chattz_token"); + await storageRemoveCritical("chattz_refresh_token"); try { await api("/auth/logout", { method: "POST" }); } catch (e) { } location.reload(); }; @@ -1705,7 +1780,7 @@ async function init() { state.selectedVoiceChannelId = null; state.selectedDmUserId = null; state.selectedDmDisplayName = null; - localStorage.setItem(LAST_GUILD_STORAGE_KEY, guild.id); + storageSet(LAST_GUILD_STORAGE_KEY, guild.id); renderGuilds(); await loadChannels(); await loadGuildMembers(); @@ -1889,7 +1964,7 @@ async function init() { if (inviteCode) { try { const joinedGuild = await api(`/invites/${encodeURIComponent(inviteCode)}/join`, { method: "POST" }); - localStorage.setItem(LAST_GUILD_STORAGE_KEY, joinedGuild.id); + storageSet(LAST_GUILD_STORAGE_KEY, joinedGuild.id); } catch (err) { alert(`Failed to join invite: ${err.message}`); } finally { params.delete("invite"); const nextQuery = params.toString(); @@ -1908,10 +1983,10 @@ async function init() { } catch (err) { console.warn("presence sync failed", err); } if (state.guilds.length > 0) { - const lastGuildId = localStorage.getItem(LAST_GUILD_STORAGE_KEY); + const lastGuildId = storageGet(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); + storageSet(LAST_GUILD_STORAGE_KEY, guild.id); renderGuilds(); await loadChannels(); await loadGuildMembers(); @@ -1927,7 +2002,7 @@ async function init() { startVoicePresencePolling(); // Auto-reconnect to voice if previously joined - const savedVoiceChannel = localStorage.getItem("active_voice_channel"); + const savedVoiceChannel = storageGet("active_voice_channel"); if (savedVoiceChannel) { console.log("Auto-reconnecting to voice channel:", savedVoiceChannel); state.selectedVoiceChannelId = savedVoiceChannel; @@ -1936,7 +2011,6 @@ async function init() { }, 800); } - initUpdater(); lucide.createIcons(); } catch (err) { console.error("init failed", err); @@ -1947,6 +2021,53 @@ async function init() { function initUpdater() { if (!window.electronAPI || !el.updateNotifier || !el.updateDownloadBtn || !el.updateInstallBtn) return; + if (updaterInitialized) return; + updaterInitialized = true; + + const applyUpdateState = (state) => { + if (!state || !state.status) return; + if (state.status === 'available') { + const v = state.info?.version ? ` ${state.info.version}` : ''; + el.updateNotifier.classList.remove("hidden"); + el.updateDownloadBtn.classList.remove("hidden"); + el.updateInstallBtn.classList.add("hidden"); + el.updateDownloadBtn.disabled = false; + el.updateDownloadBtn.style.opacity = ""; + el.updateDownloadBtn.title = `Download Update${v}`; + return; + } + if (state.status === 'downloading') { + el.updateNotifier.classList.remove("hidden"); + el.updateDownloadBtn.classList.remove("hidden"); + el.updateInstallBtn.classList.add("hidden"); + el.updateDownloadBtn.disabled = true; + el.updateDownloadBtn.style.opacity = "0.5"; + el.updateDownloadBtn.title = "Downloading update..."; + return; + } + if (state.status === 'downloaded') { + const v = state.info?.version ? ` ${state.info.version}` : ''; + el.updateNotifier.classList.remove("hidden"); + el.updateDownloadBtn.classList.add("hidden"); + el.updateInstallBtn.classList.remove("hidden"); + el.updateInstallBtn.title = `Install Update${v}`; + return; + } + if (state.status === 'not-available') { + el.updateNotifier.classList.add("hidden"); + el.updateDownloadBtn.disabled = false; + el.updateDownloadBtn.style.opacity = ""; + return; + } + if (state.status === 'error') { + el.updateDownloadBtn.disabled = false; + el.updateDownloadBtn.style.opacity = ""; + } + }; + + window.electronAPI.onUpdateState((state) => { + applyUpdateState(state); + }); window.electronAPI.onUpdateAvailable((info) => { console.log("Update available:", info.version); @@ -1978,6 +2099,20 @@ function initUpdater() { el.updateInstallBtn.onclick = () => { window.electronAPI.quitAndInstall(); }; + + // Recover missed startup events and then run a fresh check now that listeners exist. + window.electronAPI.getUpdateState?.() + .then((state) => applyUpdateState(state)) + .catch((err) => console.warn("Failed to fetch initial update state", err)) + .finally(() => { + if (window.electronAPI.checkForUpdatesNow) { + void window.electronAPI.checkForUpdatesNow() + .then((state) => applyUpdateState(state)) + .catch((err) => console.warn("Direct update check failed", err)); + } else { + window.electronAPI.checkForUpdates?.(); + } + }); } // Config fetcher will trigger init() From ad6eaad5d28a262eb878994b17d2372ccd1e528a Mon Sep 17 00:00:00 2001 From: pavel Date: Fri, 27 Feb 2026 12:35:56 +0100 Subject: [PATCH 04/20] updater stuff --- .forgejo/workflows/pipeline.yaml | 9 +++ desktop/index.html | 2 +- main.js | 117 +++++++++++++++++-------------- static/shared/app-core.js | 17 +++-- 4 files changed, 83 insertions(+), 62 deletions(-) diff --git a/.forgejo/workflows/pipeline.yaml b/.forgejo/workflows/pipeline.yaml index 9e241e8..089f873 100644 --- a/.forgejo/workflows/pipeline.yaml +++ b/.forgejo/workflows/pipeline.yaml @@ -50,11 +50,20 @@ 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/index.html b/desktop/index.html index 07f8f7d..ed754ec 100644 --- a/desktop/index.html +++ b/desktop/index.html @@ -268,4 +268,4 @@ - + \ No newline at end of file diff --git a/main.js b/main.js index 009d449..3a32c94 100644 --- a/main.js +++ b/main.js @@ -51,10 +51,16 @@ function isNewerVersionAvailable(info) { return Boolean(next) && next !== app.getVersion(); } +let updateCheckInProgress = false; + async function runUpdateCheck(reason = 'manual') { + if (updateCheckInProgress) { + console.log(`Update check skipped (${reason}): another check is already in progress`); + return; + } + updateCheckInProgress = true; updateState.status = 'checking'; updateState.error = null; - broadcastUpdateState(); try { const result = await autoUpdater.checkForUpdates(); const info = result && result.updateInfo ? result.updateInfo : null; @@ -62,18 +68,18 @@ async function runUpdateCheck(reason = 'manual') { updateState.status = 'available'; updateState.info = info; updateState.error = null; - broadcastUpdateState(); } else { updateState.status = 'not-available'; updateState.info = info; updateState.error = null; - broadcastUpdateState(); } } catch (err) { updateState.status = 'error'; updateState.error = err && err.message ? err.message : String(err); - broadcastUpdateState(); console.error(`Update check failed (${reason})`, err); + } finally { + updateCheckInProgress = false; + broadcastUpdateState(); } } @@ -95,46 +101,7 @@ function createWindow() { }); win.setAutoHideMenuBar(true); - win.setMenuBarVisibility(false); - - ipcMain.handle('get-config', () => { - return { - backendUrl: (process.env.CHATTZ_URL || 'https://discord.flegr.me').replace(/\/$/, '') - }; - }); - - ipcMain.handle('clipboard-write', (event, text) => { - clipboard.writeText(text); - return true; - }); - - ipcMain.handle('storage-get', (event, key) => { - if (typeof key !== 'string' || key.length === 0) return null; - const store = readPersistentStore(); - return Object.prototype.hasOwnProperty.call(store, key) ? store[key] : null; - }); - - ipcMain.handle('storage-set', (event, key, value) => { - if (typeof key !== 'string' || key.length === 0) return false; - const store = readPersistentStore(); - store[key] = value; - writePersistentStore(store); - return true; - }); - - ipcMain.handle('storage-remove', (event, key) => { - if (typeof key !== 'string' || key.length === 0) return false; - const store = readPersistentStore(); - delete store[key]; - writePersistentStore(store); - return true; - }); - - ipcMain.handle('get-update-state', () => ({ ...updateState })); - ipcMain.handle('check-for-updates-now', async () => { - await runUpdateCheck('renderer-direct'); - return { ...updateState }; - }); + win.setMenuBarVisibility(true); // Auto-approve media permissions (camera, microphone) sess.setPermissionCheckHandler((webContents, permission) => { @@ -194,12 +161,14 @@ function createWindow() { }); }; - // Use a navigation listener to detect when the remote login is complete - win.webContents.on('will-navigate', (event, navigatedUrl) => { + // Intercept navigations/redirects that return to the backend with a token + // after the OAuth login flow. Two handlers are needed because: + // - will-navigate fires for client-side navigations (location.href, link clicks) + // - will-redirect fires for server-side 302 redirects (OAuth callback chain) + const interceptLoginRedirect = (event, navigatedUrl) => { try { const urlObj = new URL(navigatedUrl); const backendObj = new URL(backendUrl); - // Detect the redirect back to the home page with a token if (urlObj.origin === backendObj.origin && urlObj.pathname === '/') { if (urlObj.searchParams.has('token')) { console.log("Detected login success redirect, returning to desktop UI..."); @@ -210,16 +179,20 @@ function createWindow() { } catch (e) { console.error(e); } - }); + }; + + win.webContents.on('will-navigate', interceptLoginRedirect); + win.webContents.on('will-redirect', interceptLoginRedirect); loadDesktopApp(); // Ensure renderer receives latest updater state after any (re)load. + // Delay broadcast by 300ms to give the renderer time to register its + // onUpdateState IPC listener before we push state. win.webContents.on('did-finish-load', () => { - broadcastUpdateState(); - // Login flow swaps pages; force a check after desktop UI reloads so - // renderer always gets fresh updater state. - void runUpdateCheck('did-finish-load'); + setTimeout(() => { + if (!win.isDestroyed()) broadcastUpdateState(); + }, 300); }); // Uncomment to debug @@ -230,6 +203,46 @@ app.commandLine.appendSwitch('disable-webrtc-hw-encoding'); // Sometime helps re app.commandLine.appendSwitch('log-level', '3'); // Silences standard Chromium warning logs (like SRTP transport unprotect logs which are benign timing issues) app.whenReady().then(() => { + // Register IPC handlers once (before creating any windows) + ipcMain.handle('get-config', () => { + return { + backendUrl: (process.env.CHATTZ_URL || 'https://discord.flegr.me').replace(/\/$/, '') + }; + }); + + ipcMain.handle('clipboard-write', (event, text) => { + clipboard.writeText(text); + return true; + }); + + ipcMain.handle('storage-get', (event, key) => { + if (typeof key !== 'string' || key.length === 0) return null; + const store = readPersistentStore(); + return Object.prototype.hasOwnProperty.call(store, key) ? store[key] : null; + }); + + ipcMain.handle('storage-set', (event, key, value) => { + if (typeof key !== 'string' || key.length === 0) return false; + const store = readPersistentStore(); + store[key] = value; + writePersistentStore(store); + return true; + }); + + ipcMain.handle('storage-remove', (event, key) => { + if (typeof key !== 'string' || key.length === 0) return false; + const store = readPersistentStore(); + delete store[key]; + writePersistentStore(store); + return true; + }); + + ipcMain.handle('get-update-state', () => ({ ...updateState })); + ipcMain.handle('check-for-updates-now', async () => { + await runUpdateCheck('renderer-direct'); + return { ...updateState }; + }); + createWindow(); // Configure Auto-Updater diff --git a/static/shared/app-core.js b/static/shared/app-core.js index 97d405d..f14b88d 100644 --- a/static/shared/app-core.js +++ b/static/shared/app-core.js @@ -95,19 +95,18 @@ async function storageRemoveCritical(key) { async function hydrateDesktopStorage() { if (!window.electronAPI?.storageGet) return; for (const key of PERSISTED_STORAGE_KEYS) { - const localVal = localStorage.getItem(key); - if (localVal !== null) { - try { - await window.electronAPI.storageSet(key, localVal); - } catch (err) { - console.warn("Failed to backfill storage key", key, err); - } - continue; - } try { + // IPC file store (renderer-storage.json) is the source of truth — + // it always survives restarts, unlike localStorage on file:// URLs. const val = await window.electronAPI.storageGet(key); if (typeof val === "string") { localStorage.setItem(key, val); + } else { + // IPC store is empty; backfill from localStorage if available + const localVal = localStorage.getItem(key); + if (localVal !== null) { + await window.electronAPI.storageSet(key, localVal); + } } } catch (err) { console.warn("Failed to hydrate storage key", key, err); From 86a5c4b7c30da80e48ed63283d99b945e07bcedf Mon Sep 17 00:00:00 2001 From: Forgejo Actions Date: Fri, 27 Feb 2026 11:37:00 +0000 Subject: [PATCH 05/20] chore: bump version to 0.0.49 [skip ci] --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index f657203..7f023f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "chattz-electron", - "version": "0.0.48", + "version": "0.0.49", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "chattz-electron", - "version": "0.0.48", + "version": "0.0.49", "dependencies": { "electron-updater": "^6.8.3" }, diff --git a/package.json b/package.json index 9949c75..8484627 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "chattz-electron", - "version": "0.0.48", + "version": "0.0.49", "description": "Electron frontend for Chattz", "author": "Pavel Flegr ", "homepage": "https://discord.flegr.me", From ec3b29f4d92147a17202805a955f63410a3923f9 Mon Sep 17 00:00:00 2001 From: pavel Date: Fri, 27 Feb 2026 14:35:10 +0100 Subject: [PATCH 06/20] missing rpm --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 8484627..0a59d4f 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "productName": "Chattz", "linux": { "target": [ - "AppImage" + "AppImage", + "rpm" ], "category": "Chat", "icon": "build/icon.png" From 80be74782c762ccc0c3b012a52d43359e5ee481a Mon Sep 17 00:00:00 2001 From: Forgejo Actions Date: Fri, 27 Feb 2026 13:37:20 +0000 Subject: [PATCH 07/20] chore: bump version to 0.0.50 [skip ci] --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7f023f4..686ca4b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "chattz-electron", - "version": "0.0.49", + "version": "0.0.50", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "chattz-electron", - "version": "0.0.49", + "version": "0.0.50", "dependencies": { "electron-updater": "^6.8.3" }, diff --git a/package.json b/package.json index 0a59d4f..6967283 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "chattz-electron", - "version": "0.0.49", + "version": "0.0.50", "description": "Electron frontend for Chattz", "author": "Pavel Flegr ", "homepage": "https://discord.flegr.me", From 003391e60e01f06e8e9b595cf203868b769e53c4 Mon Sep 17 00:00:00 2001 From: pavel Date: Fri, 27 Feb 2026 14:59:10 +0100 Subject: [PATCH 08/20] chat scroll --- static/shared/app-core.js | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/static/shared/app-core.js b/static/shared/app-core.js index f14b88d..a7ad4e1 100644 --- a/static/shared/app-core.js +++ b/static/shared/app-core.js @@ -354,6 +354,11 @@ function formatDate(isoString) { return d.toLocaleDateString(); } +function scrollMessagesToBottom() { + if (!el.messageList) return; + el.messageList.scrollTop = el.messageList.scrollHeight; +} + let audioCtx = null; // Global interaction listener to unlock AudioContext (autoplay policy) @@ -628,7 +633,17 @@ function renderMessages(messages) { state.lastMessageId = messages[0].id; } - el.messageList.scrollTop = el.messageList.scrollHeight; + scrollMessagesToBottom(); + requestAnimationFrame(() => { + scrollMessagesToBottom(); + }); + + // GIF/image height resolves after initial paint; keep chat pinned to latest. + for (const gifImage of el.messageList.querySelectorAll(".msg-gif img")) { + if (gifImage.complete) continue; + gifImage.addEventListener("load", scrollMessagesToBottom, { once: true }); + gifImage.addEventListener("error", scrollMessagesToBottom, { once: true }); + } } function renderMembers() { From cfef57d8a187e37f949b999e5b6c0bffeae5af92 Mon Sep 17 00:00:00 2001 From: pavel Date: Fri, 27 Feb 2026 15:00:17 +0100 Subject: [PATCH 09/20] skip version commit --- .forgejo/workflows/pipeline.yaml | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/.forgejo/workflows/pipeline.yaml b/.forgejo/workflows/pipeline.yaml index 089f873..c76ae86 100644 --- a/.forgejo/workflows/pipeline.yaml +++ b/.forgejo/workflows/pipeline.yaml @@ -19,17 +19,6 @@ jobs: VERSION=${GITHUB_REF_NAME#v} echo "Bumping version to $VERSION" npm version $VERSION --no-git-tag-version - - # Configure Git - git config user.name "Forgejo Actions" - git config user.email "actions@noreply.flegr.me" - - # Commit and push back to main if version changed - if [ -n "$(git status --porcelain package.json)" ]; then - git add package.json package-lock.json - git commit -m "chore: bump version to $VERSION [skip ci]" - git push origin HEAD:main - fi - run: npm ci - run: npm run dist:linux - name: Build Windows installer with 32-bit Wine prefix From e1dd679c47b7fc890c126d69fb11e9177405d57c Mon Sep 17 00:00:00 2001 From: pavel Date: Fri, 27 Feb 2026 15:38:46 +0100 Subject: [PATCH 10/20] feat: media uploads --- .env.example | 7 + Cargo.lock | 815 +++++++++++++++++- Cargo.toml | 2 + src/config.rs | 45 +- src/db.rs | 193 ++++- src/entity/attachments.rs | 66 ++ src/entity/mod.rs | 1 + src/handlers.rs | 261 +++++- src/main.rs | 12 +- src/media.rs | 74 ++ src/migration/m20260213_000001_init.rs | 29 +- src/migration/m20260213_000002_invites.rs | 7 +- .../m20260213_000004_direct_messages.rs | 12 +- src/migration/m20260227_000006_attachments.rs | 122 +++ src/migration/mod.rs | 2 + src/models.rs | 13 +- static/index.html | 4 + static/shared/app-core.js | 124 ++- static/styles.css | 91 +- 19 files changed, 1807 insertions(+), 73 deletions(-) create mode 100644 src/entity/attachments.rs create mode 100644 src/media.rs create mode 100644 src/migration/m20260227_000006_attachments.rs diff --git a/.env.example b/.env.example index 04a1dd8..c062b55 100644 --- a/.env.example +++ b/.env.example @@ -19,3 +19,10 @@ TURN_PASSWORD=replace-me # 32+ random chars; used to sign session cookies SESSION_SECRET=replace-with-long-random-secret COOKIE_SECURE=false + +# Cloudflare R2 media uploads +R2_ACCOUNT_ID=replace-me +R2_ACCESS_KEY_ID=replace-me +R2_SECRET_ACCESS_KEY=replace-me +R2_BUCKET=chattz-media +R2_PUBLIC_BASE_URL=https://media.example.com diff --git a/Cargo.lock b/Cargo.lock index 190bd84..b496651 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -92,6 +92,418 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-config" +version = "1.8.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a8fc176d53d6fe85017f230405e3255cedb4a02221cb55ed6d76dccbbb099b2" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sdk-sts", + "aws-smithy-async", + "aws-smithy-http 0.63.5", + "aws-smithy-json 0.62.4", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 1.4.0", + "time", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "aws-credential-types" +version = "1.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d203b0bf2626dcba8665f5cd0871d7c2c0930223d6b6be9097592fea21242d0" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + +[[package]] +name = "aws-lc-rs" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9a7b350e3bb1767102698302bc37256cbd48422809984b98d292c40e2579aa9" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.37.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b092fe214090261288111db7a2b2c2118e5a7f30dc2569f1732c4069a6840549" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "aws-runtime" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ede2ddc593e6c8acc6ce3358c28d6677a6dc49b65ba4b37a2befe14a11297e75" +dependencies = [ + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-eventstream", + "aws-smithy-http 0.63.5", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "bytes-utils", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", +] + +[[package]] +name = "aws-sdk-s3" +version = "1.119.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d65fddc3844f902dfe1864acb8494db5f9342015ee3ab7890270d36fbd2e01c" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-checksums", + "aws-smithy-eventstream", + "aws-smithy-http 0.62.6", + "aws-smithy-json 0.61.9", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "bytes", + "fastrand", + "hex", + "hmac", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "lru", + "percent-encoding", + "regex-lite", + "sha2", + "tracing", + "url", +] + +[[package]] +name = "aws-sdk-sts" +version = "1.99.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9acba7c62f3d4e2408fa998a3a8caacd8b9a5b5549cf36e2372fbdae329d5449" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http 0.63.5", + "aws-smithy-json 0.62.4", + "aws-smithy-observability", + "aws-smithy-query", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sigv4" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37411f8e0f4bea0c3ca0958ce7f18f6439db24d555dbd809787262cd00926aa9" +dependencies = [ + "aws-credential-types", + "aws-smithy-eventstream", + "aws-smithy-http 0.63.5", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "form_urlencoded", + "hex", + "hmac", + "http 0.2.12", + "http 1.4.0", + "percent-encoding", + "sha2", + "time", + "tracing", +] + +[[package]] +name = "aws-smithy-async" +version = "1.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc50d0f63e714784b84223abd7abbc8577de8c35d699e0edd19f0a88a08ae13" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-checksums" +version = "0.63.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87294a084b43d649d967efe58aa1f9e0adc260e13a6938eb904c0ae9b45824ae" +dependencies = [ + "aws-smithy-http 0.62.6", + "aws-smithy-types", + "bytes", + "crc-fast", + "hex", + "http 0.2.12", + "http-body 0.4.6", + "md-5", + "pin-project-lite", + "sha1", + "sha2", + "tracing", +] + +[[package]] +name = "aws-smithy-eventstream" +version = "0.60.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c0b3e587fbaa5d7f7e870544508af8ce82ea47cd30376e69e1e37c4ac746f79" +dependencies = [ + "aws-smithy-types", + "bytes", + "crc32fast", +] + +[[package]] +name = "aws-smithy-http" +version = "0.62.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "826141069295752372f8203c17f28e30c464d22899a43a0c9fd9c458d469c88b" +dependencies = [ + "aws-smithy-eventstream", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-http" +version = "0.63.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d619373d490ad70966994801bc126846afaa0d1ee920697a031f0cf63f2568e7" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-http-client" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00ccbb08c10f6bcf912f398188e42ee2eab5f1767ce215a02a73bc5df1bbdd95" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2 0.3.27", + "h2 0.4.13", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper 1.8.1", + "hyper-rustls 0.24.2", + "hyper-rustls 0.27.7", + "hyper-util", + "pin-project-lite", + "rustls 0.21.12", + "rustls 0.23.36", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tower", + "tracing", +] + +[[package]] +name = "aws-smithy-json" +version = "0.61.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49fa1213db31ac95288d981476f78d05d9cbb0353d22cdf3472cc05bb02f6551" +dependencies = [ + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-json" +version = "0.62.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b3a779093e18cad88bbae08dc4261e1d95018c4c5b9356a52bcae7c0b6e9bb" +dependencies = [ + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-observability" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3f39d5bb871aaf461d59144557f16d5927a5248a983a40654d9cf3b9ba183b" +dependencies = [ + "aws-smithy-runtime-api", +] + +[[package]] +name = "aws-smithy-query" +version = "0.60.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f76a580e3d8f8961e5d48763214025a2af65c2fa4cd1fb7f270a0e107a71b0" +dependencies = [ + "aws-smithy-types", + "urlencoding", +] + +[[package]] +name = "aws-smithy-runtime" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22ccf7f6eba8b2dcf8ce9b74806c6c185659c311665c4bf8d6e71ebd454db6bf" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http 0.63.5", + "aws-smithy-http-client", + "aws-smithy-observability", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "pin-project-lite", + "pin-utils", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4af6e5def28be846479bbeac55aa4603d6f7986fc5da4601ba324dd5d377516" +dependencies = [ + "aws-smithy-async", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.4.0", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-types" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca2734c16913a45343b37313605d84e7d8b34a4611598ce1d25b35860a2bed3" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "futures-core", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", + "tokio", + "tokio-util", +] + +[[package]] +name = "aws-smithy-xml" +version = "0.60.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b53543b4b86ed43f051644f704a98c7291b3618b67adf057ee77a366fa52fcaa" +dependencies = [ + "xmlparser", +] + +[[package]] +name = "aws-types" +version = "1.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0470cc047657c6e286346bdf10a8719d26efd6a91626992e0e64481e44323e96" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "rustc_version", + "tracing", +] + [[package]] name = "axum" version = "0.8.8" @@ -104,10 +516,10 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.8.1", "hyper-util", "itoa", "matchit", @@ -138,8 +550,8 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", @@ -166,6 +578,16 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "base64ct" version = "1.8.3" @@ -208,6 +630,16 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + [[package]] name = "cc" version = "1.2.56" @@ -215,6 +647,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -235,6 +669,8 @@ name = "chattz" version = "0.1.0" dependencies = [ "anyhow", + "aws-config", + "aws-sdk-s3", "axum", "chrono", "dotenvy", @@ -267,6 +703,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -282,6 +727,16 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -312,6 +767,28 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +[[package]] +name = "crc-fast" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ddc2d09feefeee8bd78101665bd8645637828fa9317f9f292496dbbd8c65ff3" +dependencies = [ + "crc", + "digest", + "rand 0.9.2", + "regex", + "rustversion", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -448,6 +925,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "either" version = "1.15.0" @@ -494,6 +977,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -532,6 +1021,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures" version = "0.3.31" @@ -673,6 +1168,44 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.0", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -744,6 +1277,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.4.0" @@ -754,6 +1298,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -761,7 +1316,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.4.0", ] [[package]] @@ -772,8 +1327,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -795,6 +1350,30 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + [[package]] name = "hyper" version = "1.8.1" @@ -805,8 +1384,9 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "http", - "http-body", + "h2 0.4.13", + "http 1.4.0", + "http-body 1.0.1", "httparse", "httpdate", "itoa", @@ -817,19 +1397,35 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "log", + "rustls 0.21.12", + "tokio", + "tokio-rustls 0.24.1", +] + [[package]] name = "hyper-rustls" version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ - "http", - "hyper", + "http 1.4.0", + "hyper 1.8.1", "hyper-util", - "rustls", + "rustls 0.23.36", + "rustls-native-certs", "rustls-pki-types", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tower-service", "webpki-roots 1.0.6", ] @@ -844,14 +1440,14 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http", - "http-body", - "hyper", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.8.1", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.2", "tokio", "tower-service", "tracing", @@ -1032,6 +1628,16 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.85" @@ -1120,6 +1726,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -1193,7 +1808,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-util", - "http", + "http 1.4.0", "httparse", "memchr", "mime", @@ -1278,6 +1893,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "ordered-float" version = "4.6.0" @@ -1311,6 +1932,12 @@ dependencies = [ "syn", ] +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "parking" version = "2.2.1" @@ -1484,8 +2111,8 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls", - "socket2", + "rustls 0.23.36", + "socket2 0.6.2", "thiserror", "tokio", "tracing", @@ -1504,7 +2131,7 @@ dependencies = [ "rand 0.9.2", "ring", "rustc-hash", - "rustls", + "rustls 0.23.36", "rustls-pki-types", "slab", "thiserror", @@ -1522,7 +2149,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.6.2", "tracing", "windows-sys 0.60.2", ] @@ -1642,6 +2269,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "regex-syntax" version = "0.8.9" @@ -1657,25 +2290,25 @@ dependencies = [ "base64", "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", - "hyper", - "hyper-rustls", + "hyper 1.8.1", + "hyper-rustls 0.27.7", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", "quinn", - "rustls", + "rustls 0.23.36", "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tower", "tower-http", "tower-service", @@ -1735,20 +2368,45 @@ dependencies = [ "semver", ] +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + [[package]] name = "rustls" version = "0.23.36" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" dependencies = [ + "aws-lc-rs", "once_cell", "ring", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.103.9", "subtle", "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -1759,12 +2417,23 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "rustls-webpki" version = "0.103.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -1782,12 +2451,31 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "sea-bae" version = "0.2.1" @@ -1935,6 +2623,29 @@ dependencies = [ "syn", ] +[[package]] +name = "security-framework" +version = "3.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.27" @@ -2081,6 +2792,16 @@ dependencies = [ "serde", ] +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.2" @@ -2147,7 +2868,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rustls", + "rustls 0.23.36", "serde", "serde_json", "sha2", @@ -2469,7 +3190,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", - "socket2", + "socket2 0.6.2", "tokio-macros", "windows-sys 0.61.2", ] @@ -2485,13 +3206,23 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls", + "rustls 0.23.36", "tokio", ] @@ -2557,8 +3288,8 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "http-range-header", "httpdate", @@ -2663,7 +3394,7 @@ checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" dependencies = [ "bytes", "data-encoding", - "http", + "http 1.4.0", "httparse", "log", "rand 0.9.2", @@ -2783,6 +3514,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "want" version = "0.3.1" @@ -3213,6 +3950,12 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" + [[package]] name = "yansi" version = "1.0.1" diff --git a/Cargo.toml b/Cargo.toml index 5ab0c14..d7ece96 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,8 @@ edition = "2024" [dependencies] anyhow = "1" +aws-config = { version = "1", default-features = false, features = ["behavior-version-latest", "rt-tokio", "rustls"] } +aws-sdk-s3 = { version = "1", default-features = false, features = ["rt-tokio", "rustls"] } axum = { version = "0.8", features = ["macros", "ws", "multipart"] } chrono = { version = "0.4", features = ["serde"] } dotenvy = "0.15" diff --git a/src/config.rs b/src/config.rs index 0696d55..8f55aa0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -17,6 +17,16 @@ pub struct Settings { pub turn_urls: Vec, pub turn_username: Option, pub turn_password: Option, + pub media: Option, +} + +#[derive(Clone, Debug)] +pub struct MediaSettings { + pub account_id: String, + pub access_key_id: String, + pub secret_access_key: String, + pub bucket: String, + pub public_base_url: String, } impl Settings { @@ -33,7 +43,8 @@ impl Settings { oidc_token_url: required("OIDC_TOKEN_URL")?, oidc_userinfo_url: required("OIDC_USERINFO_URL")?, oidc_redirect_url: required("OIDC_REDIRECT_URL")?, - oidc_scopes: std::env::var("OIDC_SCOPES").unwrap_or_else(|_| "openid profile email".to_string()), + oidc_scopes: std::env::var("OIDC_SCOPES") + .unwrap_or_else(|_| "openid profile email".to_string()), session_secret: required("SESSION_SECRET")?, cookie_secure: std::env::var("COOKIE_SECURE") .unwrap_or_else(|_| "false".into()) @@ -43,10 +54,42 @@ impl Settings { turn_urls: parse_csv_env("TURN_URLS", ""), turn_username: optional("TURN_USERNAME"), turn_password: optional("TURN_PASSWORD"), + media: MediaSettings::from_env()?, }) } } +impl MediaSettings { + fn from_env() -> Result> { + let account_id = optional("R2_ACCOUNT_ID"); + let access_key_id = optional("R2_ACCESS_KEY_ID"); + let secret_access_key = optional("R2_SECRET_ACCESS_KEY"); + let bucket = optional("R2_BUCKET"); + let public_base_url = optional("R2_PUBLIC_BASE_URL"); + + if account_id.is_none() + && access_key_id.is_none() + && secret_access_key.is_none() + && bucket.is_none() + && public_base_url.is_none() + { + return Ok(None); + } + + Ok(Some(Self { + account_id: account_id.context("missing env var R2_ACCOUNT_ID")?, + access_key_id: access_key_id.context("missing env var R2_ACCESS_KEY_ID")?, + secret_access_key: secret_access_key.context("missing env var R2_SECRET_ACCESS_KEY")?, + bucket: bucket.context("missing env var R2_BUCKET")?, + public_base_url: public_base_url.context("missing env var R2_PUBLIC_BASE_URL")?, + })) + } + + pub fn endpoint_url(&self) -> String { + format!("https://{}.r2.cloudflarestorage.com", self.account_id) + } +} + fn required(name: &str) -> Result { std::env::var(name).with_context(|| format!("missing env var {name}")) } diff --git a/src/db.rs b/src/db.rs index d4364bc..42d980b 100644 --- a/src/db.rs +++ b/src/db.rs @@ -9,12 +9,12 @@ use uuid::Uuid; use crate::{ entity::{ - channels, direct_messages, guild_members, guilds, invites, messages, soundboard_sounds, - users, + attachments, channels, direct_messages, guild_members, guilds, invites, messages, + soundboard_sounds, users, }, models::{ - BasicUser, Channel, DmConversation, DmMessageWithAuthor, Guild, Invite, MessageWithAuthor, - SoundboardSound, User, + Attachment, BasicUser, Channel, DmConversation, DmMessageWithAuthor, Guild, Invite, + MessageWithAuthor, SoundboardSound, User, }, }; @@ -350,6 +350,63 @@ pub async fn create_message( author_user_id: model.author_user_id, author_display_name: user.display_name, body: model.body, + attachments: Vec::new(), + created_at: model.created_at, + }) +} + +pub async fn create_message_with_attachment( + db: &DatabaseConnection, + channel_id: Uuid, + author_user_id: Uuid, + body: &str, + object_key: &str, + media_url: &str, + mime_type: &str, + size_bytes: i64, + original_filename: &str, +) -> Result { + let txn = db.begin().await?; + + let model = messages::Entity::insert(messages::ActiveModel { + id: Set(Uuid::new_v4()), + channel_id: Set(channel_id), + author_user_id: Set(author_user_id), + body: Set(body.to_string()), + ..Default::default() + }) + .exec_with_returning(&txn) + .await?; + + let attachment = attachments::Entity::insert(attachments::ActiveModel { + id: Set(Uuid::new_v4()), + channel_message_id: Set(Some(model.id)), + direct_message_id: Set(None), + uploader_user_id: Set(author_user_id), + object_key: Set(object_key.to_string()), + media_url: Set(media_url.to_string()), + mime_type: Set(mime_type.to_string()), + size_bytes: Set(size_bytes), + original_filename: Set(original_filename.to_string()), + ..Default::default() + }) + .exec_with_returning(&txn) + .await?; + + let user = users::Entity::find_by_id(author_user_id) + .one(&txn) + .await? + .ok_or_else(|| anyhow!("author not found"))?; + + txn.commit().await?; + + Ok(MessageWithAuthor { + id: model.id, + channel_id: model.channel_id, + author_user_id: model.author_user_id, + author_display_name: user.display_name, + body: model.body, + attachments: vec![map_attachment(attachment)], created_at: model.created_at, }) } @@ -367,6 +424,10 @@ pub async fn list_messages( .all(db) .await?; + let attachment_map = + list_attachments_for_channel_messages(db, rows.iter().map(|(msg, _)| msg.id).collect()) + .await?; + Ok(rows .into_iter() .map(|(msg, user)| { @@ -379,6 +440,7 @@ pub async fn list_messages( author_user_id: msg.author_user_id, author_display_name, body: msg.body, + attachments: attachment_map.get(&msg.id).cloned().unwrap_or_default(), created_at: msg.created_at, } }) @@ -412,6 +474,63 @@ pub async fn create_direct_message( recipient_user_id: model.recipient_user_id, author_display_name: user.display_name, body: model.body, + attachments: Vec::new(), + created_at: model.created_at, + }) +} + +pub async fn create_direct_message_with_attachment( + db: &DatabaseConnection, + sender_user_id: Uuid, + recipient_user_id: Uuid, + body: &str, + object_key: &str, + media_url: &str, + mime_type: &str, + size_bytes: i64, + original_filename: &str, +) -> Result { + let txn = db.begin().await?; + + let model = direct_messages::Entity::insert(direct_messages::ActiveModel { + id: Set(Uuid::new_v4()), + sender_user_id: Set(sender_user_id), + recipient_user_id: Set(recipient_user_id), + body: Set(body.to_string()), + ..Default::default() + }) + .exec_with_returning(&txn) + .await?; + + let attachment = attachments::Entity::insert(attachments::ActiveModel { + id: Set(Uuid::new_v4()), + channel_message_id: Set(None), + direct_message_id: Set(Some(model.id)), + uploader_user_id: Set(sender_user_id), + object_key: Set(object_key.to_string()), + media_url: Set(media_url.to_string()), + mime_type: Set(mime_type.to_string()), + size_bytes: Set(size_bytes), + original_filename: Set(original_filename.to_string()), + ..Default::default() + }) + .exec_with_returning(&txn) + .await?; + + let user = users::Entity::find_by_id(sender_user_id) + .one(&txn) + .await? + .ok_or_else(|| anyhow!("sender not found"))?; + + txn.commit().await?; + + Ok(DmMessageWithAuthor { + id: model.id, + author_user_id: model.sender_user_id, + recipient_user_id: model.recipient_user_id, + author_display_name: user.display_name, + body: model.body, + attachments: vec![map_attachment(attachment)], created_at: model.created_at, }) } @@ -454,6 +573,9 @@ pub async fn list_direct_messages( .map(|u| (u.id, u.display_name)) .collect(); + let attachment_map = + list_attachments_for_direct_messages(db, rows.iter().map(|msg| msg.id).collect()).await?; + Ok(rows .into_iter() .map(|msg| DmMessageWithAuthor { @@ -465,11 +587,64 @@ pub async fn list_direct_messages( .cloned() .unwrap_or_else(|| "Unknown User".to_string()), body: msg.body, + attachments: attachment_map.get(&msg.id).cloned().unwrap_or_default(), created_at: msg.created_at, }) .collect()) } +async fn list_attachments_for_channel_messages( + db: &DatabaseConnection, + message_ids: Vec, +) -> Result>> { + if message_ids.is_empty() { + return Ok(std::collections::HashMap::new()); + } + + let rows = attachments::Entity::find() + .filter(attachments::Column::ChannelMessageId.is_in(message_ids)) + .order_by_asc(attachments::Column::CreatedAt) + .all(db) + .await?; + + let mut grouped = std::collections::HashMap::>::new(); + for row in rows { + if let Some(message_id) = row.channel_message_id { + grouped + .entry(message_id) + .or_default() + .push(map_attachment(row)); + } + } + Ok(grouped) +} + +async fn list_attachments_for_direct_messages( + db: &DatabaseConnection, + message_ids: Vec, +) -> Result>> { + if message_ids.is_empty() { + return Ok(std::collections::HashMap::new()); + } + + let rows = attachments::Entity::find() + .filter(attachments::Column::DirectMessageId.is_in(message_ids)) + .order_by_asc(attachments::Column::CreatedAt) + .all(db) + .await?; + + let mut grouped = std::collections::HashMap::>::new(); + for row in rows { + if let Some(message_id) = row.direct_message_id { + grouped + .entry(message_id) + .or_default() + .push(map_attachment(row)); + } + } + Ok(grouped) +} + pub async fn list_dm_conversations( db: &DatabaseConnection, current_user_id: Uuid, @@ -543,6 +718,16 @@ fn map_guild(model: guilds::Model) -> Guild { } } +fn map_attachment(model: attachments::Model) -> Attachment { + Attachment { + id: model.id, + media_url: model.media_url, + mime_type: model.mime_type, + size_bytes: model.size_bytes, + original_filename: model.original_filename, + } +} + fn map_channel(model: channels::Model) -> Channel { Channel { id: model.id, diff --git a/src/entity/attachments.rs b/src/entity/attachments.rs new file mode 100644 index 0000000..f5581d1 --- /dev/null +++ b/src/entity/attachments.rs @@ -0,0 +1,66 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "attachments")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub channel_message_id: Option, + pub direct_message_id: Option, + pub uploader_user_id: Uuid, + pub object_key: String, + pub media_url: String, + pub mime_type: String, + pub size_bytes: i64, + pub original_filename: String, + pub created_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::messages::Entity", + from = "Column::ChannelMessageId", + to = "super::messages::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + Messages, + #[sea_orm( + belongs_to = "super::direct_messages::Entity", + from = "Column::DirectMessageId", + to = "super::direct_messages::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + DirectMessages, + #[sea_orm( + belongs_to = "super::users::Entity", + from = "Column::UploaderUserId", + to = "super::users::Column::Id", + on_update = "NoAction", + on_delete = "NoAction" + )] + Users, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Messages.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::DirectMessages.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Users.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/entity/mod.rs b/src/entity/mod.rs index d1e64f7..d5dda3d 100644 --- a/src/entity/mod.rs +++ b/src/entity/mod.rs @@ -1,3 +1,4 @@ +pub mod attachments; pub mod channels; pub mod direct_messages; pub mod guild_members; diff --git a/src/handlers.rs b/src/handlers.rs index 9337339..5c2f27b 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -1,10 +1,11 @@ use axum::{ Json, Router, - extract::{Path, Query, State, WebSocketUpgrade}, + extract::{Multipart, Path, Query, State, WebSocketUpgrade}, http::{HeaderMap, StatusCode, header}, response::{Html, IntoResponse, Redirect}, routing::{get, post}, }; +use chrono::Datelike; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -12,7 +13,7 @@ use crate::{ AppState, auth::{self, ApiError, AuthUser}, chat, db, - models::{Guild, SoundboardSound}, + models::{DmMessageWithAuthor, Guild, MessageWithAuthor, SoundboardSound}, voice, }; use tracing::info; @@ -30,6 +31,10 @@ pub fn routes() -> Router { "/dms/{other_user_id}/messages", get(list_dm_messages).post(send_dm_message), ) + .route( + "/dms/{other_user_id}/attachments", + post(upload_dm_attachment), + ) .route("/presence", get(presence_list)) .route("/rtc-config", get(rtc_config)) .route("/guilds", get(list_guilds).post(create_guild)) @@ -54,6 +59,10 @@ pub fn routes() -> Router { "/channels/{channel_id}/messages", get(list_messages).post(send_message), ) + .route( + "/channels/{channel_id}/attachments", + post(upload_channel_attachment), + ) .route("/channels/{channel_id}/voice/ws", get(voice_ws)) .route("/ws", get(chat_ws)) } @@ -680,6 +689,89 @@ async fn send_dm_message( Ok((StatusCode::CREATED, Json(SendMessageResponse { ok: true }))) } +async fn upload_channel_attachment( + State(state): State, + user: AuthUser, + Path(channel_id): Path, + multipart: Multipart, +) -> Result { + ensure_channel_member(&state, channel_id, user.id).await?; + let uploaded = + upload_media_from_multipart(&state, multipart, channel_object_key_prefix(channel_id)) + .await?; + + let message = db::create_message_with_attachment( + &state.db, + channel_id, + user.id, + "", + &uploaded.object_key, + &uploaded.media_url, + &uploaded.mime_type, + uploaded.size_bytes, + &uploaded.original_filename, + ) + .await + .map_err(|e| ApiError::internal(&format!("failed to create message attachment: {e}")))?; + + let guild_id = db::guild_id_for_channel(&state.db, channel_id) + .await + .map_err(|e| ApiError::internal(&format!("channel lookup failed: {e}")))? + .ok_or_else(|| ApiError::internal("channel not found after attachment upload"))?; + + let members = db::list_guild_member_ids(&state.db, guild_id) + .await + .map_err(|e| ApiError::internal(&format!("failed to list guild members: {e}")))?; + + broadcast_channel_message(&state, members, channel_id, &message).await; + Ok((StatusCode::CREATED, Json(message))) +} + +async fn upload_dm_attachment( + State(state): State, + user: AuthUser, + Path(other_user_id): Path, + multipart: Multipart, +) -> Result { + if other_user_id == user.id { + return Err(ApiError::bad_request("cannot send dm to yourself")); + } + + let other_user_exists = db::user_exists(&state.db, other_user_id) + .await + .map_err(|e| ApiError::internal(&format!("user check failed: {e}")))?; + if !other_user_exists { + return Err(ApiError { + status: StatusCode::NOT_FOUND, + message: "user not found".to_string(), + }); + } + + let uploaded = upload_media_from_multipart( + &state, + multipart, + dm_object_key_prefix(user.id, other_user_id), + ) + .await?; + + let message = db::create_direct_message_with_attachment( + &state.db, + user.id, + other_user_id, + "", + &uploaded.object_key, + &uploaded.media_url, + &uploaded.mime_type, + uploaded.size_bytes, + &uploaded.original_filename, + ) + .await + .map_err(|e| ApiError::internal(&format!("failed to create dm attachment: {e}")))?; + + broadcast_dm_message(&state, user.id, other_user_id, &message).await; + Ok((StatusCode::CREATED, Json(message))) +} + async fn voice_ws( ws: WebSocketUpgrade, State(state): State, @@ -794,6 +886,171 @@ async fn upload_sound( Ok(Json(sound)) } +const MAX_MEDIA_UPLOAD_BYTES: usize = 25 * 1024 * 1024; + +struct UploadedMedia { + object_key: String, + media_url: String, + mime_type: String, + size_bytes: i64, + original_filename: String, +} + +async fn upload_media_from_multipart( + state: &AppState, + mut multipart: Multipart, + object_key_prefix: String, +) -> Result { + let storage = state + .media + .as_ref() + .ok_or_else(|| ApiError::internal("media storage is not configured"))?; + + let mut file_name = None; + let mut file_data = None; + let mut mime_type = None; + + while let Some(field) = multipart + .next_field() + .await + .map_err(|e| ApiError::bad_request(&e.to_string()))? + { + if field.name().unwrap_or_default() != "file" { + continue; + } + + file_name = Some(field.file_name().unwrap_or("upload.bin").to_string()); + mime_type = Some( + field + .content_type() + .unwrap_or("application/octet-stream") + .to_string(), + ); + let bytes = field + .bytes() + .await + .map_err(|e| ApiError::bad_request(&e.to_string()))?; + if bytes.len() > MAX_MEDIA_UPLOAD_BYTES { + return Err(ApiError::bad_request("file exceeds 25MB upload limit")); + } + file_data = Some(bytes.to_vec()); + break; + } + + let (original_filename, mime_type, file_data) = match (file_name, mime_type, file_data) { + (Some(name), Some(mime), Some(data)) => (name, mime, data), + _ => return Err(ApiError::bad_request("missing file upload")), + }; + + let safe_name = sanitize_file_name(&original_filename); + let object_key = format!("{}/{}-{}", object_key_prefix, Uuid::new_v4(), safe_name); + let size_bytes = file_data.len() as i64; + let media_url = storage + .upload_object(&object_key, file_data, &mime_type, &original_filename) + .await + .map_err(|e| ApiError::internal(&e.to_string()))?; + + Ok(UploadedMedia { + object_key, + media_url, + mime_type, + size_bytes, + original_filename, + }) +} + +async fn broadcast_channel_message( + state: &AppState, + members: Vec, + channel_id: Uuid, + message: &MessageWithAuthor, +) { + state + .chat + .broadcast_to_many( + members, + chat::ServerEvent::MessageCreated { + channel_id, + message: serde_json::to_value(message).unwrap_or_default(), + }, + ) + .await; +} + +async fn broadcast_dm_message( + state: &AppState, + current_user_id: Uuid, + other_user_id: Uuid, + message: &DmMessageWithAuthor, +) { + state + .chat + .broadcast_to_user( + current_user_id, + chat::ServerEvent::DmCreated { + other_user_id, + message: serde_json::to_value(message).unwrap_or_default(), + }, + ) + .await; + state + .chat + .broadcast_to_user( + other_user_id, + chat::ServerEvent::DmCreated { + other_user_id: current_user_id, + message: serde_json::to_value(message).unwrap_or_default(), + }, + ) + .await; +} + +fn sanitize_file_name(file_name: &str) -> String { + let sanitized: String = file_name + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') { + ch + } else { + '_' + } + }) + .collect(); + + let trimmed = sanitized.trim_matches('_').trim(); + if trimmed.is_empty() { + "upload.bin".to_string() + } else { + trimmed.to_string() + } +} + +fn channel_object_key_prefix(channel_id: Uuid) -> String { + let now = chrono::Utc::now(); + format!( + "channels/{}/{:04}/{:02}", + channel_id, + now.year(), + now.month() + ) +} + +fn dm_object_key_prefix(user_a: Uuid, user_b: Uuid) -> String { + let now = chrono::Utc::now(); + let (left, right) = if user_a <= user_b { + (user_a, user_b) + } else { + (user_b, user_a) + }; + format!( + "dms/{}-{}/{:04}/{:02}", + left, + right, + now.year(), + now.month() + ) +} + async fn delete_sound_post( state: State, user: AuthUser, diff --git a/src/main.rs b/src/main.rs index 53ea6f2..303636f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod config; mod db; mod entity; mod handlers; +mod media; mod migration; mod models; mod voice; @@ -17,7 +18,9 @@ use sea_orm_migration::MigratorTrait; use tower_http::{services::ServeDir, trace::TraceLayer}; use tracing::info; -use crate::{config::Settings, handlers::routes, migration::Migrator, voice::VoiceHub}; +use crate::{ + config::Settings, handlers::routes, media::MediaStorage, migration::Migrator, voice::VoiceHub, +}; #[derive(Clone)] pub struct AppState { @@ -26,6 +29,7 @@ pub struct AppState { pub http: reqwest::Client, pub voice: Arc, pub chat: Arc, + pub media: Option>, } #[tokio::main] @@ -43,12 +47,18 @@ async fn main() -> anyhow::Result<()> { .await .with_context(|| "failed to run migrations")?; + let media = match settings.media.as_ref() { + Some(media_settings) => Some(Arc::new(MediaStorage::new(media_settings).await?)), + None => None, + }; + let state = AppState { db, settings, http: reqwest::Client::new(), voice: Arc::new(VoiceHub::default()), chat: Arc::new(chat::ChatHub::default()), + media, }; let port = state.settings.port; diff --git a/src/media.rs b/src/media.rs new file mode 100644 index 0000000..ee7bd23 --- /dev/null +++ b/src/media.rs @@ -0,0 +1,74 @@ +use anyhow::{Result, anyhow}; +use aws_config::BehaviorVersion; +use aws_sdk_s3::{ + Client, + config::{Credentials, Region}, + primitives::ByteStream, +}; + +use crate::config::MediaSettings; + +#[derive(Clone)] +pub struct MediaStorage { + client: Client, + bucket: String, + public_base_url: String, +} + +impl MediaStorage { + pub async fn new(settings: &MediaSettings) -> Result { + let shared_config = aws_config::defaults(BehaviorVersion::latest()) + .region(Region::new("auto")) + .credentials_provider(Credentials::new( + settings.access_key_id.clone(), + settings.secret_access_key.clone(), + None, + None, + "chattz-r2", + )) + .load() + .await; + + let config = aws_sdk_s3::config::Builder::from(&shared_config) + .endpoint_url(settings.endpoint_url()) + .force_path_style(true) + .build(); + + Ok(Self { + client: Client::from_conf(config), + bucket: settings.bucket.clone(), + public_base_url: settings.public_base_url.trim_end_matches('/').to_string(), + }) + } + + pub async fn upload_object( + &self, + object_key: &str, + bytes: Vec, + content_type: &str, + original_filename: &str, + ) -> Result { + self.client + .put_object() + .bucket(&self.bucket) + .key(object_key) + .body(ByteStream::from(bytes)) + .content_type(content_type) + .content_disposition(format!( + "inline; filename=\"{}\"", + sanitize_header_value(original_filename) + )) + .send() + .await + .map_err(|e| anyhow!("failed to upload object to R2: {e}"))?; + + Ok(format!("{}/{}", self.public_base_url, object_key)) + } +} + +fn sanitize_header_value(value: &str) -> String { + value + .chars() + .filter(|c| *c != '\\' && *c != '"' && !c.is_control()) + .collect() +} diff --git a/src/migration/m20260213_000001_init.rs b/src/migration/m20260213_000001_init.rs index 9c671d4..3c1d1ff 100644 --- a/src/migration/m20260213_000001_init.rs +++ b/src/migration/m20260213_000001_init.rs @@ -11,13 +11,13 @@ impl MigrationTrait for Migration { Table::create() .table(Users::Table) .if_not_exists() + .col(ColumnDef::new(Users::Id).uuid().not_null().primary_key()) .col( - ColumnDef::new(Users::Id) - .uuid() + ColumnDef::new(Users::OidcSub) + .string() .not_null() - .primary_key(), + .unique_key(), ) - .col(ColumnDef::new(Users::OidcSub).string().not_null().unique_key()) .col(ColumnDef::new(Users::Email).string()) .col(ColumnDef::new(Users::DisplayName).string().not_null()) .col(ColumnDef::new(Users::AvatarUrl).string()) @@ -42,12 +42,7 @@ impl MigrationTrait for Migration { Table::create() .table(Guilds::Table) .if_not_exists() - .col( - ColumnDef::new(Guilds::Id) - .uuid() - .not_null() - .primary_key(), - ) + .col(ColumnDef::new(Guilds::Id).uuid().not_null().primary_key()) .col(ColumnDef::new(Guilds::Name).string().not_null()) .col(ColumnDef::new(Guilds::OwnerUserId).uuid().not_null()) .col( @@ -108,12 +103,7 @@ impl MigrationTrait for Migration { Table::create() .table(Channels::Table) .if_not_exists() - .col( - ColumnDef::new(Channels::Id) - .uuid() - .not_null() - .primary_key(), - ) + .col(ColumnDef::new(Channels::Id).uuid().not_null().primary_key()) .col(ColumnDef::new(Channels::GuildId).uuid().not_null()) .col(ColumnDef::new(Channels::Name).string().not_null()) .col( @@ -138,12 +128,7 @@ impl MigrationTrait for Migration { Table::create() .table(Messages::Table) .if_not_exists() - .col( - ColumnDef::new(Messages::Id) - .uuid() - .not_null() - .primary_key(), - ) + .col(ColumnDef::new(Messages::Id).uuid().not_null().primary_key()) .col(ColumnDef::new(Messages::ChannelId).uuid().not_null()) .col(ColumnDef::new(Messages::AuthorUserId).uuid().not_null()) .col(ColumnDef::new(Messages::Body).text().not_null()) diff --git a/src/migration/m20260213_000002_invites.rs b/src/migration/m20260213_000002_invites.rs index e99c28d..3dbcd5d 100644 --- a/src/migration/m20260213_000002_invites.rs +++ b/src/migration/m20260213_000002_invites.rs @@ -11,7 +11,12 @@ impl MigrationTrait for Migration { Table::create() .table(Invites::Table) .if_not_exists() - .col(ColumnDef::new(Invites::Code).string().not_null().primary_key()) + .col( + ColumnDef::new(Invites::Code) + .string() + .not_null() + .primary_key(), + ) .col(ColumnDef::new(Invites::GuildId).uuid().not_null()) .col(ColumnDef::new(Invites::CreatedByUserId).uuid().not_null()) .col( diff --git a/src/migration/m20260213_000004_direct_messages.rs b/src/migration/m20260213_000004_direct_messages.rs index 4b723ab..63ba7e2 100644 --- a/src/migration/m20260213_000004_direct_messages.rs +++ b/src/migration/m20260213_000004_direct_messages.rs @@ -17,8 +17,16 @@ impl MigrationTrait for Migration { .not_null() .primary_key(), ) - .col(ColumnDef::new(DirectMessages::SenderUserId).uuid().not_null()) - .col(ColumnDef::new(DirectMessages::RecipientUserId).uuid().not_null()) + .col( + ColumnDef::new(DirectMessages::SenderUserId) + .uuid() + .not_null(), + ) + .col( + ColumnDef::new(DirectMessages::RecipientUserId) + .uuid() + .not_null(), + ) .col(ColumnDef::new(DirectMessages::Body).text().not_null()) .col( ColumnDef::new(DirectMessages::CreatedAt) diff --git a/src/migration/m20260227_000006_attachments.rs b/src/migration/m20260227_000006_attachments.rs new file mode 100644 index 0000000..96f6da6 --- /dev/null +++ b/src/migration/m20260227_000006_attachments.rs @@ -0,0 +1,122 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Attachments::Table) + .if_not_exists() + .col( + ColumnDef::new(Attachments::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(Attachments::ChannelMessageId).uuid().null()) + .col(ColumnDef::new(Attachments::DirectMessageId).uuid().null()) + .col(ColumnDef::new(Attachments::UploaderUserId).uuid().not_null()) + .col(ColumnDef::new(Attachments::ObjectKey).string().not_null()) + .col(ColumnDef::new(Attachments::MediaUrl).string().not_null()) + .col(ColumnDef::new(Attachments::MimeType).string().not_null()) + .col(ColumnDef::new(Attachments::SizeBytes).big_integer().not_null()) + .col(ColumnDef::new(Attachments::OriginalFilename).string().not_null()) + .col( + ColumnDef::new(Attachments::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .foreign_key( + ForeignKey::create() + .name("fk_attachments_channel_message") + .from(Attachments::Table, Attachments::ChannelMessageId) + .to(Messages::Table, Messages::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .foreign_key( + ForeignKey::create() + .name("fk_attachments_direct_message") + .from(Attachments::Table, Attachments::DirectMessageId) + .to(DirectMessages::Table, DirectMessages::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .foreign_key( + ForeignKey::create() + .name("fk_attachments_uploader") + .from(Attachments::Table, Attachments::UploaderUserId) + .to(Users::Table, Users::Id), + ) + .check( + Expr::cust( + "(channel_message_id IS NOT NULL AND direct_message_id IS NULL) OR (channel_message_id IS NULL AND direct_message_id IS NOT NULL)", + ), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_attachments_channel_message") + .table(Attachments::Table) + .col(Attachments::ChannelMessageId) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_attachments_direct_message") + .table(Attachments::Table) + .col(Attachments::DirectMessageId) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Attachments::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum Attachments { + Table, + Id, + ChannelMessageId, + DirectMessageId, + UploaderUserId, + ObjectKey, + MediaUrl, + MimeType, + SizeBytes, + OriginalFilename, + CreatedAt, +} + +#[derive(DeriveIden)] +enum Messages { + Table, + Id, +} + +#[derive(DeriveIden)] +enum DirectMessages { + Table, + Id, +} + +#[derive(DeriveIden)] +enum Users { + Table, + Id, +} diff --git a/src/migration/mod.rs b/src/migration/mod.rs index 4db4f51..1ac95c9 100644 --- a/src/migration/mod.rs +++ b/src/migration/mod.rs @@ -5,6 +5,7 @@ mod m20260213_000002_invites; mod m20260213_000003_channel_kind; mod m20260213_000004_direct_messages; mod m20260224_000005_soundboard; +mod m20260227_000006_attachments; pub struct Migrator; @@ -17,6 +18,7 @@ impl MigratorTrait for Migrator { Box::new(m20260213_000003_channel_kind::Migration), Box::new(m20260213_000004_direct_messages::Migration), Box::new(m20260224_000005_soundboard::Migration), + Box::new(m20260227_000006_attachments::Migration), ] } } diff --git a/src/models.rs b/src/models.rs index 74ac74a..aabf16d 100644 --- a/src/models.rs +++ b/src/models.rs @@ -39,6 +39,15 @@ pub struct Message { pub created_at: DateTimeWithTimeZone, } +#[derive(Debug, Clone, Serialize)] +pub struct Attachment { + pub id: Uuid, + pub media_url: String, + pub mime_type: String, + pub size_bytes: i64, + pub original_filename: String, +} + #[derive(Debug, Clone, Serialize)] pub struct BasicUser { pub id: Uuid, @@ -46,13 +55,14 @@ pub struct BasicUser { pub avatar_url: Option, } -#[derive(Debug, Clone, Serialize, sea_orm::FromQueryResult)] +#[derive(Debug, Clone, Serialize)] pub struct MessageWithAuthor { pub id: Uuid, pub channel_id: Uuid, pub author_user_id: Uuid, pub author_display_name: String, pub body: String, + pub attachments: Vec, pub created_at: DateTimeWithTimeZone, } @@ -71,6 +81,7 @@ pub struct DmMessageWithAuthor { pub recipient_user_id: Uuid, pub author_display_name: String, pub body: String, + pub attachments: Vec, pub created_at: DateTimeWithTimeZone, } diff --git a/static/index.html b/static/index.html index 5aef5f7..6386f77 100644 --- a/static/index.html +++ b/static/index.html @@ -144,6 +144,10 @@
+ + diff --git a/static/shared/app-core.js b/static/shared/app-core.js index a7ad4e1..0aa2e4f 100644 --- a/static/shared/app-core.js +++ b/static/shared/app-core.js @@ -183,6 +183,8 @@ const el = { messageList: document.getElementById("message-list"), messageForm: document.getElementById("message-form"), messageBody: document.getElementById("message-body"), + mediaUploadBtn: document.getElementById("media-upload-btn"), + mediaFileInput: document.getElementById("media-file-input"), // User Panel userName: document.getElementById("user-name"), @@ -343,6 +345,43 @@ function formatMessageBody(body) { return escaped; } +function formatBytes(size) { + if (!Number.isFinite(size) || size < 1024) return `${size || 0} B`; + const units = ["KB", "MB", "GB"]; + let value = size; + let unitIndex = -1; + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex += 1; + } + return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`; +} + +function renderAttachments(attachments = []) { + if (!attachments.length) return ""; + + return attachments.map((attachment) => { + const url = escapeHtml(attachment.media_url); + const mime = attachment.mime_type || "application/octet-stream"; + const fileName = escapeHtml(attachment.original_filename || "file"); + const size = formatBytes(attachment.size_bytes); + + if (mime.startsWith("image/")) { + return `${fileName}`; + } + + if (mime.startsWith("video/")) { + return `
`; + } + + if (mime.startsWith("audio/")) { + return ``; + } + + return `${fileName}${size}`; + }).join(""); +} + function formatDate(isoString) { const d = new Date(isoString); const now = new Date(); @@ -609,9 +648,13 @@ function renderMessages(messages) { row.className = `msg ${isGrouped ? "msg-grouped" : ""}`; const displayName = m.author_display_name || "Unknown User"; + const formattedBody = formatMessageBody(m.body || ""); + const attachmentsHtml = renderAttachments(m.attachments); + const bodyHtml = formattedBody ? `
${formattedBody}
` : ""; + const contentHtml = `${bodyHtml}${attachmentsHtml}`; if (isGrouped) { - row.innerHTML = `
${formatMessageBody(m.body)}
`; + row.innerHTML = `
${contentHtml}
`; } else { row.innerHTML = `
${shortName(displayName)}
@@ -620,7 +663,7 @@ function renderMessages(messages) { ${escapeHtml(displayName)} ${formatDate(m.created_at)}
-
${formatMessageBody(m.body)}
+ ${contentHtml} `; } @@ -644,6 +687,15 @@ function renderMessages(messages) { gifImage.addEventListener("load", scrollMessagesToBottom, { once: true }); gifImage.addEventListener("error", scrollMessagesToBottom, { once: true }); } + + for (const media of el.messageList.querySelectorAll(".msg-attachment img, .msg-attachment video, .msg-attachment audio")) { + if ("complete" in media && media.complete) continue; + media.addEventListener("loadeddata", scrollMessagesToBottom, { once: true }); + media.addEventListener("load", scrollMessagesToBottom, { once: true }); + media.addEventListener("error", scrollMessagesToBottom, { once: true }); + } + + lucide.createIcons(); } function renderMembers() { @@ -1862,6 +1914,26 @@ async function init() { } catch (err) { alert(err.message); } }; + el.mediaUploadBtn.onclick = () => { + if (!state.selectedTextChannelId && !state.selectedDmUserId) { + alert("Select a chat first."); + return; + } + el.mediaFileInput.click(); + }; + + el.mediaFileInput.onchange = async () => { + const file = el.mediaFileInput.files?.[0]; + if (!file) return; + try { + await uploadMediaFile(file); + } catch (err) { + alert(err.message); + } finally { + el.mediaFileInput.value = ""; + } + }; + // 4. Voice/Soundboard Controls el.voiceVideoBtn.onclick = toggleVideo; el.voiceScreenBtn.onclick = toggleScreenShare; @@ -1962,6 +2034,54 @@ async function init() { } } + async function uploadMediaFile(file) { + const token = storageGet("chattz_token"); + if (!token) throw new Error("You need to log in again."); + + const path = state.selectedTextChannelId + ? `/channels/${state.selectedTextChannelId}/attachments` + : state.selectedDmUserId + ? `/dms/${state.selectedDmUserId}/attachments` + : null; + if (!path) throw new Error("Select a chat first."); + + const formData = new FormData(); + formData.append("file", file); + + el.mediaUploadBtn.disabled = true; + el.mediaUploadBtn.title = "Uploading..."; + + const fullUrl = API_BASE_URL ? `${API_BASE_URL}${path}` : path; + try { + const response = await fetch(fullUrl, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + }, + body: formData, + }); + + if (!response.ok) { + let detail = "upload failed"; + try { + const payload = await response.json(); + detail = payload.error || detail; + } catch { } + throw new Error(`${response.status}: ${detail}`); + } + + const messages = state.selectedTextChannelId + ? await api(`/channels/${state.selectedTextChannelId}/messages?limit=100`) + : await api(`/dms/${state.selectedDmUserId}/messages?limit=100`); + renderMessages(messages); + await loadDMConversations(); + renderDMs(); + } finally { + el.mediaUploadBtn.disabled = false; + el.mediaUploadBtn.title = "Upload File"; + } + } + // 6. Initial Data Loading & App Start try { state.me = await api("/me"); diff --git a/static/styles.css b/static/styles.css index 9147a37..aac3dd9 100644 --- a/static/styles.css +++ b/static/styles.css @@ -885,6 +885,10 @@ select { color: var(--text-normal); } +.hidden-file-input { + display: none; +} + /* ═══════════════════════════════════════════════════════════ */ /* GIF Picker Modal */ /* ═══════════════════════════════════════════════════════════ */ @@ -996,6 +1000,91 @@ select { object-fit: contain; } +.msg-attachment { + display: block; + margin-top: 8px; +} + +.msg-attachment-image { + max-width: min(440px, 100%); + border-radius: var(--radius-md); + overflow: hidden; +} + +.msg-attachment-image img { + width: 100%; + max-height: 360px; + object-fit: contain; + display: block; + background: rgba(0, 0, 0, 0.18); +} + +.msg-attachment-video { + max-width: min(520px, 100%); +} + +.msg-attachment-video video { + width: 100%; + max-height: 420px; + border-radius: var(--radius-md); + background: #000; +} + +.msg-attachment-audio { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px 14px; + max-width: min(420px, 100%); + border-radius: var(--radius-md); + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.msg-attachment-audio audio { + width: 100%; +} + +.msg-attachment-audio a { + color: var(--text-link); + font-size: 13px; +} + +.msg-attachment-file { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + max-width: min(420px, 100%); + border-radius: var(--radius-md); + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.06); + color: var(--text-normal); +} + +.msg-attachment-file:hover { + background: rgba(255, 255, 255, 0.07); +} + +.msg-attachment-file i { + width: 18px; + height: 18px; + color: var(--text-link); + flex-shrink: 0; +} + +.msg-attachment-file span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.msg-attachment-file small { + color: var(--text-muted); + margin-left: auto; + white-space: nowrap; +} + /* ═══════════════════════════════════════════════════════════ */ /* Sound Board */ /* ═══════════════════════════════════════════════════════════ */ @@ -1618,4 +1707,4 @@ select { .chat-header { padding: 0 8px; } -} \ No newline at end of file +} From 5c97cc515fc77c9faf58354e585eaf596d91a5d2 Mon Sep 17 00:00:00 2001 From: pavel Date: Fri, 27 Feb 2026 15:51:03 +0100 Subject: [PATCH 11/20] fix: uploads --- desktop/index.html | 6 +++++- static/shared/app-core.js | 38 ++++++++++++++++++++------------------ 2 files changed, 25 insertions(+), 19 deletions(-) diff --git a/desktop/index.html b/desktop/index.html index ed754ec..fac5aa8 100644 --- a/desktop/index.html +++ b/desktop/index.html @@ -144,6 +144,10 @@
+ + @@ -268,4 +272,4 @@ - \ No newline at end of file + diff --git a/static/shared/app-core.js b/static/shared/app-core.js index 0aa2e4f..f7013a9 100644 --- a/static/shared/app-core.js +++ b/static/shared/app-core.js @@ -1914,25 +1914,27 @@ async function init() { } catch (err) { alert(err.message); } }; - el.mediaUploadBtn.onclick = () => { - if (!state.selectedTextChannelId && !state.selectedDmUserId) { - alert("Select a chat first."); - return; - } - el.mediaFileInput.click(); - }; + if (el.mediaUploadBtn && el.mediaFileInput) { + el.mediaUploadBtn.onclick = () => { + if (!state.selectedTextChannelId && !state.selectedDmUserId) { + alert("Select a chat first."); + return; + } + el.mediaFileInput.click(); + }; - el.mediaFileInput.onchange = async () => { - const file = el.mediaFileInput.files?.[0]; - if (!file) return; - try { - await uploadMediaFile(file); - } catch (err) { - alert(err.message); - } finally { - el.mediaFileInput.value = ""; - } - }; + el.mediaFileInput.onchange = async () => { + const file = el.mediaFileInput.files?.[0]; + if (!file) return; + try { + await uploadMediaFile(file); + } catch (err) { + alert(err.message); + } finally { + el.mediaFileInput.value = ""; + } + }; + } // 4. Voice/Soundboard Controls el.voiceVideoBtn.onclick = toggleVideo; From 6882b49336324285d2e9af0483c4feaf36eb0ba5 Mon Sep 17 00:00:00 2001 From: pavel Date: Fri, 27 Feb 2026 15:57:39 +0100 Subject: [PATCH 12/20] refactor --- desktop/index.html | 6 +- desktop/styles.css | 1629 ------------------------------- package.json | 11 +- scripts/generate-html.js | 53 + shared-html/index.template.html | 272 ++++++ static/index.html | 1 + 6 files changed, 335 insertions(+), 1637 deletions(-) delete mode 100644 desktop/styles.css create mode 100644 scripts/generate-html.js create mode 100644 shared-html/index.template.html diff --git a/desktop/index.html b/desktop/index.html index fac5aa8..cb1de11 100644 --- a/desktop/index.html +++ b/desktop/index.html @@ -1,3 +1,4 @@ + @@ -8,7 +9,7 @@ - + @@ -116,8 +117,7 @@
+ + diff --git a/shared-html/index.template.html b/shared-html/index.template.html index 9780068..bbf97e8 100644 --- a/shared-html/index.template.html +++ b/shared-html/index.template.html @@ -266,6 +266,16 @@ + + diff --git a/src/handlers.rs b/src/handlers.rs index 5c2f27b..6982750 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -1,6 +1,7 @@ use axum::{ Json, Router, - extract::{Multipart, Path, Query, State, WebSocketUpgrade}, + body::Bytes, + extract::{DefaultBodyLimit, Path, Query, State, WebSocketUpgrade}, http::{HeaderMap, StatusCode, header}, response::{Html, IntoResponse, Redirect}, routing::{get, post}, @@ -35,6 +36,7 @@ pub fn routes() -> Router { "/dms/{other_user_id}/attachments", post(upload_dm_attachment), ) + .layer(DefaultBodyLimit::max(MAX_MEDIA_UPLOAD_BYTES)) .route("/presence", get(presence_list)) .route("/rtc-config", get(rtc_config)) .route("/guilds", get(list_guilds).post(create_guild)) @@ -50,6 +52,7 @@ pub fn routes() -> Router { "/guilds/{guild_id}/sounds", get(list_sounds).post(upload_sound), ) + .layer(DefaultBodyLimit::max(MAX_MEDIA_UPLOAD_BYTES)) .route( "/guilds/{guild_id}/sounds/{sound_id}", post(delete_sound_post).delete(delete_sound), @@ -63,6 +66,7 @@ pub fn routes() -> Router { "/channels/{channel_id}/attachments", post(upload_channel_attachment), ) + .layer(DefaultBodyLimit::max(MAX_MEDIA_UPLOAD_BYTES)) .route("/channels/{channel_id}/voice/ws", get(voice_ws)) .route("/ws", get(chat_ws)) } @@ -693,12 +697,17 @@ async fn upload_channel_attachment( State(state): State, user: AuthUser, Path(channel_id): Path, - multipart: Multipart, + headers: HeaderMap, + body: Bytes, ) -> Result { ensure_channel_member(&state, channel_id, user.id).await?; - let uploaded = - upload_media_from_multipart(&state, multipart, channel_object_key_prefix(channel_id)) - .await?; + let uploaded = upload_media_from_request( + &state, + &headers, + body, + channel_object_key_prefix(channel_id), + ) + .await?; let message = db::create_message_with_attachment( &state.db, @@ -731,7 +740,8 @@ async fn upload_dm_attachment( State(state): State, user: AuthUser, Path(other_user_id): Path, - multipart: Multipart, + headers: HeaderMap, + body: Bytes, ) -> Result { if other_user_id == user.id { return Err(ApiError::bad_request("cannot send dm to yourself")); @@ -747,9 +757,10 @@ async fn upload_dm_attachment( }); } - let uploaded = upload_media_from_multipart( + let uploaded = upload_media_from_request( &state, - multipart, + &headers, + body, dm_object_key_prefix(user.id, other_user_id), ) .await?; @@ -886,7 +897,7 @@ async fn upload_sound( Ok(Json(sound)) } -const MAX_MEDIA_UPLOAD_BYTES: usize = 25 * 1024 * 1024; +const MAX_MEDIA_UPLOAD_BYTES: usize = 50 * 1024 * 1024; struct UploadedMedia { object_key: String, @@ -896,9 +907,10 @@ struct UploadedMedia { original_filename: String, } -async fn upload_media_from_multipart( +async fn upload_media_from_request( state: &AppState, - mut multipart: Multipart, + headers: &HeaderMap, + body: Bytes, object_key_prefix: String, ) -> Result { let storage = state @@ -906,47 +918,34 @@ async fn upload_media_from_multipart( .as_ref() .ok_or_else(|| ApiError::internal("media storage is not configured"))?; - let mut file_name = None; - let mut file_data = None; - let mut mime_type = None; + let original_filename = headers + .get("x-file-name") + .and_then(|v| v.to_str().ok()) + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(ToString::to_string) + .ok_or_else(|| ApiError::bad_request("missing x-file-name header"))?; - while let Some(field) = multipart - .next_field() - .await - .map_err(|e| ApiError::bad_request(&e.to_string()))? - { - if field.name().unwrap_or_default() != "file" { - continue; - } + let mime_type = headers + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .map(str::trim) + .filter(|v| !v.is_empty()) + .unwrap_or("application/octet-stream") + .to_string(); - file_name = Some(field.file_name().unwrap_or("upload.bin").to_string()); - mime_type = Some( - field - .content_type() - .unwrap_or("application/octet-stream") - .to_string(), - ); - let bytes = field - .bytes() - .await - .map_err(|e| ApiError::bad_request(&e.to_string()))?; - if bytes.len() > MAX_MEDIA_UPLOAD_BYTES { - return Err(ApiError::bad_request("file exceeds 25MB upload limit")); - } - file_data = Some(bytes.to_vec()); - break; + if body.is_empty() { + return Err(ApiError::bad_request("missing file upload")); + } + if body.len() > MAX_MEDIA_UPLOAD_BYTES { + return Err(ApiError::bad_request("file exceeds 50MB upload limit")); } - - let (original_filename, mime_type, file_data) = match (file_name, mime_type, file_data) { - (Some(name), Some(mime), Some(data)) => (name, mime, data), - _ => return Err(ApiError::bad_request("missing file upload")), - }; let safe_name = sanitize_file_name(&original_filename); let object_key = format!("{}/{}-{}", object_key_prefix, Uuid::new_v4(), safe_name); - let size_bytes = file_data.len() as i64; + let size_bytes = body.len() as i64; let media_url = storage - .upload_object(&object_key, file_data, &mime_type, &original_filename) + .upload_object(&object_key, body.to_vec(), &mime_type, &original_filename) .await .map_err(|e| ApiError::internal(&e.to_string()))?; diff --git a/static/index.html b/static/index.html index 65093cb..96baa20 100644 --- a/static/index.html +++ b/static/index.html @@ -270,6 +270,16 @@ + + diff --git a/static/shared/app-core.js b/static/shared/app-core.js index f7013a9..4779042 100644 --- a/static/shared/app-core.js +++ b/static/shared/app-core.js @@ -230,6 +230,9 @@ const el = { soundFile: document.getElementById('sound-file'), soundModalCancel: document.getElementById('sound-modal-cancel'), soundSubmitBtn: document.getElementById('sound-submit-btn'), + uploadLimitModal: document.getElementById('upload-limit-modal'), + uploadLimitMessage: document.getElementById('upload-limit-message'), + uploadLimitOk: document.getElementById('upload-limit-ok'), // Mobile mobileMenuBtn: document.getElementById("mobile-menu-btn"), @@ -357,6 +360,8 @@ function formatBytes(size) { return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`; } +const MAX_MEDIA_UPLOAD_BYTES = 50 * 1024 * 1024; + function renderAttachments(attachments = []) { if (!attachments.length) return ""; @@ -382,6 +387,16 @@ function renderAttachments(attachments = []) { }).join(""); } +function showMediaUploadLimitError(file) { + const selectedSize = formatBytes(file?.size || 0); + if (el.uploadLimitModal && el.uploadLimitMessage) { + el.uploadLimitMessage.textContent = `Uploads are limited to 50 MB per file. Selected file size: ${selectedSize}.`; + el.uploadLimitModal.classList.remove("hidden"); + return; + } + alert(`Uploads are limited to 50 MB per file.\nSelected file size: ${selectedSize}.`); +} + function formatDate(isoString) { const d = new Date(isoString); const now = new Date(); @@ -1967,6 +1982,17 @@ async function init() { } }; + if (el.uploadLimitOk && el.uploadLimitModal) { + el.uploadLimitOk.onclick = () => { + el.uploadLimitModal.classList.add("hidden"); + }; + el.uploadLimitModal.onclick = (e) => { + if (e.target === el.uploadLimitModal) { + el.uploadLimitModal.classList.add("hidden"); + } + }; + } + // 5. GIF Picker const TENOR_API_KEY = "exTiFGKJ0CzESIHzVQWy3pRO8I1MAdpRomg95DBSu2sg6e7YcHgThMI4giGAx8D0"; const TENOR_CLIENT_KEY = "pavel-discord"; @@ -2039,6 +2065,10 @@ async function init() { async function uploadMediaFile(file) { const token = storageGet("chattz_token"); if (!token) throw new Error("You need to log in again."); + if (file.size > MAX_MEDIA_UPLOAD_BYTES) { + showMediaUploadLimitError(file); + return; + } const path = state.selectedTextChannelId ? `/channels/${state.selectedTextChannelId}/attachments` @@ -2047,9 +2077,6 @@ async function init() { : null; if (!path) throw new Error("Select a chat first."); - const formData = new FormData(); - formData.append("file", file); - el.mediaUploadBtn.disabled = true; el.mediaUploadBtn.title = "Uploading..."; @@ -2059,8 +2086,10 @@ async function init() { method: "POST", headers: { Authorization: `Bearer ${token}`, + "Content-Type": file.type || "application/octet-stream", + "X-File-Name": file.name, }, - body: formData, + body: file, }); if (!response.ok) { diff --git a/static/styles.css b/static/styles.css index aac3dd9..cc5a4ad 100644 --- a/static/styles.css +++ b/static/styles.css @@ -1478,6 +1478,13 @@ select { letter-spacing: -0.3px; } +.modal-copy { + margin: 0; + text-align: center; + color: var(--text-normal); + line-height: 1.5; +} + .form-item { margin-bottom: 20px; } From 3acd082fb0bfadcbc89740ea9e5791c4ed5a7747 Mon Sep 17 00:00:00 2001 From: pavel Date: Fri, 27 Feb 2026 20:34:33 +0100 Subject: [PATCH 14/20] crazy refactor --- .env.example | 7 +- Cargo.lock | 51 +- Cargo.toml | 5 +- README.md | 11 +- desktop/index.html | 5 +- desktop/preload.js | 18 +- main.js | 143 +-- scripts/generate-html.js | 14 +- shared-html/index.template.html | 5 +- src/auth.rs | 272 ++-- src/chat.rs | 317 ++++- src/config.rs | 128 +- src/db.rs | 478 ++++++- src/entity/mod.rs | 1 + src/entity/sessions.rs | 35 + src/entity/soundboard_sounds.rs | 6 +- src/handlers.rs | 419 +++++-- src/main.rs | 1116 ++++++++++++++++- src/media.rs | 15 +- src/migration/m20260227_000007_sessions.rs | 107 ++ .../m20260227_000008_soundboard_media.rs | 72 ++ ...27_000009_soundboard_file_path_nullable.rs | 50 + src/migration/mod.rs | 6 + src/models.rs | 13 +- src/voice.rs | 494 ++++++-- static/index.html | 9 +- static/shared/app-core.js | 507 ++++---- static/vendor/lucide.min.js | 12 + 28 files changed, 3467 insertions(+), 849 deletions(-) create mode 100644 src/entity/sessions.rs create mode 100644 src/migration/m20260227_000007_sessions.rs create mode 100644 src/migration/m20260227_000008_soundboard_media.rs create mode 100644 src/migration/m20260227_000009_soundboard_file_path_nullable.rs create mode 100644 static/vendor/lucide.min.js diff --git a/.env.example b/.env.example index c062b55..1390efc 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ DATABASE_URL=postgres://postgres:postgres@localhost:5432/chattz PORT=3000 +APP_BASE_URL=http://localhost:3000 # Authentik OIDC app values OIDC_CLIENT_ID=replace-me @@ -16,13 +17,9 @@ TURN_URLS=turn:turn.example.com:3478?transport=udp,turn:turn.example.com:3478?tr TURN_USERNAME=replace-me TURN_PASSWORD=replace-me -# 32+ random chars; used to sign session cookies -SESSION_SECRET=replace-with-long-random-secret -COOKIE_SECURE=false - # Cloudflare R2 media uploads R2_ACCOUNT_ID=replace-me R2_ACCESS_KEY_ID=replace-me R2_SECRET_ACCESS_KEY=replace-me R2_BUCKET=chattz-media -R2_PUBLIC_BASE_URL=https://media.example.com +MEDIA_BASE_URL=https://media.example.com diff --git a/Cargo.lock b/Cargo.lock index b496651..c44764d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -675,13 +675,14 @@ dependencies = [ "chrono", "dotenvy", "futures-util", - "jsonwebtoken", "reqwest", "sea-orm", "sea-orm-migration", "serde", "serde_json", + "sha2", "tokio", + "tower", "tower-http", "tracing", "tracing-subscriber", @@ -1648,21 +1649,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "jsonwebtoken" -version = "9.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" -dependencies = [ - "base64", - "js-sys", - "pem", - "ring", - "serde", - "serde_json", - "simple_asn1", -] - [[package]] name = "lazy_static" version = "1.5.0" @@ -1825,16 +1811,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "num-bigint" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" -dependencies = [ - "num-integer", - "num-traits", -] - [[package]] name = "num-bigint-dig" version = "0.8.6" @@ -1967,16 +1943,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "pem" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" -dependencies = [ - "base64", - "serde_core", -] - [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -2765,18 +2731,6 @@ dependencies = [ "rand_core 0.6.4", ] -[[package]] -name = "simple_asn1" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" -dependencies = [ - "num-bigint", - "num-traits", - "thiserror", - "time", -] - [[package]] name = "slab" version = "0.4.12" @@ -3131,7 +3085,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", "serde_core", diff --git a/Cargo.toml b/Cargo.toml index b6a6c27..6dbd768 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,14 +10,15 @@ aws-sdk-s3 = { version = "1", default-features = false, features = ["rt-tokio", axum = { version = "0.8", features = ["macros", "ws", "multipart"] } chrono = { version = "0.4", features = ["serde"] } dotenvy = "0.15" -jsonwebtoken = "9" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } -sea-orm = { version = "1.1", default-features = false, features = ["sqlx-postgres", "runtime-tokio-rustls", "macros", "with-chrono", "with-uuid"] } +sea-orm = { version = "1.1", default-features = false, features = ["sqlx-postgres", "runtime-tokio-rustls", "macros", "with-chrono", "with-uuid", "mock"] } sea-orm-migration = { version = "1.1", default-features = false, features = ["sqlx-postgres", "runtime-tokio-rustls"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +sha2 = "0.10" futures-util = "0.3" tokio = { version = "1", features = ["fs", "io-util", "macros", "rt-multi-thread"] } +tower = { version = "0.5", features = ["util"] } tower-http = { version = "0.6", features = ["trace", "fs"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } diff --git a/README.md b/README.md index af5e8f0..2708a60 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A simple single-instance Discord-style monolith in Rust using: ## What this includes - OIDC login flow (`/auth/login`, `/auth/callback`, `/auth/logout`) -- Signed session cookie auth +- HttpOnly session cookie auth - Channel voice chat over WebRTC (P2P mesh) with server WebSocket signaling - Guild invite codes (create + join) - Direct messages (DM) between users @@ -41,6 +41,11 @@ For voice reliability on restrictive networks, configure TURN in `.env`: - `TURN_USERNAME` - `TURN_PASSWORD` +For production deployments: +- `APP_BASE_URL` must be your public app origin and should use `https` +- `MEDIA_BASE_URL` should be a separate media origin for user uploads +- uploads and soundboard require R2/object storage to be configured + 3. Run app: ```bash @@ -55,7 +60,7 @@ Web UI is available at `http://localhost:${PORT}/`. ## Authentik setup notes Create an Authentik OAuth2/OIDC provider + application and set: -- Redirect URI: `http://localhost:3000/auth/callback` +- Redirect URI: `${APP_BASE_URL}/auth/callback` - Scopes including at least: `openid profile email` If you change `PORT`, update `OIDC_REDIRECT_URL` and this redirect URI to match. @@ -91,6 +96,7 @@ For Authentik these are commonly under `/application/o/...` for the app slug. - `GET /channels/:channel_id/voice/ws` (WebSocket signaling) All endpoints except health and auth flow require the session cookie from successful login. +Authenticated WebSocket connections (`/ws`, `/channels/:channel_id/voice/ws`) also use the same cookie session. ## Notes @@ -98,6 +104,7 @@ This is intentionally minimal and monolithic (single process, single Postgres in Voice is implemented as browser-to-browser WebRTC audio with signaling in this server. For two users behind strict NAT/firewall, you may need TURN for reliable connectivity. The web UI remembers the last selected guild in browser local storage and auto-selects it on reload. +User uploads are served from the configured media origin, not from `/static`. Mic filter modes in the UI: - `NSNet2 (Compat)`: always-on denoising mode (implemented using DeepFilterNet3 with lighter suppression preset) diff --git a/desktop/index.html b/desktop/index.html index e04011c..1684077 100644 --- a/desktop/index.html +++ b/desktop/index.html @@ -6,12 +6,9 @@ Chattz - - - - + diff --git a/desktop/preload.js b/desktop/preload.js index dfcc665..f43670e 100644 --- a/desktop/preload.js +++ b/desktop/preload.js @@ -1,18 +1,20 @@ const { contextBridge, ipcRenderer } = require('electron'); +function onIpc(channel, callback) { + const listener = (_event, payload) => callback(payload); + ipcRenderer.on(channel, listener); + return () => ipcRenderer.removeListener(channel, listener); +} + contextBridge.exposeInMainWorld('electronAPI', { copyToClipboard: (text) => ipcRenderer.invoke('clipboard-write', text), - getConfig: () => ipcRenderer.invoke('get-config'), - storageGet: (key) => ipcRenderer.invoke('storage-get', key), - storageSet: (key, value) => ipcRenderer.invoke('storage-set', key, value), - storageRemove: (key) => ipcRenderer.invoke('storage-remove', key), getUpdateState: () => ipcRenderer.invoke('get-update-state'), checkForUpdatesNow: () => ipcRenderer.invoke('check-for-updates-now'), checkForUpdates: () => ipcRenderer.send('check-for-updates'), downloadUpdate: () => ipcRenderer.send('download-update'), quitAndInstall: () => ipcRenderer.send('quit-and-install'), - onUpdateState: (callback) => ipcRenderer.on('update-state', (event, state) => callback(state)), - onUpdateAvailable: (callback) => ipcRenderer.on('update-available', (event, info) => callback(info)), - onUpdateDownloaded: (callback) => ipcRenderer.on('update-downloaded', (event, info) => callback(info)), - onUpdateError: (callback) => ipcRenderer.on('update-error', (event, error) => callback(error)) + onUpdateState: (callback) => onIpc('update-state', callback), + onUpdateAvailable: (callback) => onIpc('update-available', callback), + onUpdateDownloaded: (callback) => onIpc('update-downloaded', callback), + onUpdateError: (callback) => onIpc('update-error', callback) }); diff --git a/main.js b/main.js index 3a32c94..1baa6e2 100644 --- a/main.js +++ b/main.js @@ -1,8 +1,6 @@ const { app, BrowserWindow, session, desktopCapturer, ipcMain, clipboard } = require('electron'); const { autoUpdater } = require('electron-updater'); const path = require('path'); -const fs = require('fs'); -const url = require('url'); const updateState = { status: 'idle', // idle | checking | available | downloading | downloaded | not-available | error @@ -19,33 +17,6 @@ function broadcastUpdateState() { } } -function getStorageFilePath() { - return path.join(app.getPath('userData'), 'renderer-storage.json'); -} - -function readPersistentStore() { - const filePath = getStorageFilePath(); - try { - if (!fs.existsSync(filePath)) return {}; - const raw = fs.readFileSync(filePath, 'utf8'); - const parsed = JSON.parse(raw); - return parsed && typeof parsed === 'object' ? parsed : {}; - } catch (err) { - console.error('Failed to read persistent store', err); - return {}; - } -} - -function writePersistentStore(store) { - const filePath = getStorageFilePath(); - try { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, JSON.stringify(store), 'utf8'); - } catch (err) { - console.error('Failed to write persistent store', err); - } -} - function isNewerVersionAvailable(info) { const next = info && typeof info.version === 'string' ? info.version : ''; return Boolean(next) && next !== app.getVersion(); @@ -84,8 +55,9 @@ async function runUpdateCheck(reason = 'manual') { } function createWindow() { - // Create a persistent session for chattz to keep the user logged in const sess = session.fromPartition('persist:chattz'); + const backendUrl = (process.env.CHATTZ_URL || process.env.APP_BASE_URL || 'http://localhost:3000').replace(/\/$/, ''); + const backendOrigin = new URL(backendUrl).origin; const win = new BrowserWindow({ width: 1200, @@ -94,7 +66,6 @@ function createWindow() { webPreferences: { nodeIntegration: false, contextIsolation: true, - webSecurity: false, // Required for cross-origin fetch/ws from file:// with cookies session: sess, preload: path.join(__dirname, 'desktop', 'preload.js') } @@ -103,32 +74,33 @@ function createWindow() { win.setAutoHideMenuBar(true); win.setMenuBarVisibility(true); - // Auto-approve media permissions (camera, microphone) sess.setPermissionCheckHandler((webContents, permission) => { - if (permission === 'media' || permission === 'clipboard-read' || permission === 'clipboard-write') { - return true; - } - return false; + const origin = safeOrigin(webContents.getURL()); + if (origin !== backendOrigin) return false; + return permission === 'media' || permission === 'clipboard-write'; }); sess.setPermissionRequestHandler((webContents, permission, callback) => { - if (permission === 'media' || permission === 'clipboard-read' || permission === 'clipboard-write') { + const origin = safeOrigin(webContents.getURL()); + if (origin === backendOrigin && (permission === 'media' || permission === 'clipboard-write')) { callback(true); } else { callback(false); } }); - // Handle screen share requests sess.setDisplayMediaRequestHandler((request, callback) => { + const origin = safeOrigin(request.frame?.url || win.webContents.getURL()); + if (origin !== backendOrigin) { + callback(null); + return; + } desktopCapturer.getSources({ types: ['screen', 'window'] }).then((sources) => { - // Provide the first screen source by default, or implement a picker window here if (sources && sources.length > 0) { - // We prefer a screen over a window if available, simple heuristic const screenSource = sources.find(s => s.id.startsWith('screen')) || sources[0]; callback({ video: screenSource, audio: 'loopback' }); } else { - callback(null); // Reject safely + callback(null); } }).catch(err => { console.error("Failed to get desktop sources for screen share", err); @@ -136,55 +108,9 @@ function createWindow() { }); }); - const backendUrl = (process.env.CHATTZ_URL || 'https://discord.flegr.me').replace(/\/$/, ''); - const indexPath = path.join(__dirname, 'desktop', 'index.html'); - - const loadDesktopApp = (queryParams = '') => { - const options = {}; - if (queryParams) { - try { - options.query = Object.fromEntries(new URLSearchParams(queryParams)); - } catch (e) { - console.error("Failed to parse query params", e); - } - } - - // Use setImmediate to ensure the current navigation tick is cleared, - // which prevents ERR_ABORTED (-3) on some platforms when interrupting a redirect. - setImmediate(() => { - if (win.isDestroyed()) return; - win.loadFile(indexPath, options).catch((err) => { - // Ignore aborted errors as they often happen during fast redirects - if (err.toString().includes('-3') || err.code === -3) return; - console.error(`Failed to load desktop file: ${err}`); - }); - }); - }; - - // Intercept navigations/redirects that return to the backend with a token - // after the OAuth login flow. Two handlers are needed because: - // - will-navigate fires for client-side navigations (location.href, link clicks) - // - will-redirect fires for server-side 302 redirects (OAuth callback chain) - const interceptLoginRedirect = (event, navigatedUrl) => { - try { - const urlObj = new URL(navigatedUrl); - const backendObj = new URL(backendUrl); - if (urlObj.origin === backendObj.origin && urlObj.pathname === '/') { - if (urlObj.searchParams.has('token')) { - console.log("Detected login success redirect, returning to desktop UI..."); - event.preventDefault(); - loadDesktopApp(urlObj.search.slice(1)); - } - } - } catch (e) { - console.error(e); - } - }; - - win.webContents.on('will-navigate', interceptLoginRedirect); - win.webContents.on('will-redirect', interceptLoginRedirect); - - loadDesktopApp(); + win.loadURL(backendUrl).catch((err) => { + console.error(`Failed to load desktop app: ${err}`); + }); // Ensure renderer receives latest updater state after any (re)load. // Delay broadcast by 300ms to give the renderer time to register its @@ -203,40 +129,11 @@ app.commandLine.appendSwitch('disable-webrtc-hw-encoding'); // Sometime helps re app.commandLine.appendSwitch('log-level', '3'); // Silences standard Chromium warning logs (like SRTP transport unprotect logs which are benign timing issues) app.whenReady().then(() => { - // Register IPC handlers once (before creating any windows) - ipcMain.handle('get-config', () => { - return { - backendUrl: (process.env.CHATTZ_URL || 'https://discord.flegr.me').replace(/\/$/, '') - }; - }); - ipcMain.handle('clipboard-write', (event, text) => { clipboard.writeText(text); return true; }); - ipcMain.handle('storage-get', (event, key) => { - if (typeof key !== 'string' || key.length === 0) return null; - const store = readPersistentStore(); - return Object.prototype.hasOwnProperty.call(store, key) ? store[key] : null; - }); - - ipcMain.handle('storage-set', (event, key, value) => { - if (typeof key !== 'string' || key.length === 0) return false; - const store = readPersistentStore(); - store[key] = value; - writePersistentStore(store); - return true; - }); - - ipcMain.handle('storage-remove', (event, key) => { - if (typeof key !== 'string' || key.length === 0) return false; - const store = readPersistentStore(); - delete store[key]; - writePersistentStore(store); - return true; - }); - ipcMain.handle('get-update-state', () => ({ ...updateState })); ipcMain.handle('check-for-updates-now', async () => { await runUpdateCheck('renderer-direct'); @@ -314,3 +211,11 @@ app.on('window-all-closed', () => { app.quit(); } }); + +function safeOrigin(value) { + try { + return new URL(value).origin; + } catch (_) { + return null; + } +} diff --git a/scripts/generate-html.js b/scripts/generate-html.js index 1b3497b..a773454 100644 --- a/scripts/generate-html.js +++ b/scripts/generate-html.js @@ -5,26 +5,30 @@ const root = path.resolve(__dirname, '..'); const templatePath = path.join(root, 'shared-html', 'index.template.html'); const template = fs.readFileSync(templatePath, 'utf8'); +const updaterMarkup = ``; + const variants = { web: { styles_href: '/static/styles.css', + lucide_src: '/static/vendor/lucide.min.js', app_src: '/static/app.js?v=20260227-shared-core-1', web_downloads: `
Desktop downloads: Windows Linux (RPM)
`, - desktop_updater: '', + desktop_updater: updaterMarkup, outPath: path.join(root, 'static', 'index.html'), }, desktop: { styles_href: '../static/styles.css', + lucide_src: '../static/vendor/lucide.min.js', app_src: 'app.js?v=20260227-shared-core-1', web_downloads: '', - desktop_updater: ``, + desktop_updater: updaterMarkup, outPath: path.join(root, 'desktop', 'index.html'), }, }; diff --git a/shared-html/index.template.html b/shared-html/index.template.html index bbf97e8..3425611 100644 --- a/shared-html/index.template.html +++ b/shared-html/index.template.html @@ -5,12 +5,9 @@ Chattz - - - - + diff --git a/src/auth.rs b/src/auth.rs index 667c64d..535953e 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,33 +1,25 @@ -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -use anyhow::{Context, Result, anyhow}; +use anyhow::{Result, anyhow}; use axum::{ Json, extract::{FromRef, FromRequestParts}, - http::{StatusCode, request::Parts}, + http::{HeaderMap, StatusCode, request::Parts}, response::{IntoResponse, Response}, }; -use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode}; -use serde::{Deserialize, Serialize}; +use chrono::{Duration, Utc}; +use serde::Serialize; +use sha2::{Digest, Sha256}; use uuid::Uuid; use crate::{AppState, db}; -const OAUTH_STATE_COOKIE: &str = "chattz_oauth_state"; -const SESSION_TTL_SECS: u64 = 60 * 15; // 15 minutes -const REFRESH_TTL_SECS: u64 = 60 * 60 * 24 * 30; // 30 days - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct SessionClaims { - pub sub: String, - pub kind: String, // "access" or "refresh" - pub exp: usize, - pub iat: usize, -} +pub const OAUTH_STATE_COOKIE: &str = "chattz_oauth_state"; +pub const SESSION_COOKIE: &str = "chattz_session"; +const SESSION_TTL_DAYS: i64 = 30; #[derive(Debug, Clone)] pub struct AuthUser { pub id: Uuid, + pub session_id: String, } #[derive(Debug)] @@ -44,6 +36,13 @@ impl ApiError { } } + pub fn forbidden(msg: &str) -> Self { + Self { + status: StatusCode::FORBIDDEN, + message: msg.to_string(), + } + } + pub fn bad_request(msg: &str) -> Self { Self { status: StatusCode::BAD_REQUEST, @@ -57,6 +56,13 @@ impl ApiError { message: msg.to_string(), } } + + pub fn service_unavailable(msg: &str) -> Self { + Self { + status: StatusCode::SERVICE_UNAVAILABLE, + message: msg.to_string(), + } + } } #[derive(Serialize)] @@ -84,29 +90,25 @@ impl From for ApiError { impl FromRequestParts for AuthUser where - AppState: axum::extract::FromRef, + AppState: FromRef, S: Send + Sync, { type Rejection = ApiError; async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { let app = AppState::from_ref(state); + let session_id = read_cookie_from_headers(&parts.headers, SESSION_COOKIE) + .ok_or_else(|| ApiError::unauthorized("missing session cookie"))?; - let token = read_bearer_token(parts) - .or_else(|| read_query_token(parts)) - .ok_or_else(|| ApiError::unauthorized("missing jwt token"))?; - - let user_id = verify_session(&token, &app.settings.session_secret, "access") - .map_err(|_| ApiError::unauthorized("invalid or expired token"))?; - - let exists = db::user_exists(&app.db, user_id) + let user_id = db::touch_active_session(&app.db, &session_id) .await - .map_err(|_| ApiError::unauthorized("session user not found"))?; - if !exists { - return Err(ApiError::unauthorized("session user not found")); - } + .map_err(|_| ApiError::unauthorized("invalid or expired session"))? + .ok_or_else(|| ApiError::unauthorized("invalid or expired session"))?; - Ok(Self { id: user_id }) + Ok(Self { + id: user_id, + session_id, + }) } } @@ -114,6 +116,14 @@ pub fn new_oauth_state() -> String { Uuid::new_v4().to_string() } +pub fn new_session_id() -> String { + Uuid::new_v4().simple().to_string() +} + +pub fn session_expiry() -> chrono::DateTime { + (Utc::now() + Duration::days(SESSION_TTL_DAYS)).fixed_offset() +} + pub fn make_oauth_state_cookie(value: &str, secure: bool) -> String { format!( "{name}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=600{secure_flag}", @@ -123,76 +133,32 @@ pub fn make_oauth_state_cookie(value: &str, secure: bool) -> String { } pub fn clear_oauth_state_cookie(secure: bool) -> String { + clear_cookie(OAUTH_STATE_COOKIE, secure, "Lax") +} + +pub fn make_session_cookie(value: &str, secure: bool) -> String { format!( - "{name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0{secure_flag}", - name = OAUTH_STATE_COOKIE, + "{name}={value}; Path=/; HttpOnly; SameSite=Strict; Max-Age={ttl}{secure_flag}", + name = SESSION_COOKIE, + ttl = Duration::days(SESSION_TTL_DAYS).num_seconds(), secure_flag = if secure { "; Secure" } else { "" } ) } -pub fn read_oauth_state_from_headers(headers: &axum::http::HeaderMap) -> Option { +pub fn clear_session_cookie(secure: bool) -> String { + clear_cookie(SESSION_COOKIE, secure, "Strict") +} + +pub fn read_cookie_from_headers(headers: &HeaderMap, cookie_name: &str) -> Option { let raw = headers.get(axum::http::header::COOKIE)?.to_str().ok()?; raw.split(';').find_map(|pair| { let mut kv = pair.trim().splitn(2, '='); let key = kv.next()?; let value = kv.next()?; - (key == OAUTH_STATE_COOKIE).then(|| value.to_string()) + (key == cookie_name).then(|| value.to_string()) }) } -pub fn new_jwt_tokens(user_id: Uuid, secret: &str) -> Result<(String, String)> { - let now = now_ts(); - - let access_claims = SessionClaims { - sub: user_id.to_string(), - kind: "access".to_string(), - iat: now as usize, - exp: (now + SESSION_TTL_SECS) as usize, - }; - - let refresh_claims = SessionClaims { - sub: user_id.to_string(), - kind: "refresh".to_string(), - iat: now as usize, - exp: (now + REFRESH_TTL_SECS) as usize, - }; - - let access_token = encode( - &Header::default(), - &access_claims, - &EncodingKey::from_secret(secret.as_bytes()), - ) - .context("failed to encode access token")?; - - let refresh_token = encode( - &Header::default(), - &refresh_claims, - &EncodingKey::from_secret(secret.as_bytes()), - ) - .context("failed to encode refresh token")?; - - Ok((access_token, refresh_token)) -} - -pub fn verify_session(token: &str, secret: &str, expected_kind: &str) -> Result { - let mut validation = Validation::default(); - validation.validate_exp = true; - - let data = decode::( - token, - &DecodingKey::from_secret(secret.as_bytes()), - &validation, - ) - .context("failed to decode session token")?; - - if data.claims.kind != expected_kind { - return Err(anyhow!("invalid token kind")); - } - - let user_id = Uuid::parse_str(&data.claims.sub).context("invalid sub in session token")?; - Ok(user_id) -} - pub fn validate_oauth_state(expected_cookie: Option, query_state: &str) -> Result<()> { let expected = expected_cookie.ok_or_else(|| anyhow!("missing oauth state cookie"))?; if expected != query_state { @@ -201,37 +167,113 @@ pub fn validate_oauth_state(expected_cookie: Option, query_state: &str) Ok(()) } -fn read_bearer_token(parts: &Parts) -> Option { - let raw = parts - .headers - .get(axum::http::header::AUTHORIZATION)? - .to_str() - .ok()?; - if raw.starts_with("Bearer ") { - Some(raw["Bearer ".len()..].trim().to_string()) - } else { - None +pub fn user_agent_hash(headers: &HeaderMap) -> Option { + header_hash(headers, axum::http::header::USER_AGENT.as_str()) +} + +pub fn ip_hash(headers: &HeaderMap) -> Option { + headers + .get("x-forwarded-for") + .and_then(|v| v.to_str().ok()) + .and_then(|raw| raw.split(',').next().map(str::trim)) + .filter(|value| !value.is_empty()) + .map(hash_string) + .or_else(|| header_hash(headers, "x-real-ip")) +} + +pub fn origin_matches(headers: &HeaderMap, expected_origin: &str) -> bool { + headers + .get(axum::http::header::ORIGIN) + .and_then(|value| value.to_str().ok()) + .map(|origin| origin == expected_origin) + .unwrap_or(false) +} + +fn header_hash(headers: &HeaderMap, header_name: &str) -> Option { + headers + .get(header_name) + .and_then(|v| v.to_str().ok()) + .filter(|value| !value.trim().is_empty()) + .map(hash_string) +} + +fn hash_string(value: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(value.as_bytes()); + let digest = hasher.finalize(); + let mut out = String::with_capacity(digest.len() * 2); + for byte in digest { + out.push(nibble_to_hex(byte >> 4)); + out.push(nibble_to_hex(byte & 0x0f)); + } + out +} + +fn nibble_to_hex(value: u8) -> char { + match value { + 0..=9 => (b'0' + value) as char, + 10..=15 => (b'a' + (value - 10)) as char, + _ => unreachable!(), } } -fn read_query_token(parts: &Parts) -> Option { - let query = parts.uri.query()?; +fn clear_cookie(name: &str, secure: bool, same_site: &str) -> String { + format!( + "{name}=; Path=/; HttpOnly; SameSite={same_site}; Max-Age=0{secure_flag}", + secure_flag = if secure { "; Secure" } else { "" } + ) +} - // Simple query param parsing without pulling in url::Url overhead - for pair in query.split('&') { - let mut kv = pair.splitn(2, '='); - let key = kv.next()?; - let value = kv.next()?; - if key == "token" { - return Some(value.to_string()); - } +#[cfg(test)] +mod tests { + use super::{ + SESSION_COOKIE, clear_session_cookie, hash_string, make_session_cookie, origin_matches, + read_cookie_from_headers, + }; + use axum::http::{HeaderMap, header}; + + #[test] + fn hash_string_is_stable() { + assert_eq!( + hash_string("example"), + "50d858e0985ecc7f60418aaf0cc5ab587f42c2570a884095a9e8ccacd0f6545c" + ); } - None -} -fn now_ts() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_else(|_| Duration::from_secs(0)) - .as_secs() + #[test] + fn reads_named_cookie_from_header() { + let mut headers = HeaderMap::new(); + headers.insert( + header::COOKIE, + "other=value; chattz_session=session-123; another=ok" + .parse() + .unwrap(), + ); + + assert_eq!( + read_cookie_from_headers(&headers, SESSION_COOKIE), + Some("session-123".to_string()) + ); + } + + #[test] + fn origin_match_requires_exact_origin() { + let mut headers = HeaderMap::new(); + headers.insert(header::ORIGIN, "http://localhost:3000".parse().unwrap()); + + assert!(origin_matches(&headers, "http://localhost:3000")); + assert!(!origin_matches(&headers, "https://localhost:3000")); + } + + #[test] + fn session_cookie_has_strict_policy_and_clear_cookie_expires() { + let session_cookie = make_session_cookie("session-123", false); + let cleared_cookie = clear_session_cookie(false); + + assert!(session_cookie.contains("HttpOnly")); + assert!(session_cookie.contains("SameSite=Strict")); + assert!(session_cookie.contains("Max-Age=")); + assert!(cleared_cookie.contains("SameSite=Strict")); + assert!(cleared_cookie.contains("Max-Age=0")); + } } diff --git a/src/chat.rs b/src/chat.rs index 8b59bf6..565f08b 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -1,27 +1,30 @@ -use crate::AppState; +use std::collections::HashMap; + use axum::extract::ws::{Message, WebSocket}; use futures_util::{SinkExt, StreamExt}; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; use tokio::sync::{RwLock, mpsc}; use uuid::Uuid; +use crate::{AppState, db}; + #[derive(Clone)] pub struct ChatClient { tx: mpsc::UnboundedSender, is_idle: bool, } -#[derive(Serialize, Clone)] +#[derive(Serialize, Clone, Debug, PartialEq, Eq)] pub struct OnlineUser { pub user_id: Uuid, + pub online: bool, pub idle: bool, } #[derive(Default)] pub struct ChatHub { - // user_id -> client - clients: RwLock>, + // user_id -> connection_id -> client + clients: RwLock>>, } #[derive(Serialize, Clone)] @@ -45,85 +48,113 @@ pub enum ServerEvent { #[derive(Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] enum ClientEvent { - // Currently no interactive client events for the general chat WS Ping, SetIdleStatus { is_idle: bool }, } impl ChatHub { - pub async fn add_client(&self, user_id: Uuid, tx: mpsc::UnboundedSender) { + pub async fn add_client( + &self, + user_id: Uuid, + connection_id: Uuid, + tx: mpsc::UnboundedSender, + ) -> Option { let mut clients = self.clients.write().await; - clients.insert(user_id, ChatClient { tx, is_idle: false }); - } + let previous = aggregate_presence(clients.get(&user_id), user_id); - pub async fn remove_client(&self, user_id: Uuid) { - let mut clients = self.clients.write().await; - clients.remove(&user_id); - } - - pub async fn get_online_users(&self) -> Vec { - let clients = self.clients.read().await; clients + .entry(user_id) + .or_default() + .insert(connection_id, ChatClient { tx, is_idle: false }); + + let current = aggregate_presence(clients.get(&user_id), user_id); + presence_delta(previous, current) + } + + pub async fn remove_client(&self, user_id: Uuid, connection_id: Uuid) -> Option { + let mut clients = self.clients.write().await; + let previous = aggregate_presence(clients.get(&user_id), user_id); + + if let Some(connections) = clients.get_mut(&user_id) { + connections.remove(&connection_id); + if connections.is_empty() { + clients.remove(&user_id); + } + } + + let current = aggregate_presence(clients.get(&user_id), user_id); + presence_delta(previous, current) + } + + pub async fn get_online_users_for(&self, visible_user_ids: &[Uuid]) -> Vec { + let clients = self.clients.read().await; + visible_user_ids .iter() - .map(|(id, client)| OnlineUser { - user_id: *id, - idle: client.is_idle, - }) + .filter_map(|user_id| aggregate_presence(clients.get(user_id), *user_id)) + .filter(|presence| presence.online) .collect() } - pub async fn broadcast_all(&self, event: ServerEvent) { - let clients = self.clients.read().await; - for client in clients.values() { - let _ = client.tx.send(event.clone()); - } - } - - pub async fn broadcast_to_user(&self, user_id: Uuid, event: ServerEvent) { - let clients = self.clients.read().await; - if let Some(client) = clients.get(&user_id) { - let _ = client.tx.send(event); - } - } - pub async fn broadcast_to_many(&self, user_ids: Vec, event: ServerEvent) { let clients = self.clients.read().await; for user_id in user_ids { - if let Some(client) = clients.get(&user_id) { + if let Some(connections) = clients.get(&user_id) { + for client in connections.values() { + let _ = client.tx.send(event.clone()); + } + } + } + } + + pub async fn broadcast_to_user(&self, user_id: Uuid, event: ServerEvent) { + let clients = self.clients.read().await; + if let Some(connections) = clients.get(&user_id) { + for client in connections.values() { let _ = client.tx.send(event.clone()); } } } - pub async fn set_idle_status(&self, user_id: Uuid, is_idle: bool) { + pub async fn set_idle_status( + &self, + user_id: Uuid, + connection_id: Uuid, + is_idle: bool, + ) -> Option { + let mut clients = self.clients.write().await; + let previous = aggregate_presence(clients.get(&user_id), user_id); + + if let Some(connections) = clients.get_mut(&user_id) + && let Some(client) = connections.get_mut(&connection_id) { - let mut clients = self.clients.write().await; - if let Some(client) = clients.get_mut(&user_id) { - client.is_idle = is_idle; - } + client.is_idle = is_idle; } - self.broadcast_all(ServerEvent::UserPresence { - user_id, - online: true, - idle: is_idle, - }) - .await; + + let current = aggregate_presence(clients.get(&user_id), user_id); + presence_delta(previous, current) } } pub async fn handle_socket(state: AppState, socket: WebSocket, user_id: Uuid) { let (mut ws_sender, mut ws_receiver) = socket.split(); let (tx, mut rx) = mpsc::unbounded_channel::(); + let connection_id = Uuid::new_v4(); - state.chat.add_client(user_id, tx).await; - state - .chat - .broadcast_all(ServerEvent::UserPresence { - user_id, - online: true, - idle: false, - }) - .await; + if let Some(presence) = state.chat.add_client(user_id, connection_id, tx).await { + if let Ok(visible_user_ids) = db::list_visible_user_ids(&state.db, user_id).await { + state + .chat + .broadcast_to_many( + visible_user_ids, + ServerEvent::UserPresence { + user_id: presence.user_id, + online: presence.online, + idle: presence.idle, + }, + ) + .await; + } + } let send_task = tokio::spawn(async move { while let Some(event) = rx.recv().await { @@ -142,8 +173,24 @@ pub async fn handle_socket(state: AppState, socket: WebSocket, user_id: Uuid) { Message::Text(text) => { if let Ok(ClientEvent::SetIdleStatus { is_idle }) = serde_json::from_str::(&text) + && let Some(presence) = state + .chat + .set_idle_status(user_id, connection_id, is_idle) + .await + && let Ok(visible_user_ids) = + db::list_visible_user_ids(&state.db, user_id).await { - state.chat.set_idle_status(user_id, is_idle).await; + state + .chat + .broadcast_to_many( + visible_user_ids, + ServerEvent::UserPresence { + user_id: presence.user_id, + online: presence.online, + idle: presence.idle, + }, + ) + .await; } } _ => {} @@ -151,13 +198,157 @@ pub async fn handle_socket(state: AppState, socket: WebSocket, user_id: Uuid) { } send_task.abort(); - state.chat.remove_client(user_id).await; - state - .chat - .broadcast_all(ServerEvent::UserPresence { - user_id, + if let Some(presence) = state.chat.remove_client(user_id, connection_id).await + && let Ok(visible_user_ids) = db::list_visible_user_ids(&state.db, user_id).await + { + state + .chat + .broadcast_to_many( + visible_user_ids, + ServerEvent::UserPresence { + user_id: presence.user_id, + online: presence.online, + idle: presence.idle, + }, + ) + .await; + } +} + +fn aggregate_presence( + connections: Option<&HashMap>, + user_id: Uuid, +) -> Option { + let connections = connections?; + if connections.is_empty() { + return None; + } + + let idle = connections.values().all(|client| client.is_idle); + Some(OnlineUser { + user_id, + online: true, + idle, + }) +} + +fn presence_delta(previous: Option, current: Option) -> Option { + match (previous, current) { + (None, None) => None, + (Some(prev), Some(curr)) if prev == curr => None, + (Some(prev), None) => Some(OnlineUser { + user_id: prev.user_id, online: false, idle: false, - }) - .await; + }), + (_, Some(curr)) => Some(curr), + } +} + +#[cfg(test)] +mod tests { + use super::{ChatHub, OnlineUser, ServerEvent}; + use serde_json::json; + use tokio::sync::mpsc; + use uuid::Uuid; + + #[tokio::test] + async fn multiple_connections_keep_user_online_until_last_disconnect() { + let hub = ChatHub::default(); + let user_id = Uuid::new_v4(); + let first_connection = Uuid::new_v4(); + let second_connection = Uuid::new_v4(); + let (tx1, _rx1) = mpsc::unbounded_channel(); + let (tx2, _rx2) = mpsc::unbounded_channel(); + + let first_presence = hub.add_client(user_id, first_connection, tx1).await; + let second_presence = hub.add_client(user_id, second_connection, tx2).await; + let after_first_disconnect = hub.remove_client(user_id, first_connection).await; + let after_last_disconnect = hub.remove_client(user_id, second_connection).await; + + assert_eq!( + first_presence, + Some(OnlineUser { + user_id, + online: true, + idle: false, + }) + ); + assert_eq!(second_presence, None); + assert_eq!(after_first_disconnect, None); + assert_eq!( + after_last_disconnect, + Some(OnlineUser { + user_id, + online: false, + idle: false, + }) + ); + } + + #[tokio::test] + async fn idle_status_only_flips_when_all_connections_are_idle() { + let hub = ChatHub::default(); + let user_id = Uuid::new_v4(); + let first_connection = Uuid::new_v4(); + let second_connection = Uuid::new_v4(); + let (tx1, _rx1) = mpsc::unbounded_channel(); + let (tx2, _rx2) = mpsc::unbounded_channel(); + + hub.add_client(user_id, first_connection, tx1).await; + hub.add_client(user_id, second_connection, tx2).await; + + let first_idle = hub.set_idle_status(user_id, first_connection, true).await; + let second_idle = hub.set_idle_status(user_id, second_connection, true).await; + let active_again = hub.set_idle_status(user_id, first_connection, false).await; + + assert_eq!(first_idle, None); + assert_eq!( + second_idle, + Some(OnlineUser { + user_id, + online: true, + idle: true, + }) + ); + assert_eq!( + active_again, + Some(OnlineUser { + user_id, + online: true, + idle: false, + }) + ); + } + + #[tokio::test] + async fn broadcast_to_user_reaches_all_active_connections() { + let hub = ChatHub::default(); + let user_id = Uuid::new_v4(); + let first_connection = Uuid::new_v4(); + let second_connection = Uuid::new_v4(); + let (tx1, mut rx1) = mpsc::unbounded_channel(); + let (tx2, mut rx2) = mpsc::unbounded_channel(); + + hub.add_client(user_id, first_connection, tx1).await; + hub.add_client(user_id, second_connection, tx2).await; + + hub.broadcast_to_user( + user_id, + ServerEvent::DmCreated { + other_user_id: Uuid::new_v4(), + message: json!({ "body": "hello" }), + }, + ) + .await; + + assert!(matches!( + rx1.try_recv(), + Ok(ServerEvent::DmCreated { message, .. }) if message["body"] == "hello" + )); + assert!(matches!( + rx2.try_recv(), + Ok(ServerEvent::DmCreated { message, .. }) if message["body"] == "hello" + )); + } } diff --git a/src/config.rs b/src/config.rs index 8f55aa0..fbe7ecb 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,8 +1,11 @@ -use anyhow::{Context, Result}; +use anyhow::{Context, Result, anyhow}; +use reqwest::Url; #[derive(Clone, Debug)] pub struct Settings { pub port: u16, + pub app_base_url: String, + pub app_origin: String, pub database_url: String, pub oidc_client_id: String, pub oidc_client_secret: String, @@ -11,8 +14,7 @@ pub struct Settings { pub oidc_userinfo_url: String, pub oidc_redirect_url: String, pub oidc_scopes: String, - pub session_secret: String, - pub cookie_secure: bool, + pub session_cookie_secure: bool, pub stun_urls: Vec, pub turn_urls: Vec, pub turn_username: Option, @@ -31,11 +33,17 @@ pub struct MediaSettings { impl Settings { pub fn from_env() -> Result { + let app_base_url = required("APP_BASE_URL")?; + let app_url = Url::parse(&app_base_url).context("APP_BASE_URL must be a valid URL")?; + let app_origin = origin_from_url(&app_url)?; + Ok(Self { port: std::env::var("PORT") .unwrap_or_else(|_| "3000".into()) .parse() .context("PORT must be a valid u16")?, + app_base_url: trim_trailing_slash(&app_base_url), + app_origin, database_url: required("DATABASE_URL")?, oidc_client_id: required("OIDC_CLIENT_ID")?, oidc_client_secret: required("OIDC_CLIENT_SECRET")?, @@ -45,11 +53,7 @@ impl Settings { oidc_redirect_url: required("OIDC_REDIRECT_URL")?, oidc_scopes: std::env::var("OIDC_SCOPES") .unwrap_or_else(|_| "openid profile email".to_string()), - session_secret: required("SESSION_SECRET")?, - cookie_secure: std::env::var("COOKIE_SECURE") - .unwrap_or_else(|_| "false".into()) - .parse() - .context("COOKIE_SECURE must be true/false")?, + session_cookie_secure: requires_secure_cookie(&app_url)?, stun_urls: parse_csv_env("STUN_URLS", "stun:stun.l.google.com:19302"), turn_urls: parse_csv_env("TURN_URLS", ""), turn_username: optional("TURN_USERNAME"), @@ -65,7 +69,7 @@ impl MediaSettings { let access_key_id = optional("R2_ACCESS_KEY_ID"); let secret_access_key = optional("R2_SECRET_ACCESS_KEY"); let bucket = optional("R2_BUCKET"); - let public_base_url = optional("R2_PUBLIC_BASE_URL"); + let public_base_url = optional("MEDIA_BASE_URL").or_else(|| optional("R2_PUBLIC_BASE_URL")); if account_id.is_none() && access_key_id.is_none() @@ -81,7 +85,9 @@ impl MediaSettings { access_key_id: access_key_id.context("missing env var R2_ACCESS_KEY_ID")?, secret_access_key: secret_access_key.context("missing env var R2_SECRET_ACCESS_KEY")?, bucket: bucket.context("missing env var R2_BUCKET")?, - public_base_url: public_base_url.context("missing env var R2_PUBLIC_BASE_URL")?, + public_base_url: trim_trailing_slash( + &public_base_url.context("missing env var MEDIA_BASE_URL")?, + ), })) } @@ -109,3 +115,105 @@ fn parse_csv_env(name: &str, default_value: &str) -> Vec { .map(ToString::to_string) .collect() } + +fn origin_from_url(url: &Url) -> Result { + let host = url + .host_str() + .ok_or_else(|| anyhow!("APP_BASE_URL must include a host"))?; + let mut origin = format!("{}://{}", url.scheme(), host); + if let Some(port) = url.port() { + origin.push(':'); + origin.push_str(&port.to_string()); + } + Ok(origin) +} + +fn requires_secure_cookie(url: &Url) -> Result { + match url.scheme() { + "https" => Ok(true), + "http" => { + let host = url + .host_str() + .ok_or_else(|| anyhow!("APP_BASE_URL must include a host"))?; + if matches!(host, "localhost" | "127.0.0.1" | "::1") { + Ok(false) + } else { + Err(anyhow!( + "APP_BASE_URL must use https outside localhost when session cookies are enabled" + )) + } + } + other => Err(anyhow!("APP_BASE_URL scheme {other} is not supported")), + } +} + +fn trim_trailing_slash(value: &str) -> String { + value.trim_end_matches('/').to_string() +} + +#[cfg(test)] +mod tests { + use super::{MediaSettings, origin_from_url, requires_secure_cookie, trim_trailing_slash}; + use reqwest::Url; + + #[test] + fn origin_from_url_preserves_explicit_port() { + let url = Url::parse("https://chat.example.com:8443/app").unwrap(); + let origin = origin_from_url(&url).unwrap(); + + assert_eq!(origin, "https://chat.example.com:8443"); + } + + #[test] + fn secure_cookie_is_required_for_https_origins() { + let url = Url::parse("https://chat.example.com").unwrap(); + + assert_eq!(requires_secure_cookie(&url).unwrap(), true); + } + + #[test] + fn localhost_http_is_allowed_without_secure_cookie() { + let url = Url::parse("http://localhost:3000").unwrap(); + + assert_eq!(requires_secure_cookie(&url).unwrap(), false); + } + + #[test] + fn non_localhost_http_is_rejected() { + let url = Url::parse("http://chat.example.com").unwrap(); + let err = requires_secure_cookie(&url).unwrap_err(); + + assert!( + err.to_string() + .contains("APP_BASE_URL must use https outside localhost") + ); + } + + #[test] + fn trim_trailing_slash_removes_only_suffix_slashes() { + assert_eq!( + trim_trailing_slash("https://chat.example.com///"), + "https://chat.example.com" + ); + assert_eq!( + trim_trailing_slash("https://chat.example.com/app"), + "https://chat.example.com/app" + ); + } + + #[test] + fn media_endpoint_url_uses_account_id() { + let media = MediaSettings { + account_id: "acct123".to_string(), + access_key_id: "key".to_string(), + secret_access_key: "secret".to_string(), + bucket: "bucket".to_string(), + public_base_url: "https://media.example.com".to_string(), + }; + + assert_eq!( + media.endpoint_url(), + "https://acct123.r2.cloudflarestorage.com" + ); + } +} diff --git a/src/db.rs b/src/db.rs index 42d980b..c86dd8c 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,15 +1,17 @@ +use std::collections::HashSet; + use anyhow::{Result, anyhow}; use chrono::{Duration, Utc}; use sea_orm::{ ActiveModelTrait, ActiveValue::Set, ColumnTrait, Condition, DatabaseConnection, DatabaseTransaction, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, - TransactionTrait, sea_query::OnConflict, + Statement, TransactionTrait, sea_query::OnConflict, }; use uuid::Uuid; use crate::{ entity::{ - attachments, channels, direct_messages, guild_members, guilds, invites, messages, + attachments, channels, direct_messages, guild_members, guilds, invites, messages, sessions, soundboard_sounds, users, }, models::{ @@ -26,6 +28,88 @@ pub async fn user_exists(db: &DatabaseConnection, user_id: Uuid) -> Result Ok(count > 0) } +pub async fn create_session( + db: &DatabaseConnection, + session_id: &str, + user_id: Uuid, + expires_at: chrono::DateTime, + user_agent_hash: Option, + ip_hash: Option, +) -> Result<()> { + sessions::Entity::insert(sessions::ActiveModel { + id: Set(session_id.to_string()), + user_id: Set(user_id), + expires_at: Set(expires_at), + created_at: Set(Utc::now().fixed_offset()), + last_seen_at: Set(Utc::now().fixed_offset()), + revoked_at: Set(None), + user_agent_hash: Set(user_agent_hash), + ip_hash: Set(ip_hash), + }) + .exec(db) + .await?; + + Ok(()) +} + +pub async fn touch_active_session( + db: &DatabaseConnection, + session_id: &str, +) -> Result> { + let session = sessions::Entity::find_by_id(session_id.to_string()) + .one(db) + .await?; + + let Some(session) = session else { + return Ok(None); + }; + + if session.revoked_at.is_some() || session.expires_at <= Utc::now().fixed_offset() { + return Ok(None); + } + + let user_id = session.user_id; + sessions::Entity::update_many() + .col_expr( + sessions::Column::LastSeenAt, + sea_orm::sea_query::Expr::value(Utc::now().fixed_offset()), + ) + .filter(sessions::Column::Id.eq(session_id.to_string())) + .exec(db) + .await?; + Ok(Some(user_id)) +} + +pub async fn revoke_session(db: &DatabaseConnection, session_id: &str) -> Result<()> { + if let Some(session) = sessions::Entity::find_by_id(session_id.to_string()) + .one(db) + .await? + { + sessions::Entity::update_many() + .col_expr( + sessions::Column::RevokedAt, + sea_orm::sea_query::Expr::value(Some(Utc::now().fixed_offset())), + ) + .filter(sessions::Column::Id.eq(session.id)) + .exec(db) + .await?; + } + + Ok(()) +} + +pub async fn cleanup_sessions(db: &DatabaseConnection) -> Result<()> { + sessions::Entity::delete_many() + .filter( + Condition::any() + .add(sessions::Column::ExpiresAt.lte(Utc::now().fixed_offset())) + .add(sessions::Column::RevokedAt.is_not_null()), + ) + .exec(db) + .await?; + Ok(()) +} + pub async fn upsert_user_from_oidc( db: &DatabaseConnection, oidc_sub: &str, @@ -106,18 +190,58 @@ pub async fn list_guilds_for_user(db: &DatabaseConnection, user_id: Uuid) -> Res .collect()) } +pub async fn list_visible_user_ids(db: &DatabaseConnection, user_id: Uuid) -> Result> { + let guild_ids: Vec = guild_members::Entity::find() + .filter(guild_members::Column::UserId.eq(user_id)) + .select_only() + .column(guild_members::Column::GuildId) + .into_tuple() + .all(db) + .await?; + + let mut visible = HashSet::from([user_id]); + + if !guild_ids.is_empty() { + let guild_users = guild_members::Entity::find() + .filter(guild_members::Column::GuildId.is_in(guild_ids)) + .all(db) + .await?; + visible.extend(guild_users.into_iter().map(|membership| membership.user_id)); + } + + let dm_rows = direct_messages::Entity::find() + .filter( + Condition::any() + .add(direct_messages::Column::SenderUserId.eq(user_id)) + .add(direct_messages::Column::RecipientUserId.eq(user_id)), + ) + .all(db) + .await?; + + for row in dm_rows { + if row.sender_user_id == user_id { + visible.insert(row.recipient_user_id); + } else { + visible.insert(row.sender_user_id); + } + } + + Ok(visible.into_iter().collect()) +} + pub async fn create_guild( db: &DatabaseConnection, owner_user_id: Uuid, name: &str, ) -> Result { + let txn = db.begin().await?; let guild = guilds::Entity::insert(guilds::ActiveModel { id: Set(Uuid::new_v4()), name: Set(name.to_string()), owner_user_id: Set(owner_user_id), ..Default::default() }) - .exec_with_returning(db) + .exec_with_returning(&txn) .await?; guild_members::Entity::insert(guild_members::ActiveModel { @@ -133,17 +257,13 @@ pub async fn create_guild( .do_nothing() .to_owned(), ) - .exec(db) + .exec(&txn) .await?; + txn.commit().await?; Ok(map_guild(guild)) } -pub async fn get_guild_by_id(db: &DatabaseConnection, guild_id: Uuid) -> Result> { - let row = guilds::Entity::find_by_id(guild_id).one(db).await?; - Ok(row.map(map_guild)) -} - pub async fn is_guild_owner( db: &DatabaseConnection, guild_id: Uuid, @@ -197,7 +317,12 @@ pub async fn create_invite( pub async fn join_invite(db: &DatabaseConnection, code: &str, user_id: Uuid) -> Result { let txn = db.begin().await?; - let invite = invites::Entity::find_by_id(code.to_string()) + let invite = invites::Entity::find() + .from_raw_sql(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + r#"SELECT * FROM invites WHERE code = $1 FOR UPDATE"#, + [code.into()], + )) .one(&txn) .await? .ok_or_else(|| anyhow!("invite not found"))?; @@ -797,7 +922,10 @@ pub async fn create_sound( created_by_user_id: Uuid, name: &str, icon: &str, - file_path: &str, + object_key: &str, + media_url: &str, + mime_type: &str, + size_bytes: i64, ) -> Result { let model = soundboard_sounds::Entity::insert(soundboard_sounds::ActiveModel { id: Set(Uuid::new_v4()), @@ -805,7 +933,13 @@ pub async fn create_sound( created_by_user_id: Set(created_by_user_id), name: Set(name.to_string()), icon: Set(icon.to_string()), - file_path: Set(file_path.to_string()), + object_key: Set(Some(object_key.to_string())), + media_url: Set(media_url.to_string()), + mime_type: Set(Some(mime_type.to_string())), + size_bytes: Set(Some(size_bytes)), + // Keep the legacy column populated until every deployment has applied + // the nullable migration and old fallback paths are fully removed. + file_path: Set(Some(media_url.to_string())), ..Default::default() }) .exec_with_returning(db) @@ -819,6 +953,11 @@ pub async fn get_sound_by_id(db: &DatabaseConnection, id: Uuid) -> Result