diff --git a/.forgejo/workflows/pipeline.yaml b/.forgejo/workflows/pipeline.yaml index 30aa0d7..9e241e8 100644 --- a/.forgejo/workflows/pipeline.yaml +++ b/.forgejo/workflows/pipeline.yaml @@ -11,6 +11,25 @@ jobs: with: node-version: 24 - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Update version from tag + run: | + # Extract version from tag (e.g. v1.2.3 -> 1.2.3) + VERSION=${GITHUB_REF_NAME#v} + echo "Bumping version to $VERSION" + npm version $VERSION --no-git-tag-version + + # Configure Git + git config user.name "Forgejo Actions" + git config user.email "actions@noreply.flegr.me" + + # Commit and push back to main if version changed + if [ -n "$(git status --porcelain package.json)" ]; then + git add package.json package-lock.json + git commit -m "chore: bump version to $VERSION [skip ci]" + git push origin HEAD:main + fi - run: npm ci - run: npm run dist:linux - name: Build Windows installer with 32-bit Wine prefix @@ -36,6 +55,9 @@ jobs: copy_first '*.AppImage' 'chattz-linux.AppImage' copy_first '*.exe' 'chattz-windows.exe' copy_first '*.msi' 'chattz-windows.msi' + # Metadata files for electron-updater + copy_first 'latest-linux.yml' 'latest-linux.yml' + copy_first 'latest.yml' 'latest.yml' - name: Cache Cargo registry uses: actions/cache@v4 with: diff --git a/desktop/app.js b/desktop/app.js index 0cb3112..d260cfa 100644 --- a/desktop/app.js +++ b/desktop/app.js @@ -112,6 +112,9 @@ const el = { userName: document.getElementById("user-name"), userAvatar: document.getElementById("user-avatar"), logoutBtn: document.getElementById("logout-btn"), + updateNotifier: document.getElementById("update-notifier"), + updateDownloadBtn: document.getElementById("update-download-btn"), + updateInstallBtn: document.getElementById("update-install-btn"), // Voice Connection voiceConnection: document.getElementById("voice-connection"), @@ -1940,6 +1943,324 @@ async function init() { await loadGuilds(); await loadDMConversations(); + } catch (err) { + console.error("presence sync failed", err); + } +} + +function initUpdater() { + if (!window.electronAPI) return; + + window.electronAPI.onUpdateAvailable((info) => { + console.log("Update available:", info.version); + el.updateNotifier.classList.remove("hidden"); + el.updateDownloadBtn.classList.remove("hidden"); + el.updateInstallBtn.classList.add("hidden"); + }); + + window.electronAPI.onUpdateDownloaded((info) => { + console.log("Update downloaded:", info.version); + el.updateNotifier.classList.remove("hidden"); + el.updateDownloadBtn.classList.add("hidden"); + el.updateInstallBtn.classList.remove("hidden"); + el.updateInstallBtn.title = `Install Update ${info.version}`; + }); + + window.electronAPI.onUpdateError((err) => { + console.error("Update error:", err); + // Optionally hide indicator on error + // el.updateNotifier.classList.add("hidden"); + }); + + el.updateDownloadBtn.onclick = () => { + el.updateDownloadBtn.disabled = true; + el.updateDownloadBtn.style.opacity = "0.5"; + window.electronAPI.downloadUpdate(); + }; + + el.updateInstallBtn.onclick = () => { + window.electronAPI.quitAndInstall(); + }; +} + +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; + } + el.modalTitle.textContent = "Create Text Channel"; + el.channelModal.classList.remove("hidden"); + }; + + el.addVoiceBtn.onclick = () => { + if (!state.selectedGuildId) { + alert("Create or select a server first."); + return; + } + el.modalTitle.textContent = "Create Voice Channel"; + el.channelModal.classList.remove("hidden"); + }; + + el.channelModalCancel.onclick = () => { el.channelModal.classList.add("hidden"); }; + + el.guildForm.onsubmit = async (e) => { + e.preventDefault(); + const name = el.guildName.value.trim(); + if (!name) return; + try { + const guild = await api("/guilds", { + method: "POST", + body: JSON.stringify({ name }), + }); + 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(); + await api(`/channels/${created.id}/messages?limit=100`).then(renderMessages); + } else { + state.selectedVoiceChannelId = created.id; + state.selectedTextChannelId = null; + state.selectedDmUserId = null; + state.selectedDmDisplayName = null; + renderChannels(); + renderDMs(); + updateHeaderLabels(); + await joinVoice(); + } + } catch (err) { alert(err.message); } + }; + + el.messageForm.onsubmit = async (e) => { + e.preventDefault(); + const body = el.messageBody.value.trim(); + if (!body) return; + try { + if (state.selectedTextChannelId) { + await api(`/channels/${state.selectedTextChannelId}/messages`, { + method: "POST", + body: JSON.stringify({ body }), + }); + } else if (state.selectedDmUserId) { + await api(`/dms/${state.selectedDmUserId}/messages`, { + method: "POST", + body: JSON.stringify({ body }), + }); + } else { + return; + } + el.messageBody.value = ""; + const messages = state.selectedTextChannelId + ? await api(`/channels/${state.selectedTextChannelId}/messages?limit=100`) + : await api(`/dms/${state.selectedDmUserId}/messages?limit=100`); + renderMessages(messages); + await loadDMConversations(); + renderDMs(); + } catch (err) { alert(err.message); } + }; + + 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 = '