crazy refactor
All checks were successful
/ upload (release) Successful in 4m59s

This commit is contained in:
pavel 2026-02-27 20:34:33 +01:00
commit 3acd082fb0
28 changed files with 3454 additions and 836 deletions

View file

@ -6,12 +6,9 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Chattz</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/styles.css" />
<!-- Lucide Icons -->
<script src="https://unpkg.com/lucide@latest"></script>
<script src="/static/vendor/lucide.min.js"></script>
</head>
<body>
@ -120,6 +117,10 @@
<div class="user-status">Online</div>
</div>
<div class="user-actions">
<div id="update-notifier" class="update-notifier hidden">
<button id="update-download-btn" title="Download Update"><i data-lucide="download"></i></button>
<button id="update-install-btn" title="Install Update" class="hidden"><i data-lucide="arrow-up-circle"></i></button>
</div>
<button id="user-settings-btn" title="Settings"><i data-lucide="settings"></i></button>
<button id="logout-btn" title="Logout"><i data-lucide="log-out"></i></button>
</div>

View file

@ -1,4 +1,5 @@
const state = {
sessionActive: false,
me: null,
guilds: [],
channels: [],
@ -38,15 +39,8 @@ const state = {
userVolumes: new Map(), // userId -> volume (0.0 to 2.0)
};
// --- Desktop Backend Configuration ---
let API_BASE_URL = ''; // Will be initialized via IPC
// --- Storage ---
const LAST_GUILD_STORAGE_KEY = "chattz:lastGuildId";
const PERSISTED_STORAGE_KEYS = [
"chattz_token",
"chattz_refresh_token",
LAST_GUILD_STORAGE_KEY,
"active_voice_channel",
];
function storageGet(key) {
return localStorage.getItem(key);
@ -54,103 +48,15 @@ function storageGet(key) {
function storageSet(key, value) {
localStorage.setItem(key, value);
if (window.electronAPI?.storageSet) {
void window.electronAPI.storageSet(key, value).catch((err) => {
console.warn("Failed to persist storage key", key, err);
});
}
}
function storageRemove(key) {
localStorage.removeItem(key);
if (window.electronAPI?.storageRemove) {
void window.electronAPI.storageRemove(key).catch((err) => {
console.warn("Failed to remove persisted storage key", key, err);
});
}
}
async function storageSetCritical(key, value) {
localStorage.setItem(key, value);
if (window.electronAPI?.storageSet) {
try {
await window.electronAPI.storageSet(key, value);
} catch (err) {
console.warn("Failed to persist critical storage key", key, err);
}
}
}
async function storageRemoveCritical(key) {
localStorage.removeItem(key);
if (window.electronAPI?.storageRemove) {
try {
await window.electronAPI.storageRemove(key);
} catch (err) {
console.warn("Failed to remove critical storage key", key, err);
}
}
}
async function hydrateDesktopStorage() {
if (!window.electronAPI?.storageGet) return;
for (const key of PERSISTED_STORAGE_KEYS) {
try {
// IPC file store (renderer-storage.json) is the source of truth —
// it always survives restarts, unlike localStorage on file:// URLs.
const val = await window.electronAPI.storageGet(key);
if (typeof val === "string") {
localStorage.setItem(key, val);
} else {
// IPC store is empty; backfill from localStorage if available
const localVal = localStorage.getItem(key);
if (localVal !== null) {
await window.electronAPI.storageSet(key, localVal);
}
}
} catch (err) {
console.warn("Failed to hydrate storage key", key, err);
}
}
}
async function initializeConfig() {
try {
if (window.electronAPI?.getConfig) {
const config = await window.electronAPI.getConfig();
API_BASE_URL = config.backendUrl;
console.log(`Backend initialized: ${API_BASE_URL}`);
} else {
API_BASE_URL = '';
}
} catch (err) {
console.error("Failed to fetch config via IPC", err);
// Fallback to URL search params if IPC fails (unlikely in desktop app)
const urlParams = new URLSearchParams(window.location.search);
API_BASE_URL = urlParams.get('backend') || '';
} finally {
// Start the app once config resolution has completed.
init();
}
}
function getWsUrl(path) {
let urlStr;
if (!API_BASE_URL) {
const proto = location.protocol === "https:" ? "wss" : "ws";
urlStr = `${proto}://${location.host}${path}`;
} else {
const url = new URL(API_BASE_URL);
const proto = url.protocol === "https:" ? "wss" : "ws";
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
urlStr = `${proto}://${url.host}${normalizedPath}`;
}
const token = storageGet("chattz_token");
if (token) {
urlStr += (urlStr.includes('?') ? '&' : '?') + `token=${token}`;
}
return urlStr;
const proto = location.protocol === "https:" ? "wss" : "ws";
return `${proto}://${location.host}${path}`;
}
@ -248,57 +154,83 @@ const el = {
gifResults: document.getElementById("gif-results"),
};
function stopVoicePresencePolling() {
if (!state.voicePresencePollId) return;
clearInterval(state.voicePresencePollId);
state.voicePresencePollId = null;
}
function closeChatWs() {
if (!state.chatWs) return;
const ws = state.chatWs;
state.chatWs = null;
ws.onclose = null;
ws.onmessage = null;
ws.onerror = null;
try {
ws.close();
} catch { }
}
async function showLoggedOutState(message = "Session expired, please log in again") {
state.sessionActive = false;
stopVoicePresencePolling();
closeChatWs();
await leaveVoice();
state.me = null;
state.guilds = [];
state.channels = [];
state.dmConversations = [];
state.members = [];
state.voicePresence.clear();
state.selectedGuildId = null;
state.selectedTextChannelId = null;
state.selectedDmUserId = null;
state.selectedDmDisplayName = null;
state.selectedVoiceChannelId = null;
state.lastMessageId = null;
state.onlineUsers.clear();
state.idleUsers.clear();
state.userVolumes.clear();
state.voice.viewMode = 'chat';
el.userName.textContent = "Username";
el.userAvatar.textContent = "U";
el.status.textContent = message;
el.soundboard.classList.add("hidden");
el.voiceConnection.classList.add("hidden");
renderGuilds();
renderChannels();
renderDMs();
renderMembers();
renderMessages([]);
updateHeaderLabels();
el.main.classList.add("hidden");
el.authScreen.classList.remove("hidden");
}
// --- 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 = storageGet("chattz_token");
if (token) {
headers["Authorization"] = `Bearer ${token}`;
if (!headers["content-type"] && options.body && !(options.body instanceof FormData)) {
headers["content-type"] = "application/json";
}
let res = await fetch(fullUrl, {
const res = await fetch(path, {
...options,
headers,
credentials: "same-origin",
});
// Handle 401 Unauthorized via Refresh Token
if (res.status === 401 && !path.includes('/auth/refresh')) {
const refreshToken = storageGet("chattz_refresh_token");
if (refreshToken) {
try {
const refreshUrl = API_BASE_URL ? `${API_BASE_URL}/auth/refresh` : '/auth/refresh';
const refreshRes = await fetch(refreshUrl, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken })
});
if (refreshRes.ok) {
const newTokens = await refreshRes.json();
await storageSetCritical("chattz_token", newTokens.access_token);
await storageSetCritical("chattz_refresh_token", newTokens.refresh_token);
// Retry original request with new token
headers["Authorization"] = `Bearer ${newTokens.access_token}`;
res = await fetch(fullUrl, { ...options, headers });
} else {
// Both tokens invalid/expired, wipe out to force clear state
throw new Error("Refresh token expired or invalid");
}
} catch (err) {
await storageRemoveCritical("chattz_token");
await storageRemoveCritical("chattz_refresh_token");
location.href = API_BASE_URL ? `${API_BASE_URL}/auth/login` : "/auth/login";
throw new Error("Session expired, please log in again");
}
}
if (res.status === 401) {
await showLoggedOutState("Session expired, please log in again");
const err = new Error("Session expired, please log in again");
err.isAuthError = true;
throw err;
}
if (!res.ok) {
@ -336,16 +268,18 @@ function escapeHtml(s) {
.replaceAll("'", "&#39;");
}
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 `<div class="msg-gif"><img src="${url}" loading="lazy" /></div>`;
});
const GIF_URL_REGEX = /(https?:\/\/[^\s]+(?:\.gif|\/giphy\.gif)[^\s]*)/gi;
function normalizeMediaUrl(raw) {
try {
const url = new URL(raw, location.origin);
if (url.protocol !== "http:" && url.protocol !== "https:") {
return null;
}
return url.href;
} catch {
return null;
}
return escaped;
}
function formatBytes(size) {
@ -362,29 +296,125 @@ function formatBytes(size) {
const MAX_MEDIA_UPLOAD_BYTES = 50 * 1024 * 1024;
function renderAttachments(attachments = []) {
if (!attachments.length) return "";
function buildMessageBody(body) {
const fragment = document.createDocumentFragment();
const text = String(body || "");
let cursor = 0;
for (const match of text.matchAll(new RegExp(GIF_URL_REGEX))) {
const [rawUrl] = match;
const start = match.index ?? 0;
if (start > cursor) {
fragment.appendChild(document.createTextNode(text.slice(cursor, start)));
}
const safeUrl = normalizeMediaUrl(rawUrl);
if (safeUrl) {
const gifWrap = document.createElement("div");
gifWrap.className = "msg-gif";
const image = document.createElement("img");
image.loading = "lazy";
image.src = safeUrl;
gifWrap.appendChild(image);
fragment.appendChild(gifWrap);
} else {
fragment.appendChild(document.createTextNode(rawUrl));
}
cursor = start + rawUrl.length;
}
if (cursor < text.length) {
fragment.appendChild(document.createTextNode(text.slice(cursor)));
}
return fragment;
}
function buildAttachments(attachments = []) {
const fragment = document.createDocumentFragment();
for (const attachment of attachments) {
const safeUrl = normalizeMediaUrl(attachment.media_url);
if (!safeUrl) continue;
return attachments.map((attachment) => {
const url = escapeHtml(attachment.media_url);
const mime = attachment.mime_type || "application/octet-stream";
const fileName = escapeHtml(attachment.original_filename || "file");
const fileName = attachment.original_filename || "file";
const size = formatBytes(attachment.size_bytes);
if (mime.startsWith("image/")) {
return `<a class="msg-attachment msg-attachment-image" href="${url}" target="_blank" rel="noopener noreferrer"><img src="${url}" alt="${fileName}" loading="lazy" /></a>`;
const link = document.createElement("a");
link.className = "msg-attachment msg-attachment-image";
link.href = safeUrl;
link.target = "_blank";
link.rel = "noopener noreferrer";
const image = document.createElement("img");
image.src = safeUrl;
image.alt = fileName;
image.loading = "lazy";
link.appendChild(image);
fragment.appendChild(link);
continue;
}
if (mime.startsWith("video/")) {
return `<div class="msg-attachment msg-attachment-video"><video controls preload="metadata" src="${url}"></video></div>`;
const wrapper = document.createElement("div");
wrapper.className = "msg-attachment msg-attachment-video";
const video = document.createElement("video");
video.controls = true;
video.preload = "metadata";
video.src = safeUrl;
wrapper.appendChild(video);
fragment.appendChild(wrapper);
continue;
}
if (mime.startsWith("audio/")) {
return `<div class="msg-attachment msg-attachment-audio"><audio controls preload="metadata" src="${url}"></audio><a href="${url}" target="_blank" rel="noopener noreferrer">${fileName}</a></div>`;
const wrapper = document.createElement("div");
wrapper.className = "msg-attachment msg-attachment-audio";
const audio = document.createElement("audio");
audio.controls = true;
audio.preload = "metadata";
audio.src = safeUrl;
wrapper.appendChild(audio);
const link = document.createElement("a");
link.href = safeUrl;
link.target = "_blank";
link.rel = "noopener noreferrer";
link.textContent = fileName;
wrapper.appendChild(link);
fragment.appendChild(wrapper);
continue;
}
return `<a class="msg-attachment msg-attachment-file" href="${url}" target="_blank" rel="noopener noreferrer"><i data-lucide="file"></i><span>${fileName}</span><small>${size}</small></a>`;
}).join("");
const link = document.createElement("a");
link.className = "msg-attachment msg-attachment-file";
link.href = safeUrl;
link.target = "_blank";
link.rel = "noopener noreferrer";
const icon = document.createElement("i");
icon.setAttribute("data-lucide", "file");
link.appendChild(icon);
const name = document.createElement("span");
name.textContent = fileName;
link.appendChild(name);
const meta = document.createElement("small");
meta.textContent = size;
link.appendChild(meta);
fragment.appendChild(link);
}
return fragment;
}
function showMediaUploadLimitError(file) {
@ -663,25 +693,40 @@ function renderMessages(messages) {
row.className = `msg ${isGrouped ? "msg-grouped" : ""}`;
const displayName = m.author_display_name || "Unknown User";
const formattedBody = formatMessageBody(m.body || "");
const attachmentsHtml = renderAttachments(m.attachments);
const bodyHtml = formattedBody ? `<div class="msg-body">${formattedBody}</div>` : "";
const contentHtml = `${bodyHtml}${attachmentsHtml}`;
const content = document.createElement("div");
content.className = "msg-content";
if (isGrouped) {
row.innerHTML = `<div class="msg-content">${contentHtml}</div>`;
} else {
row.innerHTML = `
<div class="msg-avatar">${shortName(displayName)}</div>
<div class="msg-content">
<div class="msg-header">
<span class="msg-author">${escapeHtml(displayName)}</span>
<span class="msg-time">${formatDate(m.created_at)}</span>
</div>
${contentHtml}
</div>
`;
if (!isGrouped) {
const avatar = document.createElement("div");
avatar.className = "msg-avatar";
avatar.textContent = shortName(displayName);
row.appendChild(avatar);
const header = document.createElement("div");
header.className = "msg-header";
const author = document.createElement("span");
author.className = "msg-author";
author.textContent = displayName;
header.appendChild(author);
const time = document.createElement("span");
time.className = "msg-time";
time.textContent = formatDate(m.created_at);
header.appendChild(time);
content.appendChild(header);
}
if (m.body) {
const body = document.createElement("div");
body.className = "msg-body";
body.appendChild(buildMessageBody(m.body));
content.appendChild(body);
}
content.appendChild(buildAttachments(m.attachments));
row.appendChild(content);
el.messageList.appendChild(row);
lastAuthorId = m.author_user_id;
lastTime = mDate;
@ -784,8 +829,7 @@ async function createInviteLink() {
body: JSON.stringify({ max_uses: 50, expires_in_hours: 24 }),
});
// Use the backend URL if available, otherwise fallback to current origin
const base = API_BASE_URL ? new URL(API_BASE_URL).origin : location.origin;
const link = `${base}/?invite=${encodeURIComponent(invite.code)}`;
const link = `${location.origin}/?invite=${encodeURIComponent(invite.code)}`;
// Use Electron's native clipboard API if available (defined in preload.js)
if (window.electronAPI && window.electronAPI.copyToClipboard) {
@ -871,13 +915,15 @@ async function refreshVoicePresence() {
}
function startVoicePresencePolling() {
if (state.voicePresencePollId) clearInterval(state.voicePresencePollId);
stopVoicePresencePolling();
state.voicePresencePollId = setInterval(() => {
if (!state.sessionActive) return;
refreshVoicePresence().catch(() => { });
}, 3000);
}
function initChatWs() {
if (!state.sessionActive) return;
if (state.chatWs) state.chatWs.close();
const wsUrl = getWsUrl('/ws');
const ws = new WebSocket(wsUrl);
@ -920,6 +966,7 @@ function initChatWs() {
};
ws.onclose = () => {
if (!state.sessionActive) return;
console.log("Chat WS closed, reconnecting...");
setTimeout(initChatWs, 3000);
};
@ -1505,7 +1552,7 @@ async function joinVoice() {
});
renderChannels();
} else if (msg.type === "play_sound") {
const audio = new Audio(msg.sound_url);
const audio = new Audio(msg.media_url);
audio.play().catch(console.error);
}
refreshVoicePresence().catch(() => { });
@ -1680,11 +1727,22 @@ function renderSounds(sounds) {
// 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 = `
<div class="sound-icon">${sound.icon}</div>
<div class="sound-name">${sound.name}</div>
${canDelete ? `<button class="sound-delete" title="Delete sound"><i data-lucide="x"></i></button>` : ''}
`;
const icon = document.createElement('div');
icon.className = 'sound-icon';
icon.textContent = sound.icon;
const name = document.createElement('div');
name.className = 'sound-name';
name.textContent = sound.name;
item.appendChild(icon);
item.appendChild(name);
if (canDelete) {
const deleteBtn = document.createElement('button');
deleteBtn.className = 'sound-delete';
deleteBtn.title = 'Delete sound';
deleteBtn.innerHTML = '<i data-lucide="x"></i>';
item.appendChild(deleteBtn);
}
item.onclick = (e) => {
if (e.target.closest('.sound-delete')) {
@ -1694,7 +1752,7 @@ function renderSounds(sounds) {
}
return;
}
playRemoteSound(sound.file_path);
playRemoteSound(sound.id, sound.media_url);
};
el.soundboardGrid.appendChild(item);
@ -1705,27 +1763,19 @@ function renderSounds(sounds) {
async function deleteSound(soundId) {
if (!state.selectedGuildId) return;
try {
const url = API_BASE_URL ? `${API_BASE_URL}/guilds/${state.selectedGuildId}/sounds/${soundId}` : `/guilds/${state.selectedGuildId}/sounds/${soundId}`;
const response = await fetch(url, {
method: 'DELETE'
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.error || 'Delete failed');
}
await api(`/guilds/${state.selectedGuildId}/sounds/${soundId}`, { method: 'DELETE' });
await loadSounds();
} catch (err) {
alert(err.message);
}
}
function playRemoteSound(url) {
const fullUrl = API_BASE_URL && !url.startsWith('http') ? `${API_BASE_URL}${url}` : url;
function playRemoteSound(soundId, mediaUrl) {
if (state.voice.ws && state.voice.ws.readyState === WebSocket.OPEN) {
state.voice.ws.send(JSON.stringify({ type: 'play_sound', sound_url: fullUrl }));
state.voice.ws.send(JSON.stringify({ type: 'play_sound', sound_id: soundId }));
}
// Also play locally immediately
const audio = new Audio(fullUrl);
const audio = new Audio(mediaUrl);
audio.play().catch(console.error);
}
@ -1790,41 +1840,23 @@ document.addEventListener('click', resetInactivityTimer);
async function init() {
lucide.createIcons();
await hydrateDesktopStorage();
initUpdater();
// 1. Handle tokens in URL (from successful login redirects)
try {
const searchParams = new URLSearchParams(location.search);
const jwtToken = searchParams.get("token");
const refreshToken = searchParams.get("refresh_token");
if (jwtToken || refreshToken) {
if (jwtToken) await storageSetCritical("chattz_token", jwtToken);
if (refreshToken) await storageSetCritical("chattz_refresh_token", refreshToken);
searchParams.delete("token");
searchParams.delete("refresh_token");
const nextQuery = searchParams.toString();
const nextUrl = `${location.pathname}${nextQuery ? `?${nextQuery}` : ""}`;
history.replaceState(null, "", nextUrl);
}
} catch (e) {
console.error("Failed to parse token from URL:", e);
}
// 2. Global Button Handlers
el.loginBtn.onclick = () => {
const loginPath = "/auth/login";
location.href = API_BASE_URL ? `${API_BASE_URL}${loginPath}` : loginPath;
el.status.textContent = "";
location.href = "/auth/login";
};
el.logoutBtn.onclick = async () => {
await leaveVoice();
await storageRemoveCritical("chattz_token");
await storageRemoveCritical("chattz_refresh_token");
try { await api("/auth/logout", { method: "POST" }); } catch (e) { }
location.reload();
try {
await api("/auth/logout", { method: "POST" });
} catch (e) {
if (!e?.isAuthError) {
console.warn("logout request failed", e);
}
}
await showLoggedOutState("Signed out.");
};
el.addGuildBtn.onclick = () => { el.modalContainer.classList.remove("hidden"); };
@ -1970,8 +2002,11 @@ async function init() {
el.soundSubmitBtn.disabled = true;
el.soundSubmitBtn.textContent = 'Uploading...';
try {
const url = API_BASE_URL ? `${API_BASE_URL}/guilds/${state.selectedGuildId}/sounds` : `/guilds/${state.selectedGuildId}/sounds`;
const response = await fetch(url, { method: 'POST', body: formData });
const response = await fetch(`/guilds/${state.selectedGuildId}/sounds`, {
method: 'POST',
body: formData,
credentials: 'same-origin',
});
if (!response.ok) { const err = await response.json(); throw new Error(err.error || 'Upload failed'); }
el.soundModal.classList.add("hidden");
el.soundForm.reset();
@ -2063,8 +2098,6 @@ async function init() {
}
async function uploadMediaFile(file) {
const token = storageGet("chattz_token");
if (!token) throw new Error("You need to log in again.");
if (file.size > MAX_MEDIA_UPLOAD_BYTES) {
showMediaUploadLimitError(file);
return;
@ -2080,16 +2113,15 @@ async function init() {
el.mediaUploadBtn.disabled = true;
el.mediaUploadBtn.title = "Uploading...";
const fullUrl = API_BASE_URL ? `${API_BASE_URL}${path}` : path;
try {
const response = await fetch(fullUrl, {
const response = await fetch(path, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": file.type || "application/octet-stream",
"X-File-Name": file.name,
},
body: file,
credentials: "same-origin",
});
if (!response.ok) {
@ -2116,10 +2148,12 @@ async function init() {
// 6. Initial Data Loading & App Start
try {
state.me = await api("/me");
state.sessionActive = true;
if (state.me && state.me.display_name) {
el.userName.textContent = state.me.display_name;
el.userAvatar.textContent = shortName(state.me.display_name);
}
el.status.textContent = "";
el.authScreen.classList.add("hidden");
el.main.classList.remove("hidden");
@ -2144,7 +2178,8 @@ async function init() {
try {
const online = await api("/presence");
state.onlineUsers = new Set(online);
state.onlineUsers = new Set(online.filter((entry) => entry.online).map((entry) => entry.user_id));
state.idleUsers = new Set(online.filter((entry) => entry.idle).map((entry) => entry.user_id));
} catch (err) { console.warn("presence sync failed", err); }
if (state.guilds.length > 0) {
@ -2178,9 +2213,10 @@ async function init() {
lucide.createIcons();
} catch (err) {
console.error("init failed", err);
el.authScreen.classList.remove("hidden");
el.main.classList.add("hidden");
if (!err?.isAuthError) {
console.error("init failed", err);
await showLoggedOutState("Unable to load the app.");
}
}
}
@ -2280,5 +2316,4 @@ function initUpdater() {
});
}
// Config fetcher will trigger init()
initializeConfig();
init();

12
static/vendor/lucide.min.js vendored Normal file

File diff suppressed because one or more lines are too long