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 = ``;
+ } else {
+ row.innerHTML = `
+ ${shortName(displayName)}
+
+
+
${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 @@
-
+