This commit is contained in:
Ben Miller 2026-05-12 18:50:49 -05:00
commit 9611103b27
39 changed files with 3312 additions and 0 deletions

26
.gitignore vendored Normal file
View file

@ -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/

50
GEMINI.md Normal file
View file

@ -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/<room>/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 `<iframe>` with a `<video>` element playing the incoming WebRTC stream.
---
## 3. Security Concerns
*(No pending issues in this category at this time)*

1
Procfile Normal file
View file

@ -0,0 +1 @@
web: gunicorn --worker-tmp-dir /dev/shm --config gunicorn_config.py "app:create_app()"

111
README.md Normal file
View file

@ -0,0 +1,111 @@
# Transient Chat
> Everything is ephemeral.
**Transient.chat** is a lightweight, in-memory chat platform designed for real-time, impermanent conversation.
There are no profiles. No feeds. No likes.
Just temporary rooms, simple handles, message expiration, and live synced watchrooms.
---
## About
- All messages automatically disappear after **1 hour**
- Empty rooms are purged — nothing is saved
- Users are identified by transient handles, released when inactive
- No databases, no disk writes, no persistence of any kind
- **Watchrooms** allow embedded synced YouTube viewing and conversation
- Works without WebSockets using traditional HTTP polling
This project is a throwback to early internet chatrooms — lightweight, anonymous, and inherently temporary.
Perfect for those who miss casual chat with no expectations or archives.
---
## Features
- Regional and topical chatroom lists
- Synchronized YouTube playback in "watchparty" mode
- Ephemeral message and session design
- Audio indicators and user join/leave sounds
- Owner moderation (kick users from rooms)
- Admin panel for IP/username bans
- Admin routes protected by environment-based Basic Auth
- No database dependency (runs entirely in volatile memory)
---
## Deployment
This app is intended to be deployed on **[DigitalOcean App Platform](https://www.digitalocean.com/products/app-platform/)**
as a Python Flask application.
You should:
- Point DigitalOcean to this repo
- Specify Python as the runtime
- Set environment variables for your admin login:
```env
ADMIN_USERNAME=your_admin_name
ADMIN_PASSWORD=your_secure_password
```
---
## Project Structure
```
transient.chat/
├── app.py # Flask app factory and launcher
├── config.py # Loads environment variables
├── models/
│ ├── state.py # In-memory shared state
│ └── cleanup.py # Background cleanup thread
├── blueprints/ # Modular route definitions
│ ├── auth.py
│ ├── chat.py
│ ├── rooms.py
│ ├── admin.py
│ ├── watchparty.py
│ └── static_routes.py
├── static/
│ ├── index.html
│ ├── chat.html
│ ├── watchparty_chat.html
│ ├── server_list.html
│ ├── faq.html
│ ├── admin.html
│ └── assets/ # Frontend resources
│ ├── css/ # Stylesheets
│ │ └── styles.css
│ ├── js/ # JavaScript files
│ │ ├── admin.js
│ │ ├── chat.js
│ │ ├── faq.js
│ │ ├── index.js
│ │ ├── server_list.js
│ │ └── watchparty_chat.js
│ ├── sounds/ # .wav audio clips
│ │ ├── user-in.wav
│ │ ├── user-out.wav
│ │ └── message.wav
│ └── favicon.ico
└── requirements.txt # Python dependencies
```
---
## Contributing
Pull requests and issues are welcome.
If you'd like to add features, improve styling, or help build out exportable modules, feel free to fork and contribute to the project.
---
Nothing is archived.
Nothing is retained.
**Everything is ephemeral.**

21
app.py Normal file
View file

@ -0,0 +1,21 @@
from flask import Flask
from config import Config
from extensions import CORS
from blueprints import register_blueprints
from models.cleanup import start_cleanup_thread
from werkzeug.middleware.proxy_fix import ProxyFix
def create_app():
app = Flask(__name__)
app.config.from_object(Config)
CORS(app)
register_blueprints(app)
start_cleanup_thread()
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)
return app
app = create_app()
if __name__ == "__main__":
app.run(debug=True)

16
blueprints/__init__.py Normal file
View file

@ -0,0 +1,16 @@
from flask import Flask
from .auth import auth_bp
from .chat import chat_bp
from .rooms import rooms_bp
from .watchparty import watchparty_bp
from .admin import admin_bp
from .static_routes import static_bp
def register_blueprints(app: Flask):
app.register_blueprint(auth_bp)
app.register_blueprint(chat_bp)
app.register_blueprint(rooms_bp)
app.register_blueprint(watchparty_bp)
app.register_blueprint(admin_bp)
app.register_blueprint(static_bp)

217
blueprints/admin.py Normal file
View file

@ -0,0 +1,217 @@
from flask import Blueprint, request, jsonify, Response, send_from_directory
from functools import wraps
from datetime import datetime, timezone
import os
import time
from models.state import (
user_sessions,
user_ips,
room_users,
messages,
banned_ips,
banned_usernames,
kicked_users,
video_state,
room_owners,
nuked_rooms,
public_chat_rooms,
)
admin_bp = Blueprint("admin", __name__)
SERVER_START_TIME = time.time()
def check_auth(username, password):
return username == os.getenv("ADMIN_USERNAME") and password == os.getenv(
"ADMIN_PASSWORD"
)
def authenticate():
return Response(
"Access Denied", 401, {"WWW-Authenticate": 'Basic realm="Login Required"'}
)
def requires_auth(f):
@wraps(f)
def decorated(*args, **kwargs):
auth = request.authorization
if not auth or not check_auth(auth.username, auth.password):
return authenticate()
return f(*args, **kwargs)
return decorated
@admin_bp.route("/admin")
@requires_auth
def admin_panel():
"""Serves the static admin HTML file."""
return send_from_directory("static", "admin.html")
@admin_bp.route("/admin/stats", methods=["GET"])
@requires_auth
def admin_stats():
total_messages = sum(len(msgs) for msgs in messages.values())
return jsonify({
"uptime": int(time.time() - SERVER_START_TIME),
"active_users": len(user_sessions),
"active_rooms": len(room_users),
"total_messages": total_messages,
"banned_count": len(banned_ips) + len(banned_usernames)
})
@admin_bp.route("/admin/users", methods=["GET"])
@requires_auth
def admin_users():
return jsonify(
[
{
"username": u,
"ip": user_ips.get(u, "unknown"),
"last_active": s["last_active"],
}
for u, s in user_sessions.items()
]
)
@admin_bp.route("/admin/rooms", methods=["GET"])
@requires_auth
def admin_rooms():
return jsonify(
[
{
"name": r,
"users": list(room_users.get(r, {}).keys()),
"type": "watchparty" if r in video_state else "chat",
"owner": room_owners.get(r, "System"),
"msg_count": len(messages.get(r, [])),
"is_public": r in public_chat_rooms,
}
for r in list(messages.keys())
]
)
@admin_bp.route("/admin/banip", methods=["POST"])
@requires_auth
def admin_ban_ip():
data = request.get_json()
username = data.get("username")
ip_to_ban = user_ips.get(username)
if not ip_to_ban:
return jsonify({"error": "no ip"}), 400
banned_ips.add(ip_to_ban)
user_sessions.pop(username, None)
for room in room_users:
room_users[room].pop(username, None)
return jsonify({"banned": ip_to_ban})
@admin_bp.route("/admin/banusername", methods=["POST"])
@requires_auth
def admin_ban_username():
data = request.get_json()
username = data.get("username")
if not username:
return jsonify({"error": "no username"}), 400
banned_usernames.add(username)
user_sessions.pop(username, None)
for room in room_users:
room_users[room].pop(username, None)
return jsonify({"banned": username})
@admin_bp.route("/admin/banrawip", methods=["POST"])
@requires_auth
def admin_ban_raw_ip():
data = request.get_json()
ip = data.get("ip")
if not ip:
return jsonify({"error": "no ip"}), 400
banned_ips.add(ip)
return jsonify({"banned": ip})
@admin_bp.route("/admin/banned", methods=["GET"])
@requires_auth
def admin_banned_list():
banned = [{"type": "ip", "value": ip} for ip in banned_ips]
banned += [{"type": "username", "value": uname} for uname in banned_usernames]
return jsonify(banned)
@admin_bp.route("/admin/releaseuser", methods=["POST"])
@requires_auth
def admin_release_user():
data = request.get_json()
username = data.get("username")
if not username:
return jsonify({"error": "no username"}), 400
user_sessions.pop(username, None)
user_ips.pop(username, None)
for room in room_users:
room_users[room].pop(username, None)
return jsonify({"released": username})
@admin_bp.route("/admin/broadcast", methods=["POST"])
@requires_auth
def admin_broadcast():
"""Sends a system message to all active rooms."""
data = request.get_json()
text = data.get("text")
if not text:
return jsonify({"error": "no text"}), 400
timestamp = datetime.now(timezone.utc).isoformat()
message = {"username": "SYSTEM", "text": text, "timestamp": timestamp}
count = 0
for room in messages:
messages[room].append(message)
count += 1
return jsonify({"status": "sent", "rooms_affected": count})
@admin_bp.route("/admin/nukeroom", methods=["POST"])
@requires_auth
def admin_nuke_room():
"""Instantly deletes a room and clears its state."""
data = request.get_json()
room = data.get("room")
# Lock the room for 10 seconds to force all clients to disconnect
nuked_rooms[room] = time.time() + 10
messages.pop(room, None)
room_users.pop(room, None)
room_owners.pop(room, None)
video_state.pop(room, None)
if room in public_chat_rooms:
public_chat_rooms.remove(room)
return jsonify({"status": "nuked", "room": room})

50
blueprints/auth.py Normal file
View file

@ -0,0 +1,50 @@
from flask import Blueprint, request, jsonify
from datetime import datetime, timezone
import os
import secrets
from models.state import user_sessions, user_ips, banned_ips, banned_usernames
auth_bp = Blueprint("auth", __name__)
def generate_user_key(length=16):
return secrets.token_urlsafe(16)
@auth_bp.route("/register", methods=["POST"])
def register():
data = request.get_json()
username = data.get("username")
user_ip = request.remote_addr
if not username:
return jsonify({"error": "No username provided"}), 400
if username in user_sessions:
return jsonify({"error": "Username taken"}), 409
if user_ip in banned_ips:
return jsonify({"error": "IP banned"}), 403
if username in banned_usernames:
return jsonify({"error": "Username banned"}), 403
user_key = generate_user_key()
user_sessions[username] = {
"key": user_key,
"last_active": datetime.now(timezone.utc).isoformat(),
}
user_ips[username] = user_ip
return jsonify({"username": username, "key": user_key})
@auth_bp.route("/validate", methods=["POST"])
def validate():
data = request.get_json()
username = data.get("username")
key = data.get("key")
session = user_sessions.get(username)
if session and session["key"] == key:
session["last_active"] = datetime.now(timezone.utc).isoformat()
return jsonify({"status": "valid"})
return jsonify({"error": "Invalid credentials"}), 403

124
blueprints/chat.py Normal file
View file

@ -0,0 +1,124 @@
from flask import Blueprint, request, jsonify
from datetime import datetime, timezone
import time
from models.state import (
messages,
room_users,
kicked_users,
room_owners,
banned_ips,
banned_usernames,
user_ips,
nuked_rooms,
)
chat_bp = Blueprint("chat", __name__)
@chat_bp.route("/chat", methods=["GET", "POST"])
def general_chat():
if request.method == "POST":
new_message = request.json.get("message")
if new_message:
messages.setdefault("global", []).append(
{"text": new_message, "timestamp": datetime.now(timezone.utc).isoformat()}
)
if len(messages["global"]) > 100:
messages["global"] = messages["global"][-100:]
return jsonify(messages.get("global", []))
@chat_bp.route("/chat/<room>", methods=["GET", "POST"])
def chat_room(room):
room = room.strip()
if request.method == "POST":
data = request.json
username = data.get("username")
text = data.get("text")
audio = data.get("audio")
user_ip = request.remote_addr
if (
user_ip in banned_ips
or username in banned_usernames
or (username in user_ips and user_ips[username] in banned_ips)
):
return jsonify({"error": "banned"}), 403
message = {
"username": username,
"text": text,
"audio": audio,
"timestamp": datetime.now(timezone.utc).isoformat(),
}
messages.setdefault(room, []).append(message)
if len(messages[room]) > 100:
messages[room] = messages[room][-100:]
since = request.args.get("since")
room_messages = messages.setdefault(room, [])
if since:
try:
since_time = datetime.fromisoformat(since)
new_msgs = [
m
for m in room_messages
if datetime.fromisoformat(m["timestamp"]) > since_time
]
return jsonify(new_msgs)
except ValueError:
pass # Ignore bad timestamp
return jsonify(room_messages)
@chat_bp.route("/chat/<room>/users", methods=["GET", "POST"])
def room_user_list(room):
room = room.strip()
# Check if room is temporarily locked (nuked)
if room in nuked_rooms:
if time.time() < nuked_rooms[room]:
return jsonify({"error": "Room is unavailable"}), 403
else:
nuked_rooms.pop(room, None)
if request.method == "POST":
data = request.json
username = data.get("username")
if not username:
return jsonify({"error": "No username"}), 400
if username in kicked_users.get(room, set()):
return jsonify({"error": "Kicked from room"}), 403
room_users.setdefault(room, {})
if room not in room_owners and username not in room_users[room]:
room_owners[room] = username # First user becomes the owner
room_users[room][username] = datetime.now(timezone.utc).isoformat()
users = room_users.get(room, {})
response = {"users": list(users.keys())}
if room in room_owners:
response["owner"] = room_owners[room]
return jsonify(response)
@chat_bp.route("/chat/<room>/kick", methods=["POST"])
def kick_user(room):
data = request.json
owner = data.get("owner")
target = data.get("target")
if not owner or not target:
return jsonify({"error": "Missing owner or target"}), 400
if room_owners.get(room) != owner:
return jsonify({"error": "Not authorized"}), 403
kicked_users.setdefault(room, set()).add(target)
room_users.get(room, {}).pop(target, None)
return jsonify({"kicked": target})

58
blueprints/rooms.py Normal file
View file

@ -0,0 +1,58 @@
from flask import Blueprint, request, jsonify
from models.state import (
room_users,
messages,
public_chat_rooms,
regional_chat_rooms,
topical_chat_rooms,
video_state,
)
rooms_bp = Blueprint("rooms", __name__)
@rooms_bp.route("/rooms", methods=["GET", "POST"])
def get_or_create_rooms():
if request.method == "POST":
data = request.get_json()
room_name = data.get("name", "").strip()
if not room_name:
return jsonify({"error": "Room name required"}), 400
all_rooms = regional_chat_rooms + topical_chat_rooms + public_chat_rooms
if room_name in all_rooms:
return jsonify({"error": "Room already exists"}), 400
public_chat_rooms.append(room_name)
return jsonify({"name": room_name})
active_only = request.args.get("activeOnly") == "true"
def build_room_data(room_list, active=False):
result = []
for room in room_list:
users = room_users.get(room, {})
if active and not users:
continue
result.append(
{
"name": room,
"users": len(users),
"type": "watchparty" if room in video_state else "chat",
}
)
return result
all_users = set()
for users in room_users.values():
all_users.update(users.keys())
return jsonify(
{
"regional": build_room_data(regional_chat_rooms, active_only),
"topical": build_room_data(topical_chat_rooms, active_only),
"public": build_room_data(public_chat_rooms, active_only),
"unique_users": len(all_users),
}
)

View file

@ -0,0 +1,13 @@
from flask import Blueprint, send_from_directory
static_bp = Blueprint("static", __name__)
@static_bp.route("/")
def index():
return send_from_directory("static", "index.html")
@static_bp.route("/<path:path>")
def serve_static_files(path):
return send_from_directory("static", path)

93
blueprints/watchparty.py Normal file
View file

@ -0,0 +1,93 @@
from flask import Blueprint, request, jsonify
from datetime import datetime, timezone
from models.state import video_state, public_chat_rooms
watchparty_bp = Blueprint("watchparty", __name__)
@watchparty_bp.route("/watchparty/<room>/video", methods=["GET", "POST"])
def watchparty_video_control(room):
room = room.strip()
# Ensure it's tracked as a public room
if room not in public_chat_rooms:
public_chat_rooms.append(room)
# Ensure video_state storage is initialized
video_state.setdefault(room, {})
if request.method == "POST":
data = request.get_json()
url = data.get("url")
if not url:
return jsonify({"error": "No video URL"}), 400
video_state[room] = {
"url": url,
"started_at": datetime.now(timezone.utc).isoformat(),
"is_paused": False,
}
return jsonify({"status": "started"})
# Handle GET: return URL + elapsed time
video_data = video_state.get(room)
if not video_data or "url" not in video_data:
return jsonify({"error": "No video"}), 404
try:
is_paused = video_data.get("is_paused", False)
if is_paused:
elapsed = video_data.get("elapsed_at_pause", 0)
else:
start_time = datetime.fromisoformat(video_data["started_at"])
elapsed = (datetime.now(timezone.utc) - start_time).total_seconds()
return jsonify(
{"url": video_data["url"], "elapsed": int(elapsed), "is_paused": is_paused}
)
except ValueError:
return jsonify({"error": "Invalid timestamp"}), 500
@watchparty_bp.route("/watchparty/<room>/video/clear", methods=["POST"])
def clear_watchparty_video(room):
room = room.strip()
video_state.pop(room, None)
return jsonify({"status": "cleared"})
@watchparty_bp.route("/watchparty/<room>/video/pause", methods=["POST"])
def watchparty_pause_video(room):
room = room.strip()
state = video_state.get(room)
if not state or state.get("is_paused"):
return jsonify({"status": "ignored"})
# Calculate elapsed time and freeze it
start_time = datetime.fromisoformat(state["started_at"])
elapsed = (datetime.now(timezone.utc) - start_time).total_seconds()
state["is_paused"] = True
state["elapsed_at_pause"] = elapsed
return jsonify({"status": "paused"})
@watchparty_bp.route("/watchparty/<room>/video/resume", methods=["POST"])
def watchparty_resume_video(room):
room = room.strip()
state = video_state.get(room)
if not state or not state.get("is_paused"):
return jsonify({"status": "ignored"})
# Adjust started_at so that (now - started_at) == elapsed_at_pause
# This effectively shifts the start time forward by the duration of the pause
elapsed = state.get("elapsed_at_pause", 0)
new_start_timestamp = datetime.now(timezone.utc).timestamp() - elapsed
state["started_at"] = datetime.fromtimestamp(new_start_timestamp, timezone.utc).isoformat()
state["is_paused"] = False
state.pop("elapsed_at_pause", None)
return jsonify({"status": "resumed"})

6
config.py Normal file
View file

@ -0,0 +1,6 @@
import os
class Config:
ADMIN_USERNAME = os.getenv("ADMIN_USERNAME")
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD")

1
extensions.py Normal file
View file

@ -0,0 +1 @@
from flask_cors import CORS

2
gunicorn_config.py Normal file
View file

@ -0,0 +1,2 @@
bind = "0.0.0.0:8080"
workers = 1 # Keep 1 worker due to in-memory-only architecture

65
models/cleanup.py Normal file
View file

@ -0,0 +1,65 @@
import threading
import time
from datetime import datetime, timedelta, timezone
from models.state import (
messages,
room_users,
public_chat_rooms,
kicked_users,
user_sessions,
video_state,
)
def cleanup_loop():
while True:
# Remove chat messages older than 1 hour
message_cutoff = datetime.now(timezone.utc) - timedelta(hours=1)
for room, room_messages in list(messages.items()):
messages[room] = [
msg
for msg in room_messages
if datetime.fromisoformat(msg["timestamp"]) > message_cutoff
]
# Remove inactive users from rooms (after 1 min)
inactive_cutoff = datetime.now(timezone.utc) - timedelta(minutes=1)
for room in list(room_users.keys()):
room_users[room] = {
user: ts
for user, ts in room_users[room].items()
if datetime.fromisoformat(ts) > inactive_cutoff
}
# Remove inactive public rooms
for room in list(public_chat_rooms):
last_active = room_users.get(room, {}).values()
if not last_active or all(
datetime.fromisoformat(ts) < inactive_cutoff for ts in last_active
):
if room in public_chat_rooms:
public_chat_rooms.remove(room)
messages.pop(room, None)
room_users.pop(room, None)
kicked_users.pop(room, None)
video_state.pop(room, None)
# Remove stale user sessions
user_cutoff = datetime.now(timezone.utc) - timedelta(days=3)
for user, session in list(user_sessions.items()):
if datetime.fromisoformat(session["last_active"]) < user_cutoff:
user_sessions.pop(user, None)
time.sleep(60) # Run every 60 seconds
_cleanup_thread_started = False
def start_cleanup_thread():
global _cleanup_thread_started
if _cleanup_thread_started:
return
_cleanup_thread_started = True
thread = threading.Thread(target=cleanup_loop, daemon=True)
thread.start()

117
models/state.py Normal file
View file

@ -0,0 +1,117 @@
from typing import Dict, List, Set
# In-Memory Data Stores
# Since this app is ephemeral, we use global dictionaries instead of a database.
user_sessions: Dict[str, dict] = {} # {username: {'key': str, 'last_active': str}}
messages: Dict[str, List[dict]] = {} # {room_name: [{'username': str, 'text': str, 'timestamp': str}]}
room_users: Dict[str, Dict[str, str]] = {} # {room_name: {username: last_active_timestamp}}
banned_ips: Set[str] = set()
banned_usernames: Set[str] = set()
user_ips: Dict[str, str] = {} # {username: ip_address}
kicked_users: Dict[str, Set[str]] = {} # {room_name: {kicked_username}}
room_owners: Dict[str, str] = {} # {room_name: owner_username}
public_chat_rooms: List[str] = [] # List of user-created public room names
video_state: Dict[str, dict] = {} # {room_name: {'url': str, 'started_at': str}}
nuked_rooms: Dict[str, float] = {} # {room_name: expiry_timestamp_epoch}
regional_chat_rooms = [
"Alabama",
"Alaska",
"Arizona",
"Arkansas",
"California",
"Colorado",
"Connecticut",
"Delaware",
"Florida",
"Georgia",
"Hawaii",
"Idaho",
"Illinois",
"Indiana",
"Iowa",
"Kansas",
"Kentucky",
"Louisiana",
"Maine",
"Maryland",
"Massachusetts",
"Michigan",
"Minnesota",
"Mississippi",
"Missouri",
"Montana",
"Nebraska",
"Nevada",
"New Hampshire",
"New Jersey",
"New Mexico",
"New York",
"North Carolina",
"North Dakota",
"Ohio",
"Oklahoma",
"Oregon",
"Pennsylvania",
"Rhode Island",
"South Carolina",
"South Dakota",
"Tennessee",
"Texas",
"Utah",
"Vermont",
"Virginia",
"Washington",
"West Virginia",
"Wisconsin",
"Wyoming",
]
topical_chat_rooms = [
"Tech",
"Science",
"Gaming",
"Movies",
"Music",
"Books",
"Current Events",
"Sports",
"Travel",
"Food & Cooking",
"Health & Fitness",
"History",
"Programming",
"Investing",
"Cryptocurrency",
"Parenting",
"DIY & Home Improvement",
"Photography",
"Fashion",
"Automotive",
"Theater & Performing Arts",
"Pets & Animals",
"Space Exploration",
"Climate Change",
"Entrepreneurship",
"Relationships",
"Comics & Manga",
"Anime",
"Board Games",
"Card Games",
"Tabletop RPGs",
"Language Learning",
"Economics",
"Politics",
"Fan Theories",
"Science Fiction",
"Fantasy",
"Artificial Intelligence",
"Web Development",
"Mobile Apps",
"Cybersecurity",
"3D Printing",
"Virtual Reality",
"Augmented Reality",
"Home Automation",
"Sustainable Living",
]

4
requirements.txt Normal file
View file

@ -0,0 +1,4 @@
Flask
requests
flask_cors
gunicorn

55
static/admin.html Normal file
View file

@ -0,0 +1,55 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Transient.chat - Admin Panel</title>
<link rel="stylesheet" href="/assets/css/styles.css" />
<link rel="stylesheet" href="/assets/css/admin.css" />
<link rel="icon" href="/assets/images/favicon.ico" type="image/x-icon" />
</head>
<body>
<div class="title-row">
<a href="index.html">
<img src="/assets/images/transientchat-blue.png" alt="Transient.chat" />
</a>
<h1>Admin Panel</h1>
</div>
<div id="serverStats">
<!-- Stats will be loaded here -->
<div>Loading stats...</div>
</div>
<h2 class="collapsible">Active Rooms <span id="activeRoomCount"></span></h2>
<div class="collapsible-content" id="roomList">Loading...</div>
<h2 class="collapsible">Active Users <span id="activeUserCount"></span></h2>
<div class="collapsible-content" id="userList">Loading...</div>
<h2>System Broadcast</h2>
<div style="text-align: center; margin-bottom: 20px;">
<input type="text" id="broadcastInput" placeholder="Message to all rooms..." style="width: 300px;">
<button onclick="broadcastSystemMessage()">Send to All</button>
</div>
<h2>Bulk Ban IPs / Usernames</h2>
<textarea
id="bulkInput"
rows="5"
cols="50"
placeholder="Enter one IP per line or one username per line, don't mix."
></textarea
><br />
<div>
<button onclick="bulkBanIPs()">Ban IPs</button>
<button onclick="bulkBanUsernames()">Ban Usernames</button>
</div>
<h2>Banned Users & IPs</h2>
<div id="bannedList">Loading...</div>
<script src="/assets/js/admin.js"></script>
</body>
</html>

View file

@ -0,0 +1,76 @@
/* === Admin Panel === */
#serverStats {
display: flex;
flex-direction: row;
justify-content: center;
gap: 15px;
width: 100%;
max-width: 1000px;
margin-bottom: 30px;
background-color: #2a2a2a;
padding: 20px;
border-radius: 8px;
border: 1px solid #444;
flex-wrap: wrap;
}
#serverStats > div {
background-color: #3d3d3d;
padding: 15px 20px;
border-radius: 6px;
min-width: 120px;
text-align: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
flex: 1;
}
#roomList,
#userList,
#bannedList {
width: 100%;
max-width: 1000px;
display: flex;
flex-direction: column;
gap: 10px;
margin-left: 0; /* Override collapsible default */
}
#roomList > div,
#userList > div,
#bannedList > div {
background-color: #2a2a2a;
border: 1px solid #444;
padding: 12px 15px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 10px;
}
/* Make buttons in lists smaller/neater */
#roomList button,
#userList button {
min-height: 32px;
height: auto;
font-size: 12px;
padding: 4px 12px;
}
#broadcastInput {
background-color: #ffffff;
color: #444;
}
#bulkInput {
width: 100%;
max-width: 600px;
background-color: #ffffff;
color: #333;
border: 1px solid #ccc;
border-radius: 4px;
padding: 10px;
font-family: monospace;
margin-bottom: 10px;
}

186
static/assets/css/chat.css Normal file
View file

@ -0,0 +1,186 @@
/* === Chat Room Layout === */
#page-container {
display: flex;
justify-content: center;
align-items: flex-start;
width: 100%;
max-width: 1200px;
padding: 0 8px;
margin-top: 5px;
flex-wrap: nowrap;
gap: 10px;
height: 100%;
flex: 1;
height: calc(100vh - 100px);
}
#chat-center {
flex: 1 1 700px;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
height: 100%;
max-height: 100%;
}
/* === Messages === */
#messages {
background-color: #2e2e2e;
border: 1px solid #444;
padding: 8px;
border-radius: 8px;
width: 100%;
height: 400px;
overflow-y: auto;
display: flex;
flex-direction: column;
flex-grow: 1;
height: auto;
max-height: calc(100vh - 205px);
}
#messages .message {
line-height: 1.5em;
padding: 4px 10px;
word-wrap: break-word;
background-color: #399fff8a;
color: #f0f0f0;
border-radius: 10px;
margin-bottom: 4px;
max-width: 90%;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
align-self: flex-start;
text-align: left
}
#messages .my-message {
align-self: flex-end;
background-color: #18794e;
text-align: right;
color: #fff;
}
/* === Chat Input Form === */
#chat-form {
display: flex;
gap: 6px;
width: 100%;
margin-top: 10px;
}
#message-input.inputfield {
height: 42px;
padding: 0 12px;
/* horizontal padding only */
line-height: 1;
}
/* === User List Sidebar === */
#user-list-container {
flex: 0 1 230px;
min-width: 180px;
background-color: #2a2a2a;
border: 1px solid #444;
border-radius: 8px;
padding: 8px;
height: fit-content;
transition: max-height 0.3s ease-in-out;
}
#user-list-container h3 {
margin-top: 0;
font-size: 16px;
}
#user-list div {
padding: 5px 0;
color: #ccc;
border-bottom: 1px solid #333;
}
#user-list-container > button {
display: block; /* Forces buttons to stack vertically */
width: fit-content; /* Only as wide as the text, not "full screen" */
margin-bottom: 4px;
}
#user-list-container #user-list-toggle {
display: none;
}
@media (max-width: 750px) {
#page-container {
flex-direction: column;
height: auto;
align-items: stretch;
}
#user-list-container {
display: block;
order: -1;
width: 100%;
flex: none;
max-height: 38px;
overflow: hidden;
margin-bottom: 10px;
padding: 0;
cursor: default;
transition: max-height 0.3s ease-in-out;
}
#user-list-container.expanded {
max-height: 1200px;
padding-bottom: 10px;
}
#user-list-container #user-list-toggle {
display: block;
width: 100%; /* The toggle bar should be full width on mobile */
margin-bottom: 0;
border-radius: 7px 7px 0 0;
}
#user-list-container.expanded #user-list-toggle {
margin-bottom: 10px;
border-bottom: 1px solid #444;
}
#user-list-container > *:not(#user-list-toggle) {
margin-left: 10px;
margin-right: 10px;
}
#user-list-container h3 {
display: block;
margin-top: 10px;
text-align: left;
}
}
/* === Chat Components === */
.mic-button {
margin: 0 0.5em;
}
.mic-button.recording {
background-color: #ff4d4d !important;
}
.toggle-off {
background-color: #808080 !important;
}
.audio-container {
margin-top: 0.25em;
}
.voice-message-audio {
height: 30px;
max-width: 100%;
}
.kick-button {
margin-left: 0.5em;
}

View file

@ -0,0 +1,193 @@
/* === Global === */
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 10px;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #1e1e1e;
color: #ffffff;
display: flex;
flex-direction: column;
align-items: center;
min-height: 100vh;
}
/* === Headings === */
h1,
h2,
h3 {
color: #ffffff;
text-align: center;
margin-bottom: 10px;
}
/* === Layout Containers === */
.container {
width: 100%;
max-width: 1200px;
padding: 0 15px;
}
.title-row {
display: flex;
align-items: center;
justify-content: center;
}
.title-row img {
height: 60px;
margin-right: 15px;
}
#message-input {
flex: 1;
background-color: #1a1a1a;
color: #ffffff;
border: 1px solid #444;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
}
button {
min-height: 36px;
height: auto;
padding: 4px 12px;
display: inline-flex;
align-items: center;
justify-content: center;
/* horizontal only */
font-weight: bold;
font-size: 14px;
background-color: #39a0ff;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s ease-in-out;
}
button:hover {
background-color: #1e7cc9;
}
/* === Shared Forms/Input Styles === */
input[type="text"],
input[type="password"] {
background-color: #1a1a1a;
color: #ffffff;
border: 1px solid #444;
padding: 10px;
border-radius: 4px;
font-size: 14px;
width: 100%;
box-sizing: border-box;
margin-bottom: 10px;
}
/* === Index && Server List Styling === */
#info-text {
max-width: 800px;
text-align: center;
font-size: 16px;
padding: 0 15px;
color: #ccc;
}
#login-container {
padding: 25px 30px;
background-color: #2a2a2a;
border: 1px solid #444;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
text-align: center;
max-width: 400px;
margin: 20px auto;
}
#chat-rooms {
background-color: #2a2a2a;
border: 1px solid #444;
padding: 20px;
border-radius: 8px;
margin-top: 30px;
width: 100%;
max-width: 600px;
}
ul {
list-style-type: none;
padding-left: 0;
}
li {
margin: 8px 0;
color: #ccc;
}
/* Collapsible headers */
.collapsible {
cursor: pointer;
margin: 12px 0 8px;
}
.collapsible-content {
margin-left: 15px;
}
/* === Buttons Index Page === */
#create-room-form,
#start-chat-button,
#create-room-btn {
margin-top: 10px;
}
#create-room-btn,
#create-unlisted-room-btn {
margin-top: 0;
}
.button-row {
display: flex;
justify-content: center;
gap: 12px;
margin-top: 10px;
}
/* === Links === */
a {
color: #79c4ff;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
a:visited {
color: #79c4ff;
}
#username-input,
#new-room-name,
#message-input,
#new-watchparty-name,
#video-url-input {
background-color: #ffffff;
color: #444;
}
li a {
display: block;
text-align: center;
color: #ffffff;
text-decoration: underline;
}
/* === Utilities === */
.hidden {
display: none !important;
}

View file

@ -0,0 +1,127 @@
.watchparty #video-chat-container {
display: flex;
flex-direction: row;
gap: 20px;
width: 100%;
max-width: 2400px;
margin-top: 20px;
}
.watchparty #video-section {
flex: 2;
max-width: 1200px;
}
.watchparty #chat-center {
flex: 1.2;
display: flex;
flex-direction: column;
align-items: center;
max-width: 800px;
}
/* Ensure video controls look clean */
.watchparty #video-controls {
margin-bottom: 15px;
width: 100%;
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.watchparty #video-iframe {
width: 100%;
aspect-ratio: 16 / 9;
max-height: 1080px;
border-radius: 8px;
display: block;
height: auto;
}
#paused-overlay {
width: 100%;
height: 100%;
object-fit: contain;
background: #000;
}
@media (max-width: 950px) {
.watchparty #video-chat-container {
flex-direction: column;
}
.watchparty #video-iframe {
aspect-ratio: auto;
/* Disable aspect ratio for small screens */
height: 420px;
/* Fixed height */
max-height: none;
/* Remove previous cap */
}
.watchparty #video-section,
.watchparty #chat-center {
max-width: 100%;
}
.watchparty #user-list-container {
display: block;
order: -1;
width: 100%;
max-height: 38px;
overflow: hidden;
margin-bottom: 10px;
padding: 0;
transition: max-height 0.3s ease-in-out;
}
.watchparty #user-list-container.expanded {
max-height: 1000px;
padding-bottom: 10px;
}
.watchparty #user-list-container #user-list-toggle {
display: block;
width: 100%; /* The toggle bar should be full width on mobile */
margin-bottom: 0;
border-radius: 7px 7px 0 0;
}
.watchparty #user-list-container.expanded #user-list-toggle {
margin-bottom: 10px;
border-bottom: 1px solid #444;
}
.watchparty #user-list-container > *:not(#user-list-toggle) {
margin-left: 10px;
margin-right: 10px;
}
.watchparty #user-list-container h3 {
display: block;
margin-top: 10px;
text-align: left;
}
.watchparty #room-title {
display: none;
}
.watchparty #chat-center {
flex: 1;
display: flex;
flex-direction: column;
align-items: stretch;
}
.watchparty #messages {
max-height: calc(100vh - 550px);
/* Adjust as needed */
min-height: 150px;
overflow-y: auto;
}
.watchparty #chat-form {
margin-top: 10px;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

259
static/assets/js/admin.js Normal file
View file

@ -0,0 +1,259 @@
async function loadStats() {
const res = await fetch('/admin/stats');
const stats = await res.json();
const div = document.getElementById('serverStats');
const formatTime = (s) => {
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
return `${h}h ${m}m`;
};
div.innerHTML = `
<div style="text-align: center;"><strong>Uptime</strong><br>${formatTime(stats.uptime)}</div>
<div style="text-align: center;"><strong>Users</strong><br>${stats.active_users}</div>
<div style="text-align: center;"><strong>Rooms</strong><br>${stats.active_rooms}</div>
<div style="text-align: center;"><strong>Messages</strong><br>${stats.total_messages}</div>
<div style="text-align: center;"><strong>Bans</strong><br>${stats.banned_count}</div>
`;
}
async function loadUsers() {
const res = await fetch('/admin/users');
const users = await res.json();
const div = document.getElementById('userList');
const countDisplay = document.getElementById('activeUserCount');
div.innerHTML = '';
users.forEach(user => {
const item = document.createElement('div');
const label = document.createElement('span');
label.textContent = `${user.username} (${user.ip})`;
const banIpBtn = document.createElement('button');
banIpBtn.textContent = 'Ban IP';
banIpBtn.style.marginLeft = '10px';
banIpBtn.onclick = () => banUser(user.username);
const banUserBtn = document.createElement('button');
banUserBtn.textContent = 'Ban Username';
banUserBtn.style.marginLeft = '5px';
banUserBtn.onclick = () => banUsername(user.username);
const releaseUserBtn = document.createElement('button');
releaseUserBtn.textContent = 'Release Username';
releaseUserBtn.style.marginLeft = '5px';
releaseUserBtn.onclick = () => releaseUser(user.username);
item.append(label, banIpBtn, banUserBtn, releaseUserBtn);
div.appendChild(item);
});
countDisplay.textContent = `(${users.length})`;
}
async function loadRooms() {
const res = await fetch('/admin/rooms');
const rooms = await res.json();
const div = document.getElementById('roomList');
const countDisplay = document.getElementById('activeRoomCount');
const activeRooms = rooms.filter(r => r.users.length > 0); // Filter active only
div.innerHTML = ''; // Clear previous content
activeRooms.forEach(r => {
const container = document.createElement('div');
const link = document.createElement('a');
link.href = r.type === 'watchparty'
? `/watchparty_chat.html?room=${encodeURIComponent(r.name)}`
: `/chat.html?room=${encodeURIComponent(r.name)}`;
link.target = "_blank";
link.textContent = r.name;
const details = document.createElement('span');
details.style.fontSize = '0.9em';
details.style.color = '#666';
details.style.marginLeft = '10px';
details.textContent = `Owner: ${r.owner} | Msgs: ${r.msg_count} | ${r.is_public ? 'Public' : 'Unlisted'} | Users: ${r.users.join(', ')}`;
const nukeBtn = document.createElement('button');
nukeBtn.textContent = 'Nuke';
nukeBtn.style.marginLeft = '10px';
nukeBtn.style.backgroundColor = '#d9534f'; // Red warning color
nukeBtn.onclick = () => nukeRoom(r.name);
container.append(link, details, nukeBtn);
div.appendChild(container);
});
countDisplay.textContent = `(${activeRooms.length})`;
}
async function banUser(username) {
if (!confirm(`Ban user ${username}?`)) return;
const res = await fetch('/admin/banip', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username })
});
const result = await res.json();
alert(JSON.stringify(result));
loadUsers();
loadRooms();
loadBanned();
}
async function banUsername(username) {
if (!confirm(`Ban username ${username}?`)) return;
const res = await fetch('/admin/banusername', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username })
});
const result = await res.json();
alert(JSON.stringify(result));
loadUsers();
loadRooms();
loadBanned();
}
async function loadBanned() {
const res = await fetch('/admin/banned');
const banned = await res.json();
const div = document.getElementById('bannedList');
div.innerHTML = '';
banned.forEach(item => {
const el = document.createElement('div');
el.textContent = `${item.type}: ${item.value}`;
div.appendChild(el);
});
}
async function bulkBanIPs() {
const lines = document.getElementById('bulkInput').value
.split('\n')
.map(s => s.trim())
.filter(Boolean);
for (const ip of lines) {
const res = await fetch('/admin/banrawip', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ip })
});
const result = await res.json();
if (!res.ok) {
console.warn(`Failed to ban IP: ${ip}${result.error}`);
} else {
console.log(`Banned IP: ${ip}`, result);
}
}
alert("Raw IP ban requests completed.");
loadUsers();
loadRooms();
loadBanned();
}
async function bulkBanUsernames() {
const lines = document.getElementById('bulkInput').value.split('\n').map(s => s.trim()).filter(Boolean);
for (const username of lines) {
const res = await fetch('/admin/banusername', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username })
});
const result = await res.json();
console.log(result);
}
alert("Username ban requests completed.");
loadUsers();
loadRooms();
loadBanned();
}
async function releaseUser(username) {
if (!confirm(`Release user ${username}?`)) return;
const res = await fetch('/admin/releaseuser', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username })
});
const result = await res.json();
alert(JSON.stringify(result));
loadUsers();
loadRooms();
loadBanned();
}
async function broadcastSystemMessage() {
const input = document.getElementById('broadcastInput');
const text = input.value.trim();
if (!text) return;
if (!confirm(`Broadcast to ALL rooms: "${text}"?`)) return;
const res = await fetch('/admin/broadcast', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text })
});
const result = await res.json();
alert(`Broadcast sent to ${result.rooms_affected} rooms.`);
input.value = '';
}
async function nukeRoom(room) {
if (!confirm(`Are you sure you want to NUKE room: "${room}"? This will delete all history and kick users.`)) return;
await fetch('/admin/nukeroom', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ room })
});
loadRooms();
}
// Initial load
loadUsers();
loadRooms();
loadBanned();
loadStats();
// Refresh every 5 seconds
setInterval(() => {
loadUsers();
loadRooms();
loadBanned();
loadStats();
}, 5000);
document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll(".collapsible").forEach(header => {
const countSpan = header.querySelector('span');
const labelText = header.childNodes[0].textContent.trim(); // original text
// Set initial state
if (countSpan) {
header.childNodes[0].textContent = '▶ ' + labelText + ' ';
} else {
header.textContent = '▶ ' + labelText;
}
const content = header.nextElementSibling;
if (content && content.classList.contains("collapsible-content")) {
content.style.display = 'none';
header.style.cursor = "pointer";
header.addEventListener("click", () => {
const isOpen = content.style.display === 'block';
content.style.display = isOpen ? 'none' : 'block';
if (countSpan) {
header.childNodes[0].textContent = (isOpen ? '▶ ' : '▼ ') + labelText + ' ';
} else {
header.textContent = (isOpen ? '▶ ' : '▼ ') + labelText;
}
});
}
});
});

548
static/assets/js/chat.js Normal file
View file

@ -0,0 +1,548 @@
document.addEventListener("DOMContentLoaded", async () => {
const params = new URLSearchParams(window.location.search);
const room = params.get("room") || "Unknown Room";
const username = localStorage.getItem("username");
const userKey = localStorage.getItem("userKey");
let firstLoad = true;
let isRefreshing = false;
if (!room || !username || !userKey) {
alert("Missing room or user credentials.");
const url = `/index.html${room ? '?room=' + encodeURIComponent(room) : ''}`;
window.location.href = url;
return;
}
document.title = room;
try {
const res = await fetch('/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, key: userKey })
});
if (!res.ok) {
localStorage.clear();
alert("User validation failed.");
window.location.href = '/';
return;
}
} catch (err) {
console.error('Validation error:', err);
localStorage.clear();
alert("Validation failed.");
const redirectUrl = `/index.html${room ? '?room=' + encodeURIComponent(room) : ''}`;
window.location.href = redirectUrl;
return;
}
// 🔒 Security: Escape HTML to prevent XSS
function escapeHtml(text) {
if (!text) return text;
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
document.getElementById("room-title").innerHTML = `
<div class="title-row">
<a href="server_list.html">
<img src="/assets/images/transientchat-blue.png" alt="Transient.chat" style="height: 60px;"/>
<a/>
Room: ${escapeHtml(room)}
</div>
`;
const messagesDiv = document.getElementById("messages");
const form = document.getElementById("chat-form");
const input = document.getElementById("message-input");
const usersDiv = document.getElementById("user-list");
const soundToggleBtn = document.getElementById("sound-toggle");
const externalToggleBtn = document.getElementById("external-toggle");
// 🎤 Voice Message Setup
const micBtn = document.createElement("button");
micBtn.textContent = "🎤";
micBtn.type = "button";
micBtn.className = "mic-button";
micBtn.title = "Hold to record (max 15s)";
// Insert mic button after input (between input and send button)
if (input && input.parentNode) {
input.parentNode.insertBefore(micBtn, input.nextSibling);
}
// 🗣️ Autoplay Toggle
const autoplayToggleBtn = document.createElement("button");
autoplayToggleBtn.id = "autoplay-toggle";
// Insert after external toggle
if (externalToggleBtn && externalToggleBtn.parentNode) {
externalToggleBtn.parentNode.insertBefore(autoplayToggleBtn, externalToggleBtn.nextSibling);
}
// Audio setup
const sounds = {
message: new Audio('/assets/sounds/message.wav'),
in: new Audio('/assets/sounds/user-in.wav'),
out: new Audio('/assets/sounds/user-out.wav'),
};
// Load saved preferences from localStorage
soundEnabled = localStorage.getItem("soundEnabled") !== "false"; // default true
externalContentEnabled = localStorage.getItem("externalContentEnabled") !== "false"; // default true
let autoplayEnabled = localStorage.getItem("autoplayEnabled") !== "false"; // default true
soundToggleBtn.textContent = soundEnabled ? "🔊 Sound: On" : "🔇 Sound: Off";
soundToggleBtn.classList.toggle("toggle-off", !soundEnabled);
const updateAutoplayToggleBtn = () => {
autoplayToggleBtn.textContent = autoplayEnabled
? "🗣️ Autoplay: On"
: "🔇 Autoplay: Off";
autoplayToggleBtn.classList.toggle("toggle-off", !autoplayEnabled);
};
updateAutoplayToggleBtn();
const updateExternalToggleBtn = () => {
externalToggleBtn.textContent = externalContentEnabled
? "🖥️ External Content: On"
: "🔒 External Content: Off";
externalToggleBtn.classList.toggle("toggle-off", !externalContentEnabled);
};
updateExternalToggleBtn(); // Reset style on load
// Mobile Toggle for User List & Settings
const userListContainer = document.getElementById("user-list-container");
if (userListContainer) {
const toggleBtn = document.createElement("button");
toggleBtn.id = "user-list-toggle";
toggleBtn.textContent = "Show Users & Settings";
userListContainer.prepend(toggleBtn);
toggleBtn.addEventListener("click", () => {
const isExpanded = userListContainer.classList.toggle("expanded");
toggleBtn.textContent = isExpanded ? "Hide Users & Settings" : "Show Users & Settings";
});
}
soundToggleBtn.addEventListener("click", () => {
soundEnabled = !soundEnabled;
localStorage.setItem("soundEnabled", soundEnabled); // ✅ Persist
soundToggleBtn.textContent = soundEnabled ? "🔊 Sound: On" : "🔇 Sound: Off";
soundToggleBtn.classList.toggle("toggle-off", !soundEnabled);
});
externalToggleBtn.addEventListener("click", () => {
externalContentEnabled = !externalContentEnabled;
localStorage.setItem("externalContentEnabled", externalContentEnabled); // ✅ Persist
updateExternalToggleBtn();
});
autoplayToggleBtn.addEventListener("click", () => {
autoplayEnabled = !autoplayEnabled;
localStorage.setItem("autoplayEnabled", autoplayEnabled);
updateAutoplayToggleBtn();
});
// Recording Logic
let mediaRecorder;
let audioChunks = [];
let isRecording = false;
let recordingTimeout;
async function startRecording() {
if (isRecording) return;
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
// Low bitrate preference (16kbps)
const options = { mimeType: 'audio/webm;codecs=opus', bitsPerSecond: 16000 };
// Fallback if codec not supported
if (!MediaRecorder.isTypeSupported(options.mimeType)) delete options.mimeType;
mediaRecorder = new MediaRecorder(stream, options);
audioChunks = [];
isRecording = true;
micBtn.classList.add("recording");
micBtn.textContent = "⏹️";
mediaRecorder.ondataavailable = e => audioChunks.push(e.data);
mediaRecorder.onstop = () => {
micBtn.classList.remove("recording");
micBtn.textContent = "🎤";
isRecording = false;
clearTimeout(recordingTimeout);
const blob = new Blob(audioChunks, { type: 'audio/webm' });
const reader = new FileReader();
reader.readAsDataURL(blob);
reader.onloadend = () => {
const base64Audio = reader.result;
// Send immediately
fetch(`/chat/${encodeURIComponent(room)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, text: "🎤 Voice Message", audio: base64Audio })
}).then(() => refresh());
};
stream.getTracks().forEach(t => t.stop());
};
mediaRecorder.start();
recordingTimeout = setTimeout(() => {
if (isRecording && mediaRecorder.state === "recording") mediaRecorder.stop();
}, 15000); // 15s limit
} catch (err) {
console.error("Mic error:", err);
alert("Could not access microphone.");
}
}
function stopRecording() {
if (isRecording && mediaRecorder && mediaRecorder.state === "recording") {
mediaRecorder.stop();
}
}
micBtn.addEventListener("mousedown", startRecording);
micBtn.addEventListener("mouseup", stopRecording);
micBtn.addEventListener("mouseleave", stopRecording);
micBtn.addEventListener("touchstart", (e) => { e.preventDefault(); startRecording(); });
micBtn.addEventListener("touchend", (e) => { e.preventDefault(); stopRecording(); });
async function refreshUserPresence() {
const res = await fetch(`/chat/${encodeURIComponent(room)}/users`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username })
});
if (!res.ok) {
alert("You were removed from the room.");
window.location.href = "/server_list.html"; // ✅ go to server list
return;
}
}
let lastUserList = [];
let lastTimestamp = null;
const seenMessageTimestamps = new Set();
const audioQueue = [];
let isPlayingAudio = false;
function processAudioQueue() {
if (isPlayingAudio || audioQueue.length === 0) return;
const audio = audioQueue.shift();
isPlayingAudio = true;
const onComplete = () => {
isPlayingAudio = false;
processAudioQueue();
};
audio.addEventListener('ended', onComplete, { once: true });
audio.addEventListener('error', () => {
console.error("Audio playback error");
onComplete();
}, { once: true });
audio.play().catch(e => {
console.log("Autoplay blocked or failed:", e);
onComplete();
});
}
let windowFocused = true;
let unreadCount = 0;
window.addEventListener('focus', () => {
windowFocused = true;
unreadCount = 0;
document.title = `${room}`;
});
window.addEventListener('blur', () => {
windowFocused = false;
});
function linkify(text) {
// 1. Escape the raw text first to prevent XSS
const safeText = escapeHtml(text);
// 2. Replace URLs in the escaped text with HTML
return safeText.replace(/(https?:\/\/[^\s]+)/g, (url) => {
const imagePattern = /\.(png|jpe?g|gif)$/i;
const youtubeRegex = /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/watch\?v=|youtu\.be\/)([\w-]{11})/;
const id = `preview-${Math.random().toString(36).substr(2, 9)}`;
// ✅ YouTube Video
const ytMatch = url.match(youtubeRegex);
if (ytMatch) {
const videoId = ytMatch[1];
const embedUrl = `https://www.youtube.com/embed/${videoId}`;
if (externalContentEnabled) {
return `
<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>
<div style="padding: 0.5em; text-align: center;">
<iframe
src="${embedUrl}"
title="YouTube video player"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerpolicy="strict-origin-when-cross-origin"
allowfullscreen
style="width: 640px; height: 360px; max-width: 100%; border-radius: 4px;"
></iframe>
</div>
`;
} else {
return `
<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>
<div class="external-image-placeholder" style="padding: 0.5em; text-align: center;">
<button onclick="
this.style.display='none';
const iframe = document.createElement('iframe');
iframe.src = '${embedUrl}';
iframe.title = 'YouTube video player';
iframe.frameBorder = 0;
iframe.allow = 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share';
iframe.referrerPolicy = 'strict-origin-when-cross-origin';
iframe.allowFullscreen = true;
iframe.style.width = '640px';
iframe.style.height = '360px';
iframe.style.maxWidth = '100%';
iframe.style.borderRadius = '4px';
document.getElementById('${id}').appendChild(iframe);
">
Show external content
</button>
<div id="${id}" style="margin-top: 0.5em;"></div>
</div>
`;
}
}
// ✅ Image
if (imagePattern.test(url)) {
if (externalContentEnabled) {
return `
<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>
<div style="padding: 0.5em; text-align: center;">
<img src="${url}" style="width: 100%; height: auto; border-radius: 4px;" />
</div>
`;
} else {
return `
<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>
<div class="external-image-placeholder" style="padding: 0.5em; text-align: center;">
<button onclick="
this.style.display='none';
const img = document.createElement('img');
img.src = '${url}';
img.style.width = '100%';
img.style.height = 'auto';
img.style.borderRadius = '4px';
document.getElementById('${id}').appendChild(img);
">
Show external content
</button>
<div id="${id}" style="margin-top: 0.5em;"></div>
</div>
`;
}
}
// 🔗 Default fallback: plain link
return `<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`;
});
}
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 = `<strong>${escapeHtml(msg.username)}:</strong> ${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);
});

87
static/assets/js/index.js Normal file
View file

@ -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.');
}
});
}
});

View file

@ -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 <li>
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();
}
});

View file

@ -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();
});

Binary file not shown.

Binary file not shown.

Binary file not shown.

71
static/chat.html Normal file
View file

@ -0,0 +1,71 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Transient.chat - Chat Room</title>
<link rel="stylesheet" href="/assets/css/styles.css" />
<link rel="stylesheet" href="/assets/css/chat.css" />
<link rel="icon" href="/assets/images/favicon.ico" type="image/x-icon" />
</head>
<body>
<h1 id="room-title">Chat Room</h1>
<div id="page-container">
<div id="chat-center">
<div id="messages"></div>
<form id="chat-form">
<input
type="text"
class="inputfield"
id="message-input"
placeholder="Type a message, paste in a link, or url to an image or youtube video."
autocomplete="off"
required
/>
<button type="submit">Send</button>
</form>
</div>
<div id="user-list-container">
<a
href="server_list.html"
style="
display: inline-block;
margin-bottom: 10px;
text-decoration: none;
"
>
<button>Server List</button>
</a>
<br />
<button
id="sound-toggle"
style="
margin-bottom: 10px;
text-decoration: none;
display: inline-block;
text-align: left;
"
>
🔊 Sound: On
</button>
<br />
<button
id="external-toggle"
style="
margin-bottom: 10px;
text-decoration: none;
display: inline-block;
text-align: left;
"
>
🔒 External Content: Off
</button>
<br />
<br />
<h3>Users in room</h3>
<div id="user-list"></div>
</div>
</div>
<script src="/assets/js/chat.js"></script>
</body>
</html>

74
static/faq.html Normal file
View file

@ -0,0 +1,74 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FAQ - Transient.chat</title>
<meta
name="description"
content="Frequently asked questions about using Transient.chat"
/>
<link rel="stylesheet" href="/assets/css/styles.css" />
<link rel="icon" href="/assets/images/favicon.ico" type="image/x-icon" />
</head>
<body>
<div id="info-text">
<div class="title-row">
<a href="index.html">
<img
src="/assets/images/transientchat-blue.png"
alt="Transient.chat"
/>
</a>
<h1>Frequently Asked Questions</h1>
</div>
<h2>How long do messages last?</h2>
<p>All chat messages automatically disappear after 1 hour.</p>
<h2>How long do rooms last?</h2>
<p>
All chat rooms automatically disappear after all users leave the room.
</p>
<h2>Do I need an account to use this?</h2>
<p>
No. Just pick a handle and start chatting. If you don't come back in 3
days your handle is released.
</p>
<h2>Is anything stored permanently?</h2>
<p>
No databases, no disk writes. Everything exists in memory and vanishes
when inactive.
</p>
<h2>What is a Watchroom?</h2>
<p>
A Watchroom lets you sync YouTube playback with others while chatting.
Everyone sees the same video at the same time.
</p>
<h2>Can I send voice messages?</h2>
<p>
Yes. You can record short voice clips (up to 15 seconds) by holding the microphone button.
Enable <strong>Autoplay</strong> in the sidebar to hear messages automatically as they arrive.
</p>
<h2>What is External Content?</h2>
<p>
External images and videos linked by other users. You can toggle <strong>External Content</strong> on to automatically show embedded media.
</p>
<h2>Can users be removed?</h2>
<p>Yes. Room owners can kick users. Admins can ban users by IP.</p>
<h2>Where can I find the source code?</h2>
<p><a href="https://github.com/benjimanmiller/transient.chat">https://github.com/benjimanmiller/transient.chat</a></p>
<br /><br />
<a href="index.html">← Back to Home</a>
</div>
</body>
</html>

63
static/index.html Normal file
View file

@ -0,0 +1,63 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Transient.chat</title>
<meta
name="description"
content="Live chats that vanish. No data. No history. Pure ephemerality."
/>
<link rel="stylesheet" href="/assets/css/styles.css" />
<link rel="icon" href="/assets/images/favicon.ico" type="image/x-icon" />
</head>
<body>
<div id="info-text">
<div class="title-row">
<a href="index.html">
<img
src="/assets/images/transientchat-blue.png"
alt="Transient.chat"
/>
</a>
<h1>Chat without a trace!</h1>
</div>
<p>
Real-time conversations that disappear after
<strong>1 hour</strong>.<br />
No accounts. No data trails. Nothing is saved... ever.<br />
Everything is held in server memory and is never written to any disk or
database. <br /><br />
Join a room and talk freely. When the room empties, everything
vanishes.<br />
Want to sync up a video? Try a <strong>Watchroom</strong> and chat while
watching YouTube together.<br /><br />
<strong>Voice Messages:</strong> Hold the mic button to record and send ephemeral voice clips. Enable Autoplay to hear them live.<br /><br />
<strong>No tracking • No storage • 100% ephemeral</strong><br /><br />
If you're tired of feeds, filters, or forever — you're in the right
place.
<a href="faq.html">Learn More →</a>
</p>
</div>
<div id="login-container">
<input type="text" id="username-input" placeholder="Enter Your Handle" />
<br />
<br />
<button id="start-chat-button">Start Chatting!</button>
</div>
<div id="info-text">
<p>
Don't trust me? I don't blame you. Review the source and host your own
server!<br />
<a href="https://github.com/benjimanmiller/transient.chat">
https://github.com/benjimanmiller/transient.chat
</a>
</p>
</div>
<script src="/assets/js/index.js"></script>
</body>
</html>

75
static/server_list.html Normal file
View file

@ -0,0 +1,75 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Transient.chat - Server List</title>
<link rel="stylesheet" href="/assets/css/styles.css" />
<link rel="icon" href="/assets/images/favicon.ico" type="image/x-icon" />
</head>
<body>
<div class="title-row">
<a href="index.html">
<img src="/assets/images/transientchat-blue.png" alt="Transient.chat" />
</a>
<h1>Server List</h1>
</div>
<div id="user-info"></div>
<div id="user-count" style="margin-top: 8px; font-weight: bold"></div>
<div id="chat-rooms">
<h2 class="collapsible">▶ US Regional Rooms</h2>
<ul
id="regional-rooms"
class="collapsible-content"
style="display: none"
></ul>
<h2 class="collapsible">▶ Topical Rooms</h2>
<ul
id="topical-rooms"
class="collapsible-content"
style="display: none"
></ul>
<h2 class="collapsible">▶ User Created Public Rooms</h2>
<ul
id="public-rooms"
class="collapsible-content"
style="display: none"
></ul>
<h2 class="collapsible">▶ Watch Party Rooms</h2>
<ul
id="watchparty-rooms"
class="collapsible-content"
style="display: none"
></ul>
<h2 class="collapsible" id="active-rooms-header">▶ Active Rooms</h2>
<ul
id="active-rooms"
class="collapsible-content"
style="display: none"
></ul>
<br />
<div id="create-room-form">
<input
type="text"
id="new-room-name"
placeholder="Enter New Room Name"
/>
<br />
<div class="button-row">
<button id="create-room-btn">Create Public Room</button>
<button id="create-unlisted-room-btn">Create Unlisted Room</button>
<button id="create-watchparty-btn">Create Watch Party</button>
</div>
</div>
<script src="/assets/js/server_list.js"></script>
</body>
</html>

109
static/watchparty_chat.html Normal file
View file

@ -0,0 +1,109 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Transient.chat - Watch Party</title>
<link rel="stylesheet" href="/assets/css/styles.css" />
<link rel="stylesheet" href="/assets/css/chat.css" />
<link rel="stylesheet" href="/assets/css/watchparty.css" />
<link rel="icon" href="/assets/images/favicon.ico" type="image/x-icon" />
</head>
<body class="watchparty">
<h1 id="room-title">Watch Party Room</h1>
<div id="video-chat-container">
<!-- Column 1: Video Section -->
<div id="video-section">
<div id="video-controls">
<input
type="text"
id="video-url-input"
placeholder="Paste YouTube video URL..."
/>
<button id="start-video-button">▶️ Play</button>
<button id="pause-video-button" class="hidden">⏸ Pause</button>
<button id="resume-video-button" class="hidden">▶ Resume</button>
<button id="clear-video-button" class="hidden">
🛑 Clear Video
</button>
</div>
<div class="video-wrapper">
<iframe
id="video-iframe"
frameborder="0"
allowfullscreen
allow="autoplay; encrypted-media"
class="hidden"
></iframe>
<img
id="paused-overlay"
src="/assets/images/paused.png"
class="hidden"
/>
</div>
</div>
<!-- Column 2: Chat Section -->
<div id="chat-center">
<div id="messages"></div>
<form id="chat-form">
<input
type="text"
class="inputfield"
id="message-input"
placeholder="Type a message, paste in a link, or url to an image or YouTube video."
autocomplete="off"
required
/>
<button type="submit">Send</button>
</form>
</div>
<!-- Column 3: User List + Controls -->
<div id="user-list-container">
<a
href="server_list.html"
style="
display: inline-block;
margin-bottom: 10px;
text-decoration: none;
"
>
<button>Server List</button>
</a>
<br />
<button
id="sound-toggle"
style="
margin-bottom: 10px;
text-decoration: none;
display: inline-block;
text-align: left;
"
>
🔊 Sound: On
</button>
<br />
<button
id="external-toggle"
style="
margin-bottom: 10px;
text-decoration: none;
display: inline-block;
text-align: left;
"
>
🔒 External Content: Off
</button>
<br /><br />
<h3>Users in room</h3>
<div id="user-list"></div>
</div>
</div>
<script src="/assets/js/chat.js"></script>
<script src="/assets/js/watchparty_chat.js"></script>
</body>
</html>