const state = {
me: null,
guilds: [],
channels: [],
dmConversations: [],
members: [],
voicePresence: new Map(),
selectedGuildId: null,
selectedTextChannelId: null,
selectedDmUserId: null,
selectedDmDisplayName: null,
selectedVoiceChannelId: null,
voice: {
ws: null,
joinedChannelId: null,
localStream: null,
rawStream: null,
videoStream: null,
screenStream: null,
peerConnections: new Map(),
videoSenders: new Map(),
screenSenders: new Map(),
muted: false,
sharingVideo: false,
sharingScreen: false,
deepFilterProcessor: null,
deepFilterModule: null,
viewMode: 'chat', // 'chat' or 'video'
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
peerGainNodes: new Map(), // userId -> GainNode
},
voicePresencePollId: null,
chatWs: null,
lastMessageId: null,
onlineUsers: new Set(),
idleUsers: new Set(),
userVolumes: new Map(), // userId -> volume (0.0 to 2.0)
};
const el = {
authScreen: document.getElementById("auth-screen"),
loginBtn: document.getElementById("login-btn"),
main: document.getElementById("main"),
status: document.getElementById("status"),
// Guilds
guildList: document.getElementById("guild-list"),
addGuildBtn: document.getElementById("add-guild-btn"),
guildTitle: document.getElementById("guild-title"),
createInviteBtn: document.getElementById("create-invite-btn"),
inviteCopiedBadge: document.getElementById("invite-copied-badge"),
// Channels
channelList: document.getElementById("channel-list"),
voiceChannelList: document.getElementById("voice-channel-list"),
addTextBtn: document.getElementById("add-text-btn"),
addVoiceBtn: document.getElementById("add-voice-btn"),
dmList: document.getElementById("dm-list"),
channelTitle: document.getElementById("channel-title"),
// Messages
messageList: document.getElementById("message-list"),
messageForm: document.getElementById("message-form"),
messageBody: document.getElementById("message-body"),
// User Panel
userName: document.getElementById("user-name"),
userAvatar: document.getElementById("user-avatar"),
logoutBtn: document.getElementById("logout-btn"),
// Voice Connection
voiceConnection: document.getElementById("voice-connection"),
vcChannelName: document.getElementById("vc-channel-name"),
voiceVideoBtn: document.getElementById("voice-video-btn"),
voiceScreenBtn: document.getElementById("voice-screen-btn"),
voiceMuteBtn: document.getElementById("voice-mute-btn"),
voiceLeaveBtn: document.getElementById("voice-leave-btn"),
videoGrid: document.getElementById("video-grid"),
messageForm: document.getElementById("message-form"),
messageInputWrapper: document.querySelector(".chat-input-wrapper"),
// Members
memberList: document.getElementById("member-list"),
// Modals
modalContainer: document.getElementById("modal-container"),
guildForm: document.getElementById("guild-form"),
guildName: document.getElementById("guild-name"),
modalCancel: document.getElementById("modal-cancel"),
channelModal: document.getElementById("channel-modal"),
channelForm: document.getElementById("channel-form"),
channelName: document.getElementById("channel-name"),
channelModalCancel: document.getElementById("channel-modal-cancel"),
// Sound Board
soundboard: document.getElementById('soundboard'),
soundboardGrid: document.getElementById('soundboard-grid'),
addSoundBtn: document.getElementById('add-sound-btn'),
soundModal: document.getElementById('sound-modal'),
soundForm: document.getElementById('sound-form'),
soundName: document.getElementById('sound-name'),
soundIcon: document.getElementById('sound-icon'),
soundFile: document.getElementById('sound-file'),
soundModalCancel: document.getElementById('sound-modal-cancel'),
soundSubmitBtn: document.getElementById('sound-submit-btn'),
// Mobile
mobileMenuBtn: document.getElementById("mobile-menu-btn"),
mobileMembersBtn: document.getElementById("mobile-members-btn"),
overlay: document.getElementById("overlay"),
utilitySidebar: document.getElementById("utility-sidebar"),
// GIF Picker
gifBtn: document.getElementById("gif-btn"),
gifPicker: document.getElementById("gif-picker"),
gifPickerClose: document.getElementById("gif-picker-close"),
gifSearchInput: document.getElementById("gif-search-input"),
gifResults: document.getElementById("gif-results"),
};
// --- API Helpers ---
async function api(path, options = {}) {
const headers = {
"content-type": "application/json",
...(options.headers || {}),
};
const token = localStorage.getItem("chattz_token");
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
let res = await fetch(path, {
...options,
headers,
});
// Handle 401 Unauthorized via Refresh Token
if (res.status === 401 && !path.includes('/auth/refresh')) {
const refreshToken = localStorage.getItem("chattz_refresh_token");
if (refreshToken) {
try {
const refreshRes = await fetch('/auth/refresh', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken })
});
if (refreshRes.ok) {
const newTokens = await refreshRes.json();
localStorage.setItem("chattz_token", newTokens.access_token);
localStorage.setItem("chattz_refresh_token", newTokens.refresh_token);
// Retry original request with new token
headers["Authorization"] = `Bearer ${newTokens.access_token}`;
res = await fetch(path, { ...options, headers });
} else {
// Both tokens invalid/expired
throw new Error("Refresh token expired or invalid");
}
} catch (err) {
localStorage.removeItem("chattz_token");
localStorage.removeItem("chattz_refresh_token");
location.href = "/auth/login";
throw new Error("Session expired, please log in again");
}
}
}
if (!res.ok) {
let detail = "request failed";
try {
const body = await res.json();
detail = body.error || detail;
} catch { }
throw new Error(`${res.status}: ${detail}`);
}
if (res.status === 204) return null;
return res.json();
}
// --- Utils ---
function shortName(name) {
if (!name || typeof name !== 'string') return "?";
const trimmed = name.trim();
if (!trimmed) return "?";
const words = trimmed.split(/\s+/).filter(w => w.length > 0).slice(0, 2);
if (words.length === 0) return "?";
if (words.length === 1) return words[0].substring(0, 2).toUpperCase();
return words.map((w) => w[0]?.toUpperCase() || "").join("");
}
function escapeHtml(s) {
if (s === null || s === undefined) return "";
return String(s)
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
function formatMessageBody(body) {
const escaped = escapeHtml(body);
// Detect GIF URLs (simple regex for demo)
const urlRegex = /(https?:\/\/[^\s]+(?:\.gif|\/giphy\.gif)[^\s]*)/gi;
if (urlRegex.test(body)) {
return escaped.replace(urlRegex, (url) => {
return `

`;
});
}
return escaped;
}
function formatDate(isoString) {
const d = new Date(isoString);
const now = new Date();
const isToday = d.toDateString() === now.toDateString();
if (isToday) {
return `Today at ${d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
}
return d.toLocaleDateString();
}
let audioCtx = null;
// Global interaction listener to unlock AudioContext (autoplay policy)
window.addEventListener('click', () => {
if (audioCtx && audioCtx.state === 'suspended') {
audioCtx.resume();
}
}, { once: true });
function playSound(type) {
try {
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
if (audioCtx.state === 'suspended') audioCtx.resume();
const sounds = {
message: { freq: 880, type: 'sine', duration: 0.1, volume: 0.1 },
dm: { freq: [660, 880], type: 'sine', duration: 0.15, volume: 0.1 },
join: { freq: [440, 880], type: 'sine', duration: 0.2, volume: 0.1 },
leave: { freq: [880, 440], type: 'sine', duration: 0.2, volume: 0.1 },
'peer-join': { freq: [660, 990], type: 'sine', duration: 0.15, volume: 0.05 },
'peer-leave': { freq: [990, 660], type: 'sine', duration: 0.15, volume: 0.05 },
};
const s = sounds[type];
if (!s) return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = s.type;
if (Array.isArray(s.freq)) {
osc.frequency.setValueAtTime(s.freq[0], audioCtx.currentTime);
osc.frequency.exponentialRampToValueAtTime(s.freq[1], audioCtx.currentTime + s.duration);
} else {
osc.frequency.setValueAtTime(s.freq, audioCtx.currentTime);
}
gain.gain.setValueAtTime(s.volume, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + s.duration);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + s.duration);
} catch (err) {
console.warn("playSound failed", err);
}
}
const LAST_GUILD_STORAGE_KEY = "chattz:lastGuildId";
// --- Renderers ---
function renderGuilds() {
el.guildList.innerHTML = "";
for (const guild of state.guilds) {
const btn = document.createElement("button");
btn.className = `guild-pill ${state.selectedGuildId === guild.id ? "active" : ""}`;
btn.title = guild.name;
btn.textContent = shortName(guild.name);
btn.onclick = async () => {
state.selectedGuildId = guild.id;
state.selectedTextChannelId = null;
state.selectedVoiceChannelId = null;
state.selectedDmUserId = null;
state.selectedDmDisplayName = null;
localStorage.setItem(LAST_GUILD_STORAGE_KEY, guild.id);
renderGuilds();
renderDMs();
await loadChannels();
await loadGuildMembers();
await refreshVoicePresence();
startVoicePresencePolling();
renderMessages([]);
updateHeaderLabels();
};
el.guildList.appendChild(btn);
}
}
function renderChannels() {
el.channelList.innerHTML = "";
el.voiceChannelList.innerHTML = "";
for (const channel of state.channels) {
const row = document.createElement("button");
row.className = `channel-row ${(channel.kind === 'text' && state.selectedTextChannelId === channel.id) ||
(channel.kind === 'voice' && state.selectedVoiceChannelId === channel.id) ? "active" : ""
}`;
const iconName = channel.kind === 'text' ? 'hash' : 'volume-2';
row.innerHTML = ` ${escapeHtml(channel.name)}`;
row.onclick = async () => {
if (channel.kind === 'text') {
state.selectedDmUserId = null;
state.selectedDmDisplayName = null;
state.selectedTextChannelId = channel.id;
state.voice.viewMode = 'chat';
renderChannels();
renderDMs();
updateView();
updateHeaderLabels();
const messages = await api(`/channels/${channel.id}/messages?limit=100`);
renderMessages(messages);
} else {
state.selectedVoiceChannelId = channel.id;
state.voice.viewMode = 'video';
renderChannels();
renderDMs();
updateView();
updateHeaderLabels();
try {
await joinVoice();
} catch (err) {
console.error("joinVoice failed from channel click", err);
alert(`Voice join failed: ${err?.message || err}`);
}
}
if (window.innerWidth <= 768) closeMobileMenus();
};
if (channel.kind === 'text') {
el.channelList.appendChild(row);
} else {
el.voiceChannelList.appendChild(row);
const participants = state.voicePresence.get(channel.id) || [];
if (participants.length > 0) {
const pList = document.createElement("div");
pList.className = "voice-row-members channel-list";
pList.style.paddingLeft = "24px";
for (const p of participants) {
const pRow = document.createElement("div");
pRow.className = `channel-row ${p.is_speaking ? 'voice-speaking' : ''}`;
pRow.style.padding = "2px 8px";
pRow.style.flexWrap = "wrap";
let sliderHtml = '';
if (p.user_id !== state.me.id) {
const vol = state.userVolumes.get(p.user_id) ?? 1.0;
sliderHtml = `
${Math.round(vol * 100)}%
`;
}
pRow.innerHTML = `
${shortName(p.display_name)}
${escapeHtml(p.display_name)}
${p.is_muted ? '
' : ''}
${sliderHtml}
`;
const slider = pRow.querySelector('.volume-slider');
if (slider) {
slider.addEventListener('input', (e) => {
const val = parseFloat(e.target.value);
state.userVolumes.set(p.user_id, val);
pRow.querySelector('.vol-pct').textContent = `${Math.round(val * 100)}%`;
const gainNode = state.voice.peerGainNodes.get(p.user_id);
if (gainNode) {
gainNode.gain.setTargetAtTime(val, state.voice.audioContext.currentTime, 0.05);
}
});
// Stop propagation to prevent joining channel again when clicking slider
slider.addEventListener('click', (e) => e.stopPropagation());
}
pList.appendChild(pRow);
}
el.voiceChannelList.appendChild(pList);
}
}
}
lucide.createIcons();
}
function renderDMs() {
el.dmList.innerHTML = "";
for (const dm of state.dmConversations) {
const isOnline = state.onlineUsers.has(dm.user_id);
const isIdle = state.idleUsers.has(dm.user_id);
const row = document.createElement("button");
row.className = `channel-row ${state.selectedDmUserId === dm.user_id ? "active" : ""}`;
row.innerHTML = `
${escapeHtml(dm.display_name)}
`;
row.onclick = async () => {
state.selectedDmUserId = dm.user_id;
state.selectedDmDisplayName = dm.display_name;
state.selectedTextChannelId = null;
state.selectedVoiceChannelId = null;
state.voice.viewMode = 'chat';
renderChannels();
renderDMs();
updateView();
updateHeaderLabels();
const messages = await api(`/dms/${dm.user_id}/messages?limit=100`);
renderMessages(messages);
};
el.dmList.appendChild(row);
}
lucide.createIcons();
}
function renderMessages(messages) {
el.messageList.innerHTML = "";
let lastAuthorId = null;
let lastTime = null;
for (const m of messages.slice().reverse()) {
const row = document.createElement("div");
const mDate = new Date(m.created_at);
const isGrouped = lastAuthorId === m.author_user_id &&
lastTime && (mDate - lastTime < 300000); // 5 minutes
row.className = `msg ${isGrouped ? "msg-grouped" : ""}`;
const displayName = m.author_display_name || "Unknown User";
if (isGrouped) {
row.innerHTML = `${formatMessageBody(m.body)}
`;
} else {
row.innerHTML = `
${shortName(displayName)}
${formatMessageBody(m.body)}
`;
}
el.messageList.appendChild(row);
lastAuthorId = m.author_user_id;
lastTime = mDate;
}
if (messages.length > 0) {
state.lastMessageId = messages[0].id;
}
el.messageList.scrollTop = el.messageList.scrollHeight;
}
function renderMembers() {
el.memberList.innerHTML = "";
for (const m of state.members) {
const isOnline = state.onlineUsers.has(m.id);
const isIdle = state.idleUsers.has(m.id);
const row = document.createElement("div");
row.className = "member-row";
row.innerHTML = `
${shortName(m.display_name)}
${escapeHtml(m.display_name)}
`;
row.style.cursor = "pointer";
row.onclick = async () => {
if (state.me && m.id === state.me.id) return;
state.selectedDmUserId = m.id;
state.selectedDmDisplayName = m.display_name;
state.selectedTextChannelId = null;
state.selectedVoiceChannelId = null;
state.voice.viewMode = 'chat';
renderChannels();
renderDMs();
updateView();
updateHeaderLabels();
const messages = await api(`/dms/${m.id}/messages?limit=100`);
renderMessages(messages);
};
el.memberList.appendChild(row);
}
}
function updateHeaderLabels() {
const guild = state.guilds.find((g) => g.id === state.selectedGuildId);
el.guildTitle.textContent = guild ? guild.name : "No server selected";
if (state.selectedDmUserId) {
const dmFromConversations = state.dmConversations.find((u) => u.user_id === state.selectedDmUserId);
const dmFromMembers = state.members.find((m) => m.id === state.selectedDmUserId);
const displayName = dmFromConversations?.display_name || dmFromMembers?.display_name || state.selectedDmDisplayName;
const dmName = displayName ? `@${displayName}` : "Direct Message";
el.channelTitle.textContent = dmName;
el.messageBody.placeholder = displayName
? `Message @${displayName}`
: "Message";
return;
}
if (state.voice.viewMode === 'video' && state.selectedVoiceChannelId) {
const channel = state.channels.find((c) => c.id === state.selectedVoiceChannelId);
el.channelTitle.textContent = channel ? channel.name : "Voice Arena";
return;
}
const channel = state.channels.find((c) => c.id === state.selectedTextChannelId);
el.channelTitle.textContent = channel ? channel.name : "Select a channel";
el.messageBody.placeholder = channel ? `Message #${channel.name}` : "Select a channel";
}
async function createInviteLink() {
if (!state.selectedGuildId) {
alert("Select a server first.");
return;
}
try {
const invite = await api(`/guilds/${state.selectedGuildId}/invites`, {
method: "POST",
body: JSON.stringify({ max_uses: 50, expires_in_hours: 24 }),
});
const link = `${location.origin}/?invite=${encodeURIComponent(invite.code)}`;
try {
await navigator.clipboard.writeText(link);
showCopiedBadge();
} catch {
prompt("Copy invite link:", link);
}
} catch (err) {
alert(err.message);
}
}
function showCopiedBadge() {
el.inviteCopiedBadge.classList.remove("hidden");
setTimeout(() => {
el.inviteCopiedBadge.classList.add("hidden");
}, 2000);
}
// --- Logic ---
async function loadGuilds() {
state.guilds = await api("/guilds");
renderGuilds();
}
async function loadDMConversations() {
state.dmConversations = await api("/dms");
renderDMs();
}
async function loadGuildMembers() {
if (!state.selectedGuildId) {
state.members = [];
renderMembers();
return;
}
state.members = await api(`/guilds/${state.selectedGuildId}/members`);
renderMembers();
}
async function loadChannels() {
if (!state.selectedGuildId) {
state.channels = [];
renderChannels();
return;
}
state.channels = await api(`/guilds/${state.selectedGuildId}/channels`);
renderChannels();
}
async function refreshVoicePresence() {
if (!state.selectedGuildId) {
state.voicePresence.clear();
renderChannels();
return;
}
try {
const res = await api(`/guilds/${state.selectedGuildId}/voice-presence`);
state.voicePresence.clear();
for (const entry of res.channels || []) {
state.voicePresence.set(entry.channel_id, entry.participants || []);
}
renderChannels();
} catch (err) {
console.warn("voice presence failed", err);
}
}
function startVoicePresencePolling() {
if (state.voicePresencePollId) clearInterval(state.voicePresencePollId);
state.voicePresencePollId = setInterval(() => {
refreshVoicePresence().catch(() => { });
}, 3000);
}
function initChatWs() {
if (state.chatWs) state.chatWs.close();
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
let wsUrl = `${protocol}//${location.host}/ws`;
const token = localStorage.getItem("chattz_token");
if (token) {
wsUrl += (wsUrl.includes('?') ? '&' : '?') + `token=${token}`;
}
const ws = new WebSocket(wsUrl);
state.chatWs = ws;
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
const authorId = msg.message?.author_user_id;
const isFromMe = authorId && state.me && authorId === state.me.id;
if (msg.type === "message_created") {
if (!isFromMe) playSound('message');
if (state.selectedTextChannelId === msg.channel_id) {
api(`/channels/${state.selectedTextChannelId}/messages?limit=100`).then(renderMessages);
}
} else if (msg.type === "dm_created") {
if (!isFromMe) playSound('dm');
if (state.selectedDmUserId === msg.other_user_id) {
api(`/dms/${state.selectedDmUserId}/messages?limit=100`).then(renderMessages);
playSound('dm');
loadDMConversations().then(renderDMs);
} else {
loadDMConversations().then(renderDMs);
}
} else if (msg.type === "user_presence") {
if (msg.online) {
state.onlineUsers.add(msg.user_id);
if (msg.idle) {
state.idleUsers.add(msg.user_id);
} else {
state.idleUsers.delete(msg.user_id);
}
} else {
state.onlineUsers.delete(msg.user_id);
state.idleUsers.delete(msg.user_id);
}
renderMembers();
renderDMs();
}
};
ws.onclose = () => {
console.log("Chat WS closed, reconnecting...");
setTimeout(initChatWs, 3000);
};
}
// --- Voice ---
function getVoiceWsUrl(channelId) {
const proto = location.protocol === "https:" ? "wss" : "ws";
let urlStr = `${proto}://${location.host}/channels/${channelId}/voice/ws`;
const token = localStorage.getItem("chattz_token");
if (token) {
urlStr += (urlStr.includes('?') ? '&' : '?') + `token=${token}`;
}
return urlStr;
}
function shouldInitiateOffer(peerId) {
if (!state.me || !state.me.id) return false;
return state.me.id > peerId;
}
function stopAndClearAudioPipeline() {
if (state.voice.deepFilterProcessor) {
state.voice.deepFilterProcessor.destroy();
state.voice.deepFilterProcessor = null;
}
if (state.voice.localStream) {
for (const track of state.voice.localStream.getTracks()) track.stop();
}
if (state.voice.rawStream && state.voice.rawStream !== state.voice.localStream) {
for (const track of state.voice.rawStream.getTracks()) track.stop();
}
if (state.voice.audioContext) {
state.voice.audioContext.close().catch(() => { });
}
state.voice.localStream = null;
state.voice.rawStream = null;
state.voice.audioContext = null;
}
function stopAndClearVideoPipeline() {
if (state.voice.videoStream) {
for (const track of state.voice.videoStream.getTracks()) track.stop();
}
state.voice.videoStream = null;
state.voice.sharingVideo = false;
document.getElementById(`video-${state.me.id}-camera`)?.parentElement?.remove();
}
function stopAndClearScreenPipeline() {
if (state.voice.screenStream) {
for (const track of state.voice.screenStream.getTracks()) track.stop();
}
state.voice.screenStream = null;
state.voice.sharingScreen = false;
document.getElementById(`video-${state.me.id}-screen`)?.parentElement?.remove();
}
function updateVideoGridVisibility() {
// Persistence logic: we always update the grid visibility based on participants
// however the actual container visibility in the UI is managed by updateView()
}
function updateView() {
if (state.voice.viewMode === 'video') {
el.videoGrid.classList.remove("hidden");
el.messageList.classList.add("hidden");
el.messageInputWrapper.classList.add("hidden");
} else {
el.videoGrid.classList.add("hidden");
el.messageList.classList.remove("hidden");
el.messageInputWrapper.classList.remove("hidden");
}
}
function renderVideo(peerId, displayName, stream, source) {
const videoId = `video-${peerId}-${source}`;
let videoEl = document.getElementById(videoId);
if (!videoEl) {
const container = document.createElement("div");
container.className = "video-item";
const label = source === 'screen' ? `${escapeHtml(displayName)}'s Screen` : escapeHtml(displayName);
container.innerHTML = `
${label}
`;
container.onclick = () => {
const isFullscreen = container.classList.contains('fullscreen');
// Reset all
document.querySelectorAll('.video-item').forEach(el => el.classList.remove('fullscreen'));
if (isFullscreen) {
el.videoGrid.classList.remove('has-fullscreen');
} else {
container.classList.add('fullscreen');
el.videoGrid.classList.add('has-fullscreen');
}
};
el.videoGrid.appendChild(container);
videoEl = container.querySelector("video");
}
videoEl.srcObject = stream;
}
async function buildAudioPipeline(rawStream) {
const ctx = new AudioContext();
state.voice.audioContext = ctx;
const source = ctx.createMediaStreamSource(rawStream);
let processedSource = source;
try {
const deepFilter = await getDeepFilterModule();
const processor = new deepFilter.DeepFilterNet3Core({
sampleRate: 48000,
noiseReductionLevel: 70,
assetConfig: {
cdnUrl: resolveDeepFilterAssetsBaseUrl(),
},
});
await processor.initialize();
const workletNode = await processor.createAudioWorkletNode(ctx);
processor.setNoiseSuppressionEnabled(true);
source.connect(workletNode);
processedSource = workletNode;
state.voice.deepFilterProcessor = processor;
console.info('DeepFilterNet3 enabled');
} catch (err) {
console.warn('DeepFilterNet3 unavailable, falling back to raw mic audio', err);
state.voice.deepFilterProcessor = null;
}
// Metering/Speaking detection with adaptive threshold and hysteresis.
const analyser = ctx.createAnalyser();
analyser.fftSize = 1024;
processedSource.connect(analyser);
const supportsFloatTimeDomain = typeof analyser.getFloatTimeDomainData === 'function';
const timeData = supportsFloatTimeDomain ? new Float32Array(analyser.fftSize) : new Uint8Array(analyser.fftSize);
let localIsSpeaking = false;
let speechFrames = 0;
let silenceFrames = 0;
let noiseFloor = 0.004;
let levelEma = 0;
const checkVolume = () => {
if (!state.voice.audioContext || state.voice.audioContext.state === 'closed') return;
try {
if (supportsFloatTimeDomain) {
analyser.getFloatTimeDomainData(timeData);
} else {
analyser.getByteTimeDomainData(timeData);
}
} catch (err) {
setTimeout(checkVolume, 60);
return;
}
let sumSquares = 0;
let peak = 0;
for (let i = 0; i < timeData.length; i++) {
const v = supportsFloatTimeDomain ? timeData[i] : (timeData[i] - 128) / 128;
sumSquares += v * v;
const abs = Math.abs(v);
if (abs > peak) peak = abs;
}
const rms = Math.sqrt(sumSquares / timeData.length);
const level = Math.max(rms, peak * 0.5);
levelEma = levelEma * 0.75 + level * 0.25;
if (!localIsSpeaking) {
noiseFloor = noiseFloor * 0.98 + levelEma * 0.02;
} else {
noiseFloor = Math.min(noiseFloor, levelEma);
}
const startThreshold = Math.max(noiseFloor * 2.2, 0.010);
const stopThreshold = Math.max(noiseFloor * 1.5, 0.006);
if (levelEma > startThreshold) {
speechFrames = Math.min(speechFrames + 1, 8);
silenceFrames = 0;
} else if (levelEma < stopThreshold) {
silenceFrames = Math.min(silenceFrames + 1, 8);
speechFrames = Math.max(speechFrames - 1, 0);
}
const newSpeakingState = localIsSpeaking ? silenceFrames < 3 : speechFrames >= 2;
if (newSpeakingState !== localIsSpeaking) {
localIsSpeaking = newSpeakingState;
if (state.voice.ws && state.voice.ws.readyState === WebSocket.OPEN) {
state.voice.ws.send(JSON.stringify({ type: 'set_speaking_status', is_speaking: localIsSpeaking }));
}
state.voicePresence.get(state.selectedVoiceChannelId)?.forEach(p => {
if (p.user_id === state.me.id) p.is_speaking = localIsSpeaking;
});
renderChannels();
const myVideo = document.getElementById(`video-${state.me.id}-camera`) || document.getElementById(`video-${state.me.id}-screen`);
if (myVideo) myVideo.parentElement.classList.toggle('speaking', localIsSpeaking);
}
setTimeout(checkVolume, 60);
};
checkVolume();
const destination = ctx.createMediaStreamDestination();
processedSource.connect(destination);
return destination.stream;
}
function resolveDeepFilterModuleUrl() {
if (location.protocol === 'file:') {
return new URL('./vendor/deepfilternet3-noise-filter.esm.js', location.href).toString();
}
return '/static/vendor/deepfilternet3-noise-filter.esm.js';
}
function resolveDeepFilterAssetsBaseUrl() {
if (location.protocol === 'file:') {
return new URL('./vendor/deepfilternet3', location.href).toString();
}
return '/static/vendor/deepfilternet3';
}
async function getDeepFilterModule() {
if (state.voice.deepFilterModule) return state.voice.deepFilterModule;
const moduleUrl = resolveDeepFilterModuleUrl();
state.voice.deepFilterModule = await import(moduleUrl);
return state.voice.deepFilterModule;
}
async function createLocalVoiceStream() {
const constraints = {
audio: {
channelCount: 1,
sampleRate: 48000,
echoCancellation: true,
noiseSuppression: false,
autoGainControl: false,
},
video: false,
};
const rawStream = await navigator.mediaDevices.getUserMedia(constraints);
let localStream = rawStream;
try {
localStream = await buildAudioPipeline(rawStream);
} catch (err) {
console.warn('voice audio pipeline failed, using raw mic stream', err);
}
state.voice.rawStream = rawStream;
state.voice.localStream = localStream;
if (state.voice.muted) {
state.voice.localStream.getAudioTracks().forEach((t) => {
t.enabled = false;
});
}
}
function ensurePeerConnection(peerId) {
if (state.voice.peerConnections.has(peerId)) {
return state.voice.peerConnections.get(peerId);
}
const pc = new RTCPeerConnection({
iceServers: state.voice.iceServers
});
pc.makingOffer = false;
pc.ignoreOffer = false;
pc.polite = !shouldInitiateOffer(peerId);
// 1. Audio
if (state.voice.localStream) {
state.voice.localStream.getAudioTracks().forEach(track => {
pc.addTrack(track, state.voice.localStream);
});
} else {
pc.addTransceiver('audio', { direction: 'recvonly' });
}
// 2. Camera Video
if (state.voice.videoStream) {
state.voice.videoStream.getVideoTracks().forEach(track => {
const sender = pc.addTrack(track, state.voice.videoStream);
state.voice.videoSenders.set(peerId, sender);
});
}
// 3. Screen Video
if (state.voice.screenStream) {
state.voice.screenStream.getVideoTracks().forEach(track => {
const sender = pc.addTrack(track, state.voice.screenStream);
state.voice.screenSenders.set(peerId, sender);
});
}
pc.onicecandidate = (event) => {
if (!event.candidate || !state.voice.ws) return;
state.voice.ws.send(JSON.stringify({
type: "signal",
to_user_id: peerId,
kind: "ice",
data: event.candidate,
}));
};
pc.oniceconnectionstatechange = async () => {
const iceState = pc.iceConnectionState;
console.debug(`voice ice state (${peerId}):`, iceState);
if (iceState !== "failed" || pc.restartingIce) return;
pc.restartingIce = true;
try {
const offer = await pc.createOffer({ iceRestart: true });
await pc.setLocalDescription(offer);
if (state.voice.ws && state.voice.ws.readyState === WebSocket.OPEN) {
state.voice.ws.send(JSON.stringify({
type: "signal",
to_user_id: peerId,
kind: "offer",
data: pc.localDescription,
}));
}
} catch (err) {
console.warn("ICE restart failed", err);
} finally {
pc.restartingIce = false;
}
};
pc.ontrack = (event) => {
const stream = event.streams[0];
if (event.track.kind === "audio") {
let audio = document.getElementById(`audio-${peerId}`);
if (!audio) {
audio = document.createElement("audio");
audio.id = `audio-${peerId}`;
audio.autoplay = true;
audio.playsInline = true;
document.body.appendChild(audio);
}
audio.muted = false;
audio.srcObject = new MediaStream([event.track]);
const tryPlay = () => {
const playPromise = audio.play();
if (playPromise && typeof playPromise.catch === "function") {
playPromise.catch((err) => {
console.warn(`remote audio playback blocked for ${peerId}`, err);
});
}
};
audio.onloadedmetadata = tryPlay;
audio.oncanplay = tryPlay;
tryPlay();
// Web Audio Pipeline for Volume Boosting
if (!state.voice.audioContext) {
state.voice.audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
const ctx = state.voice.audioContext;
if (ctx.state === 'suspended') ctx.resume();
const sourceNode = ctx.createMediaStreamSource(audio.srcObject);
const gainNode = ctx.createGain();
const currentVolume = state.userVolumes.get(peerId) ?? 1.0;
gainNode.gain.setValueAtTime(currentVolume, ctx.currentTime);
sourceNode.connect(gainNode);
gainNode.connect(ctx.destination);
state.voice.peerGainNodes.set(peerId, gainNode);
// Mute the original element as we play through Web Audio destination
audio.volume = 0;
audio.muted = true;
} else if (event.track.kind === "video") {
const peer = state.members.find(m => m.id === peerId) || { display_name: "Unknown" };
renderVideo(peerId, peer.display_name, stream, event.track.id);
event.track.onmute = () => {
document.getElementById(`video-${peerId}-${event.track.id}`)?.parentElement?.remove();
};
event.track.onended = () => {
document.getElementById(`video-${peerId}-${event.track.id}`)?.parentElement?.remove();
};
}
};
pc.onnegotiationneeded = async () => {
try {
pc.makingOffer = true;
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
state.voice.ws.send(JSON.stringify({
type: "signal",
to_user_id: peerId,
kind: "offer",
data: pc.localDescription,
}));
} catch (err) {
console.error("negotiation failed", err);
} finally {
pc.makingOffer = false;
}
};
state.voice.peerConnections.set(peerId, pc);
return pc;
}
async function attachLocalAudioToPeerConnections() {
if (!state.voice.localStream) return;
const localTrack = state.voice.localStream.getAudioTracks()[0];
if (!localTrack) return;
await Promise.all(
Array.from(state.voice.peerConnections.values()).map(async (pc) => {
const audioTransceiver = pc.getTransceivers().find((t) => t.receiver?.track?.kind === 'audio');
if (audioTransceiver?.sender) {
await audioTransceiver.sender.replaceTrack(localTrack);
if (audioTransceiver.direction === 'recvonly') {
audioTransceiver.direction = 'sendrecv';
}
} else {
pc.addTrack(localTrack, state.voice.localStream);
}
})
);
}
async function handleSignal(fromPeerId, kind, data) {
const pc = ensurePeerConnection(fromPeerId);
try {
if (kind === "offer") {
const offerCollision = pc.makingOffer || pc.signalingState !== "stable";
pc.ignoreOffer = !pc.polite && offerCollision;
if (pc.ignoreOffer) return;
await pc.setRemoteDescription(new RTCSessionDescription(data));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
state.voice.ws.send(JSON.stringify({
type: "signal",
to_user_id: fromPeerId,
kind: "answer",
data: pc.localDescription,
}));
} else if (kind === "answer") {
await pc.setRemoteDescription(new RTCSessionDescription(data));
} else if (kind === "ice") {
try {
await pc.addIceCandidate(data ? new RTCIceCandidate(data) : null);
} catch (err) {
if (!pc.ignoreOffer) {
console.warn("failed to add ice candidate", err);
}
}
}
} catch (err) {
console.error("handleSignal failed", err);
}
}
async function joinVoice() {
if (!state.selectedVoiceChannelId) {
alert('No voice channel selected.');
return;
}
if (state.voice.joinedChannelId === state.selectedVoiceChannelId &&
state.voice.ws &&
state.voice.ws.readyState === WebSocket.OPEN) {
return;
}
await leaveVoice();
el.voiceConnection.classList.remove('hidden');
el.vcChannelName.textContent = 'Connecting...';
const ws = new WebSocket(getVoiceWsUrl(state.selectedVoiceChannelId));
const currentWs = ws;
let wsOpened = false;
let micUnavailable = false;
state.voice.ws = ws;
state.voice.joinedChannelId = state.selectedVoiceChannelId;
// Start mic setup in parallel so channel join is not blocked by device init.
createLocalVoiceStream()
.then(async () => {
if (state.voice.ws !== currentWs) {
stopAndClearAudioPipeline();
return;
}
try {
await attachLocalAudioToPeerConnections();
} catch (err) {
console.warn('failed to attach local audio to existing peer connections', err);
}
})
.catch((err) => {
micUnavailable = true;
console.warn('failed to initialize local voice stream, joining as listen-only', err);
state.voice.localStream = null;
state.voice.rawStream = null;
if (state.voice.audioContext) {
state.voice.audioContext.close().catch(() => { });
state.voice.audioContext = null;
}
});
const connectTimer = setTimeout(() => {
if (!wsOpened && state.voice.ws === currentWs) {
console.error('voice websocket connect timeout');
try { currentWs.close(); } catch (_) { }
el.vcChannelName.textContent = 'Connection timeout';
alert('Could not connect to voice channel (timeout). Please try again.');
}
}, 5000);
ws.onopen = () => {
if (state.voice.ws !== currentWs) return;
wsOpened = true;
clearTimeout(connectTimer);
const channel = state.channels.find(c => c.id === state.selectedVoiceChannelId);
el.vcChannelName.textContent = channel ? channel.name : "Voice";
if (micUnavailable) {
console.warn('Voice joined in listen-only mode because microphone is unavailable.');
}
el.voiceConnection.classList.remove("hidden");
el.soundboard.classList.remove("hidden");
playSound('join');
refreshVoicePresence().catch(() => { });
loadSounds().catch(() => { });
};
ws.onmessage = async (event) => {
if (state.voice.ws !== currentWs) return;
const msg = JSON.parse(event.data);
if (msg.type === "peers") {
for (const peer of msg.peers) {
ensurePeerConnection(peer.user_id);
}
} else if (msg.type === "peer_joined") {
playSound('peer-join');
ensurePeerConnection(msg.user_id);
} else if (msg.type === "peer_left") {
playSound('peer-leave');
const pc = state.voice.peerConnections.get(msg.user_id);
if (pc) {
pc.close();
state.voice.peerConnections.delete(msg.user_id);
}
document.getElementById(`audio-${msg.user_id}`)?.remove();
} else if (msg.type === "signal") {
await handleSignal(msg.from_user_id, msg.kind, msg.data);
} else if (msg.type === "video_status_changed") {
if (!msg.is_sharing_video) {
document.getElementById(`video-${msg.user_id}-camera`)?.parentElement?.remove();
}
} else if (msg.type === "screen_status_changed") {
if (!msg.is_sharing_screen) {
document.getElementById(`video-${msg.user_id}-screen`)?.parentElement?.remove();
}
} else if (msg.type === "speaking_status_changed") {
state.voicePresence.get(state.selectedVoiceChannelId)?.forEach(p => {
if (p.user_id === msg.user_id) p.is_speaking = msg.is_speaking;
});
renderChannels();
const videoEl = document.getElementById(`video-${msg.user_id}-camera`) || document.getElementById(`video-${msg.user_id}-screen`);
if (videoEl) {
videoEl.parentElement.classList.toggle('speaking', msg.is_speaking);
}
} else if (msg.type === "mute_status_changed") {
state.voicePresence.get(state.selectedVoiceChannelId)?.forEach(p => {
if (p.user_id === msg.user_id) p.is_muted = msg.is_muted;
});
renderChannels();
} else if (msg.type === "play_sound") {
const audio = new Audio(msg.sound_url);
audio.play().catch(console.error);
}
refreshVoicePresence().catch(() => { });
};
ws.onerror = (event) => {
if (state.voice.ws !== currentWs) return;
console.error('voice websocket error', event);
el.vcChannelName.textContent = 'Connection failed';
};
ws.onclose = (event) => {
if (state.voice.ws !== currentWs) return;
clearTimeout(connectTimer);
if (!wsOpened) {
console.error('voice websocket closed before open', event.code, event.reason);
const code = event && typeof event.code === 'number' ? event.code : 'unknown';
el.vcChannelName.textContent = `Connection failed (${code})`;
alert(`Voice connection failed (code ${code}).`);
}
if (wsOpened) {
el.voiceConnection.classList.add("hidden");
}
el.soundboard.classList.add("hidden");
for (const pc of state.voice.peerConnections.values()) pc.close();
state.voice.peerConnections.clear();
state.voice.videoSenders.clear();
state.voice.screenSenders.clear();
stopAndClearAudioPipeline();
state.voice.joinedChannelId = null;
state.voice.ws = null;
playSound('leave');
refreshVoicePresence().catch(() => { });
};
}
async function leaveVoice() {
if (state.voice.ws) state.voice.ws.close();
}
function toggleMute() {
if (!state.voice.localStream) return;
state.voice.muted = !state.voice.muted;
state.voice.localStream.getAudioTracks().forEach((t) => {
t.enabled = !state.voice.muted;
});
if (state.voice.ws && state.voice.ws.readyState === WebSocket.OPEN) {
state.voice.ws.send(JSON.stringify({ type: "set_mute_status", is_muted: state.voice.muted }));
}
el.voiceMuteBtn.innerHTML = state.voice.muted ? '' : '';
el.voiceMuteBtn.style.color = state.voice.muted ? 'var(--danger)' : 'var(--text-muted)';
lucide.createIcons();
}
async function toggleVideo() {
if (state.voice.sharingVideo) {
stopAndClearVideoPipeline();
if (state.voice.ws) {
state.voice.ws.send(JSON.stringify({ type: "set_video_status", is_sharing_video: false }));
}
for (const [peerId, pc] of state.voice.peerConnections) {
const sender = state.voice.videoSenders.get(peerId);
if (sender) {
pc.removeTrack(sender);
state.voice.videoSenders.delete(peerId);
}
}
} else {
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
state.voice.videoStream = stream;
state.voice.sharingVideo = true;
renderVideo(state.me.id, state.me.display_name, stream, 'camera');
if (state.voice.ws) {
state.voice.ws.send(JSON.stringify({ type: "set_video_status", is_sharing_video: true }));
}
for (const [peerId, pc] of state.voice.peerConnections) {
stream.getVideoTracks().forEach(track => {
const sender = pc.addTrack(track, stream);
state.voice.videoSenders.set(peerId, sender);
});
}
} catch (err) {
console.error("camera denied", err);
alert("Could not access camera.");
return;
}
}
el.voiceVideoBtn.innerHTML = state.voice.sharingVideo ? '' : '';
el.voiceVideoBtn.style.color = state.voice.sharingVideo ? 'var(--green)' : 'var(--text-muted)';
lucide.createIcons();
}
async function toggleScreenShare() {
if (state.voice.sharingScreen) {
stopAndClearScreenPipeline();
if (state.voice.ws) {
state.voice.ws.send(JSON.stringify({ type: "set_screen_status", is_sharing_screen: false }));
}
for (const [peerId, pc] of state.voice.peerConnections) {
const sender = state.voice.screenSenders.get(peerId);
if (sender) {
pc.removeTrack(sender);
state.voice.screenSenders.delete(peerId);
}
}
} else {
try {
const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true });
state.voice.screenStream = stream;
state.voice.sharingScreen = true;
renderVideo(state.me.id, state.me.display_name, stream, 'screen');
// Stop sharing if user clicks "Stop sharing" in browser UI
stream.getVideoTracks()[0].onended = () => {
if (state.voice.sharingScreen) toggleScreenShare();
};
if (state.voice.ws) {
state.voice.ws.send(JSON.stringify({ type: "set_screen_status", is_sharing_screen: true }));
}
for (const [peerId, pc] of state.voice.peerConnections) {
stream.getVideoTracks().forEach(track => {
const sender = pc.addTrack(track, stream);
state.voice.screenSenders.set(peerId, sender);
});
}
} catch (err) {
console.error("screen share denied", err);
return;
}
}
el.voiceScreenBtn.innerHTML = state.voice.sharingScreen ? '' : '';
el.voiceScreenBtn.style.color = state.voice.sharingScreen ? 'var(--green)' : 'var(--text-muted)';
lucide.createIcons();
}
function toggleWatchVideo() {
state.voice.watchingVideo = !state.voice.watchingVideo;
updateVideoGridVisibility();
el.voiceWatchBtn.innerHTML = state.voice.watchingVideo ? '' : '';
el.voiceWatchBtn.style.color = state.voice.watchingVideo ? 'var(--green)' : 'var(--danger)';
el.voiceWatchBtn.title = state.voice.watchingVideo ? 'Stop Watching' : 'Start Watching';
lucide.createIcons();
}
// --- Sound Board ---
async function loadSounds() {
if (!state.selectedGuildId) return;
try {
const sounds = await api(`/guilds/${state.selectedGuildId}/sounds`);
renderSounds(sounds);
} catch (err) {
console.error("failed to load sounds", err);
}
}
function renderSounds(sounds) {
el.soundboardGrid.innerHTML = '';
sounds.forEach(sound => {
const item = document.createElement('div');
item.className = 'sound-item';
// Only show delete button if user is creator or owner
const canDelete = state.me && (state.me.id === sound.created_by_user_id || (state.guilds.find(g => g.id === state.selectedGuildId)?.owner_user_id === state.me.id));
item.innerHTML = `
${sound.icon}
${sound.name}
${canDelete ? `` : ''}
`;
item.onclick = (e) => {
if (e.target.closest('.sound-delete')) {
e.stopPropagation();
if (confirm(`Are you sure you want to delete "${sound.name}"?`)) {
deleteSound(sound.id);
}
return;
}
playRemoteSound(sound.file_path);
};
el.soundboardGrid.appendChild(item);
});
lucide.createIcons();
}
async function deleteSound(soundId) {
if (!state.selectedGuildId) return;
try {
const response = await fetch(`/guilds/${state.selectedGuildId}/sounds/${soundId}`, {
method: 'DELETE'
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.error || 'Delete failed');
}
await loadSounds();
} catch (err) {
alert(err.message);
}
}
function playRemoteSound(url) {
if (state.voice.ws && state.voice.ws.readyState === WebSocket.OPEN) {
state.voice.ws.send(JSON.stringify({ type: 'play_sound', sound_url: url }));
}
// Also play locally immediately
const audio = new Audio(url);
audio.play().catch(console.error);
}
// --- Mobile Logic ---
function toggleMobileMenu() {
const isOpen = el.main.classList.contains("menu-open");
if (isOpen) {
el.main.classList.remove("menu-open");
el.overlay.classList.add("hidden");
} else {
el.main.classList.add("menu-open");
el.utilitySidebar.classList.remove("active"); // close members if open
el.overlay.classList.remove("hidden");
}
}
function toggleMobileMembers() {
const isOpen = el.utilitySidebar.classList.contains("active");
if (isOpen) {
el.utilitySidebar.classList.remove("active");
el.overlay.classList.add("hidden");
} else {
el.utilitySidebar.classList.add("active");
el.main.classList.remove("menu-open"); // close menu if open
el.overlay.classList.remove("hidden");
}
}
function closeMobileMenus() {
el.main.classList.remove("menu-open");
el.utilitySidebar.classList.remove("active");
el.overlay.classList.add("hidden");
}
// --- Initialization ---
let inactivityTimer = null;
let isCurrentlyIdle = false;
function resetInactivityTimer() {
if (isCurrentlyIdle) {
isCurrentlyIdle = false;
if (state.chatWs && state.chatWs.readyState === WebSocket.OPEN) {
state.chatWs.send(JSON.stringify({ type: "set_idle_status", is_idle: false }));
}
}
if (inactivityTimer) clearTimeout(inactivityTimer);
inactivityTimer = setTimeout(() => {
isCurrentlyIdle = true;
if (state.chatWs && state.chatWs.readyState === WebSocket.OPEN) {
state.chatWs.send(JSON.stringify({ type: "set_idle_status", is_idle: true }));
}
}, 5 * 60 * 1000); // 5 minutes
}
document.addEventListener('mousemove', resetInactivityTimer);
document.addEventListener('keydown', resetInactivityTimer);
document.addEventListener('click', resetInactivityTimer);
async function init() {
lucide.createIcons();
try {
const searchParams = new URLSearchParams(location.search);
const jwtToken = searchParams.get("token");
const refreshToken = searchParams.get("refresh_token");
if (jwtToken || refreshToken) {
if (jwtToken) localStorage.setItem("chattz_token", jwtToken);
if (refreshToken) localStorage.setItem("chattz_refresh_token", refreshToken);
searchParams.delete("token");
searchParams.delete("refresh_token");
const nextQuery = searchParams.toString();
const nextUrl = `${location.pathname}${nextQuery ? `?${nextQuery}` : ""}`;
history.replaceState(null, "", nextUrl);
}
} catch (e) {
console.error("Failed to parse token from URL:", e);
}
el.loginBtn.onclick = () => { location.href = "/auth/login"; };
el.logoutBtn.onclick = async () => {
await leaveVoice();
localStorage.removeItem("chattz_token");
localStorage.removeItem("chattz_refresh_token");
try { await api("/auth/logout", { method: "POST" }); } catch (e) { }
location.reload();
};
el.addGuildBtn.onclick = () => { el.modalContainer.classList.remove("hidden"); };
el.createInviteBtn.onclick = createInviteLink;
el.modalCancel.onclick = () => { el.modalContainer.classList.add("hidden"); };
el.addTextBtn.onclick = () => {
if (!state.selectedGuildId) {
alert("Create or select a server first.");
return;
}
document.querySelector('input[name="channel-kind"][value="text"]').checked = true;
el.channelModal.classList.remove("hidden");
};
el.addVoiceBtn.onclick = () => {
if (!state.selectedGuildId) {
alert("Create or select a server first.");
return;
}
document.querySelector('input[name="channel-kind"][value="voice"]').checked = true;
el.channelModal.classList.remove("hidden");
};
el.channelModalCancel.onclick = () => { el.channelModal.classList.add("hidden"); };
// Mobile Listeners
if (el.mobileMenuBtn) el.mobileMenuBtn.onclick = toggleMobileMenu;
if (el.mobileMembersBtn) el.mobileMembersBtn.onclick = toggleMobileMembers;
if (el.overlay) el.overlay.onclick = closeMobileMenus;
el.guildForm.onsubmit = async (e) => {
e.preventDefault();
try {
const guild = await api("/guilds", {
method: "POST",
body: JSON.stringify({ name: el.guildName.value }),
});
el.guildName.value = "";
el.modalContainer.classList.add("hidden");
state.guilds.push(guild);
state.selectedGuildId = guild.id;
state.selectedTextChannelId = null;
state.selectedVoiceChannelId = null;
state.selectedDmUserId = null;
state.selectedDmDisplayName = null;
localStorage.setItem(LAST_GUILD_STORAGE_KEY, guild.id);
renderGuilds();
await loadChannels();
await loadGuildMembers();
await refreshVoicePresence();
renderMessages([]);
updateHeaderLabels();
} catch (err) { alert(err.message); }
};
el.channelForm.onsubmit = async (e) => {
e.preventDefault();
if (!state.selectedGuildId) {
alert("Select a server first.");
return;
}
const kindInput = document.querySelector('input[name="channel-kind"]:checked');
const kind = kindInput ? kindInput.value : "text";
const channelName = el.channelName.value.trim();
if (!channelName) {
alert("Channel name is required.");
return;
}
try {
const created = await api("/channels", {
method: "POST",
body: JSON.stringify({
guild_id: state.selectedGuildId,
name: channelName,
kind: kind,
}),
});
el.channelName.value = "";
el.channelModal.classList.add("hidden");
await loadChannels();
if (created.kind === "text") {
state.selectedTextChannelId = created.id;
state.selectedVoiceChannelId = null;
state.selectedDmUserId = null;
state.selectedDmDisplayName = null;
renderChannels();
renderDMs();
updateHeaderLabels();
const messages = await api(`/channels/${created.id}/messages?limit=100`);
renderMessages(messages);
} else {
state.selectedVoiceChannelId = created.id;
state.selectedTextChannelId = null;
state.selectedDmUserId = null;
state.selectedDmDisplayName = null;
renderChannels();
renderDMs();
updateHeaderLabels();
}
} catch (err) { alert(err.message); }
};
el.messageForm.onsubmit = async (e) => {
e.preventDefault();
const body = el.messageBody.value.trim();
if (!body) return;
try {
if (state.selectedTextChannelId) {
await api(`/channels/${state.selectedTextChannelId}/messages`, {
method: "POST",
body: JSON.stringify({ body }),
});
} else if (state.selectedDmUserId) {
await api(`/dms/${state.selectedDmUserId}/messages`, {
method: "POST",
body: JSON.stringify({ body }),
});
} else {
return;
}
el.messageBody.value = "";
const messages = state.selectedTextChannelId
? await api(`/channels/${state.selectedTextChannelId}/messages?limit=100`)
: await api(`/dms/${state.selectedDmUserId}/messages?limit=100`);
renderMessages(messages);
await loadDMConversations();
renderDMs();
} catch (err) { alert(err.message); }
};
el.voiceVideoBtn.onclick = toggleVideo;
el.voiceScreenBtn.onclick = toggleScreenShare;
el.voiceMuteBtn.onclick = toggleMute;
el.voiceLeaveBtn.onclick = leaveVoice;
el.addSoundBtn.onclick = () => {
el.soundModal.classList.remove("hidden");
};
el.soundModalCancel.onclick = () => {
el.soundModal.classList.add("hidden");
};
el.soundForm.onsubmit = async (e) => {
e.preventDefault();
if (!state.selectedGuildId) return;
const formData = new FormData();
formData.append('name', el.soundName.value);
formData.append('icon', el.soundIcon.value);
formData.append('file', el.soundFile.files[0]);
el.soundSubmitBtn.disabled = true;
el.soundSubmitBtn.textContent = 'Uploading...';
try {
// Need to use fetch directly because api() might not handle FormData correctly depending on implementation
const response = await fetch(`/guilds/${state.selectedGuildId}/sounds`, {
method: 'POST',
body: formData
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.error || 'Upload failed');
}
el.soundModal.classList.add("hidden");
el.soundForm.reset();
await loadSounds();
} catch (err) {
alert(err.message);
} finally {
el.soundSubmitBtn.disabled = false;
el.soundSubmitBtn.textContent = 'Add Sound';
}
};
// --- GIF Picker Logic ---
const TENOR_API_KEY = "exTiFGKJ0CzESIHzVQWy3pRO8I1MAdpRomg95DBSu2sg6e7YcHgThMI4giGAx8D0"; // Placeholder / Public key if available, otherwise "YOUR_API_KEY"
const TENOR_CLIENT_KEY = "pavel-discord";
el.gifBtn.onclick = () => {
el.gifPicker.classList.remove("hidden");
searchGifs(""); // Initial featured search
};
el.gifPickerClose.onclick = () => {
el.gifPicker.classList.add("hidden");
};
let searchTimeout = null;
el.gifSearchInput.oninput = () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
searchGifs(el.gifSearchInput.value);
}, 500);
};
async function searchGifs(query) {
el.gifResults.innerHTML = 'Searching...
';
const baseUrl = "https://api.klipy.com/v2";
const endpoint = query
? `${baseUrl}/search?q=${encodeURIComponent(query)}&key=${TENOR_API_KEY}&client_key=${TENOR_CLIENT_KEY}&limit=20`
: `${baseUrl}/featured?key=${TENOR_API_KEY}&client_key=${TENOR_CLIENT_KEY}&limit=20`;
try {
const res = await fetch(endpoint);
const data = await res.json();
renderGifs(data.results);
} catch (err) {
el.gifResults.innerHTML = 'Error loading GIFs
';
}
}
function renderGifs(gifs) {
el.gifResults.innerHTML = "";
if (!gifs || gifs.length === 0) {
el.gifResults.innerHTML = 'No GIFs found
';
return;
}
gifs.forEach(gif => {
// Use tinygif for preview, standard gif for sending
const previewUrl = gif.media_formats.tinygif.url;
const fullUrl = gif.media_formats.gif.url;
const item = document.createElement("div");
item.className = "gif-item";
item.innerHTML = `
`;
item.onclick = () => {
sendGif(fullUrl);
el.gifPicker.classList.add("hidden");
el.gifSearchInput.value = "";
};
el.gifResults.appendChild(item);
});
}
async function sendGif(url) {
try {
if (state.selectedTextChannelId) {
await api(`/channels/${state.selectedTextChannelId}/messages`, {
method: "POST",
body: JSON.stringify({ body: url }),
});
} else if (state.selectedDmUserId) {
await api(`/dms/${state.selectedDmUserId}/messages`, {
method: "POST",
body: JSON.stringify({ body: url }),
});
}
const messages = state.selectedTextChannelId
? await api(`/channels/${state.selectedTextChannelId}/messages?limit=100`)
: await api(`/dms/${state.selectedDmUserId}/messages?limit=100`);
renderMessages(messages);
} catch (err) {
console.error("Failed to send GIF", err);
}
}
try {
state.me = await api("/me");
if (state.me && state.me.display_name) {
el.userName.textContent = state.me.display_name;
el.userAvatar.textContent = shortName(state.me.display_name);
}
el.authScreen.classList.add("hidden");
el.main.classList.remove("hidden");
const params = new URLSearchParams(location.search);
const inviteCode = params.get("invite");
if (inviteCode) {
try {
const joinedGuild = await api(`/invites/${encodeURIComponent(inviteCode)}/join`, {
method: "POST",
});
localStorage.setItem(LAST_GUILD_STORAGE_KEY, joinedGuild.id);
} catch (err) {
alert(`Failed to join invite: ${err.message}`);
} finally {
params.delete("invite");
const nextQuery = params.toString();
const nextUrl = `${location.pathname}${nextQuery ? `?${nextQuery}` : ""}`;
history.replaceState(null, "", nextUrl);
}
}
await loadGuilds();
await loadDMConversations();
try {
const online = await api("/presence");
state.onlineUsers = new Set(online);
} catch (err) { console.warn("presence sync failed", err); }
if (state.guilds.length > 0) {
const lastGuildId = localStorage.getItem(LAST_GUILD_STORAGE_KEY);
const guild = state.guilds.find((g) => g.id === lastGuildId) || state.guilds[0];
state.selectedGuildId = guild.id;
localStorage.setItem(LAST_GUILD_STORAGE_KEY, guild.id);
renderGuilds();
await loadChannels();
await loadGuildMembers();
await refreshVoicePresence();
updateHeaderLabels();
} else {
state.members = [];
renderMembers();
updateHeaderLabels();
}
initChatWs();
startVoicePresencePolling();
lucide.createIcons();
} catch (err) {
console.error("init failed", err);
el.authScreen.classList.remove("hidden");
el.main.classList.add("hidden");
}
}
init();