feat: initialize transient.chat application with static UI, blueprints, and state management

This commit is contained in:
Ben Miller 2026-10-05 21:36:54 -05:00
parent 1a5f55eb93
commit 3accce44db
20 changed files with 1726 additions and 1572 deletions

109
README.md
View file

@ -1,52 +1,45 @@
# Transient Chat # Transient Chat
> Everything is ephemeral. > Everything is ephemeral. Encryption first.
**Transient.chat** is a lightweight, in-memory chat platform designed for real-time, impermanent conversation. **Transient.chat** is a lightweight, encryption-first in-memory chat platform designed for real-time, impermanent conversation.
There are no profiles. No feeds. No likes. There are no profiles. No feeds. No tracking. No persistent databases.
Just temporary rooms, simple handles, message expiration, and live synced watchrooms. Just on-demand private encrypted rooms, zero plaintext in server memory, message expiration, and integrated toggleable watch parties.
--- ---
## About ## About
- All messages automatically disappear after **1 hour** - **Encryption-First:** Client-side zero-knowledge E2EE using AES-256-GCM and PBKDF2 (100,000 iterations). All text and voice messages are encrypted in browser before sending.
- Empty rooms are purged — nothing is saved - **Encrypted In-Memory Storage:** The server only ever holds ephemeral ciphertext and IVs in memory. No plaintext or raw audio is ever stored in memory or written to disk.
- Users are identified by transient handles, released when inactive - **On-Demand Private Rooms:** Rooms are created on demand and protected by passwords.
- No databases, no disk writes, no persistence of any kind - **Active Server Browser:** The server browser exclusively lists active rooms currently occupied by users.
- **Watchrooms** allow embedded synced YouTube viewing and conversation - **Integrated Watch Party:** Any room can toggle YouTube synchronized playback on and off in real time.
- Works without WebSockets using traditional HTTP polling - **Ephemeral Voice Notes:** Record and send encrypted voice messages with optional autoplay.
- **1-Hour Expiration & Room Purge:** Messages expire after 1 hour. When all users leave a room, the room, its password, video state, and messages vanish completely.
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 ## Features
- Regional and topical chatroom lists - Zero-knowledge end-to-end encryption without complicated keyring management
- Synchronized YouTube playback in "watchparty" mode - Ephemeral in-memory ciphertext storage (zero plaintext)
- Ephemeral message and session design - On-demand room creation and password verification
- Audio indicators and user join/leave sounds - Active-only server browser
- Owner moderation (kick users from rooms) - Integrated YouTube Watch Party toggle in all rooms
- Admin panel for IP/username bans - Ephemeral audio notes (WebM / Opus) with optional autoplay
- Admin routes protected by environment-based Basic Auth - Room owner moderation (kick users)
- No database dependency (runs entirely in volatile memory) - Admin dashboard for IP/username bans and system notifications
- Pure Vanilla CSS and lightweight modern architecture
--- ---
## Deployment ## Deployment
This app is intended to be deployed on **[DigitalOcean App Platform](https://www.digitalocean.com/products/app-platform/)** This app is intended to be deployed on **[DigitalOcean App Platform](https://www.digitalocean.com/products/app-platform/)** or any container/PaaS as a Python Flask application.
as a Python Flask application.
You should: Set environment variables for admin login:
- Point DigitalOcean to this repo
- Specify Python as the runtime
- Set environment variables for your admin login:
```env ```env
ADMIN_USERNAME=your_admin_name ADMIN_USERNAME=your_admin_name
@ -62,50 +55,40 @@ transient.chat/
├── app.py # Flask app factory and launcher ├── app.py # Flask app factory and launcher
├── config.py # Loads environment variables ├── config.py # Loads environment variables
├── models/ ├── models/
│ ├── state.py # In-memory shared state │ ├── state.py # In-memory shared state & password hashing
│ └── cleanup.py # Background cleanup thread │ └── cleanup.py # Background cleanup thread for empty rooms & expired msgs
├── blueprints/ # Modular route definitions ├── blueprints/ # Modular route definitions
│ ├── auth.py │ ├── auth.py # Handle registration and token validation
│ ├── chat.py │ ├── chat.py # Encrypted message relay and polling
│ ├── rooms.py │ ├── rooms.py # Active rooms browser and password join/create
│ ├── admin.py │ ├── admin.py # Admin dashboard endpoints
│ ├── watchparty.py │ ├── watchparty.py # YouTube synchronized playback control
│ └── static_routes.py │ └── static_routes.py
├── static/ ├── static/
│ ├── index.html │ ├── index.html # Landing page & handle registration
│ ├── chat.html │ ├── chat.html # Unified encrypted chat & watch party interface
│ ├── watchparty_chat.html │ ├── server_list.html # Active rooms server browser & room creation
│ ├── server_list.html │ ├── faq.html # Frequently asked questions & security details
│ ├── faq.html │ ├── admin.html # Admin dashboard interface
│ ├── admin.html
│ └── assets/ # Frontend resources │ └── assets/ # Frontend resources
│ ├── css/ # Stylesheets │ ├── css/ # Vanilla CSS stylesheets
│ │ └── styles.css │ │ ├── styles.css
│ ├── js/ # JavaScript files │ │ ├── chat.css
│ │ ├── admin.js │ │ ├── watchparty.css
│ │ ├── chat.js │ │ └── admin.css
│ │ ├── faq.js │ ├── js/ # Frontend JavaScript
│ │ ├── crypto.js # Web Crypto API (PBKDF2 + AES-GCM)
│ │ ├── chat.js # Unified encrypted chat & watch party
│ │ ├── server_list.js # Active rooms & password join modal
│ │ ├── index.js │ │ ├── index.js
│ │ ├── server_list.js │ │ └── admin.js
│ │ └── watchparty_chat.js │ ├── sounds/ # Notification audio clips (.wav)
│ ├── sounds/ # .wav audio clips │ └── images/ # Logos and branding
│ │ ├── user-in.wav
│ │ ├── user-out.wav
│ │ └── message.wav
│ └── favicon.ico
└── requirements.txt # Python dependencies └── 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 archived.
Nothing is retained. Nothing is retained.
**Everything is ephemeral.** **Everything is ephemeral.**

View file

@ -1,6 +1,7 @@
from flask import Blueprint, request, jsonify, Response, send_from_directory from flask import Blueprint, request, jsonify, Response, send_from_directory
from functools import wraps from functools import wraps
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Dict, Any, List, Tuple, Callable
import os import os
import time import time
@ -8,6 +9,8 @@ from models.state import (
user_sessions, user_sessions,
user_ips, user_ips,
room_users, room_users,
room_passwords,
authorized_room_users,
messages, messages,
banned_ips, banned_ips,
banned_usernames, banned_usernames,
@ -15,7 +18,6 @@ from models.state import (
video_state, video_state,
room_owners, room_owners,
nuked_rooms, nuked_rooms,
public_chat_rooms,
) )
admin_bp = Blueprint("admin", __name__) admin_bp = Blueprint("admin", __name__)
@ -23,21 +25,21 @@ admin_bp = Blueprint("admin", __name__)
SERVER_START_TIME = time.time() SERVER_START_TIME = time.time()
def check_auth(username, password): def check_auth(username: str | None, password: str | None) -> bool:
return username == os.getenv("ADMIN_USERNAME") and password == os.getenv( return username == os.getenv("ADMIN_USERNAME") and password == os.getenv(
"ADMIN_PASSWORD" "ADMIN_PASSWORD"
) )
def authenticate(): def authenticate() -> Response:
return Response( return Response(
"Access Denied", 401, {"WWW-Authenticate": 'Basic realm="Login Required"'} "Access Denied", 401, {"WWW-Authenticate": 'Basic realm="Login Required"'}
) )
def requires_auth(f): def requires_auth(f: Callable) -> Callable:
@wraps(f) @wraps(f)
def decorated(*args, **kwargs): def decorated(*args: Any, **kwargs: Any) -> Any:
auth = request.authorization auth = request.authorization
if not auth or not check_auth(auth.username, auth.password): if not auth or not check_auth(auth.username, auth.password):
return authenticate() return authenticate()
@ -48,19 +50,20 @@ def requires_auth(f):
@admin_bp.route("/admin") @admin_bp.route("/admin")
@requires_auth @requires_auth
def admin_panel(): def admin_panel() -> Response:
"""Serves the static admin HTML file.""" """Serves the static admin HTML file."""
return send_from_directory("static", "admin.html") return send_from_directory("static", "admin.html")
@admin_bp.route("/admin/stats", methods=["GET"]) @admin_bp.route("/admin/stats", methods=["GET"])
@requires_auth @requires_auth
def admin_stats(): def admin_stats() -> Response:
total_messages = sum(len(msgs) for msgs in messages.values()) total_messages = sum(len(msgs) for msgs in messages.values())
active_room_names = {r for r, u in room_users.items() if len(u) > 0}
return jsonify({ return jsonify({
"uptime": int(time.time() - SERVER_START_TIME), "uptime": int(time.time() - SERVER_START_TIME),
"active_users": len(user_sessions), "active_users": len(user_sessions),
"active_rooms": len(room_users), "active_rooms": len(active_room_names),
"total_messages": total_messages, "total_messages": total_messages,
"banned_count": len(banned_ips) + len(banned_usernames) "banned_count": len(banned_ips) + len(banned_usernames)
}) })
@ -68,7 +71,7 @@ def admin_stats():
@admin_bp.route("/admin/users", methods=["GET"]) @admin_bp.route("/admin/users", methods=["GET"])
@requires_auth @requires_auth
def admin_users(): def admin_users() -> Response:
return jsonify( return jsonify(
[ [
{ {
@ -83,26 +86,27 @@ def admin_users():
@admin_bp.route("/admin/rooms", methods=["GET"]) @admin_bp.route("/admin/rooms", methods=["GET"])
@requires_auth @requires_auth
def admin_rooms(): def admin_rooms() -> Response:
all_rooms = set(room_users.keys()) | set(room_passwords.keys())
return jsonify( return jsonify(
[ [
{ {
"name": r, "name": r,
"users": list(room_users.get(r, {}).keys()), "users": list(room_users.get(r, {}).keys()),
"type": "watchparty" if r in video_state else "chat", "type": "watchparty" if r in video_state and video_state[r].get("url") else "chat",
"owner": room_owners.get(r, "System"), "owner": room_owners.get(r, "System"),
"msg_count": len(messages.get(r, [])), "msg_count": len(messages.get(r, [])),
"is_public": r in public_chat_rooms, "is_public": False,
} }
for r in list(messages.keys()) for r in all_rooms
] ]
) )
@admin_bp.route("/admin/banip", methods=["POST"]) @admin_bp.route("/admin/banip", methods=["POST"])
@requires_auth @requires_auth
def admin_ban_ip(): def admin_ban_ip() -> Tuple[Response, int] | Response:
data = request.get_json() data: Dict[str, Any] = request.get_json() or {}
username = data.get("username") username = data.get("username")
ip_to_ban = user_ips.get(username) ip_to_ban = user_ips.get(username)
@ -110,51 +114,52 @@ def admin_ban_ip():
return jsonify({"error": "no ip"}), 400 return jsonify({"error": "no ip"}), 400
banned_ips.add(ip_to_ban) banned_ips.add(ip_to_ban)
user_sessions.pop(username, None) user_sessions.pop(username, None)
for room in room_users: for room in room_users:
room_users[room].pop(username, None) room_users[room].pop(username, None)
for room in authorized_room_users:
authorized_room_users[room].discard(username)
return jsonify({"banned": ip_to_ban}) return jsonify({"banned": ip_to_ban})
@admin_bp.route("/admin/banusername", methods=["POST"]) @admin_bp.route("/admin/banusername", methods=["POST"])
@requires_auth @requires_auth
def admin_ban_username(): def admin_ban_username() -> Tuple[Response, int] | Response:
data = request.get_json() data: Dict[str, Any] = request.get_json() or {}
username = data.get("username") username = data.get("username")
if not username: if not username:
return jsonify({"error": "no username"}), 400 return jsonify({"error": "no username"}), 400
banned_usernames.add(username) banned_usernames.add(username)
user_sessions.pop(username, None) user_sessions.pop(username, None)
for room in room_users: for room in room_users:
room_users[room].pop(username, None) room_users[room].pop(username, None)
for room in authorized_room_users:
authorized_room_users[room].discard(username)
return jsonify({"banned": username}) return jsonify({"banned": username})
@admin_bp.route("/admin/banrawip", methods=["POST"]) @admin_bp.route("/admin/banrawip", methods=["POST"])
@requires_auth @requires_auth
def admin_ban_raw_ip(): def admin_ban_raw_ip() -> Tuple[Response, int] | Response:
data = request.get_json() data: Dict[str, Any] = request.get_json() or {}
ip = data.get("ip") ip = data.get("ip")
if not ip: if not ip:
return jsonify({"error": "no ip"}), 400 return jsonify({"error": "no ip"}), 400
banned_ips.add(ip) banned_ips.add(ip)
return jsonify({"banned": ip}) return jsonify({"banned": ip})
@admin_bp.route("/admin/banned", methods=["GET"]) @admin_bp.route("/admin/banned", methods=["GET"])
@requires_auth @requires_auth
def admin_banned_list(): def admin_banned_list() -> Response:
banned = [{"type": "ip", "value": ip} for ip in banned_ips] banned = [{"type": "ip", "value": ip} for ip in banned_ips]
banned += [{"type": "username", "value": uname} for uname in banned_usernames] banned += [{"type": "username", "value": uname} for uname in banned_usernames]
return jsonify(banned) return jsonify(banned)
@ -162,8 +167,8 @@ def admin_banned_list():
@admin_bp.route("/admin/releaseuser", methods=["POST"]) @admin_bp.route("/admin/releaseuser", methods=["POST"])
@requires_auth @requires_auth
def admin_release_user(): def admin_release_user() -> Tuple[Response, int] | Response:
data = request.get_json() data: Dict[str, Any] = request.get_json() or {}
username = data.get("username") username = data.get("username")
if not username: if not username:
@ -174,26 +179,34 @@ def admin_release_user():
for room in room_users: for room in room_users:
room_users[room].pop(username, None) room_users[room].pop(username, None)
for room in authorized_room_users:
authorized_room_users[room].discard(username)
return jsonify({"released": username}) return jsonify({"released": username})
@admin_bp.route("/admin/broadcast", methods=["POST"]) @admin_bp.route("/admin/broadcast", methods=["POST"])
@requires_auth @requires_auth
def admin_broadcast(): def admin_broadcast() -> Tuple[Response, int] | Response:
"""Sends a system message to all active rooms.""" """Sends a system notice to all active rooms."""
data = request.get_json() data: Dict[str, Any] = request.get_json() or {}
text = data.get("text") text = data.get("text")
if not text: if not text:
return jsonify({"error": "no text"}), 400 return jsonify({"error": "no text"}), 400
timestamp = datetime.now(timezone.utc).isoformat() timestamp = datetime.now(timezone.utc).isoformat()
message = {"username": "SYSTEM", "text": text, "timestamp": timestamp} message = {
"username": "SYSTEM",
"ciphertext": "",
"iv": "",
"system_notice": text,
"timestamp": timestamp,
}
count = 0 count = 0
for room in messages: for room in list(room_users.keys()):
messages[room].append(message) messages.setdefault(room, []).append(message)
count += 1 count += 1
return jsonify({"status": "sent", "rooms_affected": count}) return jsonify({"status": "sent", "rooms_affected": count})
@ -201,30 +214,27 @@ def admin_broadcast():
@admin_bp.route("/admin/nukeroom", methods=["POST"]) @admin_bp.route("/admin/nukeroom", methods=["POST"])
@requires_auth @requires_auth
def admin_nuke_room(): def admin_nuke_room() -> Response:
"""Instantly deletes a room and clears its state.""" """Instantly deletes a room and clears its state."""
data = request.get_json() data: Dict[str, Any] = request.get_json() or {}
room = data.get("room") room = data.get("room")
# Lock the room for 10 seconds to force all clients to disconnect
nuked_rooms[room] = time.time() + 10 nuked_rooms[room] = time.time() + 10
messages.pop(room, None) messages.pop(room, None)
room_users.pop(room, None) room_users.pop(room, None)
room_passwords.pop(room, None)
authorized_room_users.pop(room, None)
room_owners.pop(room, None) room_owners.pop(room, None)
video_state.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}) return jsonify({"status": "nuked", "room": room})
@admin_bp.route("/admin/unban", methods=["POST"]) @admin_bp.route("/admin/unban", methods=["POST"])
@requires_auth @requires_auth
def admin_unban(): def admin_unban() -> Tuple[Response, int] | Response:
data = request.get_json() data: Dict[str, Any] = request.get_json() or {}
ban_type = data.get("type") ban_type = data.get("type")
value = data.get("value") value = data.get("value")
@ -243,10 +253,10 @@ def admin_unban():
@admin_bp.route("/admin/room/setowner", methods=["POST"]) @admin_bp.route("/admin/room/setowner", methods=["POST"])
@requires_auth @requires_auth
def admin_set_room_owner(): def admin_set_room_owner() -> Tuple[Response, int] | Response:
data = request.get_json() data: Dict[str, Any] = request.get_json() or {}
room = data.get("room") room = data.get("room")
owner = data.get("owner") # If None, clears the owner owner = data.get("owner")
if not room: if not room:
return jsonify({"error": "Missing room name"}), 400 return jsonify({"error": "Missing room name"}), 400
@ -261,8 +271,8 @@ def admin_set_room_owner():
@admin_bp.route("/admin/kick", methods=["POST"]) @admin_bp.route("/admin/kick", methods=["POST"])
@requires_auth @requires_auth
def admin_kick_user(): def admin_kick_user() -> Tuple[Response, int] | Response:
data = request.get_json() data: Dict[str, Any] = request.get_json() or {}
room = data.get("room") room = data.get("room")
target = data.get("target") target = data.get("target")
@ -270,14 +280,18 @@ def admin_kick_user():
return jsonify({"error": "Missing room or target"}), 400 return jsonify({"error": "Missing room or target"}), 400
kicked_users.setdefault(room, set()).add(target) kicked_users.setdefault(room, set()).add(target)
room_users.get(room, {}).pop(target, None) if room in authorized_room_users:
authorized_room_users[room].discard(target)
if room in room_users:
room_users[room].pop(target, None)
return jsonify({"kicked": target, "room": room}) return jsonify({"kicked": target, "room": room})
@admin_bp.route("/admin/room/clearmessages", methods=["POST"]) @admin_bp.route("/admin/room/clearmessages", methods=["POST"])
@requires_auth @requires_auth
def admin_clear_room_messages(): def admin_clear_room_messages() -> Tuple[Response, int] | Response:
data = request.get_json() data: Dict[str, Any] = request.get_json() or {}
room = data.get("room") room = data.get("room")
if not room: if not room:
@ -289,9 +303,8 @@ def admin_clear_room_messages():
@admin_bp.route("/admin/room/messages", methods=["GET"]) @admin_bp.route("/admin/room/messages", methods=["GET"])
@requires_auth @requires_auth
def admin_room_messages(): def admin_room_messages() -> Tuple[Response, int] | Response:
room = request.args.get("room") room = request.args.get("room")
if not room: if not room:
return jsonify({"error": "Missing room parameter"}), 400 return jsonify({"error": "Missing room parameter"}), 400
return jsonify(messages.get(room, [])) return jsonify(messages.get(room, []))

View file

@ -1,45 +1,46 @@
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify, Response
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Dict, Any, Tuple
import time import time
from blueprints.auth import verify_user_key
from models.state import ( from models.state import (
messages, messages,
room_users, room_users,
authorized_room_users,
kicked_users, kicked_users,
room_owners, room_owners,
banned_ips, banned_ips,
banned_usernames, banned_usernames,
user_ips, user_ips,
user_sessions,
nuked_rooms, nuked_rooms,
) )
chat_bp = Blueprint("chat", __name__) 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"]) @chat_bp.route("/chat/<room>", methods=["GET", "POST"])
def chat_room(room): def chat_room(room: str) -> Tuple[Response, int] | Response:
room = room.strip() room = room.strip()
if request.method == "POST": if request.method == "POST":
data = request.json data: Dict[str, Any] = request.get_json() or {}
username = data.get("username") username: str = str(data.get("username", "")).strip()
text = data.get("text") key: str = str(data.get("key", "")).strip()
audio = data.get("audio") ciphertext: str = str(data.get("ciphertext", "")).strip()
iv: str = str(data.get("iv", "")).strip()
user_ip = request.remote_addr user_ip = request.remote_addr
# Verify authentication
if not username or not key or not verify_user_key(username, key):
return jsonify({"error": "Invalid user credentials"}), 403
# Verify room authorization
if username not in authorized_room_users.get(room, set()):
return jsonify({"error": "Room password authorization required", "code": "AUTH_REQUIRED"}), 401
# Check bans and kicks
if ( if (
user_ip in banned_ips user_ip in banned_ips
or username in banned_usernames or username in banned_usernames
@ -47,23 +48,28 @@ def chat_room(room):
): ):
return jsonify({"error": "banned"}), 403 return jsonify({"error": "banned"}), 403
if username in kicked_users.get(room, set()):
return jsonify({"error": "kicked"}), 403
# Encryption-first enforcement: all messages stored in memory MUST be encrypted
if not ciphertext or not iv:
return jsonify({"error": "Encryption required: ciphertext and IV missing"}), 400
# Ephemeral encrypted message in server memory
message = { message = {
"username": username, "username": username,
"text": text, "ciphertext": ciphertext,
"audio": audio, "iv": iv,
"timestamp": datetime.now(timezone.utc).isoformat(), "timestamp": datetime.now(timezone.utc).isoformat(),
} }
if "encrypted" in data:
message["encrypted"] = data["encrypted"]
if "iv" in data:
message["iv"] = data["iv"]
if "keyId" in data:
message["keyId"] = data["keyId"]
messages.setdefault(room, []).append(message) messages.setdefault(room, []).append(message)
if len(messages[room]) > 100: if len(messages[room]) > 100:
messages[room] = messages[room][-100:] messages[room] = messages[room][-100:]
return jsonify({"status": "sent"})
# GET endpoint (optional polling fallback)
since = request.args.get("since") since = request.args.get("since")
room_messages = messages.setdefault(room, []) room_messages = messages.setdefault(room, [])
@ -77,16 +83,15 @@ def chat_room(room):
] ]
return jsonify(new_msgs) return jsonify(new_msgs)
except ValueError: except ValueError:
pass # Ignore bad timestamp pass
return jsonify(room_messages) return jsonify(room_messages)
@chat_bp.route("/chat/<room>/users", methods=["GET", "POST"]) @chat_bp.route("/chat/<room>/users", methods=["GET", "POST"])
def room_user_list(room): def room_user_list(room: str) -> Tuple[Response, int] | Response:
room = room.strip() room = room.strip()
# Check if room is temporarily locked (nuked)
if room in nuked_rooms: if room in nuked_rooms:
if time.time() < nuked_rooms[room]: if time.time() < nuked_rooms[room]:
return jsonify({"error": "Room is unavailable"}), 403 return jsonify({"error": "Room is unavailable"}), 403
@ -94,56 +99,69 @@ def room_user_list(room):
nuked_rooms.pop(room, None) nuked_rooms.pop(room, None)
if request.method == "POST": if request.method == "POST":
data = request.json data: Dict[str, Any] = request.get_json() or {}
username = data.get("username") username: str = str(data.get("username", "")).strip()
key: str = str(data.get("key", "")).strip()
if not username: if not username:
return jsonify({"error": "No username"}), 400 return jsonify({"error": "No username"}), 400
if key and not verify_user_key(username, key):
return jsonify({"error": "Invalid user credentials"}), 403
if username not in authorized_room_users.get(room, set()):
return jsonify({"error": "Room authorization required", "code": "AUTH_REQUIRED"}), 401
if username in kicked_users.get(room, set()): if username in kicked_users.get(room, set()):
return jsonify({"error": "Kicked from room"}), 403 return jsonify({"error": "Kicked from room"}), 403
room_users.setdefault(room, {}) 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() room_users[room][username] = datetime.now(timezone.utc).isoformat()
users = room_users.get(room, {}) users = room_users.get(room, {})
response = {"users": list(users.keys())} response: Dict[str, Any] = {"users": list(users.keys())}
if room in room_owners: if room in room_owners:
response["owner"] = room_owners[room] response["owner"] = room_owners[room]
return jsonify(response) return jsonify(response)
@chat_bp.route("/chat/<room>/kick", methods=["POST"]) @chat_bp.route("/chat/<room>/kick", methods=["POST"])
def kick_user(room): def kick_user(room: str) -> Tuple[Response, int] | Response:
data = request.json room = room.strip()
owner = data.get("owner") data: Dict[str, Any] = request.get_json() or {}
target = data.get("target") owner: str = str(data.get("owner", "")).strip()
key: str = str(data.get("key", "")).strip()
target: str = str(data.get("target", "")).strip()
if not owner or not target: if not owner or not target:
return jsonify({"error": "Missing owner or target"}), 400 return jsonify({"error": "Missing owner or target"}), 400
if key and not verify_user_key(owner, key):
return jsonify({"error": "Invalid credentials"}), 403
if room_owners.get(room) != owner: if room_owners.get(room) != owner:
return jsonify({"error": "Not authorized"}), 403 return jsonify({"error": "Not authorized"}), 403
kicked_users.setdefault(room, set()).add(target) kicked_users.setdefault(room, set()).add(target)
room_users.get(room, {}).pop(target, None) if room in authorized_room_users:
authorized_room_users[room].discard(target)
if room in room_users:
room_users[room].pop(target, None)
return jsonify({"kicked": target}) return jsonify({"kicked": target})
@chat_bp.route("/chat/<room>/poll", methods=["POST"]) @chat_bp.route("/chat/<room>/poll", methods=["POST"])
def poll_room(room): def poll_room(room: str) -> Tuple[Response, int] | Response:
room = room.strip() room = room.strip()
data = request.json or {} data: Dict[str, Any] = request.get_json() or {}
username = data.get("username") username: str = str(data.get("username", "")).strip()
key = data.get("key") key: str = str(data.get("key", "")).strip()
since = data.get("since") since: str = data.get("since")
from blueprints.auth import verify_user_key
if not username or not key or not verify_user_key(username, key): if not username or not key or not verify_user_key(username, key):
return jsonify({"error": "Invalid credentials"}), 403 return jsonify({"error": "Invalid credentials"}), 403
# Check if room is temporarily locked (nuked)
if room in nuked_rooms: if room in nuked_rooms:
if time.time() < nuked_rooms[room]: if time.time() < nuked_rooms[room]:
return jsonify({"error": "Room is unavailable"}), 403 return jsonify({"error": "Room is unavailable"}), 403
@ -153,21 +171,20 @@ def poll_room(room):
if username in kicked_users.get(room, set()): if username in kicked_users.get(room, set()):
return jsonify({"error": "Kicked from room"}), 403 return jsonify({"error": "Kicked from room"}), 403
# Check room authorization
if username not in authorized_room_users.get(room, set()):
return jsonify({"error": "Room authorization required", "code": "AUTH_REQUIRED"}), 401
# Update user presence # Update user presence
room_users.setdefault(room, {}) room_users.setdefault(room, {})
if room not in room_owners and username not in room_users[room]:
room_owners[room] = username
room_users[room][username] = datetime.now(timezone.utc).isoformat() room_users[room][username] = datetime.now(timezone.utc).isoformat()
# Restore active user session in-memory if lost on restart
from models.state import user_sessions
user_sessions[username] = { user_sessions[username] = {
"key": key, "key": key,
"last_active": datetime.now(timezone.utc).isoformat(), "last_active": datetime.now(timezone.utc).isoformat(),
} }
# Fetch messages # Fetch encrypted messages from memory
room_messages = messages.setdefault(room, []) room_messages = messages.setdefault(room, [])
filtered_messages = room_messages filtered_messages = room_messages
if since: if since:
@ -182,12 +199,11 @@ def poll_room(room):
pass pass
users_list = room_users.get(room, {}) users_list = room_users.get(room, {})
response = { response: Dict[str, Any] = {
"messages": filtered_messages, "messages": filtered_messages,
"users": list(users_list.keys()) "users": list(users_list.keys()),
} }
if room in room_owners: if room in room_owners:
response["owner"] = room_owners[room] response["owner"] = room_owners[room]
return jsonify(response) return jsonify(response)

View file

@ -1,58 +1,136 @@
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify, Response
from datetime import datetime, timezone
from typing import Dict, Any, Tuple
import time
from blueprints.auth import verify_user_key
from models.state import ( from models.state import (
room_users, room_users,
messages, room_passwords,
public_chat_rooms, authorized_room_users,
regional_chat_rooms, room_owners,
topical_chat_rooms, kicked_users,
nuked_rooms,
banned_ips,
banned_usernames,
user_ips,
video_state, video_state,
hash_room_password,
verify_room_password,
) )
rooms_bp = Blueprint("rooms", __name__) rooms_bp = Blueprint("rooms", __name__)
@rooms_bp.route("/rooms", methods=["GET", "POST"]) @rooms_bp.route("/rooms", methods=["GET", "POST"])
def get_or_create_rooms(): def get_or_create_rooms() -> Tuple[Response, int] | Response:
if request.method == "POST": if request.method == "POST":
data = request.get_json() data: Dict[str, Any] = request.get_json() or {}
room_name = data.get("name", "").strip() room_name: str = (data.get("name") or data.get("room", "")).strip()
password: str = str(data.get("password", "")).strip()
username: str = str(data.get("username", "")).strip()
key: str = str(data.get("key", "")).strip()
if not room_name: return handle_room_join_or_create(room_name, password, username, key)
return jsonify({"error": "Room name required"}), 400
all_rooms = regional_chat_rooms + topical_chat_rooms + public_chat_rooms # GET request: Return ONLY active rooms
if room_name in all_rooms: active_rooms = []
return jsonify({"error": "Room already exists"}), 400 all_users = set()
public_chat_rooms.append(room_name) for room, users in list(room_users.items()):
return jsonify({"name": room_name}) user_count = len(users)
if user_count > 0:
active_only = request.args.get("activeOnly") == "true" all_users.update(users.keys())
has_wp = bool(
def build_room_data(room_list, active=False): room in video_state
result = [] and (video_state[room].get("is_active") or video_state[room].get("url"))
for room in room_list: )
users = room_users.get(room, {}) active_rooms.append(
if active and not users:
continue
result.append(
{ {
"name": room, "name": room,
"users": len(users), "users": user_count,
"type": "watchparty" if room in video_state else "chat", "has_watchparty": has_wp,
"type": "watchparty" if has_wp else "chat",
} }
) )
return result
all_users = set()
for users in room_users.values():
all_users.update(users.keys())
return jsonify( return jsonify(
{ {
"regional": build_room_data(regional_chat_rooms, active_only), "active_rooms": active_rooms,
"topical": build_room_data(topical_chat_rooms, active_only),
"public": build_room_data(public_chat_rooms, active_only),
"unique_users": len(all_users), "unique_users": len(all_users),
# Maintain backward compatibility aliases
"public": active_rooms,
"regional": [],
"topical": [],
}
)
@rooms_bp.route("/rooms/join", methods=["POST"])
def join_room() -> Tuple[Response, int] | Response:
data: Dict[str, Any] = request.get_json() or {}
room_name: str = (data.get("room") or data.get("name", "")).strip()
password: str = str(data.get("password", "")).strip()
username: str = str(data.get("username", "")).strip()
key: str = str(data.get("key", "")).strip()
return handle_room_join_or_create(room_name, password, username, key)
def handle_room_join_or_create(
room_name: str, password: str, username: str, key: str
) -> Tuple[Response, int] | Response:
if not room_name:
return jsonify({"error": "Room name is required"}), 400
if not password:
return jsonify({"error": "Room password is required for private encryption"}), 400
if not username or not key:
return jsonify({"error": "User credentials are required"}), 401
if not verify_user_key(username, key):
return jsonify({"error": "Invalid user credentials"}), 403
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": "User is banned"}), 403
if room_name in nuked_rooms:
if time.time() < nuked_rooms[room_name]:
return jsonify({"error": "Room is currently unavailable"}), 403
else:
nuked_rooms.pop(room_name, None)
if username in kicked_users.get(room_name, set()):
return jsonify({"error": "You were kicked from this room"}), 403
# On-demand room creation vs existing room verification
if room_name not in room_passwords:
# Create new on-demand encrypted room
room_passwords[room_name] = hash_room_password(password)
room_owners[room_name] = username
else:
# Verify password for existing room
if not verify_room_password(password, room_passwords[room_name]):
return jsonify({"error": "Incorrect room password"}), 401
# Authorize user session for this room
authorized_room_users.setdefault(room_name, set()).add(username)
# Register user presence
room_users.setdefault(room_name, {})[username] = datetime.now(timezone.utc).isoformat()
return jsonify(
{
"status": "ok",
"name": room_name,
"room": room_name,
"owner": room_owners.get(room_name),
"is_owner": room_owners.get(room_name) == username,
} }
) )

View file

@ -1,25 +1,20 @@
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify, Response
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Dict, Any, Tuple
from models.state import video_state, public_chat_rooms from models.state import video_state
watchparty_bp = Blueprint("watchparty", __name__) watchparty_bp = Blueprint("watchparty", __name__)
@watchparty_bp.route("/watchparty/<room>/video", methods=["GET", "POST"]) @watchparty_bp.route("/watchparty/<room>/video", methods=["GET", "POST"])
def watchparty_video_control(room): def watchparty_video_control(room: str) -> Tuple[Response, int] | Response:
room = room.strip() 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, {}) video_state.setdefault(room, {})
if request.method == "POST": if request.method == "POST":
data = request.get_json() data: Dict[str, Any] = request.get_json() or {}
url = data.get("url") url: str = data.get("url", "").strip()
if not url: if not url:
return jsonify({"error": "No video URL"}), 400 return jsonify({"error": "No video URL"}), 400
@ -27,14 +22,15 @@ def watchparty_video_control(room):
"url": url, "url": url,
"started_at": datetime.now(timezone.utc).isoformat(), "started_at": datetime.now(timezone.utc).isoformat(),
"is_paused": False, "is_paused": False,
"is_active": True,
} }
return jsonify({"status": "started"}) return jsonify({"status": "started", "url": url})
# Handle GET: return URL + elapsed time # GET: return URL, elapsed time, is_paused, is_active
video_data = video_state.get(room) video_data = video_state.get(room, {})
if not video_data or "url" not in video_data: if not video_data or "url" not in video_data:
return jsonify({"error": "No video"}), 404 return jsonify({"is_active": False, "url": None, "elapsed": 0, "is_paused": False})
try: try:
is_paused = video_data.get("is_paused", False) is_paused = video_data.get("is_paused", False)
@ -43,46 +39,49 @@ def watchparty_video_control(room):
else: else:
start_time = datetime.fromisoformat(video_data["started_at"]) start_time = datetime.fromisoformat(video_data["started_at"])
elapsed = (datetime.now(timezone.utc) - start_time).total_seconds() elapsed = (datetime.now(timezone.utc) - start_time).total_seconds()
return jsonify( return jsonify(
{"url": video_data["url"], "elapsed": int(elapsed), "is_paused": is_paused} {
"url": video_data["url"],
"elapsed": max(0, int(elapsed)),
"is_paused": is_paused,
"is_active": video_data.get("is_active", True),
}
) )
except ValueError: except Exception:
return jsonify({"error": "Invalid timestamp"}), 500 return jsonify({"is_active": False, "url": None, "elapsed": 0, "is_paused": False})
@watchparty_bp.route("/watchparty/<room>/video/clear", methods=["POST"]) @watchparty_bp.route("/watchparty/<room>/video/clear", methods=["POST"])
def clear_watchparty_video(room): def clear_watchparty_video(room: str) -> Response:
room = room.strip() room = room.strip()
video_state.pop(room, None) video_state.pop(room, None)
return jsonify({"status": "cleared"}) return jsonify({"status": "cleared"})
@watchparty_bp.route("/watchparty/<room>/video/pause", methods=["POST"]) @watchparty_bp.route("/watchparty/<room>/video/pause", methods=["POST"])
def watchparty_pause_video(room): def watchparty_pause_video(room: str) -> Response:
room = room.strip() room = room.strip()
state = video_state.get(room) state = video_state.get(room)
if not state or state.get("is_paused"): if not state or state.get("is_paused"):
return jsonify({"status": "ignored"}) return jsonify({"status": "ignored"})
# Calculate elapsed time and freeze it
start_time = datetime.fromisoformat(state["started_at"]) start_time = datetime.fromisoformat(state["started_at"])
elapsed = (datetime.now(timezone.utc) - start_time).total_seconds() elapsed = (datetime.now(timezone.utc) - start_time).total_seconds()
state["is_paused"] = True state["is_paused"] = True
state["elapsed_at_pause"] = elapsed state["elapsed_at_pause"] = max(0, int(elapsed))
return jsonify({"status": "paused"}) return jsonify({"status": "paused"})
@watchparty_bp.route("/watchparty/<room>/video/resume", methods=["POST"]) @watchparty_bp.route("/watchparty/<room>/video/resume", methods=["POST"])
def watchparty_resume_video(room): def watchparty_resume_video(room: str) -> Response:
room = room.strip() room = room.strip()
state = video_state.get(room) state = video_state.get(room)
if not state or not state.get("is_paused"): if not state or not state.get("is_paused"):
return jsonify({"status": "ignored"}) 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) elapsed = state.get("elapsed_at_pause", 0)
new_start_timestamp = datetime.now(timezone.utc).timestamp() - elapsed new_start_timestamp = datetime.now(timezone.utc).timestamp() - elapsed
@ -91,3 +90,17 @@ def watchparty_resume_video(room):
state.pop("elapsed_at_pause", None) state.pop("elapsed_at_pause", None)
return jsonify({"status": "resumed"}) return jsonify({"status": "resumed"})
@watchparty_bp.route("/watchparty/<room>/video/toggle", methods=["POST"])
def watchparty_toggle_video(room: str) -> Response:
room = room.strip()
data: Dict[str, Any] = request.get_json() or {}
state = video_state.setdefault(room, {})
if "is_active" in data:
state["is_active"] = bool(data["is_active"])
else:
state["is_active"] = not state.get("is_active", False)
return jsonify({"status": "toggled", "is_active": state["is_active"]})

View file

@ -5,17 +5,23 @@ from datetime import datetime, timedelta, timezone
from models.state import ( from models.state import (
messages, messages,
room_users, room_users,
public_chat_rooms, room_passwords,
authorized_room_users,
room_owners,
kicked_users, kicked_users,
user_sessions, user_sessions,
user_ips,
video_state, video_state,
) )
def cleanup_loop(): def cleanup_loop() -> None:
"""Background loop that periodically cleans expired messages, inactive users, and empty rooms."""
while True: while True:
# Remove chat messages older than 1 hour now = datetime.now(timezone.utc)
message_cutoff = datetime.now(timezone.utc) - timedelta(hours=1)
# 1. Remove chat messages older than 1 hour
message_cutoff = now - timedelta(hours=1)
for room, room_messages in list(messages.items()): for room, room_messages in list(messages.items()):
messages[room] = [ messages[room] = [
msg msg
@ -23,8 +29,8 @@ def cleanup_loop():
if datetime.fromisoformat(msg["timestamp"]) > message_cutoff if datetime.fromisoformat(msg["timestamp"]) > message_cutoff
] ]
# Remove inactive users from rooms (after 1 min) # 2. Remove inactive users from rooms (after 1 min of silence)
inactive_cutoff = datetime.now(timezone.utc) - timedelta(minutes=1) inactive_cutoff = now - timedelta(minutes=1)
for room in list(room_users.keys()): for room in list(room_users.keys()):
room_users[room] = { room_users[room] = {
user: ts user: ts
@ -32,31 +38,44 @@ def cleanup_loop():
if datetime.fromisoformat(ts) > inactive_cutoff if datetime.fromisoformat(ts) > inactive_cutoff
} }
# Remove inactive public rooms # 3. Clean up on-demand rooms that have become empty
for room in list(public_chat_rooms): # Rooms exist strictly while active; once empty for over 1 minute, everything vanishes.
last_active = room_users.get(room, {}).values() for room in list(room_users.keys()):
if not last_active or all( if not room_users[room]:
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) messages.pop(room, None)
room_users.pop(room, None) room_users.pop(room, None)
room_passwords.pop(room, None)
authorized_room_users.pop(room, None)
room_owners.pop(room, None)
kicked_users.pop(room, None) kicked_users.pop(room, None)
video_state.pop(room, None) video_state.pop(room, None)
# Remove stale user sessions # Also purge any dangling rooms in room_passwords that have no users
user_cutoff = datetime.now(timezone.utc) - timedelta(days=3) for room in list(room_passwords.keys()):
if room not in room_users or not room_users[room]:
messages.pop(room, None)
room_users.pop(room, None)
room_passwords.pop(room, None)
authorized_room_users.pop(room, None)
room_owners.pop(room, None)
kicked_users.pop(room, None)
video_state.pop(room, None)
# 4. Remove stale user sessions (after 3 days)
user_cutoff = now - timedelta(days=3)
for user, session in list(user_sessions.items()): for user, session in list(user_sessions.items()):
if datetime.fromisoformat(session["last_active"]) < user_cutoff: if datetime.fromisoformat(session["last_active"]) < user_cutoff:
user_sessions.pop(user, None) user_sessions.pop(user, None)
user_ips.pop(user, None)
time.sleep(60) # Run every 60 seconds time.sleep(60)
_cleanup_thread_started = False _cleanup_thread_started = False
def start_cleanup_thread():
def start_cleanup_thread() -> None:
"""Starts daemon thread for ephemeral resource cleanup."""
global _cleanup_thread_started global _cleanup_thread_started
if _cleanup_thread_started: if _cleanup_thread_started:
return return

View file

@ -1,118 +1,45 @@
from typing import Dict, List, Set from typing import Dict, List, Set
import hashlib
import secrets
# In-Memory Data Stores # In-Memory Data Stores
# Since this app is ephemeral, we use global dictionaries instead of a database. # Ephemeral in-memory state: zero persistent database, zero disk logging.
user_sessions: Dict[str, dict] = {} # {username: {'key': str, 'last_active': str}} user_sessions: Dict[str, dict] = {} # {username: {'key': str, 'last_active': str}}
messages: Dict[str, List[dict]] = {} # {room_name: [{'username': str, 'text': str, 'timestamp': str}]} messages: Dict[str, List[dict]] = {} # {room_name: [{'username': str, 'ciphertext': str, 'iv': str, 'timestamp': str}]}
room_users: Dict[str, Dict[str, str]] = {} # {room_name: {username: last_active_timestamp}} room_users: Dict[str, Dict[str, str]] = {} # {room_name: {username: last_active_timestamp}}
room_passwords: Dict[str, str] = {} # {room_name: "salt:hash"} - secure room password hashes
authorized_room_users: Dict[str, Set[str]] = {} # {room_name: {username, ...}} - validated sessions for room
room_owners: Dict[str, str] = {} # {room_name: owner_username}
kicked_users: Dict[str, Set[str]] = {} # {room_name: {kicked_username}}
banned_ips: Set[str] = set() banned_ips: Set[str] = set()
banned_usernames: Set[str] = set() banned_usernames: Set[str] = set()
user_ips: Dict[str, str] = {} # {username: ip_address} user_ips: Dict[str, str] = {} # {username: ip_address}
kicked_users: Dict[str, Set[str]] = {} # {room_name: {kicked_username}} video_state: Dict[str, dict] = {} # {room_name: {'url': str, 'started_at': str, 'is_paused': bool, 'is_active': bool}}
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} 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 = [ def hash_room_password(password: str) -> str:
"Tech", """Hashes a room password using PBKDF2-HMAC-SHA256 with a cryptographically secure salt."""
"Science", salt = secrets.token_hex(16)
"Gaming", key_hash = hashlib.pbkdf2_hmac(
"Movies", "sha256",
"Music", password.encode("utf-8"),
"Books", salt.encode("utf-8"),
"Current Events", 100000,
"Sports", )
"Travel", return f"{salt}:{key_hash.hex()}"
"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",
]
def verify_room_password(password: str, stored_hash: str) -> bool:
"""Verifies a room password against the stored salt:hash in constant time."""
try:
salt, expected_hash = stored_hash.split(":", 1)
test_hash = hashlib.pbkdf2_hmac(
"sha256",
password.encode("utf-8"),
salt.encode("utf-8"),
100000,
)
return secrets.compare_digest(test_hash.hex(), expected_hash)
except Exception:
return False

View file

@ -8,12 +8,71 @@
padding: 0 8px; padding: 0 8px;
margin-top: 5px; margin-top: 5px;
flex-wrap: nowrap; flex-wrap: nowrap;
gap: 10px; gap: 12px;
height: 100%; height: 100%;
flex: 1; flex: 1;
height: calc(100vh - 100px); height: calc(100vh - 100px);
transition: max-width 0.3s ease;
} }
body.watchparty-active #page-container {
max-width: 1600px;
}
/* === Watch Party Video Section === */
#video-section {
flex: 1.5;
display: flex;
flex-direction: column;
max-width: 800px;
width: 100%;
}
#video-section.hidden {
display: none !important;
}
#video-controls {
display: flex;
gap: 8px;
margin-bottom: 10px;
width: 100%;
flex-wrap: wrap;
}
#video-controls input {
flex: 1;
min-width: 200px;
margin-bottom: 0;
}
.video-wrapper {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
background: #000;
border-radius: 6px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
#video-iframe {
width: 100%;
height: 100%;
border: none;
}
#paused-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: contain;
background: #000;
}
/* === Chat Center === */
#chat-center { #chat-center {
flex: 1 1 700px; flex: 1 1 700px;
display: flex; display: flex;
@ -24,6 +83,10 @@
max-height: 100%; max-height: 100%;
} }
body.watchparty-active #chat-center {
flex: 1 1 500px;
}
/* === Messages === */ /* === Messages === */
#messages { #messages {
background-color: #2e2e2e; background-color: #2e2e2e;
@ -42,17 +105,16 @@
#messages .message { #messages .message {
line-height: 1.5em; line-height: 1.5em;
padding: 4px 10px; padding: 6px 12px;
word-wrap: break-word; word-wrap: break-word;
background-color: #399fff8a; background-color: #399fff8a;
color: #f0f0f0; color: #f0f0f0;
border-radius: 10px; border-radius: 10px;
margin-bottom: 4px; margin-bottom: 6px;
max-width: 90%; max-width: 90%;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
align-self: flex-start; align-self: flex-start;
text-align: left text-align: left;
} }
#messages .my-message { #messages .my-message {
@ -62,6 +124,16 @@
color: #fff; color: #fff;
} }
#messages .system-message {
align-self: center;
background-color: #4a3810;
border: 1px solid #d4a017;
color: #ffeb99;
text-align: center;
font-size: 0.9em;
max-width: 95%;
}
/* === Chat Input Form === */ /* === Chat Input Form === */
#chat-form { #chat-form {
display: flex; display: flex;
@ -73,7 +145,6 @@
#message-input.inputfield { #message-input.inputfield {
height: 42px; height: 42px;
padding: 0 12px; padding: 0 12px;
/* horizontal padding only */
line-height: 1; line-height: 1;
} }
@ -90,7 +161,7 @@
} }
#user-list-container h3 { #user-list-container h3 {
margin-top: 0; margin-top: 10px;
font-size: 16px; font-size: 16px;
} }
@ -100,23 +171,30 @@
border-bottom: 1px solid #333; border-bottom: 1px solid #333;
} }
#user-list-container > button { #user-list-container > button,
display: block; /* Forces buttons to stack vertically */ #user-list-container > a > button {
width: fit-content; /* Only as wide as the text, not "full screen" */ display: block;
margin-bottom: 4px; width: 100%;
margin-bottom: 6px;
text-align: left;
} }
#user-list-container #user-list-toggle { #user-list-container #user-list-toggle {
display: none; display: none;
} }
@media (max-width: 750px) { @media (max-width: 950px) {
#page-container { #page-container {
flex-direction: column; flex-direction: column;
height: auto; height: auto;
align-items: stretch; align-items: stretch;
} }
body.watchparty-active #video-section,
body.watchparty-active #chat-center {
max-width: 100%;
}
#user-list-container { #user-list-container {
display: block; display: block;
order: -1; order: -1;
@ -137,7 +215,7 @@
#user-list-container #user-list-toggle { #user-list-container #user-list-toggle {
display: block; display: block;
width: 100%; /* The toggle bar should be full width on mobile */ width: 100%;
margin-bottom: 0; margin-bottom: 0;
border-radius: 7px 7px 0 0; border-radius: 7px 7px 0 0;
} }
@ -183,4 +261,58 @@
.kick-button { .kick-button {
margin-left: 0.5em; margin-left: 0.5em;
padding: 2px 6px;
font-size: 11px;
background-color: #d9534f;
}
/* === Password Modal Overlay === */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.75);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-box {
background: #2a2a2a;
border: 1px solid #444;
border-radius: 8px;
padding: 24px 30px;
max-width: 440px;
width: 90%;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6);
text-align: center;
}
.modal-box h3 {
margin-top: 0;
color: #fff;
}
.modal-box p {
color: #ccc;
font-size: 14px;
margin-bottom: 16px;
}
.modal-buttons {
display: flex;
justify-content: center;
gap: 10px;
margin-top: 15px;
}
.cancel-btn {
background-color: #555 !important;
}
.cancel-btn:hover {
background-color: #666 !important;
} }

View file

@ -0,0 +1,182 @@
/* === Server List / Server Browser Styles === */
#user-info {
display: flex;
align-items: center;
justify-content: center;
gap: 15px;
margin-bottom: 15px;
}
#user-info .logout-btn {
background-color: #d9534f;
color: white;
border: none;
padding: 6px 14px;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
font-size: 14px;
transition: background-color 0.2s ease;
}
#user-info .logout-btn:hover {
background-color: #c9302c;
}
#server-browser-container {
width: 100%;
max-width: 650px;
margin-top: 15px;
}
.stats-badge {
background: #2a2a2a;
border: 1px solid #444;
padding: 10px 16px;
border-radius: 6px;
text-align: center;
margin-bottom: 20px;
font-weight: bold;
color: #79c4ff;
}
.room-card {
background: #252525;
border: 1px solid #3d3d3d;
border-radius: 6px;
padding: 14px 18px;
margin-bottom: 12px;
display: flex;
align-items: center;
justify-content: space-between;
transition: border-color 0.2s, background 0.2s;
}
.room-card:hover {
border-color: #555;
background: #2a2a2a;
}
.room-info-left {
display: flex;
flex-direction: column;
gap: 4px;
text-align: left;
}
.room-title-link {
font-size: 1.15em;
font-weight: bold;
color: #fff;
text-decoration: none;
cursor: pointer;
transition: color 0.2s;
}
.room-title-link:hover {
color: #79c4ff;
}
.room-badges {
display: flex;
gap: 8px;
font-size: 0.85em;
align-items: center;
}
.badge-encrypted {
color: #81c784;
}
.badge-watchparty {
color: #ffb74d;
}
.empty-state {
text-align: center;
color: #888;
padding: 30px 15px;
font-style: italic;
line-height: 1.6;
}
.form-card {
background: #2a2a2a;
border: 1px solid #444;
border-radius: 8px;
padding: 20px;
margin-top: 25px;
}
.form-card h2 {
margin-top: 0;
font-size: 1.25em;
}
.form-subtitle {
font-size: 0.9em;
color: #aaa;
margin-top: -5px;
margin-bottom: 15px;
}
.checkbox-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 15px;
font-size: 0.95em;
color: #ccc;
cursor: pointer;
}
.checkbox-row input {
cursor: pointer;
width: auto;
margin-bottom: 0;
}
#create-room-btn {
width: 100%;
}
/* === Password Join Modal === */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.75);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-box {
background: #2a2a2a;
border: 1px solid #444;
border-radius: 8px;
padding: 24px 30px;
max-width: 440px;
width: 90%;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6);
text-align: center;
}
.modal-buttons {
display: flex;
justify-content: center;
gap: 10px;
margin-top: 15px;
}
.cancel-btn {
background-color: #555 !important;
}
.cancel-btn:hover {
background-color: #666 !important;
}

View file

@ -76,16 +76,14 @@ async function loadRooms() {
const titleGroup = document.createElement('div'); const titleGroup = document.createElement('div');
const link = document.createElement('a'); const link = document.createElement('a');
link.href = r.type === 'watchparty' link.href = `/chat.html?room=${encodeURIComponent(r.name)}${r.type === 'watchparty' ? '&watchparty=1' : ''}`;
? `/watchparty_chat.html?room=${encodeURIComponent(r.name)}`
: `/chat.html?room=${encodeURIComponent(r.name)}`;
link.target = "_blank"; link.target = "_blank";
link.textContent = r.name; link.textContent = r.name;
link.style.fontWeight = 'bold'; link.style.fontWeight = 'bold';
link.style.fontSize = '1.1em'; link.style.fontSize = '1.1em';
const typeSpan = document.createElement('span'); const typeSpan = document.createElement('span');
typeSpan.textContent = ` (${r.type} | ${r.is_public ? 'Public' : 'Unlisted'})`; typeSpan.textContent = ` (${r.type} | Encrypted On-Demand)`;
typeSpan.style.color = '#888'; typeSpan.style.color = '#888';
typeSpan.style.fontSize = '0.95em'; typeSpan.style.fontSize = '0.95em';
typeSpan.style.marginLeft = '5px'; typeSpan.style.marginLeft = '5px';
@ -414,7 +412,11 @@ async function viewTranscript(room) {
} else { } else {
contentDiv.textContent = msgs.map(m => { contentDiv.textContent = msgs.map(m => {
const timeStr = new Date(m.timestamp).toLocaleTimeString(); const timeStr = new Date(m.timestamp).toLocaleTimeString();
return `[${timeStr}] ${m.username}: ${m.text}`; if (m.system_notice) {
return `[${timeStr}] SYSTEM: ${m.system_notice}`;
}
const snippet = m.ciphertext ? `${m.ciphertext.substring(0, 32)}...` : 'empty';
return `[${timeStr}] ${m.username}: [🔒 Encrypted Payload: ${snippet}]`;
}).join('\n'); }).join('\n');
} }
} }

File diff suppressed because it is too large Load diff

View file

@ -1,25 +1,28 @@
/**
* Transient.chat - Encryption First Engine
* Zero-Knowledge End-to-End Encryption (E2EE)
* All chat payloads (text & voice audio) are encrypted client-side using AES-256-GCM.
* Server memory stores strictly ephemeral encrypted ciphertext and IVs.
*/
(function() { (function() {
let keyring = {}; // { keyId: { passphrase, key } } // Helper: string to ArrayBuffer (UTF-8)
let activeKeyId = null;
// Helper: string to array buffer
function strToBuf(str) { function strToBuf(str) {
return new TextEncoder().encode(str); return new TextEncoder().encode(str);
} }
// Helper: array buffer to string // Helper: ArrayBuffer to string (UTF-8)
function bufToStr(buf) { function bufToStr(buf) {
return new TextDecoder().decode(buf); return new TextDecoder().decode(buf);
} }
// Helper: array buffer to hex string // Helper: ArrayBuffer to hex string
function bufToHex(buf) { function bufToHex(buf) {
return Array.from(new Uint8Array(buf)) return Array.from(new Uint8Array(buf))
.map(b => b.toString(16).padStart(2, '0')) .map(b => b.toString(16).padStart(2, '0'))
.join(''); .join('');
} }
// Helper: hex string to array buffer // Helper: hex string to ArrayBuffer
function hexToBuf(hex) { function hexToBuf(hex) {
if (!hex) return new Uint8Array(0).buffer; if (!hex) return new Uint8Array(0).buffer;
const bytes = new Uint8Array(hex.length / 2); const bytes = new Uint8Array(hex.length / 2);
@ -29,18 +32,13 @@
return bytes.buffer; return bytes.buffer;
} }
// Short hash of passphrase to identify the key (non-reversible identifier) /**
async function computeKeyId(passphrase) { * Derives a 256-bit AES-GCM key from the room password and room name.
const msgUint8 = strToBuf(passphrase); * Uses PBKDF2 with SHA-256 and 100,000 iterations with a room-bound salt.
const hashBuffer = await window.crypto.subtle.digest('SHA-256', msgUint8); */
const hashArray = Array.from(new Uint8Array(hashBuffer)); window.deriveRoomKey = async function(password, roomName) {
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('').substring(0, 8); const passwordBuffer = strToBuf(password);
} const saltBuffer = strToBuf("transient.chat.room." + roomName);
// Derive PBKDF2 key from passphrase
async function deriveKey(passphrase, roomName) {
const passwordBuffer = strToBuf(passphrase);
const saltBuffer = strToBuf("transient.chat.salt." + roomName);
const baseKey = await window.crypto.subtle.importKey( const baseKey = await window.crypto.subtle.importKey(
"raw", "raw",
@ -62,90 +60,38 @@
false, false,
["encrypt", "decrypt"] ["encrypt", "decrypt"]
); );
}
// Add a passphrase to the keyring
window.addKeyToRing = async function(passphrase, roomName) {
try {
const keyId = await computeKeyId(passphrase);
const cryptoKey = await deriveKey(passphrase, roomName);
keyring[keyId] = {
passphrase: passphrase,
key: cryptoKey
};
if (!activeKeyId) {
activeKeyId = keyId;
}
return keyId;
} catch (e) {
console.error("Failed to add key to ring:", e);
return null;
}
}; };
window.removeKeyFromRing = function(keyId) { /**
delete keyring[keyId]; * Encrypts a message payload object (text + optional audio) using the derived room key.
if (activeKeyId === keyId) { * Returns hex-encoded ciphertext and 96-bit (12-byte) initialization vector (IV).
const remaining = Object.keys(keyring); */
activeKeyId = remaining.length > 0 ? remaining[0] : null; window.encryptRoomPayload = async function(cryptoKey, payloadObj) {
} const jsonStr = JSON.stringify(payloadObj);
};
window.setActiveKey = function(keyId) {
if (keyring[keyId]) {
activeKeyId = keyId;
return true;
}
return false;
};
window.getActiveKeyId = function() {
return activeKeyId;
};
window.getKeyringIds = function() {
return Object.keys(keyring);
};
window.getKeyring = function() {
// Return passive list of { keyId, passphrase }
const list = [];
for (const [id, data] of Object.entries(keyring)) {
list.push({ keyId: id, passphrase: data.passphrase });
}
return list;
};
window.isCryptoEnabled = function() {
return activeKeyId !== null;
};
window.encryptData = async function(plaintext, keyId = activeKeyId) {
if (!keyId || !keyring[keyId]) {
throw new Error("Key not found in keyring");
}
const iv = window.crypto.getRandomValues(new Uint8Array(12)); const iv = window.crypto.getRandomValues(new Uint8Array(12));
const encrypted = await window.crypto.subtle.encrypt(
const encryptedBuffer = await window.crypto.subtle.encrypt(
{ name: "AES-GCM", iv: iv }, { name: "AES-GCM", iv: iv },
keyring[keyId].key, cryptoKey,
strToBuf(plaintext) strToBuf(jsonStr)
); );
return { return {
ciphertext: bufToHex(encrypted), ciphertext: bufToHex(encryptedBuffer),
iv: bufToHex(iv), iv: bufToHex(iv)
keyId: keyId
}; };
}; };
window.decryptData = async function(ciphertextHex, ivHex, keyId) { /**
if (!keyId || !keyring[keyId]) { * Decrypts ciphertext and IV hex strings back to the original payload object.
throw new Error("Key not found in keyring"); */
} window.decryptRoomPayload = async function(cryptoKey, ciphertextHex, ivHex) {
const decrypted = await window.crypto.subtle.decrypt( const decryptedBuffer = await window.crypto.subtle.decrypt(
{ name: "AES-GCM", iv: hexToBuf(ivHex) }, { name: "AES-GCM", iv: hexToBuf(ivHex) },
keyring[keyId].key, cryptoKey,
hexToBuf(ciphertextHex) hexToBuf(ciphertextHex)
); );
return bufToStr(decrypted);
return JSON.parse(bufToStr(decryptedBuffer));
}; };
})(); })();

View file

@ -4,15 +4,14 @@ document.addEventListener('DOMContentLoaded', async () => {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const room = params.get('room'); const room = params.get('room');
const isWatchParty = params.has('watchparty'); // Use explicit watchparty URL flag const isWatchParty = params.has('watchparty');
function redirectToRoom(roomName) { function redirectToRoom(roomName) {
if (!roomName) { if (!roomName) {
window.location.href = '/server_list.html'; window.location.href = '/server_list.html';
} else { } else {
window.location.href = isWatchParty const wpParam = isWatchParty ? '&watchparty=1' : '';
? `/watchparty_chat.html?room=${encodeURIComponent(roomName)}` window.location.href = `/chat.html?room=${encodeURIComponent(roomName)}${wpParam}`;
: `/chat.html?room=${encodeURIComponent(roomName)}`;
} }
} }
@ -29,7 +28,6 @@ document.addEventListener('DOMContentLoaded', async () => {
redirectToRoom(room); redirectToRoom(room);
return; return;
} else { } else {
// Session expired or invalid; clear storage but pre-fill username
localStorage.clear(); localStorage.clear();
const input = document.getElementById('username-input'); const input = document.getElementById('username-input');
if (input) input.value = storedUsername; if (input) input.value = storedUsername;
@ -56,7 +54,7 @@ document.addEventListener('DOMContentLoaded', async () => {
const username = usernameInput.value.trim(); const username = usernameInput.value.trim();
if (!username) { if (!username) {
alert('Please enter a username.'); alert('Please enter a handle/username.');
return; return;
} }
@ -80,7 +78,7 @@ document.addEventListener('DOMContentLoaded', async () => {
redirectToRoom(room); redirectToRoom(room);
} catch (error) { } catch (error) {
console.error('Registration error:', error); console.error('Registration error:', error);
alert('An error occurred.'); alert('An error occurred during registration.');
} }
}); });
} }

View file

@ -35,23 +35,14 @@ document.addEventListener("DOMContentLoaded", async () => {
.replace(/'/g, "&#039;"); .replace(/'/g, "&#039;");
} }
// User header bar
const userInfoEl = document.getElementById('user-info'); const userInfoEl = document.getElementById('user-info');
userInfoEl.style.display = 'flex';
userInfoEl.style.alignItems = 'center';
userInfoEl.style.justifyContent = 'flex-start';
userInfoEl.style.gap = '15px';
userInfoEl.style.marginBottom = '20px';
const logoutBtn = document.createElement('button'); const logoutBtn = document.createElement('button');
logoutBtn.textContent = 'Logout'; logoutBtn.textContent = 'Logout';
logoutBtn.style.backgroundColor = '#d9534f'; logoutBtn.className = 'logout-btn';
logoutBtn.style.color = 'white';
logoutBtn.style.border = 'none';
logoutBtn.style.padding = '6px 12px';
logoutBtn.style.borderRadius = '4px';
logoutBtn.style.cursor = 'pointer';
logoutBtn.onclick = async () => { logoutBtn.onclick = async () => {
if (confirm("Are you sure you want to logout? This will release your username immediately.")) { if (confirm("Are you sure you want to logout? This releases your username.")) {
try { try {
await fetch('/logout', { await fetch('/logout', {
method: 'POST', method: 'POST',
@ -62,6 +53,7 @@ document.addEventListener("DOMContentLoaded", async () => {
console.error("Logout request failed:", e); console.error("Logout request failed:", e);
} }
localStorage.clear(); localStorage.clear();
sessionStorage.clear();
window.location.href = '/'; window.location.href = '/';
} }
}; };
@ -71,163 +63,197 @@ document.addEventListener("DOMContentLoaded", async () => {
userInfoEl.append(logoutBtn, userSpan); userInfoEl.append(logoutBtn, userSpan);
document.querySelectorAll('.collapsible').forEach(heading => { const activeRoomsContainer = document.getElementById('active-rooms-container');
heading.addEventListener('click', () => { const statsBadge = document.getElementById('stats-badge');
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") // Modal elements
.then(response => response.json()) const joinModal = document.getElementById('join-modal');
.then(data => { const joinModalForm = document.getElementById('join-modal-form');
const { regional, topical, public: publicRooms, unique_users } = data; const modalRoomTitle = document.getElementById('modal-room-title');
const modalPasswordInput = document.getElementById('modal-room-password');
const modalCancelBtn = document.getElementById('modal-cancel-btn');
const regionalList = document.getElementById('regional-rooms'); let pendingJoinRoom = null;
const topicalList = document.getElementById('topical-rooms'); let pendingJoinHasWP = false;
const publicList = document.getElementById('public-rooms');
const watchpartyList = document.getElementById('watchparty-rooms');
// Helper to create a room <li> function openJoinModal(roomName, hasWP) {
function createRoomItem({ name, users, type }) { pendingJoinRoom = roomName;
const li = document.createElement('li'); pendingJoinHasWP = hasWP;
const a = document.createElement('a'); modalRoomTitle.textContent = `🔒 Join "${roomName}"`;
const isWatchParty = type && type.startsWith('watchparty'); modalPasswordInput.value = '';
a.href = isWatchParty joinModal.classList.remove('hidden');
? `/watchparty_chat.html?room=${encodeURIComponent(name)}` modalPasswordInput.focus();
: `/chat.html?room=${encodeURIComponent(name)}`;
a.textContent = `${name} ( ${users} )`;
li.appendChild(a);
return { li, isWatchParty };
} }
// Add regional rooms function closeJoinModal() {
regional.forEach(room => { joinModal.classList.add('hidden');
regionalList.appendChild(createRoomItem(room).li); pendingJoinRoom = null;
}
if (modalCancelBtn) {
modalCancelBtn.addEventListener('click', closeJoinModal);
}
if (joinModalForm) {
joinModalForm.addEventListener('submit', async (e) => {
e.preventDefault();
const password = modalPasswordInput.value.trim();
if (!password || !pendingJoinRoom) return;
const joinBtn = document.getElementById('modal-join-btn');
if (joinBtn) joinBtn.disabled = true;
try {
const res = await fetch('/rooms/join', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
room: pendingJoinRoom,
password: password,
username: username,
key: userKey
})
}); });
// Add topical rooms if (joinBtn) joinBtn.disabled = false;
topical.forEach(room => {
topicalList.appendChild(createRoomItem(room).li);
});
// Add public/watchparty rooms if (!res.ok) {
publicRooms.forEach(room => { const err = await res.json().catch(() => ({}));
const { li, isWatchParty } = createRoomItem(room); alert(err.error || 'Incorrect room password.');
if (isWatchParty) { modalPasswordInput.value = '';
watchpartyList.appendChild(li); modalPasswordInput.focus();
return;
}
// Success
sessionStorage.setItem(`room_password_${pendingJoinRoom}`, password);
const wpParam = pendingJoinHasWP ? '&watchparty=1' : '';
window.location.href = `/chat.html?room=${encodeURIComponent(pendingJoinRoom)}${wpParam}`;
} catch (err) {
if (joinBtn) joinBtn.disabled = false;
console.error('Join error:', err);
alert('Network error while joining room.');
}
});
}
async function loadActiveRooms() {
try {
const res = await fetch('/rooms');
if (!res.ok) throw new Error('Failed to fetch rooms');
const data = await res.json();
const rooms = data.active_rooms || [];
const uniqueUsers = data.unique_users || 0;
statsBadge.innerHTML = `🟢 Active Rooms: <strong>${rooms.length}</strong> &nbsp;•&nbsp; 👥 Online Users: <strong>${uniqueUsers}</strong>`;
activeRoomsContainer.innerHTML = '';
if (rooms.length === 0) {
activeRoomsContainer.innerHTML = `
<div class="empty-state">
No active rooms right now.<br>Create an on-demand room below to start chatting!
</div>
`;
return;
}
rooms.forEach(room => {
const card = document.createElement('div');
card.className = 'room-card';
const left = document.createElement('div');
left.className = 'room-info-left';
const titleLink = document.createElement('span');
titleLink.className = 'room-title-link';
titleLink.textContent = room.name;
const badges = document.createElement('div');
badges.className = 'room-badges';
badges.innerHTML = `
<span>👥 ${room.users} user${room.users === 1 ? '' : 's'}</span>
<span class="badge-encrypted">🔒 Encrypted</span>
${room.has_watchparty ? '<span class="badge-watchparty">🎬 Watch Party</span>' : ''}
`;
left.append(titleLink, badges);
const joinBtn = document.createElement('button');
joinBtn.textContent = 'Join Room';
const handleJoin = () => {
const storedPass = sessionStorage.getItem(`room_password_${room.name}`);
if (storedPass) {
const wpParam = room.has_watchparty ? '&watchparty=1' : '';
window.location.href = `/chat.html?room=${encodeURIComponent(room.name)}${wpParam}`;
} else { } else {
publicList.appendChild(li); openJoinModal(room.name, room.has_watchparty);
} }
};
titleLink.addEventListener('click', handleJoin);
joinBtn.addEventListener('click', handleJoin);
card.append(left, joinBtn);
activeRoomsContainer.appendChild(card);
}); });
} catch (err) {
// Show total unique user count console.error('Failed to load active rooms:', err);
document.getElementById('user-count').textContent = `Users Chatting ( ${unique_users} )`; statsBadge.textContent = 'Failed to load rooms';
}
// 🧠 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 => { await loadActiveRooms();
activeList.appendChild(createActiveRoomItem(room)); setInterval(loadActiveRooms, 3000); // Poll active rooms every 3 seconds
count++;
});
// Update Active Rooms heading with count // On-Demand Room Creation Form
const activeHeadingEl = document.getElementById("active-rooms-header"); const createRoomForm = document.getElementById('create-room-form');
if (activeHeadingEl) { if (createRoomForm) {
const baseText = activeHeadingEl.textContent.replace(/▶|▼|\(\d+\)/g, "").trim(); createRoomForm.addEventListener('submit', async (e) => {
activeHeadingEl.textContent = `▶ ${baseText} ( ${count} )`; e.preventDefault();
}
})
.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(); const roomName = document.getElementById('new-room-name').value.trim();
if (!roomName) return alert('Room name cannot be empty.'); const password = document.getElementById('new-room-password').value.trim();
const isWatchParty = document.getElementById('new-room-watchparty').checked;
fetch('/rooms', { if (!roomName || !password) {
alert('Please enter both a room name and password.');
return;
}
const createBtn = document.getElementById('create-room-btn');
createBtn.disabled = true;
try {
const res = await fetch('/rooms/join', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: roomName }) body: JSON.stringify({
room: roomName,
password: password,
username: username,
key: userKey
}) })
.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', () => { createBtn.disabled = false;
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 if (!res.ok) {
document.getElementById('create-watchparty-btn').addEventListener('click', () => { const err = await res.json().catch(() => ({}));
const roomName = document.getElementById('new-room-name').value.trim(); alert(err.error || 'Failed to create room.');
if (!roomName) return alert('Room name cannot be empty.'); return;
}
fetch('/rooms', { // Store password in session
method: 'POST', sessionStorage.setItem(`room_password_${roomName}`, password);
headers: { 'Content-Type': 'application/json' }, const wpParam = isWatchParty ? '&watchparty=1' : '';
body: JSON.stringify({ name: roomName }) window.location.href = `/chat.html?room=${encodeURIComponent(roomName)}${wpParam}`;
}) } catch (err) {
.then(response => { createBtn.disabled = false;
if (!response.ok) throw new Error('Watch party creation failed.'); console.error('Room creation error:', err);
return response.json(); alert('Network error while creating room.');
})
.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

@ -1,224 +0,0 @@
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();
});

View file

@ -3,14 +3,47 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Transient.chat - Chat Room</title> <title>Transient.chat - Private Encrypted Chat</title>
<link rel="stylesheet" href="/assets/css/styles.css" /> <link rel="stylesheet" href="/assets/css/styles.css" />
<link rel="stylesheet" href="/assets/css/chat.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" /> <link rel="icon" href="/assets/images/favicon.ico" type="image/x-icon" />
</head> </head>
<body> <body>
<h1 id="room-title">Chat Room</h1> <h1 id="room-title">Chat Room</h1>
<div id="page-container"> <div id="page-container">
<!-- Collapsible Watch Party Video Section -->
<div id="video-section" class="hidden">
<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"
alt="Video Paused"
/>
</div>
</div>
<!-- Main Chat Center -->
<div id="chat-center"> <div id="chat-center">
<div id="messages"></div> <div id="messages"></div>
<form id="chat-form"> <form id="chat-form">
@ -18,13 +51,15 @@
type="text" type="text"
class="inputfield" class="inputfield"
id="message-input" id="message-input"
placeholder="Type a message, paste in a link, or url to an image or youtube video." placeholder="Type a message, paste a link, or send an image/video URL."
autocomplete="off" autocomplete="off"
required required
/> />
<button type="submit">Send</button> <button type="submit">Send</button>
</form> </form>
</div> </div>
<!-- Sidebar Controls & Users -->
<div id="user-list-container"> <div id="user-list-container">
<a <a
href="server_list.html" href="server_list.html"
@ -61,15 +96,44 @@
🔒 External Content: Off 🔒 External Content: Off
</button> </button>
<br /> <br />
<div id="encryption-container" style="margin-bottom: 15px; border: 1px solid #444; padding: 10px; border-radius: 5px; background: rgba(33, 150, 243, 0.1); text-align: left;"> <button
<strong style="display: block; margin-bottom: 5px; font-size: 0.9em;">🔒 Encryption Keyring</strong> id="watchparty-toggle"
<div id="keyring-list" style="margin-bottom: 8px; font-size: 0.85em; line-height: 1.4;"></div> style="
<button id="add-key-btn" style="width: 100%; background: #2196f3; color: white; border: none; padding: 5px; border-radius: 3px; cursor: pointer; font-size: 0.85em;">+ Add Passphrase</button> margin-bottom: 10px;
</div> text-decoration: none;
display: inline-block;
text-align: left;
"
>
🎬 Watch Party: Off
</button>
<h3>Users in room</h3> <h3>Users in room</h3>
<div id="user-list"></div> <div id="user-list"></div>
</div> </div>
</div> </div>
<!-- Password Modal for Room Access & Key Derivation -->
<div id="password-modal" class="modal-overlay hidden">
<div class="modal-box">
<h3>🔒 Room Password Required</h3>
<p>This room is private and encrypted. Enter the room password to join and decrypt messages:</p>
<form id="password-form">
<input
type="password"
id="modal-password-input"
placeholder="Room Password"
autocomplete="current-password"
required
/>
<div class="modal-buttons">
<button type="submit" id="modal-submit-btn">Enter Room</button>
<a href="/server_list.html"><button type="button" class="cancel-btn">Back to Server List</button></a>
</div>
</form>
</div>
</div>
<script src="/assets/js/crypto.js"></script> <script src="/assets/js/crypto.js"></script>
<script src="/assets/js/chat.js"></script> <script src="/assets/js/chat.js"></script>
</body> </body>

View file

@ -6,7 +6,7 @@
<title>FAQ - Transient.chat</title> <title>FAQ - Transient.chat</title>
<meta <meta
name="description" name="description"
content="Frequently asked questions about using Transient.chat" content="Frequently asked questions about Transient.chat's encrypted, on-demand ephemeral messaging."
/> />
<link rel="stylesheet" href="/assets/css/styles.css" /> <link rel="stylesheet" href="/assets/css/styles.css" />
<link rel="icon" href="/assets/images/favicon.ico" type="image/x-icon" /> <link rel="icon" href="/assets/images/favicon.ico" type="image/x-icon" />
@ -24,56 +24,62 @@
<h1>Frequently Asked Questions</h1> <h1>Frequently Asked Questions</h1>
</div> </div>
<h2>How long do messages last?</h2> <h2>How does encryption work?</h2>
<p>All chat messages automatically disappear after 1 hour.</p>
<h2>How long do rooms last?</h2>
<p> <p>
All chat rooms automatically disappear after all users leave the room. Transient.chat is built with an <strong>encryption-first</strong> approach.
When you enter or create a room, your browser derives a 256-bit AES-GCM encryption key from your room password using PBKDF2 (100,000 rounds of SHA-256 and a room-unique salt).
All text messages and voice notes are encrypted right inside your browser before transmission.
There is no complicated keyring interface—your room password does everything automatically.
</p> </p>
<h2>Do I need an account to use this?</h2> <h2>Are all messages encrypted in server memory?</h2>
<p> <p>
No. Just pick a handle and start chatting. If you don't come back in 3 Yes. The server only holds ephemeral encrypted ciphertext and initialization vectors (IVs) in volatile RAM.
days your handle is released. The server never has access to the plaintext messages or audio notes.
Nothing is ever saved to a database or written to disk.
</p> </p>
<h2>Is anything stored permanently?</h2> <h2>Do all rooms require passwords?</h2>
<p> <p>
No databases or disk files are used for chat messages or media—everything exists temporarily in memory and vanishes within an hour. We persist only basic session configuration, ban records, and room owner mappings using a lightweight local database so they survive server updates. Yes. We use a private, on-demand room model. Every room requires a password to create or join.
Only people with the room password can enter the room and decrypt its messages.
</p> </p>
<h2>Is my chat secure / private?</h2> <h2>How does the Server Browser work?</h2>
<p> <p>
Yes! We support <strong>Zero-Knowledge End-to-End Encryption (E2EE)</strong>. The server browser displays only <strong>active rooms</strong> currently occupied by chatting users.
You can enable encryption for any room by clicking <strong>+ Add Passphrase</strong> in the sidebar and entering a passphrase. Empty rooms do not clutter the list. You can join any active room (with its password) or spin up a new room on demand.
Encryption keys are derived locally in your browser using PBKDF2 and AES-GCM.
The passphrases reside strictly in your browser's session memory and are <em>never</em> sent to the server.
Anyone without the correct passphrase will only see a placeholder indicating the message is encrypted, with a quick link to add the passphrase to their keyring if they know it.
</p> </p>
<h2>What is a Watchroom?</h2> <h2>Can any room be a Watch Party?</h2>
<p> <p>
A Watchroom lets you sync YouTube playback with others while chatting. Yes! Every room has an integrated Watch Party feature that can be toggled on or off at any time.
Everyone sees the same video at the same time. When active, you can synchronize YouTube video playback with everyone in the room in real time.
</p>
<h2>How long do messages and rooms last?</h2>
<p>
All messages automatically expire and vanish after <strong>1 hour</strong>.
When all users leave a room, the room itself, its password, video state, and all remaining messages are immediately purged from server memory.
</p> </p>
<h2>Can I send voice messages?</h2> <h2>Can I send voice messages?</h2>
<p> <p>
Yes. You can record short voice clips (up to 15 seconds) by holding the microphone button. Yes. Hold down the microphone button to record encrypted audio messages (up to 15 seconds).
Enable <strong>Autoplay</strong> in the sidebar to hear messages automatically as they arrive. You can enable <strong>Autoplay</strong> in the sidebar to hear new voice notes automatically as they arrive.
</p> </p>
<h2>What is External Content?</h2> <h2>Do I need to create an account?</h2>
<p> <p>
External images and videos linked by other users. You can toggle <strong>External Content</strong> on to automatically show embedded media. No accounts, no email addresses, no phone numbers. Simply pick a handle and start chatting.
If you are inactive for 3 days, your handle is released.
</p> </p>
<h2>Can users be removed?</h2> <h2>Where can I inspect the code?</h2>
<p>Yes. Room owners can kick users. Admins can ban users by IP.</p> <p>
Transient.chat is 100% open source. Check out the source code at:<br />
<h2>Where can I find the source code?</h2> <a href="https://forgejo.benjimanmiller.com/ben/transient.chat">https://forgejo.benjimanmiller.com/ben/transient.chat</a>
<p><a href="https://forgejo.benjimanmiller.com/ben/transient.chat">https://forgejo.benjimanmiller.com/ben/transient.chat</a></p> </p>
<br /><br /> <br /><br />
<a href="index.html">← Back to Home</a> <a href="index.html">← Back to Home</a>

View file

@ -3,10 +3,10 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Transient.chat</title> <title>Transient.chat - Private, Encrypted, Ephemeral Chat</title>
<meta <meta
name="description" name="description"
content="Live chats that vanish. No data. No history. Pure ephemerality." content="Live encrypted chats on demand. Zero logs, zero disk storage, 100% ephemeral."
/> />
<link rel="stylesheet" href="/assets/css/styles.css" /> <link rel="stylesheet" href="/assets/css/styles.css" />
<link rel="icon" href="/assets/images/favicon.ico" type="image/x-icon" /> <link rel="icon" href="/assets/images/favicon.ico" type="image/x-icon" />
@ -25,26 +25,26 @@
<h1>Chat without a trace!</h1> <h1>Chat without a trace!</h1>
</div> </div>
<p> <p>
Real-time conversations that disappear after Real-time encrypted conversations that disappear after
<strong>1 hour</strong>.<br /> <strong>1 hour</strong>.<br />
No accounts. No data trails. Nothing is saved... ever.<br /> No accounts. No logs. No trackers. Nothing is saved... ever.<br />
Everything is held in server memory and is never written to any disk or All messages are encrypted in memory on the server and never written to disk or database.<br /><br />
database. <br /><br />
Join a room and talk freely. When the room empties, everything <strong>🔐 Encryption First & On-Demand Rooms:</strong> All rooms are created on demand and protected by passwords.
vanishes.<br /> Your room password automatically encrypts all messages and audio in your browser using AES-256-GCM. No confusing keys interface needed.<br /><br />
Want to sync up a video? Try a <strong>Watchroom</strong> and chat while
watching YouTube together.<br /><br /> <strong>🎬 Integrated Watch Party:</strong> Toggle YouTube synchronized playback on and off in any room at any time.<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>End-to-End Encryption:</strong> Encrypt your chat rooms locally with passphrases. Keys remain strictly in your browser and are never sent to the server.<br /><br /> <strong>🎤 Ephemeral Voice Notes:</strong> Hold the microphone button to record encrypted audio clips. Turn on 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 <strong>🌪️ 100% Ephemeral Lifecycle:</strong> When everyone leaves a room, the room, its password, and its messages vanish completely from memory.<br /><br />
place.
<a href="faq.html">Learn More →</a> <a href="faq.html">Read the FAQ & Learn More →</a>
</p> </p>
</div> </div>
<div id="login-container"> <div id="login-container">
<input type="text" id="username-input" placeholder="Enter Your Handle" /> <input type="text" id="username-input" placeholder="Choose a Handle" autocomplete="username" />
<br /> <br />
<br /> <br />
<button id="start-chat-button">Start Chatting!</button> <button id="start-chat-button">Start Chatting!</button>
@ -52,8 +52,7 @@
<div id="info-text"> <div id="info-text">
<p> <p>
Don't trust me? I don't blame you. Review the source and host your own Don't trust me? I don't blame you. Review the open source code and host your own server!<br />
server!<br />
<a href="https://forgejo.benjimanmiller.com/ben/transient.chat"> <a href="https://forgejo.benjimanmiller.com/ben/transient.chat">
https://forgejo.benjimanmiller.com/ben/transient.chat https://forgejo.benjimanmiller.com/ben/transient.chat
</a> </a>

View file

@ -3,8 +3,9 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Transient.chat - Server List</title> <title>Transient.chat - Active Rooms</title>
<link rel="stylesheet" href="/assets/css/styles.css" /> <link rel="stylesheet" href="/assets/css/styles.css" />
<link rel="stylesheet" href="/assets/css/server_list.css" />
<link rel="icon" href="/assets/images/favicon.ico" type="image/x-icon" /> <link rel="icon" href="/assets/images/favicon.ico" type="image/x-icon" />
</head> </head>
<body> <body>
@ -12,61 +13,68 @@
<a href="index.html"> <a href="index.html">
<img src="/assets/images/transientchat-blue.png" alt="Transient.chat" /> <img src="/assets/images/transientchat-blue.png" alt="Transient.chat" />
</a> </a>
<h1>Server List</h1> <h1>Server Browser</h1>
</div> </div>
<div id="user-info"></div> <div id="user-info"></div>
<div id="user-count" style="margin-top: 8px; font-weight: bold"></div> <div id="server-browser-container">
<div id="stats-badge" class="stats-badge">
Loading active rooms...
</div>
<div id="chat-rooms"> <h2>Active Rooms</h2>
<h2 class="collapsible">▶ US Regional Rooms</h2> <div id="active-rooms-container"></div>
<ul
id="regional-rooms"
class="collapsible-content"
style="display: none"
></ul>
<h2 class="collapsible">▶ Topical Rooms</h2> <div class="form-card">
<ul <h2>Create or Join On-Demand Room</h2>
id="topical-rooms" <p class="form-subtitle">
class="collapsible-content" All rooms are private, password-protected, and end-to-end encrypted.
style="display: none" </p>
></ul> <form id="create-room-form">
<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 <input
type="text" type="text"
id="new-room-name" id="new-room-name"
placeholder="Enter New Room Name" placeholder="Room Name"
autocomplete="off"
required
/> />
<br /> <input
<div class="button-row"> type="password"
<button id="create-room-btn">Create Public Room</button> id="new-room-password"
<button id="create-unlisted-room-btn">Create Unlisted Room</button> placeholder="Room Password (required for encryption)"
<button id="create-watchparty-btn">Create Watch Party</button> autocomplete="new-password"
required
/>
<label class="checkbox-row">
<input type="checkbox" id="new-room-watchparty" />
<span>Enable Watch Party mode immediately</span>
</label>
<button type="submit" id="create-room-btn">
Enter Room
</button>
</form>
</div>
</div>
<!-- Password Prompt Modal for Joining an Active Room -->
<div id="join-modal" class="modal-overlay hidden">
<div class="modal-box">
<h3 id="modal-room-title">🔒 Enter Room Password</h3>
<p>This room is private and encrypted. Enter the password to join:</p>
<form id="join-modal-form">
<input
type="password"
id="modal-room-password"
placeholder="Password"
autocomplete="current-password"
required
/>
<div class="modal-buttons">
<button type="submit" id="modal-join-btn">Join</button>
<button type="button" class="cancel-btn" id="modal-cancel-btn">Cancel</button>
</div>
</form>
</div> </div>
</div> </div>

View file

@ -1,114 +0,0 @@
<!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>
<div id="encryption-container" style="margin-bottom: 15px; border: 1px solid #444; padding: 10px; border-radius: 5px; background: rgba(33, 150, 243, 0.1); text-align: left;">
<strong style="display: block; margin-bottom: 5px; font-size: 0.9em;">🔒 Encryption Keyring</strong>
<div id="keyring-list" style="margin-bottom: 8px; font-size: 0.85em; line-height: 1.4;"></div>
<button id="add-key-btn" style="width: 100%; background: #2196f3; color: white; border: none; padding: 5px; border-radius: 3px; cursor: pointer; font-size: 0.85em;">+ Add Passphrase</button>
</div>
<h3>Users in room</h3>
<div id="user-list"></div>
</div>
</div>
<script src="/assets/js/crypto.js"></script>
<script src="/assets/js/chat.js"></script>
<script src="/assets/js/watchparty_chat.js"></script>
</body>
</html>