From 9611103b2704bd7ae7f27f159ecb55379cd5ba86 Mon Sep 17 00:00:00 2001 From: Ben Miller Date: Tue, 12 May 2026 18:50:49 -0500 Subject: [PATCH] main --- .gitignore | 26 + GEMINI.md | 50 ++ Procfile | 1 + README.md | 111 ++++ app.py | 21 + blueprints/__init__.py | 16 + blueprints/admin.py | 217 ++++++++ blueprints/auth.py | 50 ++ blueprints/chat.py | 124 +++++ blueprints/rooms.py | 58 +++ blueprints/static_routes.py | 13 + blueprints/watchparty.py | 93 ++++ config.py | 6 + extensions.py | 1 + gunicorn_config.py | 2 + models/cleanup.py | 65 +++ models/state.py | 117 +++++ requirements.txt | 4 + static/admin.html | 55 ++ static/assets/css/admin.css | 76 +++ static/assets/css/chat.css | 186 +++++++ static/assets/css/styles.css | 193 +++++++ static/assets/css/watchparty.css | 127 +++++ static/assets/images/favicon.ico | Bin 0 -> 191362 bytes static/assets/images/paused.png | Bin 0 -> 2082009 bytes static/assets/images/transientchat-blue.png | Bin 0 -> 248083 bytes static/assets/js/admin.js | 259 +++++++++ static/assets/js/chat.js | 548 ++++++++++++++++++++ static/assets/js/index.js | 87 ++++ static/assets/js/server_list.js | 190 +++++++ static/assets/js/watchparty_chat.js | 224 ++++++++ static/assets/sounds/message.wav | Bin 0 -> 15516 bytes static/assets/sounds/user-in.wav | Bin 0 -> 12952 bytes static/assets/sounds/user-out.wav | Bin 0 -> 5236 bytes static/chat.html | 71 +++ static/faq.html | 74 +++ static/index.html | 63 +++ static/server_list.html | 75 +++ static/watchparty_chat.html | 109 ++++ 39 files changed, 3312 insertions(+) create mode 100644 .gitignore create mode 100644 GEMINI.md create mode 100644 Procfile create mode 100644 README.md create mode 100644 app.py create mode 100644 blueprints/__init__.py create mode 100644 blueprints/admin.py create mode 100644 blueprints/auth.py create mode 100644 blueprints/chat.py create mode 100644 blueprints/rooms.py create mode 100644 blueprints/static_routes.py create mode 100644 blueprints/watchparty.py create mode 100644 config.py create mode 100644 extensions.py create mode 100644 gunicorn_config.py create mode 100644 models/cleanup.py create mode 100644 models/state.py create mode 100644 requirements.txt create mode 100644 static/admin.html create mode 100644 static/assets/css/admin.css create mode 100644 static/assets/css/chat.css create mode 100644 static/assets/css/styles.css create mode 100644 static/assets/css/watchparty.css create mode 100644 static/assets/images/favicon.ico create mode 100644 static/assets/images/paused.png create mode 100644 static/assets/images/transientchat-blue.png create mode 100644 static/assets/js/admin.js create mode 100644 static/assets/js/chat.js create mode 100644 static/assets/js/index.js create mode 100644 static/assets/js/server_list.js create mode 100644 static/assets/js/watchparty_chat.js create mode 100644 static/assets/sounds/message.wav create mode 100644 static/assets/sounds/user-in.wav create mode 100644 static/assets/sounds/user-out.wav create mode 100644 static/chat.html create mode 100644 static/faq.html create mode 100644 static/index.html create mode 100644 static/server_list.html create mode 100644 static/watchparty_chat.html 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 0000000000000000000000000000000000000000..8a5250319ce26917852091fde9b75678c1a662e7 GIT binary patch literal 15516 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 H!=nEOmvs2{ literal 0 HcmV?d00001 diff --git a/static/assets/sounds/user-in.wav b/static/assets/sounds/user-in.wav new file mode 100644 index 0000000000000000000000000000000000000000..e55c9bd2cff064fb5ded2b3ffcc4d35e5aa0ab9f GIT binary patch literal 12952 zcmc(mOK%)Wx}G&VFYSL|zzZ+D@Uhq47>3WW0UNOUbla^9ED2zfr7Bh_6G~-fh;hk? z3^6ZCAxlM(L&}1s1|)R@JrD+c{)2y)pD&8!^o-8|ymPWDuaObo_5I$6lD_$mfBo0< zzrJ(lzyI6+_U{kZ#XtSWckbNz9e@AiU))LG-`)8~cMcvGkN-Jsn*T4GyWQ;n)29Ed zm6I#y`0TfTSOve|!e4CZ?mzUF_f2}fe<#Ke;`NT>Z?^QDmVeQYbNd@|Vl1Kf0PZ1C@)m? zytv(-i{>vd0#-OVQDTV~$vL;Sz7>fPZ_YVNSX^%+M%N9~G;P_=-oAPBcE7!ObN1%V zo9+Di_57NruLh*!oursVg+`X@xk&RY%W^&(isxBgjn%Hvl|IQcT%9eBv-SE}^_>2mRcYLIW7y7|B|KmyX;&fX7hyV0ni{`m%vIqb9;JKULY@2mV%&Gz-{ z0VPgPr-{UAJUWxYjeVhXHf>)bnSV)y+x%^m#>)JjMUV%cq3G^0+9D*Ts5ye02P*$g8S&y8M9$FP<0A z>4yNWDpHY=JCLWhhmQRij*nZ%T{SQZMi=!VFi{(D2(F%!E(ri`Qsz1ylL!Kr`wi5+Nt@q_mk{el)|=dai76z?UrX$> z$$@m=AJ61uCh=cO>eEQJ{nDmC_Jj=+$~OTfh$(+kEJf*C#c%c4pCqgmwXgrg5PzzEh=fo#a!D@& z*YZi2iI@r$OHyFtB$1So{R!Z9BWJon%NH+1T(A~*Agc7VuZ?o}tz=>gL=&?lU<2-B z5Gy4(xpvnc=aR&PlT@ZuI;DwhFC)lIJzx*^cBwpIZ!viK0!?E0)I8y*K)h6*>mjAT zy&N|txR&t7o*Xf;RHoYkFUFT(7WKp{ichMsXpPC_E9FLs71EdI)&6E`fL*SQbKOfa z_X%#~8gofOWl(ZtUqtb3a&M9>`DyR0-`G-InDqF~Jx*`a@Bez?ww)+1&7|6==)ARt zoXEC6Twm;O-QK;u{CiH)`!}!M9RHqn+)5f^X-cv)Tp8OWI$#W^65yt>oI88sIMU_> z`xx3wd6bu}^Lra{Y~*{hi5q2;qwuekD)eafX+zI^4A&aIO$ymxalQz zb=#YCA#%}$bRrDKSZx%0=~Z#9wA?Z5EjuS|Xl`(`QCbKKr|DW`Ak5N1M)b}&3`mLL z+qR*FDza-6Wo*+HL(7Ld(tuRplwFKny6F0Jj2z{pjp-PDQlv3a4oh$ZzN2rIPEH;6=k%lb**Yu)J0v^U_o9AAe3cZm8z`k zvMTCI@j%seWoj)%4CQ%GDfxmh8f&TAa4uHlrkF=A_eU`!>A^Gk%9VWfH>IC_Z@jQS zkq6uq%ZpP5*2Iq~|Al*sF~wXMqQgi%Fg^A|KMwN8>CMUhI1Nyp)CQdCyFuQP!^>!LJ;b-1n4Yl1e*7mM}L z;cCtPQNCHM^LlyzXr23ZrKd?3W|Oa1o7G}@w5ZpsB3oAo&{Ski*QsKa=g1M2@#F&N zrB&&=YOQEv^6os=RK^S?v}$x&o46IaHIFe&+w-eW@8)5i;N-iD^FH=xyWMto`Qh^N z-KP&%pFaQm`Qwj2eL3IK^78ER>fO6{S06ro`tj4pkMBQx`oQIT2;BJ&Iznjo3<#pM z!#L69G>8oQRL^}g+TNG;&xJYhnVmP{QWpPSWcy;^NH1w0^IESFegwOSq> z93DJ6IDCAtI9je&M++`xS5Vq+NCs*87a9_mIZ7(&o^1N*{u27F!4gJERzeED9UnAge&-zz!%H~>j0ua z131w2{e*n90oVzjKo~KBQGlsPjojJJ;)0dxr;_~O8 zfBN|kzx?utwEgmUF4$G`i#uU~)t`t_GzzJC1!Ek1qv_~VZs-hcRTb$NM7 zm|{B&h%rW-ibMM}$4_vi1uuybDo9!2w)K(#L-7Ojdz>_R^x(dP8@jP^g1UF_exJ7Q zzW@H)Z@>BW+wZ>p?)$snbN3z(z9TB{b8+|H_ut>=)rSurJbHBS=rIO8JY>4TEE>&K zP4P%bNcHQxX*t7Z>Ni($&S~g#QTN&<}#c*2>BN&Wr->ZKD*+6lG-{NU{b2 zh_tNO9ITLVy)5(fnwZ;cK++WnS!~v1rC`Uw;ewW{1rd$YA00e=c(Bee!s=+bUIItU z!=tQZrd;M5K#G!CVmf6ktOEfkeGbMI+Ky}zyp#G3aq7}kjSQS6lM#E~UR(&oUYwl~ z<=c73B=~}CJ-?FROv%1IJKqtaxD4|UrObdiNRKUb2p)}P!7Iod9u((gs|-xcmK> z1YL^rmg~(@aCCF{m}n!MWl07-Hf6rrA3gZ+cGz{=&-Ze%^Dl}yo2A$bZRyeHIRiSL96(EFGIa4}Oon>O2j_31Dsmo2SN?3AMs$x?b zofk%Ja)bT`hKpe_nzdEwEJ*AOkmV{32-mvxbxj-)cm$vi%EHEB-ar~TMh>*0ww+_i zRPH#SrfIR3XHxh)%wtc4*kgjd-t#7UE8q;>5lve@vs2iY>w_P3XLBgEB;=bFEWOp2$cH)DXL#^_Z68LB_$_gRD%DLo$~So(QjP zt8MUjnsrzon3UmWi8qIyC`MW;oyBBV=%dgk-cVwfRXfbRoid9yWN2{r{{a~u5HExJ>Rj5c` z;EQTH8Hn|0uiW7Brd0~#qi%_=4z#T*ncoX4STLxnysgM+-pq++m|RGOq%ea9oEtNa%HZI32w`-Yn^~bi z7gU3E0m>Mwn_QfX>3gU|r!2tTM{%_Z+s?KtL)fSo2}2y)NGo`eZ6VzhR|8+(P!0EXHBQpP1L!M z>vcO6tF|vT!K};>7S+(K%1P0@TKS>g_+g#JA=~sZ&!XL!K4w+xs{+;9*3iThP6ipO z#V}QQ>^If6&$FSg)EIc%uifO88Tf%?*DRW;soYqXaZ*_reASGV8e*CEy{==dtG?gl z{iv(xOVzg(nS!00+I1we#6yGETc6VoputJ8RXTt+vc^P;85?`1qU2J`+FBPCcK{~> zpc??nfS;qOyGd29Bd9yP+M5d3@=F4F(}5Voy+@dKgjTk?@*}a;+p-QMO~k=uHA&EL zZe46ks+-XRFArfu2;iHZKn^*8K(~@!<5-<)RO$ONvymSn2yN5(O^)G!E5V~W zC!Zu2iZB{L%S#IdAs5UrLSnt%qF^w7JJpBX&f!4a_QAF-0qvWy zcYd%%f9_Us4((YW|D8LJd3`YvwGE-+%p8mMBG@_FI9ffjp0FL8K-;!=c5Z5W8C5@% z#*~i97vaLz4C*^m2fWhuwX9`HsqI6C#|^|*U{`y+v2FkpZEkE;x2~xDq%h*9cCKuE zR^qXekgi+1an zg&v%Wvt9HTuDR$-%6C86vYK~l+-cn#^1sZ6tyAYi7P~@EzRb5%5ijd{w7H-4(w?;i zK2fpK?NBdhtLMP#OHI_}<#tf`#mbLobCb~<04q^c$I)z{z2u&vR&Cl@XH=FjNNoZS zMqN@wOc?UI(>cO!)D%~Yx3PVl_q+UXyv&L&Eb6&knlt;zU6?HTdesbNHFc|O7&Fzo zg&IOt4nEVPVr|IeI)aIv&f2ahd%G;U&K6w+Sj=*4QD$*Lfe6n)_8g^`MqshOOJQ=hb0*$*-PcrMpn-dC1gmT$#}oAa35D z>s8!Rulm9W#9a1^@}kR7jBNIsVOJNsZK21I>9bh17tP9CH1)jAvTo4z#V#*)V< zb3KLHZ$s`cOipxVv2W}!6!~^u>0MZtebnZxS=-SI)2&?J(cCTEWME^IQ`eF{b>0kA zzb5BM-8PVDWx6^KA%_rf&a{rVi-KC?JCZQXkQ#|hM+GJyOXaFkw^bHeF)$IW17Y7b zg^kr3W?wl96jiR_0R$xiHS5x=0&J$sT+L$*XJ;5nDKt(sL(qfS7*GDRa0|E-A>tz? zvZL;TCmXkI>)s&(wYjFM*M>z5>LPWl^GZ<@Z8tmJ2?>`esUS+%0dp1tz-d*rU>&P+ z)+;d5=?Hf>)IbkAgRLn~wvv}RP7;l(nA(D3Pq7}H8O=)f9!zE$f(e8am=**>PD9#9 z2V)4PtO2q{!&6(pg9&-Irh~dQnFGJk28u=zkZCh~*$s38m&a5>45i`+`4NRhHQ_+E zBRHS~c$nX;pmHWLd;uo-Es{f(r23$`?}DzS6|X@!H3snk{{kp7oU*Zzul$xS(;x_B z1g)hYc?TPXlErAiHTlt}6!wM5p;N7=;2{S|9fKln3qnngQ6m#r(_1P_35ps8Mv$l_ z=B5JqGH!jzJer{D<2dJdBQhJq69N9$^bys4NeP2`H5O)$)rdMO)kxVKg~);Mf!YHv zHMI|wrDyHoS23=!abbIyN1*V@T+ZgMBNJvKt3@?fX4koF6qlI1hW@a^1TEJURJZD4 zRm4d{W}z3LE=W)VZ{27!Rs3HNpa@20rDxd zRfnmw6^ILgE@ZJe3{9bCpM(0qxAn|8P|Svk1gh;AWW9vkVhE5B9)^Ppsh6QEi+%=i zrsl}blzpI`9HvHS7P+fv>)3m`XITlnhlugGGc7n@Jb*HslfcMr$4NH{W6;U_!(ED?*=Ex*VybwS_`*K`BS)iYJ7@o`CUtf}}7W`=t~P=J>mo$<$L{ z3CNBXJo2+8_Am?lj}oJ)YN4gp!K{W*QhRe7JwfbhSzD614ak9<)JB{A^4kRSIgmmiqAn~dF*#)F;M@#PV%-X4Ghlsd=-ra<;D*fp zW%|Z+Ny~(AOO_G8p%v;HH2GHYvnLc6x)jqil(Z7Khqj7&N6HuG?cIh!4O8^}za9{k znc~3*83z+dhA@$2dgbs0unLwFw=y^7_Xom;c_S1YqGPMUGs}cDLnnwArdcwFWwOa~ z7JS<_fNM=&;~-PEy#fhfF2J-eJ9sbC8z6@PF+Tdz+|^VaA`GyEyD+bzhsyW-6RMt| zWsZ1m*Y#k_Ru@dpNM?n`OoLe_;y4WL!NV3*54MmT&V;KIs1(nJVo0(fb4RlAk1h=K3xce| zSYc>pt8M0~am>mM)9kt=Ey4fR3ft;fePmIKY-q|fktW|LUWWJJ^EneU;uE?6hsR*7 zB(Nxk69~jmca=;i$ZW#jTFD^?-|Kxr$jWpGOZat#jq@G@&MiRaAxQ8Hm^G^wlyly4 zz9V9(UKCIimkBfJi~~p!Z|e?VAumvh-dP>3X<88nvSgUngB`-Lgr1(=~1=7KH7WdduH9=8|tF4)7SGbP!@|M{dHN*1nzAy$q5>Pl~A%&bnM{Gd7 ztw?N89&n44J}@AF0F-SO=!iwr5Li;p@gD`AYRqy3U`1vl1NbK*lj$^-FWPCADx9q3 zARI}BEm*RU)k4-YNnEK&;u&ve7Jj6(%Q73uCjW+ooM3hzn3|y?(G4kKKG5ch*|vik z;hGv-TgV<9S(YZ@EiAamvX8(t;Y~%N1o1WpK}pFZ2H;6k>=j1ZwTDrjJ_1<-;25;;N^1eQs-kTNML z!jEt+oC86a7{XhHl+c3i||H!s1C8TUHdR8o3RoBr1gnf%43)Wmy79l{?^o zfGk21-Gn**v{GKN4>5>s$!Jj)$;6Zrb(mV@!BB!45)i!5@6Ys8!420Fa7G|~@(5-9Y%7h|s-JrJ@oVzP?tQ^&k6vWXl?ap_H#PedrWD`V1S8b@MZ zblV$*TXdIp@>rU4kd8P_Y9#5B@U**rE|(JXvL$1t8*-1<>25lvQ;Z;D?witQ@<-B8 zsx7ahf@mYXC2cqrckDY#o)N)P&q)P274hW`-EzYFVy|>t_VPgPC9lZ2jGh{$x2{{H z0l1NxN>{W<+RJEZyBh9*YnDZ}gr`}Shvb$|9 wg4<{7?D*gC-$?!?-@oF!`Db_T$n|fx-(6qGKf#sLzj|`#&cEV&v;FV?1K(2Z(EtDd literal 0 HcmV?d00001 diff --git a/static/assets/sounds/user-out.wav b/static/assets/sounds/user-out.wav new file mode 100644 index 0000000000000000000000000000000000000000..bcd448b6224e21d6e1ab539a51b1d902b6bb6a1c GIT binary patch literal 5236 zcmbVQS##US5!P0csyrrD`2nfQORDma{D3@g*S0KKwk1o}Em0yR-T)5}yl@e`K!P`^ z+xAK;?d3zZV@r#uuAO^=U`hW;=zGXLe=4KFYL z@pmI5BVXX}_kS5-*B2wd9ock=u3uq}-k0ppM1y3lE32uRn$2p8mQ^KHQxsLvq>Q4; zX(_A82`MMXGrFA0q*YClG)+oM8Cg}cDJiQY;u%#{)Ko(c7DhpFVJe9@J;_-r;nptJL*W!1d+r&}nndm*&{S;pwq4fZ~9qC2i1Ui5mc8ti}HMGvU?zEZ$h0oBG5q*OooYcU-#frsLMiy3g+mZ!9k@ zyOw4rMjaDSeC_6)nbqa{ZttQyQD_=+b@#AlC|adm-QB4j^?D4%w@+R^-#hx@(W5ty zTivIpTPOSF>fX_VXYl63RrX-y-HRU|e)q#0^m-pZQ2Xxtr{C;vwai+@i263eNmo4HOteA##8+UU)jSjm~f?ki9Oo|av%w!ZjCktXM3UuHryNT)y0{K@tfBiU)i>P0{j1c=&R8i zW8-6Ex9}MsotT`QnV+4SynXlX)bz~U%7!-(j7GvyA(51Gx~bN;_76H+Cr7)-A78b1 zdcCK6uU@}7JN@wT>u-DCcV3=-yZ52@_^5Yw^0N2gHA02N{(t1+-HYdEKfQT*`rVJu zjvrR*9W$597jlXikMPk5pXNL+4=)HD7YGFSO>Zn73xD(U3EK`7UyS| zTtTnb&ts7giN<0)&wHIKi}TZSi}TAK*TU@l)YQFOQ&VGOqZ8A!^MKgx3vnUN@7weQ ze7=A$5JJ4YTqqO>L}D=^6y`ZT65%^$Y{Ps3f{pVzP1ChpHU|qe zJ)c*kf}V&L%v4Z_M7%CP5+k^|u;y~kuDKVd7iaEF&ELN>y>x$i*6CbYcRN>{u638s zAM|^O0(GpTvdQh$N&)Fkk8SH zj+2`dX3~&MCL_s`j3)$GNvBdtF&-CUu~;O`hk2e01wqwdh>t{txF{x5SRu?egfArM^Kmt+;?jd#%OCNMW8MeR$C!@$MD1{&c z1PsAN2<_h&vJL}@WQsfoRM4lWD)XKZtxzPou^gZ@DC7#|Vzy`&C2gmhmGM?pi&|1P zq=bYjps0yNMvNuXv2ZdS=HqfgNGDOF67ghOQqp2tFX)P-=88r^(F@p6&z4FBI0L7Y zOfroxSqdD>;F}h8;H)Mqx-2OOC?TNda)pwv=}3oC$tcogR16qsAU5C=EY0O|MdU3J z%&eLwCBn(M_D(g`Y>FXpzoFIq8bdmdD z6GgNiO@ZjjVo8MzVFXQ6m_sWmT$T*>!B_a+sW=}|BO=MO_fE)w2CLe%D_j$Ap!^?4N29^L6I_e9Qo-}9) z`NLK+22h$v0G(0=MVCp2tn61z3k+(>d;*Gr!*mibOt8NNH%b>p05?`U22^Bh?o*R! ziBJvV!%VXRX{}o`?)Z z416?}UQ*45EDC@N6mWvbqU8{ZtV^TlSnbu9YymB%9WH&eJiN>y7-S;^_7u8bS^KF6YcktqV7(n9 zJ7$9*(y0rv69;#zh*-w}EA*F5|J7iQz3P%71ELU32Fk3Cjqkvys~tv$A>WxBSN*#T zJ!AMMnyY!hp6FDu%N6_HI9PVM9rcD)SrFQ7->8AP{X)Y084UJ2Lk?W++M9jOJ&u1h n{2l)y{41`%;R^q0WQ3lF<%|9U7l}lv`|CfCjQj?dExZ2)V7&8$ literal 0 HcmV?d00001 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

    +
    +
    +
    + + + + +