From d703501b7869eb5d4a2489a640667bf36181ae2e Mon Sep 17 00:00:00 2001 From: pavel Date: Tue, 24 Feb 2026 21:37:18 +0100 Subject: [PATCH] test --- .gitignore | 3 +- desktop/app.js | 1451 ++++++++++++++++++++++++++++++++++++++++++++ desktop/index.html | 244 ++++++++ desktop/styles.css | 1296 +++++++++++++++++++++++++++++++++++++++ main.js | 79 +++ package-lock.json | 871 ++++++++++++++++++++++++++ package.json | 13 + src/auth.rs | 64 +- src/handlers.rs | 28 +- static/app.js | 76 ++- static/index.html | 4 +- test_output.txt | 5 + 12 files changed, 4062 insertions(+), 72 deletions(-) create mode 100644 desktop/app.js create mode 100644 desktop/index.html create mode 100644 desktop/styles.css create mode 100644 main.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 test_output.txt diff --git a/.gitignore b/.gitignore index 0b745e2..2b240d4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target -.env \ No newline at end of file +.env +node_modules \ No newline at end of file diff --git a/desktop/app.js b/desktop/app.js new file mode 100644 index 0000000..885a5e6 --- /dev/null +++ b/desktop/app.js @@ -0,0 +1,1451 @@ +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, + audioContext: null, + peerConnections: new Map(), + muted: false, + sharingVideo: false, + sharingScreen: false, + viewMode: 'chat', // 'chat' or 'video' + iceServers: [{ urls: "stun:stun.l.google.com:19302" }], + }, + voicePresencePollId: null, + chatWs: null, + lastMessageId: null, + onlineUsers: new Set(), +}; + +// --- Desktop Backend Configuration --- +const urlParams = new URLSearchParams(window.location.search); +const API_BASE_URL = urlParams.get('backend') || ''; // Absolute URL to the remote server + +function getWsUrl(path) { + let urlStr; + if (!API_BASE_URL) { + const proto = location.protocol === "https:" ? "wss" : "ws"; + urlStr = `${proto}://${location.host}${path}`; + } else { + const url = new URL(API_BASE_URL); + const proto = url.protocol === "https:" ? "wss" : "ws"; + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + urlStr = `${proto}://${url.host}${normalizedPath}`; + } + + const token = localStorage.getItem("chattz_token"); + if (token) { + urlStr += (urlStr.includes('?') ? '&' : '?') + `token=${token}`; + } + return urlStr; +} + + + + + + +const el = { + authScreen: document.getElementById("auth-screen"), + loginBtn: document.getElementById("login-btn"), + main: document.getElementById("main"), + status: document.getElementById("status"), + + // Guilds + guildList: document.getElementById("guild-list"), + addGuildBtn: document.getElementById("add-guild-btn"), + guildTitle: document.getElementById("guild-title"), + createInviteBtn: document.getElementById("create-invite-btn"), + + // Channels + channelList: document.getElementById("channel-list"), + voiceChannelList: document.getElementById("voice-channel-list"), + addTextBtn: document.getElementById("add-text-btn"), + addVoiceBtn: document.getElementById("add-voice-btn"), + dmList: document.getElementById("dm-list"), + channelTitle: document.getElementById("channel-title"), + + // Messages + messageList: document.getElementById("message-list"), + messageForm: document.getElementById("message-form"), + messageBody: document.getElementById("message-body"), + + // User Panel + userName: document.getElementById("user-name"), + userAvatar: document.getElementById("user-avatar"), + logoutBtn: document.getElementById("logout-btn"), + + // Voice Connection + voiceConnection: document.getElementById("voice-connection"), + vcChannelName: document.getElementById("vc-channel-name"), + voiceVideoBtn: document.getElementById("voice-video-btn"), + voiceScreenBtn: document.getElementById("voice-screen-btn"), + voiceMuteBtn: document.getElementById("voice-mute-btn"), + voiceLeaveBtn: document.getElementById("voice-leave-btn"), + videoGrid: document.getElementById("video-grid"), + messageForm: document.getElementById("message-form"), + messageInputWrapper: document.querySelector(".chat-input-wrapper"), + + // Members + memberList: document.getElementById("member-list"), + + // Modals + modalContainer: document.getElementById("modal-container"), + guildForm: document.getElementById("guild-form"), + guildName: document.getElementById("guild-name"), + modalCancel: document.getElementById("modal-cancel"), + + channelModal: document.getElementById("channel-modal"), + channelForm: document.getElementById("channel-form"), + channelName: document.getElementById("channel-name"), + channelModalCancel: document.getElementById("channel-modal-cancel"), + + // Sound Board + soundboard: document.getElementById('soundboard'), + soundboardGrid: document.getElementById('soundboard-grid'), + addSoundBtn: document.getElementById('add-sound-btn'), + soundModal: document.getElementById('sound-modal'), + soundForm: document.getElementById('sound-form'), + soundName: document.getElementById('sound-name'), + soundIcon: document.getElementById('sound-icon'), + soundFile: document.getElementById('sound-file'), + soundModalCancel: document.getElementById('sound-modal-cancel'), + soundSubmitBtn: document.getElementById('sound-submit-btn'), + + // Mobile + mobileMenuBtn: document.getElementById("mobile-menu-btn"), + mobileMembersBtn: document.getElementById("mobile-members-btn"), + overlay: document.getElementById("overlay"), + utilitySidebar: document.getElementById("utility-sidebar"), +}; + +// --- 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}`; + } + + const res = await fetch(fullUrl, { + ...options, + headers, + }); + + if (!res.ok) { + let detail = "request failed"; + try { + const body = await res.json(); + detail = body.error || detail; + } catch { } + throw new Error(`${res.status}: ${detail}`); + } + + if (res.status === 204) return null; + return res.json(); +} + +// --- Utils --- + +function shortName(name) { + if (!name || typeof name !== 'string') return "?"; + const trimmed = name.trim(); + if (!trimmed) return "?"; + const words = trimmed.split(/\s+/).filter(w => w.length > 0).slice(0, 2); + if (words.length === 0) return "?"; + if (words.length === 1) return words[0].substring(0, 2).toUpperCase(); + return words.map((w) => w[0]?.toUpperCase() || "").join(""); +} + +function escapeHtml(s) { + if (s === null || s === undefined) return ""; + return String(s) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function formatDate(isoString) { + const d = new Date(isoString); + const now = new Date(); + const isToday = d.toDateString() === now.toDateString(); + + if (isToday) { + return `Today at ${d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`; + } + return d.toLocaleDateString(); +} + +let audioCtx = null; + +// Global interaction listener to unlock AudioContext (autoplay policy) +window.addEventListener('click', () => { + if (audioCtx && audioCtx.state === 'suspended') { + audioCtx.resume(); + } +}, { once: true }); + +function playSound(type) { + try { + if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)(); + if (audioCtx.state === 'suspended') audioCtx.resume(); + + const sounds = { + message: { freq: 880, type: 'sine', duration: 0.1, volume: 0.1 }, + dm: { freq: [660, 880], type: 'sine', duration: 0.15, volume: 0.1 }, + join: { freq: [440, 880], type: 'sine', duration: 0.2, volume: 0.1 }, + leave: { freq: [880, 440], type: 'sine', duration: 0.2, volume: 0.1 }, + 'peer-join': { freq: [660, 990], type: 'sine', duration: 0.15, volume: 0.05 }, + 'peer-leave': { freq: [990, 660], type: 'sine', duration: 0.15, volume: 0.05 }, + }; + + const s = sounds[type]; + if (!s) return; + + const osc = audioCtx.createOscillator(); + const gain = audioCtx.createGain(); + + osc.type = s.type; + if (Array.isArray(s.freq)) { + osc.frequency.setValueAtTime(s.freq[0], audioCtx.currentTime); + osc.frequency.exponentialRampToValueAtTime(s.freq[1], audioCtx.currentTime + s.duration); + } else { + osc.frequency.setValueAtTime(s.freq, audioCtx.currentTime); + } + + gain.gain.setValueAtTime(s.volume, audioCtx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + s.duration); + + osc.connect(gain); + gain.connect(audioCtx.destination); + + osc.start(); + osc.stop(audioCtx.currentTime + s.duration); + } catch (err) { + console.warn("playSound failed", err); + } +} +const LAST_GUILD_STORAGE_KEY = "chattz:lastGuildId"; + +// --- Renderers --- + +function renderGuilds() { + el.guildList.innerHTML = ""; + + for (const guild of state.guilds) { + const btn = document.createElement("button"); + btn.className = `guild-pill ${state.selectedGuildId === guild.id ? "active" : ""}`; + btn.title = guild.name; + btn.textContent = shortName(guild.name); + btn.onclick = async () => { + state.selectedGuildId = guild.id; + state.selectedTextChannelId = null; + state.selectedVoiceChannelId = null; + state.selectedDmUserId = null; + state.selectedDmDisplayName = null; + localStorage.setItem(LAST_GUILD_STORAGE_KEY, guild.id); + renderGuilds(); + renderDMs(); + await loadChannels(); + await loadGuildMembers(); + await refreshVoicePresence(); + startVoicePresencePolling(); + renderMessages([]); + updateHeaderLabels(); + }; + el.guildList.appendChild(btn); + } +} + +function renderChannels() { + el.channelList.innerHTML = ""; + el.voiceChannelList.innerHTML = ""; + + for (const channel of state.channels) { + const row = document.createElement("button"); + row.className = `channel-row ${(channel.kind === 'text' && state.selectedTextChannelId === channel.id) || + (channel.kind === 'voice' && state.selectedVoiceChannelId === channel.id) ? "active" : "" + }`; + + const iconName = channel.kind === 'text' ? 'hash' : 'volume-2'; + row.innerHTML = ` ${escapeHtml(channel.name)}`; + + row.onclick = async () => { + if (channel.kind === 'text') { + state.selectedDmUserId = null; + state.selectedDmDisplayName = null; + state.selectedTextChannelId = channel.id; + state.voice.viewMode = 'chat'; + renderChannels(); + renderDMs(); + updateView(); + updateHeaderLabels(); + const messages = await api(`/channels/${channel.id}/messages?limit=100`); + renderMessages(messages); + } else { + state.selectedVoiceChannelId = channel.id; + state.voice.viewMode = 'video'; + renderChannels(); + renderDMs(); + updateView(); + updateHeaderLabels(); + joinVoice(); + } + if (window.innerWidth <= 768) closeMobileMenus(); + }; + + if (channel.kind === 'text') { + el.channelList.appendChild(row); + } else { + el.voiceChannelList.appendChild(row); + + const participants = state.voicePresence.get(channel.id) || []; + if (participants.length > 0) { + const pList = document.createElement("div"); + pList.className = "voice-row-members channel-list"; + pList.style.paddingLeft = "24px"; + for (const p of participants) { + const pRow = document.createElement("div"); + pRow.className = `channel-row ${p.is_speaking ? 'voice-speaking' : ''}`; + pRow.style.padding = "2px 8px"; + pRow.innerHTML = `
${shortName(p.display_name)}
${escapeHtml(p.display_name)}`; + pList.appendChild(pRow); + } + el.voiceChannelList.appendChild(pList); + } + } + } + lucide.createIcons(); +} + +function renderDMs() { + el.dmList.innerHTML = ""; + for (const dm of state.dmConversations) { + const isOnline = state.onlineUsers.has(dm.user_id); + const row = document.createElement("button"); + row.className = `channel-row ${state.selectedDmUserId === dm.user_id ? "active" : ""}`; + row.innerHTML = ` +
+ +
+
+ ${escapeHtml(dm.display_name)} + `; + row.onclick = async () => { + state.selectedDmUserId = dm.user_id; + state.selectedDmDisplayName = dm.display_name; + state.selectedTextChannelId = null; + state.selectedVoiceChannelId = null; + state.voice.viewMode = 'chat'; + renderChannels(); + renderDMs(); + updateView(); + updateHeaderLabels(); + const messages = await api(`/dms/${dm.user_id}/messages?limit=100`); + renderMessages(messages); + }; + el.dmList.appendChild(row); + } + + lucide.createIcons(); +} + +function renderMessages(messages) { + el.messageList.innerHTML = ""; + let lastAuthorId = null; + let lastTime = null; + + for (const m of messages.slice().reverse()) { + const row = document.createElement("div"); + const mDate = new Date(m.created_at); + const isGrouped = lastAuthorId === m.author_user_id && + lastTime && (mDate - lastTime < 300000); // 5 minutes + + row.className = `msg ${isGrouped ? "msg-grouped" : ""}`; + + const displayName = m.author_display_name || "Unknown User"; + + if (isGrouped) { + row.innerHTML = `
${escapeHtml(m.body)}
`; + } else { + row.innerHTML = ` +
${shortName(displayName)}
+
+
+ ${escapeHtml(displayName)} + ${formatDate(m.created_at)} +
+
${escapeHtml(m.body)}
+
+ `; + } + el.messageList.appendChild(row); + lastAuthorId = m.author_user_id; + lastTime = mDate; + } + + if (messages.length > 0) { + state.lastMessageId = messages[0].id; + } + + el.messageList.scrollTop = el.messageList.scrollHeight; +} + +function renderMembers() { + el.memberList.innerHTML = ""; + for (const m of state.members) { + const isOnline = state.onlineUsers.has(m.id); + const row = document.createElement("div"); + row.className = "member-row"; + row.innerHTML = ` +
+ ${shortName(m.display_name)} +
+
+
${escapeHtml(m.display_name)}
+ `; + row.style.cursor = "pointer"; + row.onclick = async () => { + if (state.me && m.id === state.me.id) return; + state.selectedDmUserId = m.id; + state.selectedDmDisplayName = m.display_name; + state.selectedTextChannelId = null; + state.selectedVoiceChannelId = null; + state.voice.viewMode = 'chat'; + renderChannels(); + renderDMs(); + updateView(); + updateHeaderLabels(); + const messages = await api(`/dms/${m.id}/messages?limit=100`); + renderMessages(messages); + }; + el.memberList.appendChild(row); + } +} + +function updateHeaderLabels() { + const guild = state.guilds.find((g) => g.id === state.selectedGuildId); + el.guildTitle.textContent = guild ? guild.name : "No server selected"; + + if (state.selectedDmUserId) { + const dmFromConversations = state.dmConversations.find((u) => u.user_id === state.selectedDmUserId); + const dmFromMembers = state.members.find((m) => m.id === state.selectedDmUserId); + const displayName = dmFromConversations?.display_name || dmFromMembers?.display_name || state.selectedDmDisplayName; + const dmName = displayName ? `@${displayName}` : "Direct Message"; + el.channelTitle.textContent = dmName; + el.messageBody.placeholder = displayName + ? `Message @${displayName}` + : "Message"; + return; + } + + if (state.voice.viewMode === 'video' && state.selectedVoiceChannelId) { + const channel = state.channels.find((c) => c.id === state.selectedVoiceChannelId); + el.channelTitle.textContent = channel ? channel.name : "Voice Arena"; + return; + } + + const channel = state.channels.find((c) => c.id === state.selectedTextChannelId); + el.channelTitle.textContent = channel ? channel.name : "Select a channel"; + el.messageBody.placeholder = channel ? `Message #${channel.name}` : "Select a channel"; +} + +async function createInviteLink() { + if (!state.selectedGuildId) { + alert("Select a server first."); + return; + } + try { + const invite = await api(`/guilds/${state.selectedGuildId}/invites`, { + method: "POST", + body: JSON.stringify({ max_uses: 50, expires_in_hours: 24 }), + }); + const link = `${location.origin}/?invite=${encodeURIComponent(invite.code)}`; + try { + await navigator.clipboard.writeText(link); + alert(`Invite link copied:\n${link}`); + } catch { + prompt("Copy invite link:", link); + } + } catch (err) { + alert(err.message); + } +} + +// --- Logic --- + +async function loadGuilds() { + state.guilds = await api("/guilds"); + renderGuilds(); +} + +async function loadDMConversations() { + state.dmConversations = await api("/dms"); + renderDMs(); +} + +async function loadGuildMembers() { + if (!state.selectedGuildId) { + state.members = []; + renderMembers(); + return; + } + state.members = await api(`/guilds/${state.selectedGuildId}/members`); + renderMembers(); +} + +async function loadChannels() { + if (!state.selectedGuildId) { + state.channels = []; + renderChannels(); + return; + } + + state.channels = await api(`/guilds/${state.selectedGuildId}/channels`); + renderChannels(); +} + +async function refreshVoicePresence() { + if (!state.selectedGuildId) { + state.voicePresence.clear(); + renderChannels(); + return; + } + + try { + const res = await api(`/guilds/${state.selectedGuildId}/voice-presence`); + state.voicePresence.clear(); + for (const entry of res.channels || []) { + state.voicePresence.set(entry.channel_id, entry.participants || []); + } + renderChannels(); + } catch (err) { + console.warn("voice presence failed", err); + } +} + +function startVoicePresencePolling() { + if (state.voicePresencePollId) clearInterval(state.voicePresencePollId); + state.voicePresencePollId = setInterval(() => { + refreshVoicePresence().catch(() => { }); + }, 3000); +} + +function initChatWs() { + if (state.chatWs) state.chatWs.close(); + const wsUrl = getWsUrl('/ws'); + const ws = new WebSocket(wsUrl); + state.chatWs = ws; + + ws.onmessage = (event) => { + const msg = JSON.parse(event.data); + const authorId = msg.message?.author_user_id; + const isFromMe = authorId && state.me && authorId === state.me.id; + + if (msg.type === "message_created") { + if (!isFromMe) playSound('message'); + if (state.selectedTextChannelId === msg.channel_id) { + api(`/channels/${state.selectedTextChannelId}/messages?limit=100`).then(renderMessages); + } + } else if (msg.type === "dm_created") { + if (!isFromMe) playSound('dm'); + if (state.selectedDmUserId === msg.other_user_id) { + api(`/dms/${state.selectedDmUserId}/messages?limit=100`).then(renderMessages); + playSound('dm'); + loadDMConversations().then(renderDMs); + } else { + loadDMConversations().then(renderDMs); + } + } else if (msg.type === "user_presence") { + if (msg.online) { + state.onlineUsers.add(msg.user_id); + } else { + state.onlineUsers.delete(msg.user_id); + } + renderMembers(); + renderDMs(); + } + }; + + ws.onclose = () => { + console.log("Chat WS closed, reconnecting..."); + setTimeout(initChatWs, 3000); + }; +} + +// --- Voice --- + +function getVoiceWsUrl(channelId) { + return getWsUrl(`/channels/${channelId}/voice/ws`); +} + +function shouldInitiateOffer(peerId) { + if (!state.me || !state.me.id) return false; + return state.me.id > peerId; +} + +function stopAndClearAudioPipeline() { + if (state.voice.localStream) { + for (const track of state.voice.localStream.getTracks()) track.stop(); + } + if (state.voice.rawStream && state.voice.rawStream !== state.voice.localStream) { + for (const track of state.voice.rawStream.getTracks()) track.stop(); + } + if (state.voice.audioContext) { + state.voice.audioContext.close().catch(() => { }); + } + state.voice.localStream = null; + state.voice.rawStream = null; + state.voice.audioContext = null; +} + +function stopAndClearVideoPipeline() { + if (state.voice.videoStream) { + for (const track of state.voice.videoStream.getTracks()) track.stop(); + } + state.voice.videoStream = null; + state.voice.sharingVideo = false; + document.getElementById(`video-${state.me.id}-camera`)?.parentElement?.remove(); +} + +function stopAndClearScreenPipeline() { + if (state.voice.screenStream) { + for (const track of state.voice.screenStream.getTracks()) track.stop(); + } + state.voice.screenStream = null; + state.voice.sharingScreen = false; + document.getElementById(`video-${state.me.id}-screen`)?.parentElement?.remove(); +} + +function updateVideoGridVisibility() { + // Persistence logic: we always update the grid visibility based on participants + // however the actual container visibility in the UI is managed by updateView() +} + +function updateView() { + if (state.voice.viewMode === 'video') { + el.videoGrid.classList.remove("hidden"); + el.messageList.classList.add("hidden"); + el.messageInputWrapper.classList.add("hidden"); + } else { + el.videoGrid.classList.add("hidden"); + el.messageList.classList.remove("hidden"); + el.messageInputWrapper.classList.remove("hidden"); + } +} + +function renderVideo(peerId, displayName, stream, source) { + const videoId = `video-${peerId}-${source}`; + let videoEl = document.getElementById(videoId); + if (!videoEl) { + const container = document.createElement("div"); + container.className = "video-item"; + const label = source === 'screen' ? `${escapeHtml(displayName)}'s Screen` : escapeHtml(displayName); + container.innerHTML = ` + +
${label}
+ `; + el.videoGrid.appendChild(container); + videoEl = container.querySelector("video"); + } + videoEl.srcObject = stream; +} + +async function buildAudioPipeline(rawStream) { + const ctx = new AudioContext(); + state.voice.audioContext = ctx; + const source = ctx.createMediaStreamSource(rawStream); + + // Metering/Speaking detection + const analyser = ctx.createAnalyser(); + analyser.fftSize = 512; + source.connect(analyser); + + const dataArray = new Uint8Array(analyser.frequencyBinCount); + let localIsSpeaking = false; + let speakCounter = 0; + + const checkVolume = () => { + if (!state.voice.audioContext || state.voice.audioContext.state === 'closed') return; + analyser.getByteFrequencyData(dataArray); + let sum = 0; + for (let i = 0; i < dataArray.length; i++) sum += dataArray[i]; + const avg = sum / dataArray.length; + + const isCurrentlySpeaking = avg > 30; // Calibrated for normal speech + + if (isCurrentlySpeaking) { + speakCounter = Math.min(speakCounter + 1, 5); + } else { + speakCounter = Math.max(speakCounter - 1, 0); + } + + const newSpeakingState = speakCounter >= 2; + + if (newSpeakingState !== localIsSpeaking) { + localIsSpeaking = newSpeakingState; + if (state.voice.ws && state.voice.ws.readyState === WebSocket.OPEN) { + state.voice.ws.send(JSON.stringify({ type: 'set_speaking_status', is_speaking: localIsSpeaking })); + } + // Local UI update + state.voicePresence.get(state.selectedVoiceChannelId)?.forEach(p => { + if (p.user_id === state.me.id) p.is_speaking = localIsSpeaking; + }); + renderChannels(); + const myVideo = document.getElementById(`video-${state.me.id}-camera`) || document.getElementById(`video-${state.me.id}-screen`); + if (myVideo) { + myVideo.parentElement.classList.toggle('speaking', localIsSpeaking); + } + } + setTimeout(checkVolume, 100); + }; + checkVolume(); + + const destination = ctx.createMediaStreamDestination(); + source.connect(destination); + return destination.stream; +} + +async function createLocalVoiceStream() { + const constraints = { + audio: { + channelCount: 1, + sampleRate: 48000, + echoCancellation: true, + noiseSuppression: true, + autoGainControl: false, + }, + video: false, + }; + const rawStream = await navigator.mediaDevices.getUserMedia(constraints); + const localStream = await buildAudioPipeline(rawStream); + state.voice.rawStream = rawStream; + state.voice.localStream = localStream; + if (state.voice.muted) { + state.voice.localStream.getAudioTracks().forEach((t) => { + t.enabled = false; + }); + } +} + +function ensurePeerConnection(peerId) { + if (state.voice.peerConnections.has(peerId)) { + return state.voice.peerConnections.get(peerId); + } + + const pc = new RTCPeerConnection({ iceServers: state.voice.iceServers }); + + // Add transceivers in a fixed order to ensure stable SDP m-lines: [Audio, Camera, Screen] + // 1. Audio + if (state.voice.localStream) { + pc.addTransceiver(state.voice.localStream.getAudioTracks()[0], { + direction: 'sendrecv', + streams: [state.voice.localStream] + }); + } else { + pc.addTransceiver('audio', { direction: 'sendrecv' }); + } + + // 2. Camera Video + if (state.voice.videoStream) { + pc.addTransceiver(state.voice.videoStream.getVideoTracks()[0], { + direction: 'sendrecv', + streams: [state.voice.videoStream] + }); + } else { + pc.addTransceiver('video', { direction: 'sendrecv' }); + } + + // 3. Screen Video + if (state.voice.screenStream) { + pc.addTransceiver(state.voice.screenStream.getVideoTracks()[0], { + direction: 'sendrecv', + streams: [state.voice.screenStream] + }); + } else { + pc.addTransceiver('video', { direction: 'sendrecv' }); + } + + pc.onicecandidate = (event) => { + if (!event.candidate || !state.voice.ws) return; + state.voice.ws.send(JSON.stringify({ + type: "signal", + to_user_id: peerId, + kind: "ice", + data: event.candidate, + })); + }; + + pc.ontrack = (event) => { + if (event.track.kind === "audio") { + let audio = document.getElementById(`audio-${peerId}`); + if (!audio) { + audio = document.createElement("audio"); + audio.id = `audio-${peerId}`; + audio.autoplay = true; + audio.playsInline = true; + document.body.appendChild(audio); + } + audio.srcObject = event.streams[0]; + } else if (event.track.kind === "video") { + const peer = state.members.find(m => m.id === peerId) || { display_name: "Unknown" }; + // Distinguish screen share from camera by checking track labels or signaling + // For simplicity, we can use the stream index or a dedicated track check. + // A more robust way is to have separate transceivers/signaling. + // Here we check if the label suggests a screen. + const isScreen = event.track.label.toLowerCase().includes('screen') || + event.track.label.toLowerCase().includes('monitor'); + renderVideo(peerId, peer.display_name, event.streams[0], isScreen ? 'screen' : 'camera'); + } + }; + + pc.onnegotiationneeded = async () => { + try { + await sendOffer(peerId); + } catch (err) { + console.error("negotiation failed", err); + } + }; + + state.voice.peerConnections.set(peerId, pc); + return pc; +} + +async function sendOffer(peerId) { + const pc = ensurePeerConnection(peerId); + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + state.voice.ws.send(JSON.stringify({ + type: "signal", + to_user_id: peerId, + kind: "offer", + data: offer, + })); +} + +async function handleSignal(fromPeerId, kind, data) { + const pc = ensurePeerConnection(fromPeerId); + const polite = !shouldInitiateOffer(fromPeerId); + + try { + if (kind === "offer") { + const offerCollision = (pc.signalingState !== "stable"); + if (offerCollision) { + if (!polite) return; + await Promise.all([ + pc.setLocalDescription({ type: "rollback" }), + pc.setRemoteDescription(new RTCSessionDescription(data)) + ]); + } else { + await pc.setRemoteDescription(new RTCSessionDescription(data)); + } + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + state.voice.ws.send(JSON.stringify({ + type: "signal", + to_user_id: fromPeerId, + kind: "answer", + data: answer, + })); + } else if (kind === "answer") { + await pc.setRemoteDescription(new RTCSessionDescription(data)); + } else if (kind === "ice") { + try { + await pc.addIceCandidate(data ? new RTCIceCandidate(data) : null); + } catch (err) { + if (pc.signalingState !== "stable") { + // Ignore ICE candidates during negotiation + } else { + console.warn("failed to add ice candidate", err); + } + } + } + } catch (err) { + console.error("handleSignal failed", err); + } +} + +async function joinVoice() { + if (!state.selectedVoiceChannelId) return; + if (state.voice.joinedChannelId === state.selectedVoiceChannelId) return; + + await leaveVoice(); + + try { + await createLocalVoiceStream(); + } catch (err) { + console.error("microphone denied", err); + return; + } + + const ws = new WebSocket(getVoiceWsUrl(state.selectedVoiceChannelId)); + state.voice.ws = ws; + state.voice.joinedChannelId = state.selectedVoiceChannelId; + + ws.onopen = () => { + const channel = state.channels.find(c => c.id === state.selectedVoiceChannelId); + el.vcChannelName.textContent = channel ? channel.name : "Voice"; + el.voiceConnection.classList.remove("hidden"); + el.soundboard.classList.remove("hidden"); + playSound('join'); + refreshVoicePresence().catch(() => { }); + loadSounds().catch(() => { }); + }; + + ws.onmessage = async (event) => { + const msg = JSON.parse(event.data); + if (msg.type === "peers") { + for (const peer of msg.peers) { + if (shouldInitiateOffer(peer.user_id)) await sendOffer(peer.user_id); + } + } else if (msg.type === "peer_joined") { + playSound('peer-join'); + if (shouldInitiateOffer(msg.user_id)) await sendOffer(msg.user_id); + } else if (msg.type === "peer_left") { + playSound('peer-leave'); + const pc = state.voice.peerConnections.get(msg.user_id); + if (pc) { + pc.close(); + state.voice.peerConnections.delete(msg.user_id); + } + document.getElementById(`audio-${msg.user_id}`)?.remove(); + } else if (msg.type === "signal") { + await handleSignal(msg.from_user_id, msg.kind, msg.data); + } else if (msg.type === "video_status_changed") { + if (!msg.is_sharing_video) { + document.getElementById(`video-${msg.user_id}-camera`)?.parentElement?.remove(); + } + } else if (msg.type === "screen_status_changed") { + if (!msg.is_sharing_screen) { + document.getElementById(`video-${msg.user_id}-screen`)?.parentElement?.remove(); + } + } else if (msg.type === "speaking_status_changed") { + state.voicePresence.get(state.selectedVoiceChannelId)?.forEach(p => { + if (p.user_id === msg.user_id) p.is_speaking = msg.is_speaking; + }); + renderChannels(); + const videoEl = document.getElementById(`video-${msg.user_id}-camera`) || document.getElementById(`video-${msg.user_id}-screen`); + if (videoEl) { + videoEl.parentElement.classList.toggle('speaking', msg.is_speaking); + } + } else if (msg.type === "play_sound") { + const audio = new Audio(msg.sound_url); + audio.play().catch(console.error); + } + refreshVoicePresence().catch(() => { }); + }; + + ws.onclose = () => { + el.voiceConnection.classList.add("hidden"); + el.soundboard.classList.add("hidden"); + for (const pc of state.voice.peerConnections.values()) pc.close(); + state.voice.peerConnections.clear(); + stopAndClearAudioPipeline(); + state.voice.joinedChannelId = null; + state.voice.ws = null; + playSound('leave'); + refreshVoicePresence().catch(() => { }); + }; +} + +async function leaveVoice() { + if (state.voice.ws) state.voice.ws.close(); +} + +function toggleMute() { + if (!state.voice.localStream) return; + state.voice.muted = !state.voice.muted; + state.voice.localStream.getAudioTracks().forEach((t) => { + t.enabled = !state.voice.muted; + }); + el.voiceMuteBtn.innerHTML = state.voice.muted ? '' : ''; + el.voiceMuteBtn.style.color = state.voice.muted ? 'var(--danger)' : 'var(--text-muted)'; + lucide.createIcons(); +} + +async function toggleVideo() { + if (state.voice.sharingVideo) { + stopAndClearVideoPipeline(); + if (state.voice.ws) { + state.voice.ws.send(JSON.stringify({ type: "set_video_status", is_sharing_video: false })); + } + for (const pc of state.voice.peerConnections.values()) { + const transceivers = pc.getTransceivers(); + const cameraTransceiver = transceivers.find(t => t.receiver.track.kind === 'video' && t.mid === transceivers[1].mid); + if (cameraTransceiver) { + cameraTransceiver.sender.replaceTrack(null); + } + } + } 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 pc of state.voice.peerConnections.values()) { + const transceivers = pc.getTransceivers(); + if (transceivers[1]) { + transceivers[1].sender.replaceTrack(stream.getVideoTracks()[0]); + } + } + } 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 pc of state.voice.peerConnections.values()) { + const transceivers = pc.getTransceivers(); + if (transceivers[2]) { + transceivers[2].sender.replaceTrack(null); + } + } + } else { + try { + const stream = await navigator.mediaDevices.getDisplayMedia({ video: 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 pc of state.voice.peerConnections.values()) { + const transceivers = pc.getTransceivers(); + if (transceivers[2]) { + transceivers[2].sender.replaceTrack(stream.getVideoTracks()[0]); + } + } + } catch (err) { + console.error("screen share denied", err); + return; + } + } + + el.voiceScreenBtn.innerHTML = state.voice.sharingScreen ? '' : ''; + el.voiceScreenBtn.style.color = state.voice.sharingScreen ? 'var(--green)' : 'var(--text-muted)'; + lucide.createIcons(); +} + +function toggleWatchVideo() { + state.voice.watchingVideo = !state.voice.watchingVideo; + updateVideoGridVisibility(); + + el.voiceWatchBtn.innerHTML = state.voice.watchingVideo ? '' : ''; + el.voiceWatchBtn.style.color = state.voice.watchingVideo ? 'var(--green)' : 'var(--danger)'; + el.voiceWatchBtn.title = state.voice.watchingVideo ? 'Stop Watching' : 'Start Watching'; + lucide.createIcons(); +} + +// --- Sound Board --- + +async function loadSounds() { + if (!state.selectedGuildId) return; + try { + const sounds = await api(`/guilds/${state.selectedGuildId}/sounds`); + renderSounds(sounds); + } catch (err) { + console.error("failed to load sounds", err); + } +} + +function renderSounds(sounds) { + el.soundboardGrid.innerHTML = ''; + sounds.forEach(sound => { + const item = document.createElement('div'); + item.className = 'sound-item'; + + // Only show delete button if user is creator or owner + const canDelete = state.me && (state.me.id === sound.created_by_user_id || (state.guilds.find(g => g.id === state.selectedGuildId)?.owner_user_id === state.me.id)); + + item.innerHTML = ` +
${sound.icon}
+
${sound.name}
+ ${canDelete ? `` : ''} + `; + + item.onclick = (e) => { + if (e.target.closest('.sound-delete')) { + e.stopPropagation(); + if (confirm(`Are you sure you want to delete "${sound.name}"?`)) { + deleteSound(sound.id); + } + return; + } + playRemoteSound(sound.file_path); + }; + + el.soundboardGrid.appendChild(item); + }); + lucide.createIcons(); +} + +async function deleteSound(soundId) { + if (!state.selectedGuildId) return; + try { + const response = await fetch(`/guilds/${state.selectedGuildId}/sounds/${soundId}`, { + method: 'DELETE' + }); + if (!response.ok) { + const err = await response.json(); + throw new Error(err.error || 'Delete failed'); + } + await loadSounds(); + } catch (err) { + alert(err.message); + } +} + +function playRemoteSound(url) { + if (state.voice.ws && state.voice.ws.readyState === WebSocket.OPEN) { + state.voice.ws.send(JSON.stringify({ type: 'play_sound', sound_url: url })); + } + // Also play locally immediately + const audio = new Audio(url); + audio.play().catch(console.error); +} + +// --- Mobile Logic --- + +function toggleMobileMenu() { + const isOpen = el.main.classList.contains("menu-open"); + if (isOpen) { + el.main.classList.remove("menu-open"); + el.overlay.classList.add("hidden"); + } else { + el.main.classList.add("menu-open"); + el.utilitySidebar.classList.remove("active"); // close members if open + el.overlay.classList.remove("hidden"); + } +} + +function toggleMobileMembers() { + const isOpen = el.utilitySidebar.classList.contains("active"); + if (isOpen) { + el.utilitySidebar.classList.remove("active"); + el.overlay.classList.add("hidden"); + } else { + el.utilitySidebar.classList.add("active"); + el.main.classList.remove("menu-open"); // close menu if open + el.overlay.classList.remove("hidden"); + } +} + +function closeMobileMenus() { + el.main.classList.remove("menu-open"); + el.utilitySidebar.classList.remove("active"); + el.overlay.classList.add("hidden"); +} + +// --- Initialization --- + +async function init() { + lucide.createIcons(); + + try { + const searchParams = new URLSearchParams(location.search); + const jwtToken = searchParams.get("token"); + if (jwtToken) { + localStorage.setItem("chattz_token", jwtToken); + searchParams.delete("token"); + const nextQuery = searchParams.toString(); + const nextUrl = `${location.pathname}${nextQuery ? `?${nextQuery}` : ""}`; + history.replaceState(null, "", nextUrl); + } + } catch (e) { + console.error("Failed to parse token from URL:", e); + } + + el.loginBtn.onclick = () => { + const loginPath = "/auth/login"; + location.href = API_BASE_URL ? `${API_BASE_URL}${loginPath}` : loginPath; + }; + + el.logoutBtn.onclick = async () => { + await leaveVoice(); + localStorage.removeItem("chattz_token"); + 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'; + } + }; + + try { + state.me = await api("/me"); + if (state.me && state.me.display_name) { + el.userName.textContent = state.me.display_name; + el.userAvatar.textContent = shortName(state.me.display_name); + } + el.authScreen.classList.add("hidden"); + el.main.classList.remove("hidden"); + + const params = new URLSearchParams(location.search); + const inviteCode = params.get("invite"); + if (inviteCode) { + try { + const joinedGuild = await api(`/invites/${encodeURIComponent(inviteCode)}/join`, { + method: "POST", + }); + localStorage.setItem(LAST_GUILD_STORAGE_KEY, joinedGuild.id); + } catch (err) { + alert(`Failed to join invite: ${err.message}`); + } finally { + params.delete("invite"); + const nextQuery = params.toString(); + const nextUrl = `${location.pathname}${nextQuery ? `?${nextQuery}` : ""}`; + history.replaceState(null, "", nextUrl); + } + } + + await loadGuilds(); + await loadDMConversations(); + + try { + const online = await api("/presence"); + state.onlineUsers = new Set(online); + } catch (err) { console.warn("presence sync failed", err); } + + if (state.guilds.length > 0) { + const lastGuildId = localStorage.getItem(LAST_GUILD_STORAGE_KEY); + const guild = state.guilds.find((g) => g.id === lastGuildId) || state.guilds[0]; + state.selectedGuildId = guild.id; + localStorage.setItem(LAST_GUILD_STORAGE_KEY, guild.id); + renderGuilds(); + await loadChannels(); + await loadGuildMembers(); + await refreshVoicePresence(); + updateHeaderLabels(); + } else { + state.members = []; + renderMembers(); + updateHeaderLabels(); + } + + initChatWs(); + startVoicePresencePolling(); + lucide.createIcons(); + } catch (err) { + console.error("init failed", err); + el.authScreen.classList.remove("hidden"); + el.main.classList.add("hidden"); + } +} + +init(); diff --git a/desktop/index.html b/desktop/index.html new file mode 100644 index 0000000..a1957ac --- /dev/null +++ b/desktop/index.html @@ -0,0 +1,244 @@ + + + + + + + Chattz + + + + + + + + + +
+
+
C
+

Chattz

+

Sign in to open your servers.

+ +

+
+
+ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/desktop/styles.css b/desktop/styles.css new file mode 100644 index 0000000..03887fe --- /dev/null +++ b/desktop/styles.css @@ -0,0 +1,1296 @@ +:root { + /* ── Backgrounds ─────────────────────────────────────────── */ + --bg-darker: #16171b; + --bg-sidebar: #1e2025; + --bg-main: #23252b; + --bg-secondary: #1a1c21; + --bg-tertiary: #111216; + --bg-modifier-selected: rgba(99, 102, 241, 0.15); + --bg-modifier-hover: rgba(255, 255, 255, 0.04); + --bg-input: #2a2d35; + + /* ── Text ────────────────────────────────────────────────── */ + --text-normal: #c9cdd4; + --text-muted: #7c818a; + --text-strong: #eef0f4; + --text-link: #818cf8; + + /* ── Accents ─────────────────────────────────────────────── */ + --brand: #6366f1; + --brand-hover: #4f46e5; + --brand-gradient: linear-gradient(135deg, #6366f1, #a855f7); + --brand-glow: rgba(99, 102, 241, 0.35); + --green: #22c55e; + --danger: #ef4444; + --yellow: #f59e0b; + + /* ── Misc ────────────────────────────────────────────────── */ + --font-main: "Inter", "Noto Sans", "Helvetica Neue", Helvetica, Arial, + sans-serif; + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 14px; + --radius-xl: 20px; + --transition: 200ms cubic-bezier(0.4, 0, 0.2, 1); +} + +/* ═══════════════════════════════════════════════════════════ */ +/* Reset & Base */ +/* ═══════════════════════════════════════════════════════════ */ +* { + box-sizing: border-box; +} + +body { + margin: 0; + height: 100vh; + height: 100dvh; + background: var(--bg-darker); + color: var(--text-normal); + font-family: var(--font-main); + overflow: hidden; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* ── Scrollbars ────────────────────────────────────────────── */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.08); + border-radius: 100px; +} + +::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.14); +} + +/* ── Base Elements ─────────────────────────────────────────── */ +button { + border: 0; + cursor: pointer; + transition: all var(--transition); + background: none; + color: inherit; + font: inherit; + padding: 0; +} + +input, +select { + border: 0; + outline: none; + background: var(--bg-input); + color: var(--text-normal); + font: inherit; +} + +.hidden { + display: none !important; +} + +/* ═══════════════════════════════════════════════════════════ */ +/* Auth Screen */ +/* ═══════════════════════════════════════════════════════════ */ +.auth-screen { + display: grid; + place-items: center; + height: 100vh; + /* Subtle animated gradient background */ + background: linear-gradient(135deg, #0f0c29, #16171b 40%, #1a1c2e 70%, #0f0c29); + background-size: 400% 400%; + animation: gradientShift 12s ease infinite; +} + +@keyframes gradientShift { + + 0%, + 100% { + background-position: 0% 50%; + } + + 50% { + background-position: 100% 50%; + } +} + +.auth-card { + width: min(440px, 92vw); + background: rgba(30, 32, 37, 0.75); + backdrop-filter: blur(24px) saturate(1.4); + -webkit-backdrop-filter: blur(24px) saturate(1.4); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: var(--radius-xl); + padding: 40px 36px; + box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5), 0 0 80px rgba(99, 102, 241, 0.08); + text-align: center; +} + +.brand-large { + width: 80px; + height: 80px; + background: var(--brand-gradient); + color: #fff; + border-radius: 22px; + display: grid; + place-items: center; + font-size: 38px; + font-weight: 800; + margin: 0 auto 24px; + box-shadow: 0 4px 24px var(--brand-glow); + transition: transform var(--transition), box-shadow var(--transition); +} + +.brand-large:hover { + transform: scale(1.05); + box-shadow: 0 6px 32px var(--brand-glow); +} + +.auth-card h1 { + margin: 0 0 8px; + color: var(--text-strong); + font-weight: 800; + font-size: 28px; + letter-spacing: -0.5px; +} + +.auth-card p { + margin: 0 0 28px; + color: var(--text-muted); + font-size: 15px; +} + +#login-btn { + width: 100%; + background: var(--brand-gradient); + color: #fff; + padding: 14px; + border-radius: var(--radius-md); + font-weight: 600; + font-size: 16px; + letter-spacing: 0.2px; + box-shadow: 0 2px 16px var(--brand-glow); + transition: all var(--transition); +} + +#login-btn:hover { + transform: translateY(-1px); + box-shadow: 0 4px 24px var(--brand-glow); + filter: brightness(1.08); +} + +#login-btn:active { + transform: translateY(0); +} + +/* ═══════════════════════════════════════════════════════════ */ +/* Main Layout */ +/* ═══════════════════════════════════════════════════════════ */ +.shell { + height: 100vh; + height: 100dvh; + display: grid; + grid-template-columns: 72px 248px 1fr 248px; + background: var(--bg-main); +} + +/* ═══════════════════════════════════════════════════════════ */ +/* Server Rail */ +/* ═══════════════════════════════════════════════════════════ */ +.server-rail { + background: var(--bg-darker); + display: flex; + flex-direction: column; + align-items: center; + padding: 12px 0; + gap: 8px; + overflow-y: auto; + scrollbar-width: none; +} + +.server-rail::-webkit-scrollbar { + display: none; +} + +.guild-list { + display: flex; + flex-direction: column; + gap: 8px; + padding: 6px 0; +} + +.brand, +.guild-pill { + width: 48px; + height: 48px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + transition: all var(--transition); + position: relative; + background: var(--bg-sidebar); +} + +.brand { + background: var(--brand-gradient); + color: #fff; + border-radius: 16px; + margin-bottom: 2px; + box-shadow: 0 2px 12px var(--brand-glow); +} + +.brand:hover { + border-radius: 16px; + box-shadow: 0 4px 20px var(--brand-glow); +} + +.separator { + width: 32px; + height: 2px; + background: rgba(255, 255, 255, 0.06); + margin-bottom: 2px; + border-radius: 1px; +} + +.guild-pill:hover, +.guild-pill.active { + border-radius: 16px; + background: var(--brand); + color: #fff; + box-shadow: 0 2px 12px var(--brand-glow); +} + +.guild-pill::before { + content: ""; + position: absolute; + left: -12px; + width: 4px; + height: 0; + background: #fff; + border-radius: 0 4px 4px 0; + transition: all var(--transition); +} + +.guild-pill:hover::before { + height: 20px; +} + +.guild-pill.active::before { + height: 40px; +} + +.action-pill { + color: var(--green); +} + +.action-pill:hover { + background: var(--green); + color: #fff; + box-shadow: 0 2px 12px rgba(34, 197, 94, 0.3); +} + +/* ═══════════════════════════════════════════════════════════ */ +/* Channel Sidebar */ +/* ═══════════════════════════════════════════════════════════ */ +.channel-sidebar { + background: var(--bg-sidebar); + display: flex; + flex-direction: column; +} + +.sidebar-header { + padding: 0 16px; + height: 48px; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); + box-shadow: 0 1px 0 rgba(0, 0, 0, 0.15); + cursor: pointer; + transition: background-color var(--transition); +} + +.sidebar-header:hover { + background: var(--bg-modifier-hover); +} + +.sidebar-header h2 { + margin: 0; + font-size: 15px; + font-weight: 700; + color: var(--text-strong); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + letter-spacing: -0.2px; +} + +.sidebar-header-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.header-action-btn { + width: 28px; + height: 28px; + border-radius: var(--radius-sm); + display: grid; + place-items: center; + color: var(--text-muted); + transition: all var(--transition); +} + +.header-action-btn:hover { + background: var(--bg-modifier-hover); + color: var(--text-strong); +} + +.header-action-btn i { + width: 16px; + height: 16px; +} + +.sidebar-scroll { + flex: 1; + overflow-y: auto; + padding-top: 12px; +} + +.sidebar-group { + margin-bottom: 20px; +} + +.group-title { + padding: 0 8px 0 2px; + display: flex; + align-items: center; + color: var(--text-muted); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + cursor: pointer; + transition: color var(--transition); +} + +.group-title:hover { + color: var(--text-normal); +} + +.group-toggle { + width: 12px; + height: 12px; + margin-right: 2px; +} + +.group-title span { + flex: 1; +} + +.add-btn { + width: 20px; + height: 20px; + border-radius: var(--radius-sm); + opacity: 0.5; + transition: opacity var(--transition), background-color var(--transition), + transform var(--transition); + display: grid; + place-items: center; +} + +.add-btn:hover { + opacity: 1; + background: var(--bg-modifier-hover); + transform: scale(1.1); +} + +.add-btn i { + width: 16px; + height: 16px; +} + +.channel-list { + padding: 0 8px; + display: flex; + flex-direction: column; + gap: 2px; +} + +.channel-row { + display: flex; + align-items: center; + padding: 7px 10px; + border-radius: var(--radius-sm); + color: var(--text-muted); + font-weight: 500; + gap: 8px; + transition: all var(--transition); + border-left: 3px solid transparent; +} + +.channel-row:hover { + background: var(--bg-modifier-hover); + color: var(--text-normal); +} + +.channel-row.active { + background: var(--bg-modifier-selected); + color: var(--text-strong); + border-left-color: var(--brand); +} + +.channel-row i { + width: 20px; + height: 20px; + opacity: 0.5; +} + +.channel-row.active i { + opacity: 0.9; +} + +/* ═══════════════════════════════════════════════════════════ */ +/* Sidebar Footer */ +/* ═══════════════════════════════════════════════════════════ */ +.sidebar-footer { + background: var(--bg-secondary); + padding: 0; +} + +.user-panel { + padding: 8px 10px; + display: flex; + align-items: center; + gap: 10px; + height: 52px; + transition: background-color var(--transition); + border-radius: var(--radius-sm); + margin: 2px; +} + +.user-panel:hover { + background: var(--bg-modifier-hover); +} + +.avatar-wrapper { + position: relative; +} + +.avatar { + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--brand-gradient); + color: #fff; + display: grid; + place-items: center; + font-weight: 700; + font-size: 13px; +} + +.status-dot { + position: absolute; + bottom: -2px; + right: -2px; + width: 14px; + height: 14px; + border-radius: 50%; + border: 3px solid var(--bg-secondary); +} + +.status-dot.online { + background: var(--green); + box-shadow: 0 0 6px rgba(34, 197, 94, 0.4); +} + +.user-info { + flex: 1; + min-width: 0; +} + +.display-name { + font-size: 14px; + font-weight: 600; + color: var(--text-strong); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.user-status { + font-size: 12px; + color: var(--text-muted); +} + +.user-actions { + display: flex; + gap: 2px; +} + +.user-actions button { + width: 32px; + height: 32px; + border-radius: var(--radius-sm); + display: grid; + place-items: center; + color: var(--text-muted); + transition: all var(--transition); +} + +.user-actions button:hover { + background: var(--bg-modifier-hover); + color: var(--text-strong); +} + +.user-actions i { + width: 20px; + height: 20px; +} + +/* ═══════════════════════════════════════════════════════════ */ +/* Voice Connection */ +/* ═══════════════════════════════════════════════════════════ */ +.voice-connection { + padding: 10px 12px; + background: var(--bg-secondary); + border-bottom: 1px solid rgba(255, 255, 255, 0.03); +} + +.vc-info { + display: flex; + align-items: center; + gap: 8px; +} + +.vc-icon { + color: var(--green); + width: 20px; + filter: drop-shadow(0 0 4px rgba(34, 197, 94, 0.4)); +} + +.vc-text { + flex: 1; + display: flex; + flex-direction: column; +} + +.vc-status { + color: var(--green); + font-size: 14px; + font-weight: 700; +} + +.vc-name { + color: var(--text-muted); + font-size: 12px; +} + +.vc-actions { + display: flex; + gap: 4px; +} + +.vc-actions button { + width: 32px; + height: 32px; + border-radius: var(--radius-sm); + color: var(--text-muted); + display: grid; + place-items: center; + transition: all var(--transition); +} + +.vc-actions button:hover { + background: var(--bg-modifier-hover); + color: var(--text-strong); +} + +/* ═══════════════════════════════════════════════════════════ */ +/* Video Grid */ +/* ═══════════════════════════════════════════════════════════ */ +.video-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 16px; + padding: 16px; + background: var(--bg-tertiary); + flex: 1; + min-height: 0; + overflow-y: auto; +} + +.video-grid.hidden { + display: none; +} + +.video-item { + position: relative; + aspect-ratio: 16 / 9; + background: #000; + border-radius: var(--radius-lg); + overflow: hidden; + transition: box-shadow var(--transition); +} + +.video-item video { + width: 100%; + height: 100%; + object-fit: contain; + background: #000; +} + +.video-item .video-label { + position: absolute; + bottom: 8px; + left: 8px; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(8px); + color: #fff; + padding: 3px 10px; + border-radius: 100px; + font-size: 12px; + font-weight: 500; +} + +/* Voice Activity */ +.voice-speaking .avatar { + box-shadow: 0 0 0 2px var(--green), 0 0 8px rgba(34, 197, 94, 0.3); +} + +.video-item.speaking { + box-shadow: 0 0 0 3px var(--green), 0 0 16px rgba(34, 197, 94, 0.25); +} + +.video-item.speaking .video-label { + background: var(--green); +} + +/* ═══════════════════════════════════════════════════════════ */ +/* Sound Board */ +/* ═══════════════════════════════════════════════════════════ */ +.soundboard-container { + padding: 12px; + background: var(--bg-secondary); + border-top: 1px solid rgba(255, 255, 255, 0.03); + display: flex; + flex-direction: column; + gap: 8px; +} + +.soundboard-header { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.4px; + color: var(--text-muted); +} + +.soundboard-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); + gap: 8px; + max-height: 200px; + overflow-y: auto; +} + +.sound-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 10px 8px; + background: var(--bg-darker); + border-radius: var(--radius-md); + cursor: pointer; + transition: all var(--transition); + border: 1px solid transparent; + position: relative; +} + +.sound-item:hover { + background: var(--bg-modifier-hover); + transform: translateY(-2px); + border-color: rgba(255, 255, 255, 0.06); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +.sound-item:active { + transform: scale(0.95); +} + +.sound-delete { + position: absolute; + top: 4px; + right: 4px; + width: 18px; + height: 18px; + border-radius: 50%; + background: rgba(0, 0, 0, 0.6); + color: var(--text-muted); + display: grid; + place-items: center; + opacity: 0; + transition: all var(--transition); + z-index: 2; +} + +.sound-delete:hover { + background: var(--danger); + color: #fff; + transform: scale(1.1); +} + +.sound-item:hover .sound-delete { + opacity: 1; +} + +.sound-icon { + font-size: 24px; +} + +.sound-name { + font-size: 11px; + text-align: center; + color: var(--text-normal); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + width: 100%; +} + +.btn-add-sound { + font-size: 12px; + color: var(--brand); + cursor: pointer; + transition: color var(--transition); +} + +.btn-add-sound:hover { + color: var(--text-link); + text-decoration: underline; +} + +/* ═══════════════════════════════════════════════════════════ */ +/* Chat Pane */ +/* ═══════════════════════════════════════════════════════════ */ +.chat-pane { + display: flex; + flex-direction: column; + background: var(--bg-main); + min-width: 0; + min-height: 0; +} + +.chat-header { + height: 48px; + padding: 0 16px; + display: flex; + align-items: center; + gap: 8px; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); + box-shadow: 0 1px 0 rgba(0, 0, 0, 0.15); +} + +.header-icon { + color: var(--text-muted); + width: 24px; +} + +.chat-header h3 { + margin: 0; + font-size: 16px; + font-weight: 700; + color: var(--text-strong); + letter-spacing: -0.2px; +} + +.message-list { + flex: 1; + overflow-y: scroll; + padding: 16px 0; +} + +.msg { + padding: 4px 16px; + display: flex; + gap: 16px; + margin-top: 1.0625rem; + border-radius: var(--radius-sm); + transition: background-color var(--transition); + border-left: 3px solid transparent; +} + +.msg:hover { + background: rgba(255, 255, 255, 0.015); + border-left-color: rgba(99, 102, 241, 0.3); +} + +.msg-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--brand-gradient); + flex-shrink: 0; + display: grid; + place-items: center; + color: #fff; + font-weight: 600; + font-size: 15px; +} + +.msg-content { + flex: 1; + min-width: 0; +} + +.msg-header { + display: flex; + align-items: baseline; + gap: 8px; + margin-bottom: 4px; +} + +.msg-author { + font-weight: 600; + color: var(--text-strong); + cursor: pointer; + font-size: 1rem; + transition: color var(--transition); +} + +.msg-author:hover { + color: var(--text-link); + text-decoration: underline; +} + +.msg-time { + font-size: 11px; + color: var(--text-muted); +} + +.msg-body { + color: var(--text-normal); + line-height: 1.45rem; + white-space: pre-wrap; + word-wrap: break-word; +} + +.msg-grouped { + margin-top: 0; + padding-top: 0; + padding-bottom: 0; +} + +.msg-grouped .msg-avatar, +.msg-grouped .msg-header { + display: none; +} + +.msg-grouped .msg-content { + padding-left: 56px; +} + +/* ═══════════════════════════════════════════════════════════ */ +/* Chat Input */ +/* ═══════════════════════════════════════════════════════════ */ +.chat-input-wrapper { + padding: 0 16px 24px; + padding-bottom: clamp(24px, calc(24px + env(safe-area-inset-bottom)), 40px); +} + +.message-form { + background: var(--bg-input); + border-radius: var(--radius-lg); + padding: 12px 18px; + border: 1px solid rgba(255, 255, 255, 0.04); + transition: border-color var(--transition), box-shadow var(--transition); +} + +.message-form:focus-within { + border-color: rgba(99, 102, 241, 0.4); + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); +} + +.message-form input { + width: 100%; + background: transparent; + color: var(--text-normal); + font-size: 15px; +} + +.message-form input::placeholder { + color: var(--text-muted); +} + +/* ═══════════════════════════════════════════════════════════ */ +/* Member List / Utility Sidebar */ +/* ═══════════════════════════════════════════════════════════ */ +.utility-sidebar { + background: var(--bg-sidebar); + display: flex; + flex-direction: column; +} + +.member-list-wrapper { + flex: 1; + overflow-y: auto; + padding: 12px 8px; +} + +.member-row { + display: flex; + align-items: center; + gap: 12px; + padding: 7px 10px; + border-radius: var(--radius-sm); + color: var(--text-muted); + cursor: pointer; + transition: all var(--transition); +} + +.member-row:hover { + background: var(--bg-modifier-hover); + color: var(--text-normal); +} + +.member-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--brand-gradient); + color: #fff; + display: grid; + place-items: center; + font-size: 13px; + font-weight: 600; + flex-shrink: 0; +} + +.member-name { + overflow: hidden; + text-overflow: ellipsis; +} + +/* ── Presence Badges ───────────────────────────────────────── */ +.avatar-wrapper { + position: relative; + display: inline-block; +} + +.status-badge { + position: absolute; + bottom: -2px; + right: -2px; + width: 12px; + height: 12px; + border-radius: 50%; + border: 2px solid var(--bg-sidebar); + background: #747f8d; +} + +.status-badge.online { + background: var(--green); + box-shadow: 0 0 6px rgba(34, 197, 94, 0.4); +} + +.member-avatar, +.msg-avatar { + position: relative; +} + +.status-dot { + position: absolute; + bottom: 0; + right: 0; + width: 10px; + height: 10px; + border-radius: 50%; + border: 2px solid var(--bg-sidebar); + background: #747f8d; +} + +.status-dot.online { + background: var(--green); + box-shadow: 0 0 6px rgba(34, 197, 94, 0.4); +} + +/* ═══════════════════════════════════════════════════════════ */ +/* Modals */ +/* ═══════════════════════════════════════════════════════════ */ +.modal-container { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + display: grid; + place-items: center; + z-index: 1000; +} + +.modal { + background: rgba(30, 32, 37, 0.92); + backdrop-filter: blur(20px) saturate(1.4); + -webkit-backdrop-filter: blur(20px) saturate(1.4); + border: 1px solid rgba(255, 255, 255, 0.06); + width: min(460px, 95vw); + border-radius: var(--radius-xl); + padding: 28px; + color: var(--text-normal); + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5); +} + +.modal h2 { + margin: 0 0 20px; + text-align: center; + color: var(--text-strong); + font-weight: 700; + font-size: 22px; + letter-spacing: -0.3px; +} + +.form-item { + margin-bottom: 20px; +} + +.form-item label { + display: block; + font-size: 11px; + font-weight: 700; + color: var(--text-muted); + margin-bottom: 8px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.form-item input { + width: 100%; + padding: 11px 14px; + border-radius: var(--radius-md); + background: var(--bg-darker); + border: 1px solid rgba(255, 255, 255, 0.04); + transition: border-color var(--transition), box-shadow var(--transition); +} + +.form-item input:focus { + border-color: rgba(99, 102, 241, 0.4); + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); +} + +.modal-footer { + margin-top: 24px; + display: flex; + justify-content: flex-end; + gap: 12px; +} + +.cancel-btn { + padding: 10px 20px; + color: var(--text-muted); + font-weight: 500; + border-radius: var(--radius-md); + transition: all var(--transition); +} + +.cancel-btn:hover { + color: var(--text-strong); + background: var(--bg-modifier-hover); +} + +.submit-btn { + background: var(--brand-gradient); + color: #fff; + padding: 10px 24px; + border-radius: var(--radius-md); + font-weight: 600; + box-shadow: 0 2px 12px var(--brand-glow); + transition: all var(--transition); +} + +.submit-btn:hover { + transform: translateY(-1px); + box-shadow: 0 4px 20px var(--brand-glow); + filter: brightness(1.06); +} + +.submit-btn:active { + transform: translateY(0); +} + +/* ── Radio Group (Channel Type) ────────────────────────────── */ +.radio-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.radio-item { + cursor: pointer; + position: relative; +} + +.radio-item input { + position: absolute; + opacity: 0; +} + +.radio-box { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + background: var(--bg-modifier-hover); + border-radius: var(--radius-md); + border: 1px solid transparent; + transition: all var(--transition); +} + +.radio-item input:checked+.radio-box { + background: var(--bg-modifier-selected); + border-color: rgba(99, 102, 241, 0.3); + color: var(--text-strong); +} + +.radio-box i { + width: 24px; + height: 24px; + color: var(--text-muted); +} + +.radio-text { + display: flex; + flex-direction: column; +} + +.radio-text strong { + font-size: 16px; +} + +.radio-text span { + font-size: 12px; + color: var(--text-muted); +} + +/* ═══════════════════════════════════════════════════════════ */ +/* Mobile Overlay */ +/* ═══════════════════════════════════════════════════════════ */ +.overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + z-index: 90; + opacity: 1; + transition: opacity 0.2s ease; +} + +.overlay.hidden { + opacity: 0; + pointer-events: none; + visibility: hidden; +} + +/* ── Mobile Toggle Buttons ─────────────────────────────────── */ +.mobile-toggle-btn { + display: none; + width: 32px; + height: 32px; + align-items: center; + justify-content: center; + color: var(--text-normal); + background: transparent; + margin-right: 8px; +} + +.mobile-toggle-btn:hover { + background: var(--bg-modifier-hover); + border-radius: var(--radius-sm); +} + +/* ═══════════════════════════════════════════════════════════ */ +/* Responsive */ +/* ═══════════════════════════════════════════════════════════ */ +@media (max-width: 1100px) { + .shell { + grid-template-columns: 72px 248px 1fr; + } + + .utility-sidebar { + position: fixed; + top: 0; + bottom: 0; + right: 0; + width: 248px; + z-index: 100; + box-shadow: -2px 0 20px rgba(0, 0, 0, 0.5); + transform: translateX(100%); + transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1); + } + + .utility-sidebar.active { + transform: translateX(0); + display: flex; + } + + .chat-header .mobile-toggle-btn { + display: flex; + } +} + +@media (max-width: 768px) { + .shell { + grid-template-columns: 1fr; + } + + .server-rail, + .channel-sidebar { + position: fixed; + top: 0; + bottom: 0; + z-index: 100; + transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1); + } + + .server-rail { + left: 0; + width: 72px; + transform: translateX(-100%); + } + + .channel-sidebar { + left: 72px; + width: 248px; + transform: translateX(-320px); + box-shadow: 2px 0 20px rgba(0, 0, 0, 0.5); + } + + .shell.menu-open .server-rail { + transform: translateX(0); + } + + .shell.menu-open .channel-sidebar { + transform: translateX(0); + display: flex; + } + + .chat-header { + padding: 0 8px; + } +} \ No newline at end of file diff --git a/main.js b/main.js new file mode 100644 index 0000000..4517396 --- /dev/null +++ b/main.js @@ -0,0 +1,79 @@ +const { app, BrowserWindow, session } = require('electron'); +const path = require('path'); +const url = require('url'); + +function createWindow() { + // Create a persistent session for chattz to keep the user logged in + const sess = session.fromPartition('persist:chattz'); + + const win = new BrowserWindow({ + width: 1200, + height: 800, + title: "Chattz Desktop", + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + webSecurity: false, // Required for cross-origin fetch/ws from file:// with cookies + session: sess + } + }); + + const backendUrl = (process.env.CHATTZ_URL || 'https://discord.flegr.me').replace(/\/$/, ''); + const indexPath = path.join(__dirname, 'desktop', 'index.html'); + + // Construct the local file URL with the backend parameter + const localUrl = url.format({ + pathname: indexPath, + protocol: 'file:', + slashes: true, + query: { backend: backendUrl } + }); + + console.log(`Loading desktop app from: ${localUrl}`); + + const loadDesktopApp = () => { + win.loadURL(localUrl).catch((err) => { + console.error(`Failed to load desktop file: ${err}`); + }); + }; + + // Use a navigation listener to detect when the remote login is complete + win.webContents.on('will-navigate', (event, navigatedUrl) => { + const normalizedNav = navigatedUrl.replace(/\/$/, ''); + // If the user lands back on the remote root, they are logged in. + // Redirect them back to our local bugfixed desktop UI. + if (normalizedNav === backendUrl) { + console.log("Detected remote landing (login success), returning to desktop UI..."); + event.preventDefault(); + loadDesktopApp(); + } + }); + + win.webContents.on('did-navigate', (event, navigatedUrl) => { + const normalizedNav = navigatedUrl.replace(/\/$/, ''); + if (normalizedNav === backendUrl) { + loadDesktopApp(); + } + }); + + loadDesktopApp(); + + // Uncomment to debug + // win.webContents.openDevTools(); +} + +app.whenReady().then(() => { + createWindow(); + + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) { + createWindow(); + } + }); +}); + +app.on('window-all-closed', () => { + if (process.platform !== 'darwin') { + app.quit(); + } +}); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..cc0ff72 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,871 @@ +{ + "name": "chattz-electron", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "chattz-electron", + "version": "0.1.0", + "devDependencies": { + "electron": "^34.5.8" + } + }, + "node_modules/@electron/get": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", + "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "20.19.33", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz", + "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/electron": { + "version": "34.5.8", + "resolved": "https://registry.npmjs.org/electron/-/electron-34.5.8.tgz", + "integrity": "sha512-vxLD65mabTzYmEVa9KceMHM0+zO+vqgrhcyNVlmTd0IGV5J7XZ8v/qElm0o4YQ4wPeq7olZkUjZkBQQEdr23/g==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@electron/get": "^2.0.0", + "@types/node": "^20.9.0", + "extract-zip": "^2.0.1" + }, + "bin": { + "electron": "cli.js" + }, + "engines": { + "node": ">= 12.20.55" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/global-agent/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..946b7b7 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "chattz-electron", + "version": "0.1.0", + "description": "Electron frontend for Chattz", + "main": "main.js", + "scripts": { + "start": "electron .", + "dev": "electron ." + }, + "devDependencies": { + "electron": "^34.5.8" + } +} diff --git a/src/auth.rs b/src/auth.rs index 66fd67a..63c901f 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -13,7 +13,6 @@ use uuid::Uuid; use crate::{AppState, db}; -const SESSION_COOKIE: &str = "chattz_session"; const OAUTH_STATE_COOKIE: &str = "chattz_oauth_state"; const SESSION_TTL_SECS: u64 = 60 * 60 * 24 * 7; @@ -65,7 +64,13 @@ struct ErrorBody<'a> { impl IntoResponse for ApiError { fn into_response(self) -> Response { - (self.status, Json(ErrorBody { error: &self.message })).into_response() + ( + self.status, + Json(ErrorBody { + error: &self.message, + }), + ) + .into_response() } } @@ -84,9 +89,13 @@ where async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { let app = AppState::from_ref(state); - let token = read_cookie(parts, SESSION_COOKIE).ok_or_else(|| ApiError::unauthorized("missing session"))?; + + 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) - .map_err(|_| ApiError::unauthorized("invalid session"))?; + .map_err(|_| ApiError::unauthorized("invalid token"))?; let exists = db::user_exists(&app.db, user_id) .await @@ -129,7 +138,7 @@ pub fn read_oauth_state_from_headers(headers: &axum::http::HeaderMap) -> Option< }) } -pub fn new_session_cookie(user_id: Uuid, secret: &str, secure: bool) -> Result { +pub fn new_jwt_token(user_id: Uuid, secret: &str) -> Result { let now = now_ts(); let claims = SessionClaims { sub: user_id.to_string(), @@ -144,20 +153,7 @@ pub fn new_session_cookie(user_id: Uuid, secret: &str, secure: bool) -> Result String { - format!( - "{name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0{secure_flag}", - name = SESSION_COOKIE, - secure_flag = if secure { "; Secure" } else { "" } - ) + Ok(token) } fn verify_session(token: &str, secret: &str) -> Result { @@ -183,14 +179,32 @@ pub fn validate_oauth_state(expected_cookie: Option, query_state: &str) Ok(()) } -fn read_cookie(parts: &Parts, name: &str) -> Option { - let raw = parts.headers.get(axum::http::header::COOKIE)?.to_str().ok()?; - raw.split(';').find_map(|pair| { - let mut kv = pair.trim().splitn(2, '='); +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 + } +} + +fn read_query_token(parts: &Parts) -> Option { + let query = parts.uri.query()?; + + // 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()?; - (key == name).then(|| value.to_string()) - }) + if key == "token" { + return Some(value.to_string()); + } + } + None } fn now_ts() -> u64 { diff --git a/src/handlers.rs b/src/handlers.rs index fd7a593..55f4352 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -213,20 +213,10 @@ async fn auth_callback( .await .map_err(|e| ApiError::internal(&format!("failed to persist user: {e}")))?; - let session_cookie = auth::new_session_cookie( - user.id, - &state.settings.session_secret, - state.settings.cookie_secure, - ) - .map_err(|e| ApiError::internal(&e.to_string()))?; + let jwt_token = auth::new_jwt_token(user.id, &state.settings.session_secret) + .map_err(|e| ApiError::internal(&e.to_string()))?; let mut headers = HeaderMap::new(); - headers.append( - header::SET_COOKIE, - session_cookie - .parse() - .map_err(|_| ApiError::internal("failed to set session cookie"))?, - ); headers.append( header::SET_COOKIE, auth::clear_oauth_state_cookie(state.settings.cookie_secure) @@ -234,19 +224,11 @@ async fn auth_callback( .map_err(|_| ApiError::internal("failed to clear oauth state cookie"))?, ); - Ok((headers, Redirect::to("/"))) + Ok((headers, Redirect::to(&format!("/?token={}", jwt_token)))) } -async fn auth_logout(State(state): State) -> Result { - let mut headers = HeaderMap::new(); - headers.append( - header::SET_COOKIE, - auth::clear_session_cookie(state.settings.cookie_secure) - .parse() - .map_err(|_| ApiError::internal("failed to clear session cookie"))?, - ); - - Ok((StatusCode::NO_CONTENT, headers)) +async fn auth_logout() -> Result { + Ok(StatusCode::NO_CONTENT) } #[derive(Serialize)] diff --git a/static/app.js b/static/app.js index c37f16c..c0d1669 100644 --- a/static/app.js +++ b/static/app.js @@ -31,6 +31,8 @@ const state = { onlineUsers: new Set(), }; + + const el = { authScreen: document.getElementById("auth-screen"), loginBtn: document.getElementById("login-btn"), @@ -724,22 +726,35 @@ function ensurePeerConnection(peerId) { const pc = new RTCPeerConnection({ iceServers: state.voice.iceServers }); + // Add transceivers in a fixed order to ensure stable SDP m-lines: [Audio, Camera, Screen] + // 1. Audio if (state.voice.localStream) { - for (const track of state.voice.localStream.getTracks()) { - pc.addTrack(track, state.voice.localStream); - } + pc.addTransceiver(state.voice.localStream.getAudioTracks()[0], { + direction: 'sendrecv', + streams: [state.voice.localStream] + }); + } else { + pc.addTransceiver('audio', { direction: 'sendrecv' }); } + // 2. Camera Video if (state.voice.videoStream) { - for (const track of state.voice.videoStream.getTracks()) { - pc.addTrack(track, state.voice.videoStream); - } + pc.addTransceiver(state.voice.videoStream.getVideoTracks()[0], { + direction: 'sendrecv', + streams: [state.voice.videoStream] + }); + } else { + pc.addTransceiver('video', { direction: 'sendrecv' }); } + // 3. Screen Video if (state.voice.screenStream) { - for (const track of state.voice.screenStream.getTracks()) { - pc.addTrack(track, state.voice.screenStream); - } + pc.addTransceiver(state.voice.screenStream.getVideoTracks()[0], { + direction: 'sendrecv', + streams: [state.voice.screenStream] + }); + } else { + pc.addTransceiver('video', { direction: 'sendrecv' }); } pc.onicecandidate = (event) => { @@ -945,11 +960,12 @@ async function toggleVideo() { if (state.voice.ws) { state.voice.ws.send(JSON.stringify({ type: "set_video_status", is_sharing_video: false })); } - const trackId = `video-${state.me.id}-camera`; for (const pc of state.voice.peerConnections.values()) { - const senders = pc.getSenders(); - const videoSender = senders.find(s => s.track && s.track.kind === "video" && !s.track.label.toLowerCase().includes('screen')); - if (videoSender) pc.removeTrack(videoSender); + const transceivers = pc.getTransceivers(); + const cameraTransceiver = transceivers.find(t => t.receiver.track.kind === 'video' && t.mid === transceivers[1].mid); + if (cameraTransceiver) { + cameraTransceiver.sender.replaceTrack(null); + } } } else { try { @@ -963,8 +979,9 @@ async function toggleVideo() { } for (const pc of state.voice.peerConnections.values()) { - for (const track of stream.getTracks()) { - pc.addTrack(track, stream); + const transceivers = pc.getTransceivers(); + if (transceivers[1]) { + transceivers[1].sender.replaceTrack(stream.getVideoTracks()[0]); } } } catch (err) { @@ -986,9 +1003,10 @@ async function toggleScreenShare() { state.voice.ws.send(JSON.stringify({ type: "set_screen_status", is_sharing_screen: false })); } for (const pc of state.voice.peerConnections.values()) { - const senders = pc.getSenders(); - const screenSender = senders.find(s => s.track && s.track.kind === "video" && (s.track.label.toLowerCase().includes('screen') || s.track.label.toLowerCase().includes('monitor'))); - if (screenSender) pc.removeTrack(screenSender); + const transceivers = pc.getTransceivers(); + if (transceivers[2]) { + transceivers[2].sender.replaceTrack(null); + } } } else { try { @@ -1007,8 +1025,9 @@ async function toggleScreenShare() { } for (const pc of state.voice.peerConnections.values()) { - for (const track of stream.getTracks()) { - pc.addTrack(track, stream); + const transceivers = pc.getTransceivers(); + if (transceivers[2]) { + transceivers[2].sender.replaceTrack(stream.getVideoTracks()[0]); } } } catch (err) { @@ -1137,11 +1156,26 @@ function closeMobileMenus() { async function init() { lucide.createIcons(); + try { + const searchParams = new URLSearchParams(location.search); + const jwtToken = searchParams.get("token"); + if (jwtToken) { + localStorage.setItem("chattz_token", jwtToken); + searchParams.delete("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(); - await api("/auth/logout", { method: "POST" }); + localStorage.removeItem("chattz_token"); + try { await api("/auth/logout", { method: "POST" }); } catch (e) { } location.reload(); }; diff --git a/static/index.html b/static/index.html index e13e17c..a1957ac 100644 --- a/static/index.html +++ b/static/index.html @@ -8,7 +8,7 @@ - + @@ -238,7 +238,7 @@ - + \ No newline at end of file diff --git a/test_output.txt b/test_output.txt new file mode 100644 index 0000000..44b2780 --- /dev/null +++ b/test_output.txt @@ -0,0 +1,5 @@ + +> chattz-electron@0.1.0 start +> electron . + +Loading desktop app from: file:///home/pavel/chattz/desktop/index.html?backend=https%3A%2F%2Fdiscord.flegr.me