commit 9611103b2704bd7ae7f27f159ecb55379cd5ba86 Author: Ben Miller Date: Tue May 12 18:50:49 2026 -0500 main diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b5fe52b --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Virtual environments +.venv/ +venv/ + +# Python cache and compiled files +__pycache__/ +*.py[cod] +*.pyo + +# Build artifacts +build/ +dist/ +*.egg-info/ + +# Environment files +.env + +# Vendor or third-party libraries +/vendor/ + +# Editor/IDE settings +.vscode/ +.idea/ + +# Notes or temp folders +/notes/ \ No newline at end of file diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..09a491b --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,50 @@ +# Transient Chat Codebase Analysis & Implementation Guide + +**Audience:** AI Engineering Agent + +This document outlines the current state of the `transient.chat` project, detailing architectural issues, security vulnerabilities, and recommended features. It serves as a roadmap for subsequent implementation tasks. + +--- + +## 0. Permanent Project Rules +*These rules must be followed for all future modifications.* +- **CSS Strategy:** Always use Vanilla CSS; never introduce Tailwind or other heavy CSS frameworks. +- **Python Conventions:** All new Python functions must have type hints. +- **Dependency Management:** Minimize external dependencies. When features are requested, check if standard libraries can be used first. +- **Architectural Scope:** The application must remain lightweight and stateless (in-memory) with no persistent databases unless explicitly approved by the user. + +--- + +## 1. Architectural Issues & Bugs +*(No pending issues in this category at this time)* + +--- + +## 2. Low-Hanging Fruit Features + +### Feature 2.1: WebSockets for Real-Time Communication +**Description:** The application currently uses HTTP polling for chat updates and watchparty video synchronization, which is inefficient. Upgrading to WebSockets will provide a true real-time experience and reduce server overhead. +**Implementation Plan:** +1. **Core Setup:** Add `Flask-SocketIO` to the project dependencies and initialize SocketIO in `app.py`. +2. **Text & Audio Messages:** Refactor the polling routes in `blueprints/chat.py` to use WebSocket event handlers (e.g., `@socketio.on('message')`, `@socketio.on('join')`). The application currently supports audio messages (base64 `webm` blobs sent via the `audio` field). **Crucially:** Ensure the WebSocket payload for messages continues to accept and broadcast this `audio` field so voice notes continue to function seamlessly. +3. **Watchparty Synchronization:** Watchparties currently poll `/watchparty//video` to stay in sync. Refactor `blueprints/watchparty.py` and `static/assets/js/watchparty_chat.js` to broadcast video events over WebSockets (e.g., `video_play`, `video_pause`, `video_sync`, `video_clear`). This will replace the interval-based polling and provide instantaneous playback synchronization for all users in the room. +4. **Frontend Updates:** Update the frontend JavaScript files in `static/assets/js/` (specifically `chat.js` and `watchparty_chat.js`) to connect via Socket.IO clients, emit messages/video-controls, and listen for incoming events instead of using `setInterval`. + +### Feature 2.2: Rate Limiting & Abuse Prevention +**Description:** Currently, there is no rate limiting on registration or chat endpoints. With the planned move to WebSockets (Feature 2.1), HTTP polling will be eliminated, but rate limiting is still essential. +**Implementation Plan:** +1. **Registration:** Implement rate limiting on the HTTP `/register` endpoint to prevent bot account creation spam. +2. **WebSockets:** Implement a custom rate limiting mechanism within the WebSocket event handlers (e.g., tracking message timestamps per user session in memory) to prevent users from flooding chat rooms with messages (both text and base64 audio payloads) over the socket connection. + +### Feature 2.3: WebSocket Screen Sharing for Watch Parties +**Description:** Expand the watch party functionality to allow users to share their screen as an alternative to watching YouTube videos. WebSockets will be used for signaling the peer-to-peer connection. +**Implementation Plan:** +1. **WebRTC Signaling:** Use the `Flask-SocketIO` setup from Feature 2.1 to act as a WebRTC signaling server, relaying SDP offers, answers, and ICE candidates between users in the watch party room. +2. **Screen Capture:** In `static/assets/js/watchparty_chat.js`, implement `navigator.mediaDevices.getDisplayMedia()` to capture the presenter's screen and audio. +3. **Peer Connections:** Establish `RTCPeerConnection`s between the presenter and other attendees. +4. **UI Integration:** Add a "Share Screen" button to the watch party interface. When active, replace the YouTube ` + + `; + } else { + return ` + ${url} +
+ +
+
+ `; + } + } + + // ✅ Image + if (imagePattern.test(url)) { + if (externalContentEnabled) { + return ` + ${url} +
+ +
+ `; + } else { + return ` + ${url} +
+ +
+
+ `; + } + } + + // 🔗 Default fallback: plain link + return `${url}`; + }); + } + + function cleanExpiredMessages() { + const now = Date.now(); + const oneHourAgo = now - 60 * 60 * 1000; + const messageDivs = document.querySelectorAll("#messages .message"); + + messageDivs.forEach(msgDiv => { + const ts = msgDiv.getAttribute("data-timestamp"); + if (!ts) return; + + const cleanedTs = ts.replace(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}).+$/, '$1Z'); + const parsedTimestamp = Date.parse(cleanedTs); + if (isNaN(parsedTimestamp)) return; + + if (parsedTimestamp < oneHourAgo) { + msgDiv.remove(); + seenMessageTimestamps.delete(ts); + } + }); + } + + async function loadUsers() { + const res = await fetch(`/chat/${encodeURIComponent(room)}/users`); + const data = await res.json(); + + const currentList = data.users || []; + const isOwner = data.owner === username; + usersDiv.innerHTML = ""; + + const joined = currentList.filter(user => !lastUserList.includes(user)); + const left = lastUserList.filter(user => !currentList.includes(user)); + + if (!firstLoad && soundEnabled) { + if (joined.length > 0) sounds.in.play(); + if (left.length > 0) sounds.out.play(); + } + + lastUserList = currentList; + + currentList.forEach(user => { + const div = document.createElement("div"); + div.textContent = user; + + if (isOwner && user !== username) { + const btn = document.createElement("button"); + btn.textContent = "Kick"; + btn.className = "kick-button"; + btn.onclick = async () => { + if (confirm(`Kick ${user}?`)) { + await fetch(`/chat/${encodeURIComponent(room)}/kick`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ owner: username, target: user }), + }); + await loadUsers(); + } + }; + div.appendChild(btn); + } + + usersDiv.appendChild(div); + }); + } + + async function loadMessages() { + const url = `/chat/${encodeURIComponent(room)}` + (lastTimestamp ? `?since=${encodeURIComponent(lastTimestamp)}` : ""); + const res = await fetch(url); + const data = await res.json(); + + if (!Array.isArray(data)) return; + + const now = Date.now(); + const oneHourAgo = now - 60 * 60 * 1000; + + const newMessages = data.filter(msg => { + const msgTime = new Date(msg.timestamp).getTime(); + if (isNaN(msgTime)) return false; + if (firstLoad) return true; + return msgTime >= oneHourAgo; + }); + + newMessages.forEach(msg => { + // Skip if already rendered + if (seenMessageTimestamps.has(msg.timestamp)) return; + + const div = document.createElement("div"); + div.classList.add("message"); + div.setAttribute("data-timestamp", msg.timestamp); + seenMessageTimestamps.add(msg.timestamp); + + if (msg.username === username) { + div.classList.add("my-message"); + } + div.innerHTML = `${escapeHtml(msg.username)}: ${linkify(msg.text)}`; + + if (msg.audio) { + const audioContainer = document.createElement("div"); + audioContainer.className = "audio-container"; + const audio = document.createElement("audio"); + audio.controls = true; + audio.className = "voice-message-audio"; + + audio.src = msg.audio; + + // Autoplay if enabled, not first load, and not my own message + if (autoplayEnabled && !firstLoad && msg.username !== username) { + audioQueue.push(audio); + processAudioQueue(); + } + + audioContainer.appendChild(audio); + div.appendChild(audioContainer); + } + + messagesDiv.appendChild(div); + }); + + if (newMessages.length > 0) { + messagesDiv.scrollTop = messagesDiv.scrollHeight; + lastTimestamp = newMessages[newMessages.length - 1].timestamp; + } + + if (!firstLoad && soundEnabled && newMessages.some(msg => msg.username !== username)) { + sounds.message.play(); + } + + if (!windowFocused && newMessages.length > 0) { + unreadCount += newMessages.length; + document.title = `${room} (${unreadCount})`; + } + } + + form.addEventListener("submit", e => { + e.preventDefault(); + const text = input.value.trim(); + if (!text) return; + + fetch(`/chat/${encodeURIComponent(room)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, text }) + }).then(() => { + input.value = ""; + refresh(); // force fetch after post to ensure sync + }); + }); + + async function refresh() { + if (isRefreshing) return; + isRefreshing = true; + + try { + // 🔒 Check if user is still valid + const res = await fetch('/validate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, key: userKey }) + }); + + if (!res.ok) { + localStorage.clear(); + alert("Session expired or you were removed."); + window.location.href = '/'; + return; + } + + await refreshUserPresence(); + await loadMessages(); + await loadUsers(); + cleanExpiredMessages(); + } catch (e) { + console.error("Refresh error:", e); + } finally { + firstLoad = false; + isRefreshing = false; + } + } + + // First full load, then enable polling + await refresh(); + setInterval(refresh, 1000); +}); \ No newline at end of file diff --git a/static/assets/js/index.js b/static/assets/js/index.js new file mode 100644 index 0000000..58db67b --- /dev/null +++ b/static/assets/js/index.js @@ -0,0 +1,87 @@ +document.addEventListener('DOMContentLoaded', async () => { + const storedUsername = localStorage.getItem('username'); + const storedKey = localStorage.getItem('userKey'); + + const params = new URLSearchParams(window.location.search); + const room = params.get('room'); + const isWatchParty = params.has('watchparty'); // Use explicit watchparty URL flag + + function redirectToRoom(roomName) { + if (!roomName) { + window.location.href = '/server_list.html'; + } else { + window.location.href = isWatchParty + ? `/watchparty_chat.html?room=${encodeURIComponent(roomName)}` + : `/chat.html?room=${encodeURIComponent(roomName)}`; + } + } + + // Auto-forward if valid session is stored + if (storedUsername && storedKey) { + try { + const response = await fetch('/validate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: storedUsername, key: storedKey }), + }); + + if (response.ok) { + redirectToRoom(room); + return; + } else { + // Session expired or invalid; clear storage but pre-fill username + localStorage.clear(); + const input = document.getElementById('username-input'); + if (input) input.value = storedUsername; + } + } catch (err) { + console.error('Validation error:', err); + } + } + + const startButton = document.getElementById('start-chat-button'); + const usernameInput = document.getElementById('username-input'); + + if (usernameInput) { + usernameInput.addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + event.preventDefault(); + startButton.click(); + } + }); + } + + if (startButton) { + startButton.addEventListener('click', async () => { + const username = usernameInput.value.trim(); + + if (!username) { + alert('Please enter a username.'); + return; + } + + try { + const response = await fetch('/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username }), + }); + + if (!response.ok) { + const err = await response.json(); + alert(err.error || 'Username registration failed.'); + return; + } + + const data = await response.json(); + localStorage.setItem('username', data.username); + localStorage.setItem('userKey', data.key); + + redirectToRoom(room); + } catch (error) { + console.error('Registration error:', error); + alert('An error occurred.'); + } + }); + } +}); \ No newline at end of file diff --git a/static/assets/js/server_list.js b/static/assets/js/server_list.js new file mode 100644 index 0000000..014b469 --- /dev/null +++ b/static/assets/js/server_list.js @@ -0,0 +1,190 @@ +document.addEventListener("DOMContentLoaded", async () => { + const username = localStorage.getItem('username'); + const userKey = localStorage.getItem('userKey'); + + if (!username || !userKey) { + window.location.href = '/'; + return; + } + + try { + const response = await fetch('/validate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, key: userKey }) + }); + if (!response.ok) { + localStorage.clear(); + window.location.href = '/'; + return; + } + } catch (err) { + console.error('Validation failed:', err); + localStorage.clear(); + window.location.href = '/'; + return; + } + + const userInfoEl = document.getElementById('user-info'); + userInfoEl.textContent = `Logged in as: ${username}`; + + document.querySelectorAll('.collapsible').forEach(heading => { + heading.addEventListener('click', () => { + const content = heading.nextElementSibling; + const isOpen = content.style.display === 'block'; + content.style.display = isOpen ? 'none' : 'block'; + heading.textContent = (isOpen ? '▶' : '▼') + heading.textContent.slice(1); + }); + }); + + fetch("/rooms") + .then(response => response.json()) + .then(data => { + const { regional, topical, public: publicRooms, unique_users } = data; + + const regionalList = document.getElementById('regional-rooms'); + const topicalList = document.getElementById('topical-rooms'); + const publicList = document.getElementById('public-rooms'); + const watchpartyList = document.getElementById('watchparty-rooms'); + + // Helper to create a room
  • + function createRoomItem({ name, users, type }) { + const li = document.createElement('li'); + const a = document.createElement('a'); + const isWatchParty = type && type.startsWith('watchparty'); + a.href = isWatchParty + ? `/watchparty_chat.html?room=${encodeURIComponent(name)}` + : `/chat.html?room=${encodeURIComponent(name)}`; + a.textContent = `${name} ( ${users} )`; + li.appendChild(a); + return { li, isWatchParty }; + } + + // Add regional rooms + regional.forEach(room => { + regionalList.appendChild(createRoomItem(room).li); + }); + + // Add topical rooms + topical.forEach(room => { + topicalList.appendChild(createRoomItem(room).li); + }); + + // Add public/watchparty rooms + publicRooms.forEach(room => { + const { li, isWatchParty } = createRoomItem(room); + if (isWatchParty) { + watchpartyList.appendChild(li); + } else { + publicList.appendChild(li); + } + }); + + // Show total unique user count + document.getElementById('user-count').textContent = `Users Chatting ( ${unique_users} )`; + + // 🧠 Load and render Active Rooms (regional, topical, public w/ users) + fetch("/rooms?activeOnly=true") + .then(response => response.json()) + .then(({ regional, topical, public: publicRooms }) => { + const activeList = document.getElementById("active-rooms"); + let count = 0; + + function createActiveRoomItem({ name, users, type }) { + const li = document.createElement("li"); + const a = document.createElement("a"); + + a.href = type === "watchparty" + ? `/watchparty_chat.html?room=${encodeURIComponent(name)}` + : `/chat.html?room=${encodeURIComponent(name)}`; + a.textContent = `${name} ( ${users} )`; + li.appendChild(a); + return li; + } + + [...regional, ...topical, ...publicRooms].forEach(room => { + activeList.appendChild(createActiveRoomItem(room)); + count++; + }); + + // Update Active Rooms heading with count + const activeHeadingEl = document.getElementById("active-rooms-header"); + if (activeHeadingEl) { + const baseText = activeHeadingEl.textContent.replace(/▶|▼|\(\d+\)/g, "").trim(); + activeHeadingEl.textContent = `▶ ${baseText} ( ${count} )`; + } + }) + .catch(err => { + console.error("Failed to load active rooms:", err); + }); + }) + .catch(err => { + console.error("Failed to load rooms:", err); + alert("Failed to load rooms."); + }); + + document.getElementById('create-room-btn').addEventListener('click', () => { + const roomName = document.getElementById('new-room-name').value.trim(); + if (!roomName) return alert('Room name cannot be empty.'); + + fetch('/rooms', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: roomName }) + }) + .then(response => { + if (!response.ok) throw new Error('Room creation failed.'); + return response.json(); + }) + .then(data => { + window.location.href = `/chat.html?room=${encodeURIComponent(data.name)}`; + }) + .catch(err => alert(err.message)); + }); + + document.getElementById('create-unlisted-room-btn').addEventListener('click', () => { + const roomName = document.getElementById('new-room-name').value.trim(); + if (!roomName) return alert('Room name cannot be empty.'); + window.location.href = `/chat.html?room=${encodeURIComponent(roomName)}`; + }); + + // 🔥 Watch Party creation + document.getElementById('create-watchparty-btn').addEventListener('click', () => { + const roomName = document.getElementById('new-room-name').value.trim(); + if (!roomName) return alert('Room name cannot be empty.'); + + fetch('/rooms', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: roomName }) + }) + .then(response => { + if (!response.ok) throw new Error('Watch party creation failed.'); + return response.json(); + }) + .then(data => { + window.location.href = `/watchparty_chat.html?room=${encodeURIComponent(data.name)}`; + }) + .catch(err => alert(err.message)); + }); +}); + +// Enable Enter key for room creation +document.getElementById('new-room-name').addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + document.getElementById('create-room-btn').click(); + } +}); + +// Enable Enter key for watch party creation +document.getElementById('new-room-name').addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + document.getElementById('create-watchparty-btn').click(); + } +}); + +window.addEventListener('pageshow', (event) => { + if (event.persisted) { + window.location.reload(); + } +}); \ No newline at end of file diff --git a/static/assets/js/watchparty_chat.js b/static/assets/js/watchparty_chat.js new file mode 100644 index 0000000..01a0007 --- /dev/null +++ b/static/assets/js/watchparty_chat.js @@ -0,0 +1,224 @@ +document.addEventListener("DOMContentLoaded", async () => { + const params = new URLSearchParams(window.location.search); + const room = params.get("room"); + const username = localStorage.getItem("username"); + const userKey = localStorage.getItem("userKey"); + + if (!room || !username || !userKey) { + alert("Missing room or user credentials."); + window.location.href = `/index.html${room ? `?room=${encodeURIComponent(room)}&watchparty=1` : ""}`; + return; + } + + // Note: We rely on chat.js (loaded in the HTML) to handle the server-side + // /validate call and the rendering of the #room-title header. + // This script focuses solely on the video synchronization logic. + + const videoControls = document.getElementById("video-controls"); + const videoInput = document.getElementById("video-url-input"); + const videoButton = document.getElementById("start-video-button"); + const pauseButton = document.getElementById("pause-video-button"); + const resumeButton = document.getElementById("resume-video-button"); + const clearButton = document.getElementById("clear-video-button"); + const iframe = document.getElementById("video-iframe"); + const pausedOverlay = document.getElementById("paused-overlay"); + + let videoExpireAt = null; + let videoStarted = false; + let lastVideoId = null; + let isRoomOwner = false; + + function extractYouTubeId(url) { + const match = url.match(/(?:youtube\.com\/.*v=|youtu\.be\/)([\w-]{11})/); + return match ? match[1] : null; + } + + async function fetchYouTubeDuration(videoId) { + return 24 * 60 * 60; // 24 hours in seconds + } + + function loadYouTubeVideo(videoId, startSeconds = 0) { + if (lastVideoId === videoId) return; + iframe.src = `https://www.youtube.com/embed/${videoId}?autoplay=1&start=${startSeconds}`; + iframe.classList.remove("hidden"); + lastVideoId = videoId; + } + + function clearVideoUI() { + iframe.src = ""; + iframe.classList.add("hidden"); + videoExpireAt = null; + videoStarted = false; + lastVideoId = null; + pausedOverlay.classList.add("hidden"); + updateControlVisibility({ hasVideo: false, isPaused: false, isOwner: isRoomOwner }); + } + + function updateControlVisibility({ hasVideo, isPaused, isOwner }) { + if (isOwner) { + if (hasVideo) { + videoInput.classList.add("hidden"); + videoButton.classList.add("hidden"); + clearButton.classList.remove("hidden"); + + if (isPaused) { + pauseButton.classList.add("hidden"); + resumeButton.classList.remove("hidden"); + } else { + pauseButton.classList.remove("hidden"); + resumeButton.classList.add("hidden"); + } + } else { + videoInput.classList.remove("hidden"); + videoButton.classList.remove("hidden"); + clearButton.classList.add("hidden"); + pauseButton.classList.add("hidden"); + resumeButton.classList.add("hidden"); + } + videoControls.classList.remove("hidden"); + } else { + videoControls.classList.add("hidden"); + } + } + + async function checkRoomOwnership() { + try { + const res = await fetch(`/chat/${encodeURIComponent(room)}/users`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username }), + }); + const data = await res.json(); + isRoomOwner = data.owner && data.owner === username; + } catch (err) { + console.error("Failed to determine ownership", err); + isRoomOwner = false; + } + } + + videoButton.addEventListener("click", async () => { + const url = videoInput.value.trim(); + const videoId = extractYouTubeId(url); + if (!videoId) return alert("Invalid YouTube URL."); + + await fetch(`/watchparty/${encodeURIComponent(room)}/video`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url }), + }); + + const duration = await fetchYouTubeDuration(videoId); + videoExpireAt = Date.now() + duration * 1000; + videoStarted = true; + + loadYouTubeVideo(videoId); + updateControlVisibility({ hasVideo: true, isPaused: false, isOwner: isRoomOwner }); + }); + + pauseButton.addEventListener("click", async () => { + try { + await fetch(`/watchparty/${encodeURIComponent(room)}/video/pause`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username }), + }); + } catch (err) { + console.error("Failed to pause video", err); + } + }); + + resumeButton.addEventListener("click", async () => { + try { + await fetch(`/watchparty/${encodeURIComponent(room)}/video/resume`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username }), + }); + } catch (err) { + console.error("Failed to resume video", err); + } + }); + + clearButton.addEventListener("click", async () => { + try { + await fetch(`/watchparty/${encodeURIComponent(room)}/video/clear`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username }), + }); + + clearVideoUI(); + } catch (err) { + console.error("Failed to clear video", err); + } + }); + + async function syncVideoFromServer() { + try { + const res = await fetch(`/watchparty/${encodeURIComponent(room)}/video`); + if (!res.ok) throw new Error("video fetch failed"); + + const { url, elapsed, is_paused } = await res.json(); + const videoId = extractYouTubeId(url); + if (!videoId) { + clearVideoUI(); + return; + } + + if (is_paused) { + if (!iframe.classList.contains("hidden")) { + iframe.classList.add("hidden"); + iframe.src = ""; // Stop audio + pausedOverlay.classList.remove("hidden"); + lastVideoId = null; // Force reload on resume + } + videoStarted = true; + updateControlVisibility({ hasVideo: true, isPaused: true, isOwner: isRoomOwner }); + return; + } + + pausedOverlay.classList.add("hidden"); + const duration = await fetchYouTubeDuration(videoId); + videoExpireAt = Date.now() + ((duration - elapsed) * 1000); + videoStarted = true; + + loadYouTubeVideo(videoId, elapsed); + updateControlVisibility({ hasVideo: true, isPaused: false, isOwner: isRoomOwner }); + } catch (err) { + // Fallback: video was likely cleared + if (videoStarted || !iframe.classList.contains("hidden")) { + clearVideoUI(); + } + } + } + + setInterval(async () => { + if (videoExpireAt && Date.now() > videoExpireAt) { + clearVideoUI(); + + if (isRoomOwner) { + try { + await fetch(`/watchparty/${encodeURIComponent(room)}/video/clear`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username }), + }); + } catch (err) { + console.error("Failed to auto-clear video", err); + } + } + } else { + await syncVideoFromServer(); + } + }, 1000); + + async function initialize() { + await checkRoomOwnership(); + await syncVideoFromServer(); + if (!videoStarted) { + updateControlVisibility({ hasVideo: false, isPaused: false, isOwner: isRoomOwner }); + } + } + + await initialize(); +}); \ No newline at end of file diff --git a/static/assets/sounds/message.wav b/static/assets/sounds/message.wav new file mode 100644 index 0000000..8a52503 Binary files /dev/null and b/static/assets/sounds/message.wav differ diff --git a/static/assets/sounds/user-in.wav b/static/assets/sounds/user-in.wav new file mode 100644 index 0000000..e55c9bd Binary files /dev/null and b/static/assets/sounds/user-in.wav differ diff --git a/static/assets/sounds/user-out.wav b/static/assets/sounds/user-out.wav new file mode 100644 index 0000000..bcd448b Binary files /dev/null and b/static/assets/sounds/user-out.wav differ diff --git a/static/chat.html b/static/chat.html new file mode 100644 index 0000000..63465a4 --- /dev/null +++ b/static/chat.html @@ -0,0 +1,71 @@ + + + + + + Transient.chat - Chat Room + + + + + +

    Chat Room

    +
    +
    +
    +
    + + +
    +
    +
    + + + +
    + +
    + +
    +
    +

    Users in room

    +
    +
    +
    + + + diff --git a/static/faq.html b/static/faq.html new file mode 100644 index 0000000..c9728f7 --- /dev/null +++ b/static/faq.html @@ -0,0 +1,74 @@ + + + + + + FAQ - Transient.chat + + + + + + +
    +
    + + Transient.chat + +

    Frequently Asked Questions

    +
    + +

    How long do messages last?

    +

    All chat messages automatically disappear after 1 hour.

    + +

    How long do rooms last?

    +

    + All chat rooms automatically disappear after all users leave the room. +

    + +

    Do I need an account to use this?

    +

    + No. Just pick a handle and start chatting. If you don't come back in 3 + days your handle is released. +

    + +

    Is anything stored permanently?

    +

    + No databases, no disk writes. Everything exists in memory and vanishes + when inactive. +

    + +

    What is a Watchroom?

    +

    + A Watchroom lets you sync YouTube playback with others while chatting. + Everyone sees the same video at the same time. +

    + +

    Can I send voice messages?

    +

    + Yes. You can record short voice clips (up to 15 seconds) by holding the microphone button. + Enable Autoplay in the sidebar to hear messages automatically as they arrive. +

    + +

    What is External Content?

    +

    + External images and videos linked by other users. You can toggle External Content on to automatically show embedded media. +

    + +

    Can users be removed?

    +

    Yes. Room owners can kick users. Admins can ban users by IP.

    + +

    Where can I find the source code?

    +

    https://github.com/benjimanmiller/transient.chat

    + +

    + ← Back to Home +
    + + diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..1e8b5cd --- /dev/null +++ b/static/index.html @@ -0,0 +1,63 @@ + + + + + + Transient.chat + + + + + + +
    +
    + + Transient.chat + + +

    Chat without a trace!

    +
    +

    + Real-time conversations that disappear after + 1 hour.
    + No accounts. No data trails. Nothing is saved... ever.
    + Everything is held in server memory and is never written to any disk or + database.

    + Join a room and talk freely. When the room empties, everything + vanishes.
    + Want to sync up a video? Try a Watchroom and chat while + watching YouTube together.

    + Voice Messages: Hold the mic button to record and send ephemeral voice clips. Enable Autoplay to hear them live.

    + No tracking • No storage • 100% ephemeral

    + If you're tired of feeds, filters, or forever — you're in the right + place. + Learn More → +

    +
    + +
    + +
    +
    + +
    + +
    +

    + Don't trust me? I don't blame you. Review the source and host your own + server!
    + + https://github.com/benjimanmiller/transient.chat + +

    +
    + + + diff --git a/static/server_list.html b/static/server_list.html new file mode 100644 index 0000000..de3a0c9 --- /dev/null +++ b/static/server_list.html @@ -0,0 +1,75 @@ + + + + + + Transient.chat - Server List + + + + +
    + + Transient.chat + +

    Server List

    +
    +
    + +
    + +
    +

    ▶ US Regional Rooms

    + + +

    ▶ Topical Rooms

    + + +

    ▶ User Created Public Rooms

    + + +

    ▶ Watch Party Rooms

    + + +

    ▶ Active Rooms

    + + +
    + +
    + +
    +
    + + + +
    +
    + + + + diff --git a/static/watchparty_chat.html b/static/watchparty_chat.html new file mode 100644 index 0000000..1cf9334 --- /dev/null +++ b/static/watchparty_chat.html @@ -0,0 +1,109 @@ + + + + + + Transient.chat - Watch Party + + + + + + +

    Watch Party Room

    + +
    + +
    +
    + + + + + +
    + +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    + + + +
    + +
    + +

    +

    Users in room

    +
    +
    +
    + + + + +