main
This commit is contained in:
commit
9611103b27
39 changed files with 3312 additions and 0 deletions
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# Python cache and compiled files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
|
||||
# Build artifacts
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
|
||||
# Vendor or third-party libraries
|
||||
/vendor/
|
||||
|
||||
# Editor/IDE settings
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Notes or temp folders
|
||||
/notes/
|
||||
50
GEMINI.md
Normal file
50
GEMINI.md
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# Transient Chat Codebase Analysis & Implementation Guide
|
||||
|
||||
**Audience:** AI Engineering Agent
|
||||
|
||||
This document outlines the current state of the `transient.chat` project, detailing architectural issues, security vulnerabilities, and recommended features. It serves as a roadmap for subsequent implementation tasks.
|
||||
|
||||
---
|
||||
|
||||
## 0. Permanent Project Rules
|
||||
*These rules must be followed for all future modifications.*
|
||||
- **CSS Strategy:** Always use Vanilla CSS; never introduce Tailwind or other heavy CSS frameworks.
|
||||
- **Python Conventions:** All new Python functions must have type hints.
|
||||
- **Dependency Management:** Minimize external dependencies. When features are requested, check if standard libraries can be used first.
|
||||
- **Architectural Scope:** The application must remain lightweight and stateless (in-memory) with no persistent databases unless explicitly approved by the user.
|
||||
|
||||
---
|
||||
|
||||
## 1. Architectural Issues & Bugs
|
||||
*(No pending issues in this category at this time)*
|
||||
|
||||
---
|
||||
|
||||
## 2. Low-Hanging Fruit Features
|
||||
|
||||
### Feature 2.1: WebSockets for Real-Time Communication
|
||||
**Description:** The application currently uses HTTP polling for chat updates and watchparty video synchronization, which is inefficient. Upgrading to WebSockets will provide a true real-time experience and reduce server overhead.
|
||||
**Implementation Plan:**
|
||||
1. **Core Setup:** Add `Flask-SocketIO` to the project dependencies and initialize SocketIO in `app.py`.
|
||||
2. **Text & Audio Messages:** Refactor the polling routes in `blueprints/chat.py` to use WebSocket event handlers (e.g., `@socketio.on('message')`, `@socketio.on('join')`). The application currently supports audio messages (base64 `webm` blobs sent via the `audio` field). **Crucially:** Ensure the WebSocket payload for messages continues to accept and broadcast this `audio` field so voice notes continue to function seamlessly.
|
||||
3. **Watchparty Synchronization:** Watchparties currently poll `/watchparty/<room>/video` to stay in sync. Refactor `blueprints/watchparty.py` and `static/assets/js/watchparty_chat.js` to broadcast video events over WebSockets (e.g., `video_play`, `video_pause`, `video_sync`, `video_clear`). This will replace the interval-based polling and provide instantaneous playback synchronization for all users in the room.
|
||||
4. **Frontend Updates:** Update the frontend JavaScript files in `static/assets/js/` (specifically `chat.js` and `watchparty_chat.js`) to connect via Socket.IO clients, emit messages/video-controls, and listen for incoming events instead of using `setInterval`.
|
||||
|
||||
### Feature 2.2: Rate Limiting & Abuse Prevention
|
||||
**Description:** Currently, there is no rate limiting on registration or chat endpoints. With the planned move to WebSockets (Feature 2.1), HTTP polling will be eliminated, but rate limiting is still essential.
|
||||
**Implementation Plan:**
|
||||
1. **Registration:** Implement rate limiting on the HTTP `/register` endpoint to prevent bot account creation spam.
|
||||
2. **WebSockets:** Implement a custom rate limiting mechanism within the WebSocket event handlers (e.g., tracking message timestamps per user session in memory) to prevent users from flooding chat rooms with messages (both text and base64 audio payloads) over the socket connection.
|
||||
|
||||
### Feature 2.3: WebSocket Screen Sharing for Watch Parties
|
||||
**Description:** Expand the watch party functionality to allow users to share their screen as an alternative to watching YouTube videos. WebSockets will be used for signaling the peer-to-peer connection.
|
||||
**Implementation Plan:**
|
||||
1. **WebRTC Signaling:** Use the `Flask-SocketIO` setup from Feature 2.1 to act as a WebRTC signaling server, relaying SDP offers, answers, and ICE candidates between users in the watch party room.
|
||||
2. **Screen Capture:** In `static/assets/js/watchparty_chat.js`, implement `navigator.mediaDevices.getDisplayMedia()` to capture the presenter's screen and audio.
|
||||
3. **Peer Connections:** Establish `RTCPeerConnection`s between the presenter and other attendees.
|
||||
4. **UI Integration:** Add a "Share Screen" button to the watch party interface. When active, replace the YouTube `<iframe>` with a `<video>` element playing the incoming WebRTC stream.
|
||||
|
||||
---
|
||||
|
||||
## 3. Security Concerns
|
||||
*(No pending issues in this category at this time)*
|
||||
1
Procfile
Normal file
1
Procfile
Normal file
|
|
@ -0,0 +1 @@
|
|||
web: gunicorn --worker-tmp-dir /dev/shm --config gunicorn_config.py "app:create_app()"
|
||||
111
README.md
Normal file
111
README.md
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
# Transient Chat
|
||||
|
||||
> Everything is ephemeral.
|
||||
|
||||
**Transient.chat** is a lightweight, in-memory chat platform designed for real-time, impermanent conversation.
|
||||
|
||||
There are no profiles. No feeds. No likes.
|
||||
Just temporary rooms, simple handles, message expiration, and live synced watchrooms.
|
||||
|
||||
---
|
||||
|
||||
## About
|
||||
|
||||
- All messages automatically disappear after **1 hour**
|
||||
- Empty rooms are purged — nothing is saved
|
||||
- Users are identified by transient handles, released when inactive
|
||||
- No databases, no disk writes, no persistence of any kind
|
||||
- **Watchrooms** allow embedded synced YouTube viewing and conversation
|
||||
- Works without WebSockets using traditional HTTP polling
|
||||
|
||||
This project is a throwback to early internet chatrooms — lightweight, anonymous, and inherently temporary.
|
||||
|
||||
Perfect for those who miss casual chat with no expectations or archives.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- Regional and topical chatroom lists
|
||||
- Synchronized YouTube playback in "watchparty" mode
|
||||
- Ephemeral message and session design
|
||||
- Audio indicators and user join/leave sounds
|
||||
- Owner moderation (kick users from rooms)
|
||||
- Admin panel for IP/username bans
|
||||
- Admin routes protected by environment-based Basic Auth
|
||||
- No database dependency (runs entirely in volatile memory)
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
This app is intended to be deployed on **[DigitalOcean App Platform](https://www.digitalocean.com/products/app-platform/)**
|
||||
as a Python Flask application.
|
||||
|
||||
You should:
|
||||
|
||||
- Point DigitalOcean to this repo
|
||||
- Specify Python as the runtime
|
||||
- Set environment variables for your admin login:
|
||||
|
||||
```env
|
||||
ADMIN_USERNAME=your_admin_name
|
||||
ADMIN_PASSWORD=your_secure_password
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
transient.chat/
|
||||
├── app.py # Flask app factory and launcher
|
||||
├── config.py # Loads environment variables
|
||||
├── models/
|
||||
│ ├── state.py # In-memory shared state
|
||||
│ └── cleanup.py # Background cleanup thread
|
||||
├── blueprints/ # Modular route definitions
|
||||
│ ├── auth.py
|
||||
│ ├── chat.py
|
||||
│ ├── rooms.py
|
||||
│ ├── admin.py
|
||||
│ ├── watchparty.py
|
||||
│ └── static_routes.py
|
||||
├── static/
|
||||
│ ├── index.html
|
||||
│ ├── chat.html
|
||||
│ ├── watchparty_chat.html
|
||||
│ ├── server_list.html
|
||||
│ ├── faq.html
|
||||
│ ├── admin.html
|
||||
│ └── assets/ # Frontend resources
|
||||
│ ├── css/ # Stylesheets
|
||||
│ │ └── styles.css
|
||||
│ ├── js/ # JavaScript files
|
||||
│ │ ├── admin.js
|
||||
│ │ ├── chat.js
|
||||
│ │ ├── faq.js
|
||||
│ │ ├── index.js
|
||||
│ │ ├── server_list.js
|
||||
│ │ └── watchparty_chat.js
|
||||
│ ├── sounds/ # .wav audio clips
|
||||
│ │ ├── user-in.wav
|
||||
│ │ ├── user-out.wav
|
||||
│ │ └── message.wav
|
||||
│ └── favicon.ico
|
||||
└── requirements.txt # Python dependencies
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Pull requests and issues are welcome.
|
||||
|
||||
If you'd like to add features, improve styling, or help build out exportable modules, feel free to fork and contribute to the project.
|
||||
|
||||
---
|
||||
|
||||
Nothing is archived.
|
||||
Nothing is retained.
|
||||
**Everything is ephemeral.**
|
||||
21
app.py
Normal file
21
app.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
from flask import Flask
|
||||
from config import Config
|
||||
from extensions import CORS
|
||||
from blueprints import register_blueprints
|
||||
from models.cleanup import start_cleanup_thread
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(Config)
|
||||
|
||||
CORS(app)
|
||||
register_blueprints(app)
|
||||
start_cleanup_thread()
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)
|
||||
return app
|
||||
|
||||
app = create_app()
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(debug=True)
|
||||
16
blueprints/__init__.py
Normal file
16
blueprints/__init__.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
from flask import Flask
|
||||
from .auth import auth_bp
|
||||
from .chat import chat_bp
|
||||
from .rooms import rooms_bp
|
||||
from .watchparty import watchparty_bp
|
||||
from .admin import admin_bp
|
||||
from .static_routes import static_bp
|
||||
|
||||
|
||||
def register_blueprints(app: Flask):
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(chat_bp)
|
||||
app.register_blueprint(rooms_bp)
|
||||
app.register_blueprint(watchparty_bp)
|
||||
app.register_blueprint(admin_bp)
|
||||
app.register_blueprint(static_bp)
|
||||
217
blueprints/admin.py
Normal file
217
blueprints/admin.py
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
from flask import Blueprint, request, jsonify, Response, send_from_directory
|
||||
from functools import wraps
|
||||
from datetime import datetime, timezone
|
||||
import os
|
||||
import time
|
||||
|
||||
from models.state import (
|
||||
user_sessions,
|
||||
user_ips,
|
||||
room_users,
|
||||
messages,
|
||||
banned_ips,
|
||||
banned_usernames,
|
||||
kicked_users,
|
||||
video_state,
|
||||
room_owners,
|
||||
nuked_rooms,
|
||||
public_chat_rooms,
|
||||
)
|
||||
|
||||
admin_bp = Blueprint("admin", __name__)
|
||||
|
||||
SERVER_START_TIME = time.time()
|
||||
|
||||
|
||||
def check_auth(username, password):
|
||||
return username == os.getenv("ADMIN_USERNAME") and password == os.getenv(
|
||||
"ADMIN_PASSWORD"
|
||||
)
|
||||
|
||||
|
||||
def authenticate():
|
||||
return Response(
|
||||
"Access Denied", 401, {"WWW-Authenticate": 'Basic realm="Login Required"'}
|
||||
)
|
||||
|
||||
|
||||
def requires_auth(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
auth = request.authorization
|
||||
if not auth or not check_auth(auth.username, auth.password):
|
||||
return authenticate()
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated
|
||||
|
||||
|
||||
@admin_bp.route("/admin")
|
||||
@requires_auth
|
||||
def admin_panel():
|
||||
"""Serves the static admin HTML file."""
|
||||
return send_from_directory("static", "admin.html")
|
||||
|
||||
|
||||
@admin_bp.route("/admin/stats", methods=["GET"])
|
||||
@requires_auth
|
||||
def admin_stats():
|
||||
total_messages = sum(len(msgs) for msgs in messages.values())
|
||||
return jsonify({
|
||||
"uptime": int(time.time() - SERVER_START_TIME),
|
||||
"active_users": len(user_sessions),
|
||||
"active_rooms": len(room_users),
|
||||
"total_messages": total_messages,
|
||||
"banned_count": len(banned_ips) + len(banned_usernames)
|
||||
})
|
||||
|
||||
|
||||
@admin_bp.route("/admin/users", methods=["GET"])
|
||||
@requires_auth
|
||||
def admin_users():
|
||||
return jsonify(
|
||||
[
|
||||
{
|
||||
"username": u,
|
||||
"ip": user_ips.get(u, "unknown"),
|
||||
"last_active": s["last_active"],
|
||||
}
|
||||
for u, s in user_sessions.items()
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@admin_bp.route("/admin/rooms", methods=["GET"])
|
||||
@requires_auth
|
||||
def admin_rooms():
|
||||
return jsonify(
|
||||
[
|
||||
{
|
||||
"name": r,
|
||||
"users": list(room_users.get(r, {}).keys()),
|
||||
"type": "watchparty" if r in video_state else "chat",
|
||||
"owner": room_owners.get(r, "System"),
|
||||
"msg_count": len(messages.get(r, [])),
|
||||
"is_public": r in public_chat_rooms,
|
||||
}
|
||||
for r in list(messages.keys())
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@admin_bp.route("/admin/banip", methods=["POST"])
|
||||
@requires_auth
|
||||
def admin_ban_ip():
|
||||
data = request.get_json()
|
||||
username = data.get("username")
|
||||
ip_to_ban = user_ips.get(username)
|
||||
|
||||
if not ip_to_ban:
|
||||
return jsonify({"error": "no ip"}), 400
|
||||
|
||||
banned_ips.add(ip_to_ban)
|
||||
user_sessions.pop(username, None)
|
||||
|
||||
for room in room_users:
|
||||
room_users[room].pop(username, None)
|
||||
|
||||
return jsonify({"banned": ip_to_ban})
|
||||
|
||||
|
||||
@admin_bp.route("/admin/banusername", methods=["POST"])
|
||||
@requires_auth
|
||||
def admin_ban_username():
|
||||
data = request.get_json()
|
||||
username = data.get("username")
|
||||
|
||||
if not username:
|
||||
return jsonify({"error": "no username"}), 400
|
||||
|
||||
banned_usernames.add(username)
|
||||
user_sessions.pop(username, None)
|
||||
|
||||
for room in room_users:
|
||||
room_users[room].pop(username, None)
|
||||
|
||||
return jsonify({"banned": username})
|
||||
|
||||
|
||||
@admin_bp.route("/admin/banrawip", methods=["POST"])
|
||||
@requires_auth
|
||||
def admin_ban_raw_ip():
|
||||
data = request.get_json()
|
||||
ip = data.get("ip")
|
||||
|
||||
if not ip:
|
||||
return jsonify({"error": "no ip"}), 400
|
||||
|
||||
banned_ips.add(ip)
|
||||
return jsonify({"banned": ip})
|
||||
|
||||
|
||||
@admin_bp.route("/admin/banned", methods=["GET"])
|
||||
@requires_auth
|
||||
def admin_banned_list():
|
||||
banned = [{"type": "ip", "value": ip} for ip in banned_ips]
|
||||
banned += [{"type": "username", "value": uname} for uname in banned_usernames]
|
||||
return jsonify(banned)
|
||||
|
||||
|
||||
@admin_bp.route("/admin/releaseuser", methods=["POST"])
|
||||
@requires_auth
|
||||
def admin_release_user():
|
||||
data = request.get_json()
|
||||
username = data.get("username")
|
||||
|
||||
if not username:
|
||||
return jsonify({"error": "no username"}), 400
|
||||
|
||||
user_sessions.pop(username, None)
|
||||
user_ips.pop(username, None)
|
||||
|
||||
for room in room_users:
|
||||
room_users[room].pop(username, None)
|
||||
|
||||
return jsonify({"released": username})
|
||||
|
||||
|
||||
@admin_bp.route("/admin/broadcast", methods=["POST"])
|
||||
@requires_auth
|
||||
def admin_broadcast():
|
||||
"""Sends a system message to all active rooms."""
|
||||
data = request.get_json()
|
||||
text = data.get("text")
|
||||
|
||||
if not text:
|
||||
return jsonify({"error": "no text"}), 400
|
||||
|
||||
timestamp = datetime.now(timezone.utc).isoformat()
|
||||
message = {"username": "SYSTEM", "text": text, "timestamp": timestamp}
|
||||
|
||||
count = 0
|
||||
for room in messages:
|
||||
messages[room].append(message)
|
||||
count += 1
|
||||
|
||||
return jsonify({"status": "sent", "rooms_affected": count})
|
||||
|
||||
|
||||
@admin_bp.route("/admin/nukeroom", methods=["POST"])
|
||||
@requires_auth
|
||||
def admin_nuke_room():
|
||||
"""Instantly deletes a room and clears its state."""
|
||||
data = request.get_json()
|
||||
room = data.get("room")
|
||||
|
||||
# Lock the room for 10 seconds to force all clients to disconnect
|
||||
nuked_rooms[room] = time.time() + 10
|
||||
|
||||
messages.pop(room, None)
|
||||
room_users.pop(room, None)
|
||||
room_owners.pop(room, None)
|
||||
video_state.pop(room, None)
|
||||
|
||||
if room in public_chat_rooms:
|
||||
public_chat_rooms.remove(room)
|
||||
|
||||
return jsonify({"status": "nuked", "room": room})
|
||||
50
blueprints/auth.py
Normal file
50
blueprints/auth.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
from flask import Blueprint, request, jsonify
|
||||
from datetime import datetime, timezone
|
||||
import os
|
||||
import secrets
|
||||
|
||||
from models.state import user_sessions, user_ips, banned_ips, banned_usernames
|
||||
|
||||
auth_bp = Blueprint("auth", __name__)
|
||||
|
||||
|
||||
def generate_user_key(length=16):
|
||||
return secrets.token_urlsafe(16)
|
||||
|
||||
|
||||
@auth_bp.route("/register", methods=["POST"])
|
||||
def register():
|
||||
data = request.get_json()
|
||||
username = data.get("username")
|
||||
user_ip = request.remote_addr
|
||||
|
||||
if not username:
|
||||
return jsonify({"error": "No username provided"}), 400
|
||||
if username in user_sessions:
|
||||
return jsonify({"error": "Username taken"}), 409
|
||||
if user_ip in banned_ips:
|
||||
return jsonify({"error": "IP banned"}), 403
|
||||
if username in banned_usernames:
|
||||
return jsonify({"error": "Username banned"}), 403
|
||||
|
||||
user_key = generate_user_key()
|
||||
user_sessions[username] = {
|
||||
"key": user_key,
|
||||
"last_active": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
user_ips[username] = user_ip
|
||||
|
||||
return jsonify({"username": username, "key": user_key})
|
||||
|
||||
|
||||
@auth_bp.route("/validate", methods=["POST"])
|
||||
def validate():
|
||||
data = request.get_json()
|
||||
username = data.get("username")
|
||||
key = data.get("key")
|
||||
|
||||
session = user_sessions.get(username)
|
||||
if session and session["key"] == key:
|
||||
session["last_active"] = datetime.now(timezone.utc).isoformat()
|
||||
return jsonify({"status": "valid"})
|
||||
return jsonify({"error": "Invalid credentials"}), 403
|
||||
124
blueprints/chat.py
Normal file
124
blueprints/chat.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
from flask import Blueprint, request, jsonify
|
||||
from datetime import datetime, timezone
|
||||
import time
|
||||
|
||||
from models.state import (
|
||||
messages,
|
||||
room_users,
|
||||
kicked_users,
|
||||
room_owners,
|
||||
banned_ips,
|
||||
banned_usernames,
|
||||
user_ips,
|
||||
nuked_rooms,
|
||||
)
|
||||
|
||||
chat_bp = Blueprint("chat", __name__)
|
||||
|
||||
|
||||
@chat_bp.route("/chat", methods=["GET", "POST"])
|
||||
def general_chat():
|
||||
if request.method == "POST":
|
||||
new_message = request.json.get("message")
|
||||
if new_message:
|
||||
messages.setdefault("global", []).append(
|
||||
{"text": new_message, "timestamp": datetime.now(timezone.utc).isoformat()}
|
||||
)
|
||||
if len(messages["global"]) > 100:
|
||||
messages["global"] = messages["global"][-100:]
|
||||
return jsonify(messages.get("global", []))
|
||||
|
||||
|
||||
@chat_bp.route("/chat/<room>", methods=["GET", "POST"])
|
||||
def chat_room(room):
|
||||
room = room.strip()
|
||||
|
||||
if request.method == "POST":
|
||||
data = request.json
|
||||
username = data.get("username")
|
||||
text = data.get("text")
|
||||
audio = data.get("audio")
|
||||
user_ip = request.remote_addr
|
||||
|
||||
if (
|
||||
user_ip in banned_ips
|
||||
or username in banned_usernames
|
||||
or (username in user_ips and user_ips[username] in banned_ips)
|
||||
):
|
||||
return jsonify({"error": "banned"}), 403
|
||||
|
||||
message = {
|
||||
"username": username,
|
||||
"text": text,
|
||||
"audio": audio,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
messages.setdefault(room, []).append(message)
|
||||
if len(messages[room]) > 100:
|
||||
messages[room] = messages[room][-100:]
|
||||
|
||||
since = request.args.get("since")
|
||||
room_messages = messages.setdefault(room, [])
|
||||
|
||||
if since:
|
||||
try:
|
||||
since_time = datetime.fromisoformat(since)
|
||||
new_msgs = [
|
||||
m
|
||||
for m in room_messages
|
||||
if datetime.fromisoformat(m["timestamp"]) > since_time
|
||||
]
|
||||
return jsonify(new_msgs)
|
||||
except ValueError:
|
||||
pass # Ignore bad timestamp
|
||||
|
||||
return jsonify(room_messages)
|
||||
|
||||
|
||||
@chat_bp.route("/chat/<room>/users", methods=["GET", "POST"])
|
||||
def room_user_list(room):
|
||||
room = room.strip()
|
||||
|
||||
# Check if room is temporarily locked (nuked)
|
||||
if room in nuked_rooms:
|
||||
if time.time() < nuked_rooms[room]:
|
||||
return jsonify({"error": "Room is unavailable"}), 403
|
||||
else:
|
||||
nuked_rooms.pop(room, None)
|
||||
|
||||
if request.method == "POST":
|
||||
data = request.json
|
||||
username = data.get("username")
|
||||
if not username:
|
||||
return jsonify({"error": "No username"}), 400
|
||||
|
||||
if username in kicked_users.get(room, set()):
|
||||
return jsonify({"error": "Kicked from room"}), 403
|
||||
|
||||
room_users.setdefault(room, {})
|
||||
if room not in room_owners and username not in room_users[room]:
|
||||
room_owners[room] = username # First user becomes the owner
|
||||
|
||||
room_users[room][username] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
users = room_users.get(room, {})
|
||||
response = {"users": list(users.keys())}
|
||||
if room in room_owners:
|
||||
response["owner"] = room_owners[room]
|
||||
return jsonify(response)
|
||||
|
||||
|
||||
@chat_bp.route("/chat/<room>/kick", methods=["POST"])
|
||||
def kick_user(room):
|
||||
data = request.json
|
||||
owner = data.get("owner")
|
||||
target = data.get("target")
|
||||
|
||||
if not owner or not target:
|
||||
return jsonify({"error": "Missing owner or target"}), 400
|
||||
if room_owners.get(room) != owner:
|
||||
return jsonify({"error": "Not authorized"}), 403
|
||||
|
||||
kicked_users.setdefault(room, set()).add(target)
|
||||
room_users.get(room, {}).pop(target, None)
|
||||
return jsonify({"kicked": target})
|
||||
58
blueprints/rooms.py
Normal file
58
blueprints/rooms.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
from flask import Blueprint, request, jsonify
|
||||
from models.state import (
|
||||
room_users,
|
||||
messages,
|
||||
public_chat_rooms,
|
||||
regional_chat_rooms,
|
||||
topical_chat_rooms,
|
||||
video_state,
|
||||
)
|
||||
|
||||
rooms_bp = Blueprint("rooms", __name__)
|
||||
|
||||
|
||||
@rooms_bp.route("/rooms", methods=["GET", "POST"])
|
||||
def get_or_create_rooms():
|
||||
if request.method == "POST":
|
||||
data = request.get_json()
|
||||
room_name = data.get("name", "").strip()
|
||||
|
||||
if not room_name:
|
||||
return jsonify({"error": "Room name required"}), 400
|
||||
|
||||
all_rooms = regional_chat_rooms + topical_chat_rooms + public_chat_rooms
|
||||
if room_name in all_rooms:
|
||||
return jsonify({"error": "Room already exists"}), 400
|
||||
|
||||
public_chat_rooms.append(room_name)
|
||||
return jsonify({"name": room_name})
|
||||
|
||||
active_only = request.args.get("activeOnly") == "true"
|
||||
|
||||
def build_room_data(room_list, active=False):
|
||||
result = []
|
||||
for room in room_list:
|
||||
users = room_users.get(room, {})
|
||||
if active and not users:
|
||||
continue
|
||||
result.append(
|
||||
{
|
||||
"name": room,
|
||||
"users": len(users),
|
||||
"type": "watchparty" if room in video_state else "chat",
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
all_users = set()
|
||||
for users in room_users.values():
|
||||
all_users.update(users.keys())
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"regional": build_room_data(regional_chat_rooms, active_only),
|
||||
"topical": build_room_data(topical_chat_rooms, active_only),
|
||||
"public": build_room_data(public_chat_rooms, active_only),
|
||||
"unique_users": len(all_users),
|
||||
}
|
||||
)
|
||||
13
blueprints/static_routes.py
Normal file
13
blueprints/static_routes.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
from flask import Blueprint, send_from_directory
|
||||
|
||||
static_bp = Blueprint("static", __name__)
|
||||
|
||||
|
||||
@static_bp.route("/")
|
||||
def index():
|
||||
return send_from_directory("static", "index.html")
|
||||
|
||||
|
||||
@static_bp.route("/<path:path>")
|
||||
def serve_static_files(path):
|
||||
return send_from_directory("static", path)
|
||||
93
blueprints/watchparty.py
Normal file
93
blueprints/watchparty.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
from flask import Blueprint, request, jsonify
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from models.state import video_state, public_chat_rooms
|
||||
|
||||
watchparty_bp = Blueprint("watchparty", __name__)
|
||||
|
||||
|
||||
@watchparty_bp.route("/watchparty/<room>/video", methods=["GET", "POST"])
|
||||
def watchparty_video_control(room):
|
||||
room = room.strip()
|
||||
|
||||
# Ensure it's tracked as a public room
|
||||
if room not in public_chat_rooms:
|
||||
public_chat_rooms.append(room)
|
||||
|
||||
# Ensure video_state storage is initialized
|
||||
video_state.setdefault(room, {})
|
||||
|
||||
if request.method == "POST":
|
||||
data = request.get_json()
|
||||
url = data.get("url")
|
||||
if not url:
|
||||
return jsonify({"error": "No video URL"}), 400
|
||||
|
||||
video_state[room] = {
|
||||
"url": url,
|
||||
"started_at": datetime.now(timezone.utc).isoformat(),
|
||||
"is_paused": False,
|
||||
}
|
||||
|
||||
return jsonify({"status": "started"})
|
||||
|
||||
# Handle GET: return URL + elapsed time
|
||||
video_data = video_state.get(room)
|
||||
if not video_data or "url" not in video_data:
|
||||
return jsonify({"error": "No video"}), 404
|
||||
|
||||
try:
|
||||
is_paused = video_data.get("is_paused", False)
|
||||
if is_paused:
|
||||
elapsed = video_data.get("elapsed_at_pause", 0)
|
||||
else:
|
||||
start_time = datetime.fromisoformat(video_data["started_at"])
|
||||
elapsed = (datetime.now(timezone.utc) - start_time).total_seconds()
|
||||
return jsonify(
|
||||
{"url": video_data["url"], "elapsed": int(elapsed), "is_paused": is_paused}
|
||||
)
|
||||
except ValueError:
|
||||
return jsonify({"error": "Invalid timestamp"}), 500
|
||||
|
||||
|
||||
@watchparty_bp.route("/watchparty/<room>/video/clear", methods=["POST"])
|
||||
def clear_watchparty_video(room):
|
||||
room = room.strip()
|
||||
video_state.pop(room, None)
|
||||
return jsonify({"status": "cleared"})
|
||||
|
||||
|
||||
@watchparty_bp.route("/watchparty/<room>/video/pause", methods=["POST"])
|
||||
def watchparty_pause_video(room):
|
||||
room = room.strip()
|
||||
state = video_state.get(room)
|
||||
if not state or state.get("is_paused"):
|
||||
return jsonify({"status": "ignored"})
|
||||
|
||||
# Calculate elapsed time and freeze it
|
||||
start_time = datetime.fromisoformat(state["started_at"])
|
||||
elapsed = (datetime.now(timezone.utc) - start_time).total_seconds()
|
||||
|
||||
state["is_paused"] = True
|
||||
state["elapsed_at_pause"] = elapsed
|
||||
|
||||
return jsonify({"status": "paused"})
|
||||
|
||||
|
||||
@watchparty_bp.route("/watchparty/<room>/video/resume", methods=["POST"])
|
||||
def watchparty_resume_video(room):
|
||||
room = room.strip()
|
||||
state = video_state.get(room)
|
||||
if not state or not state.get("is_paused"):
|
||||
return jsonify({"status": "ignored"})
|
||||
|
||||
# Adjust started_at so that (now - started_at) == elapsed_at_pause
|
||||
# This effectively shifts the start time forward by the duration of the pause
|
||||
elapsed = state.get("elapsed_at_pause", 0)
|
||||
new_start_timestamp = datetime.now(timezone.utc).timestamp() - elapsed
|
||||
|
||||
state["started_at"] = datetime.fromtimestamp(new_start_timestamp, timezone.utc).isoformat()
|
||||
state["is_paused"] = False
|
||||
state.pop("elapsed_at_pause", None)
|
||||
|
||||
return jsonify({"status": "resumed"})
|
||||
6
config.py
Normal file
6
config.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import os
|
||||
|
||||
|
||||
class Config:
|
||||
ADMIN_USERNAME = os.getenv("ADMIN_USERNAME")
|
||||
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD")
|
||||
1
extensions.py
Normal file
1
extensions.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from flask_cors import CORS
|
||||
2
gunicorn_config.py
Normal file
2
gunicorn_config.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
bind = "0.0.0.0:8080"
|
||||
workers = 1 # Keep 1 worker due to in-memory-only architecture
|
||||
65
models/cleanup.py
Normal file
65
models/cleanup.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from models.state import (
|
||||
messages,
|
||||
room_users,
|
||||
public_chat_rooms,
|
||||
kicked_users,
|
||||
user_sessions,
|
||||
video_state,
|
||||
)
|
||||
|
||||
|
||||
def cleanup_loop():
|
||||
while True:
|
||||
# Remove chat messages older than 1 hour
|
||||
message_cutoff = datetime.now(timezone.utc) - timedelta(hours=1)
|
||||
for room, room_messages in list(messages.items()):
|
||||
messages[room] = [
|
||||
msg
|
||||
for msg in room_messages
|
||||
if datetime.fromisoformat(msg["timestamp"]) > message_cutoff
|
||||
]
|
||||
|
||||
# Remove inactive users from rooms (after 1 min)
|
||||
inactive_cutoff = datetime.now(timezone.utc) - timedelta(minutes=1)
|
||||
for room in list(room_users.keys()):
|
||||
room_users[room] = {
|
||||
user: ts
|
||||
for user, ts in room_users[room].items()
|
||||
if datetime.fromisoformat(ts) > inactive_cutoff
|
||||
}
|
||||
|
||||
# Remove inactive public rooms
|
||||
for room in list(public_chat_rooms):
|
||||
last_active = room_users.get(room, {}).values()
|
||||
if not last_active or all(
|
||||
datetime.fromisoformat(ts) < inactive_cutoff for ts in last_active
|
||||
):
|
||||
if room in public_chat_rooms:
|
||||
public_chat_rooms.remove(room)
|
||||
messages.pop(room, None)
|
||||
room_users.pop(room, None)
|
||||
kicked_users.pop(room, None)
|
||||
video_state.pop(room, None)
|
||||
|
||||
# Remove stale user sessions
|
||||
user_cutoff = datetime.now(timezone.utc) - timedelta(days=3)
|
||||
for user, session in list(user_sessions.items()):
|
||||
if datetime.fromisoformat(session["last_active"]) < user_cutoff:
|
||||
user_sessions.pop(user, None)
|
||||
|
||||
time.sleep(60) # Run every 60 seconds
|
||||
|
||||
|
||||
_cleanup_thread_started = False
|
||||
|
||||
def start_cleanup_thread():
|
||||
global _cleanup_thread_started
|
||||
if _cleanup_thread_started:
|
||||
return
|
||||
_cleanup_thread_started = True
|
||||
thread = threading.Thread(target=cleanup_loop, daemon=True)
|
||||
thread.start()
|
||||
117
models/state.py
Normal file
117
models/state.py
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
from typing import Dict, List, Set
|
||||
|
||||
# In-Memory Data Stores
|
||||
# Since this app is ephemeral, we use global dictionaries instead of a database.
|
||||
user_sessions: Dict[str, dict] = {} # {username: {'key': str, 'last_active': str}}
|
||||
messages: Dict[str, List[dict]] = {} # {room_name: [{'username': str, 'text': str, 'timestamp': str}]}
|
||||
room_users: Dict[str, Dict[str, str]] = {} # {room_name: {username: last_active_timestamp}}
|
||||
banned_ips: Set[str] = set()
|
||||
banned_usernames: Set[str] = set()
|
||||
user_ips: Dict[str, str] = {} # {username: ip_address}
|
||||
kicked_users: Dict[str, Set[str]] = {} # {room_name: {kicked_username}}
|
||||
room_owners: Dict[str, str] = {} # {room_name: owner_username}
|
||||
public_chat_rooms: List[str] = [] # List of user-created public room names
|
||||
video_state: Dict[str, dict] = {} # {room_name: {'url': str, 'started_at': str}}
|
||||
nuked_rooms: Dict[str, float] = {} # {room_name: expiry_timestamp_epoch}
|
||||
|
||||
regional_chat_rooms = [
|
||||
"Alabama",
|
||||
"Alaska",
|
||||
"Arizona",
|
||||
"Arkansas",
|
||||
"California",
|
||||
"Colorado",
|
||||
"Connecticut",
|
||||
"Delaware",
|
||||
"Florida",
|
||||
"Georgia",
|
||||
"Hawaii",
|
||||
"Idaho",
|
||||
"Illinois",
|
||||
"Indiana",
|
||||
"Iowa",
|
||||
"Kansas",
|
||||
"Kentucky",
|
||||
"Louisiana",
|
||||
"Maine",
|
||||
"Maryland",
|
||||
"Massachusetts",
|
||||
"Michigan",
|
||||
"Minnesota",
|
||||
"Mississippi",
|
||||
"Missouri",
|
||||
"Montana",
|
||||
"Nebraska",
|
||||
"Nevada",
|
||||
"New Hampshire",
|
||||
"New Jersey",
|
||||
"New Mexico",
|
||||
"New York",
|
||||
"North Carolina",
|
||||
"North Dakota",
|
||||
"Ohio",
|
||||
"Oklahoma",
|
||||
"Oregon",
|
||||
"Pennsylvania",
|
||||
"Rhode Island",
|
||||
"South Carolina",
|
||||
"South Dakota",
|
||||
"Tennessee",
|
||||
"Texas",
|
||||
"Utah",
|
||||
"Vermont",
|
||||
"Virginia",
|
||||
"Washington",
|
||||
"West Virginia",
|
||||
"Wisconsin",
|
||||
"Wyoming",
|
||||
]
|
||||
|
||||
topical_chat_rooms = [
|
||||
"Tech",
|
||||
"Science",
|
||||
"Gaming",
|
||||
"Movies",
|
||||
"Music",
|
||||
"Books",
|
||||
"Current Events",
|
||||
"Sports",
|
||||
"Travel",
|
||||
"Food & Cooking",
|
||||
"Health & Fitness",
|
||||
"History",
|
||||
"Programming",
|
||||
"Investing",
|
||||
"Cryptocurrency",
|
||||
"Parenting",
|
||||
"DIY & Home Improvement",
|
||||
"Photography",
|
||||
"Fashion",
|
||||
"Automotive",
|
||||
"Theater & Performing Arts",
|
||||
"Pets & Animals",
|
||||
"Space Exploration",
|
||||
"Climate Change",
|
||||
"Entrepreneurship",
|
||||
"Relationships",
|
||||
"Comics & Manga",
|
||||
"Anime",
|
||||
"Board Games",
|
||||
"Card Games",
|
||||
"Tabletop RPGs",
|
||||
"Language Learning",
|
||||
"Economics",
|
||||
"Politics",
|
||||
"Fan Theories",
|
||||
"Science Fiction",
|
||||
"Fantasy",
|
||||
"Artificial Intelligence",
|
||||
"Web Development",
|
||||
"Mobile Apps",
|
||||
"Cybersecurity",
|
||||
"3D Printing",
|
||||
"Virtual Reality",
|
||||
"Augmented Reality",
|
||||
"Home Automation",
|
||||
"Sustainable Living",
|
||||
]
|
||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
Flask
|
||||
requests
|
||||
flask_cors
|
||||
gunicorn
|
||||
55
static/admin.html
Normal file
55
static/admin.html
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Transient.chat - Admin Panel</title>
|
||||
<link rel="stylesheet" href="/assets/css/styles.css" />
|
||||
<link rel="stylesheet" href="/assets/css/admin.css" />
|
||||
<link rel="icon" href="/assets/images/favicon.ico" type="image/x-icon" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="title-row">
|
||||
<a href="index.html">
|
||||
<img src="/assets/images/transientchat-blue.png" alt="Transient.chat" />
|
||||
</a>
|
||||
|
||||
<h1>Admin Panel</h1>
|
||||
</div>
|
||||
|
||||
<div id="serverStats">
|
||||
<!-- Stats will be loaded here -->
|
||||
<div>Loading stats...</div>
|
||||
</div>
|
||||
|
||||
<h2 class="collapsible">Active Rooms <span id="activeRoomCount"></span></h2>
|
||||
<div class="collapsible-content" id="roomList">Loading...</div>
|
||||
|
||||
<h2 class="collapsible">Active Users <span id="activeUserCount"></span></h2>
|
||||
<div class="collapsible-content" id="userList">Loading...</div>
|
||||
|
||||
<h2>System Broadcast</h2>
|
||||
<div style="text-align: center; margin-bottom: 20px;">
|
||||
<input type="text" id="broadcastInput" placeholder="Message to all rooms..." style="width: 300px;">
|
||||
<button onclick="broadcastSystemMessage()">Send to All</button>
|
||||
</div>
|
||||
|
||||
<h2>Bulk Ban IPs / Usernames</h2>
|
||||
<textarea
|
||||
id="bulkInput"
|
||||
rows="5"
|
||||
cols="50"
|
||||
placeholder="Enter one IP per line or one username per line, don't mix."
|
||||
></textarea
|
||||
><br />
|
||||
<div>
|
||||
<button onclick="bulkBanIPs()">Ban IPs</button>
|
||||
<button onclick="bulkBanUsernames()">Ban Usernames</button>
|
||||
</div>
|
||||
|
||||
<h2>Banned Users & IPs</h2>
|
||||
<div id="bannedList">Loading...</div>
|
||||
|
||||
<script src="/assets/js/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
76
static/assets/css/admin.css
Normal file
76
static/assets/css/admin.css
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
/* === Admin Panel === */
|
||||
#serverStats {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
gap: 15px;
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
margin-bottom: 30px;
|
||||
background-color: #2a2a2a;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #444;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
#serverStats > div {
|
||||
background-color: #3d3d3d;
|
||||
padding: 15px 20px;
|
||||
border-radius: 6px;
|
||||
min-width: 120px;
|
||||
text-align: center;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
#roomList,
|
||||
#userList,
|
||||
#bannedList {
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-left: 0; /* Override collapsible default */
|
||||
}
|
||||
|
||||
#roomList > div,
|
||||
#userList > div,
|
||||
#bannedList > div {
|
||||
background-color: #2a2a2a;
|
||||
border: 1px solid #444;
|
||||
padding: 12px 15px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Make buttons in lists smaller/neater */
|
||||
#roomList button,
|
||||
#userList button {
|
||||
min-height: 32px;
|
||||
height: auto;
|
||||
font-size: 12px;
|
||||
padding: 4px 12px;
|
||||
}
|
||||
|
||||
#broadcastInput {
|
||||
background-color: #ffffff;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
#bulkInput {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
background-color: #ffffff;
|
||||
color: #333;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
font-family: monospace;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
186
static/assets/css/chat.css
Normal file
186
static/assets/css/chat.css
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
/* === Chat Room Layout === */
|
||||
#page-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
padding: 0 8px;
|
||||
margin-top: 5px;
|
||||
flex-wrap: nowrap;
|
||||
gap: 10px;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
height: calc(100vh - 100px);
|
||||
}
|
||||
|
||||
#chat-center {
|
||||
flex: 1 1 700px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
/* === Messages === */
|
||||
#messages {
|
||||
background-color: #2e2e2e;
|
||||
border: 1px solid #444;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
height: auto;
|
||||
max-height: calc(100vh - 205px);
|
||||
}
|
||||
|
||||
#messages .message {
|
||||
line-height: 1.5em;
|
||||
padding: 4px 10px;
|
||||
word-wrap: break-word;
|
||||
background-color: #399fff8a;
|
||||
color: #f0f0f0;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 4px;
|
||||
max-width: 90%;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
|
||||
align-self: flex-start;
|
||||
text-align: left
|
||||
|
||||
}
|
||||
|
||||
#messages .my-message {
|
||||
align-self: flex-end;
|
||||
background-color: #18794e;
|
||||
text-align: right;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* === Chat Input Form === */
|
||||
#chat-form {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
#message-input.inputfield {
|
||||
height: 42px;
|
||||
padding: 0 12px;
|
||||
/* horizontal padding only */
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* === User List Sidebar === */
|
||||
#user-list-container {
|
||||
flex: 0 1 230px;
|
||||
min-width: 180px;
|
||||
background-color: #2a2a2a;
|
||||
border: 1px solid #444;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
height: fit-content;
|
||||
transition: max-height 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
#user-list-container h3 {
|
||||
margin-top: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
#user-list div {
|
||||
padding: 5px 0;
|
||||
color: #ccc;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
#user-list-container > button {
|
||||
display: block; /* Forces buttons to stack vertically */
|
||||
width: fit-content; /* Only as wide as the text, not "full screen" */
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
#user-list-container #user-list-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 750px) {
|
||||
#page-container {
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
#user-list-container {
|
||||
display: block;
|
||||
order: -1;
|
||||
width: 100%;
|
||||
flex: none;
|
||||
max-height: 38px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 10px;
|
||||
padding: 0;
|
||||
cursor: default;
|
||||
transition: max-height 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
#user-list-container.expanded {
|
||||
max-height: 1200px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
#user-list-container #user-list-toggle {
|
||||
display: block;
|
||||
width: 100%; /* The toggle bar should be full width on mobile */
|
||||
margin-bottom: 0;
|
||||
border-radius: 7px 7px 0 0;
|
||||
}
|
||||
|
||||
#user-list-container.expanded #user-list-toggle {
|
||||
margin-bottom: 10px;
|
||||
border-bottom: 1px solid #444;
|
||||
}
|
||||
|
||||
#user-list-container > *:not(#user-list-toggle) {
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
#user-list-container h3 {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
/* === Chat Components === */
|
||||
.mic-button {
|
||||
margin: 0 0.5em;
|
||||
}
|
||||
|
||||
.mic-button.recording {
|
||||
background-color: #ff4d4d !important;
|
||||
}
|
||||
|
||||
.toggle-off {
|
||||
background-color: #808080 !important;
|
||||
}
|
||||
|
||||
.audio-container {
|
||||
margin-top: 0.25em;
|
||||
}
|
||||
|
||||
.voice-message-audio {
|
||||
height: 30px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.kick-button {
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
193
static/assets/css/styles.css
Normal file
193
static/assets/css/styles.css
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
/* === Global === */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background-color: #1e1e1e;
|
||||
color: #ffffff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* === Headings === */
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
color: #ffffff;
|
||||
text-align: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* === Layout Containers === */
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
padding: 0 15px;
|
||||
}
|
||||
|
||||
.title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.title-row img {
|
||||
height: 60px;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
#message-input {
|
||||
flex: 1;
|
||||
background-color: #1a1a1a;
|
||||
color: #ffffff;
|
||||
border: 1px solid #444;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
button {
|
||||
min-height: 36px;
|
||||
height: auto;
|
||||
padding: 4px 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* horizontal only */
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
background-color: #39a0ff;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: #1e7cc9;
|
||||
}
|
||||
|
||||
/* === Shared Forms/Input Styles === */
|
||||
input[type="text"],
|
||||
input[type="password"] {
|
||||
background-color: #1a1a1a;
|
||||
color: #ffffff;
|
||||
border: 1px solid #444;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* === Index && Server List Styling === */
|
||||
#info-text {
|
||||
max-width: 800px;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
padding: 0 15px;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
#login-container {
|
||||
padding: 25px 30px;
|
||||
background-color: #2a2a2a;
|
||||
border: 1px solid #444;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
margin: 20px auto;
|
||||
}
|
||||
|
||||
#chat-rooms {
|
||||
background-color: #2a2a2a;
|
||||
border: 1px solid #444;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin-top: 30px;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style-type: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 8px 0;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
/* Collapsible headers */
|
||||
.collapsible {
|
||||
cursor: pointer;
|
||||
margin: 12px 0 8px;
|
||||
}
|
||||
|
||||
.collapsible-content {
|
||||
margin-left: 15px;
|
||||
}
|
||||
|
||||
/* === Buttons Index Page === */
|
||||
#create-room-form,
|
||||
#start-chat-button,
|
||||
#create-room-btn {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
#create-room-btn,
|
||||
#create-unlisted-room-btn {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* === Links === */
|
||||
a {
|
||||
color: #79c4ff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
a:visited {
|
||||
color: #79c4ff;
|
||||
}
|
||||
|
||||
#username-input,
|
||||
#new-room-name,
|
||||
#message-input,
|
||||
#new-watchparty-name,
|
||||
#video-url-input {
|
||||
background-color: #ffffff;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
li a {
|
||||
display: block;
|
||||
text-align: center;
|
||||
color: #ffffff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* === Utilities === */
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
127
static/assets/css/watchparty.css
Normal file
127
static/assets/css/watchparty.css
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
.watchparty #video-chat-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 20px;
|
||||
width: 100%;
|
||||
max-width: 2400px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.watchparty #video-section {
|
||||
flex: 2;
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.watchparty #chat-center {
|
||||
flex: 1.2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
/* Ensure video controls look clean */
|
||||
.watchparty #video-controls {
|
||||
margin-bottom: 15px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.watchparty #video-iframe {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
max-height: 1080px;
|
||||
border-radius: 8px;
|
||||
display: block;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#paused-overlay {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
@media (max-width: 950px) {
|
||||
.watchparty #video-chat-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.watchparty #video-iframe {
|
||||
aspect-ratio: auto;
|
||||
/* Disable aspect ratio for small screens */
|
||||
height: 420px;
|
||||
/* Fixed height */
|
||||
max-height: none;
|
||||
/* Remove previous cap */
|
||||
}
|
||||
|
||||
.watchparty #video-section,
|
||||
.watchparty #chat-center {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.watchparty #user-list-container {
|
||||
display: block;
|
||||
order: -1;
|
||||
width: 100%;
|
||||
max-height: 38px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 10px;
|
||||
padding: 0;
|
||||
transition: max-height 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.watchparty #user-list-container.expanded {
|
||||
max-height: 1000px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.watchparty #user-list-container #user-list-toggle {
|
||||
display: block;
|
||||
width: 100%; /* The toggle bar should be full width on mobile */
|
||||
margin-bottom: 0;
|
||||
border-radius: 7px 7px 0 0;
|
||||
}
|
||||
|
||||
.watchparty #user-list-container.expanded #user-list-toggle {
|
||||
margin-bottom: 10px;
|
||||
border-bottom: 1px solid #444;
|
||||
}
|
||||
|
||||
.watchparty #user-list-container > *:not(#user-list-toggle) {
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.watchparty #user-list-container h3 {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.watchparty #room-title {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.watchparty #chat-center {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.watchparty #messages {
|
||||
max-height: calc(100vh - 550px);
|
||||
/* Adjust as needed */
|
||||
min-height: 150px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.watchparty #chat-form {
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
BIN
static/assets/images/favicon.ico
Normal file
BIN
static/assets/images/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 187 KiB |
BIN
static/assets/images/paused.png
Normal file
BIN
static/assets/images/paused.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2 MiB |
BIN
static/assets/images/transientchat-blue.png
Normal file
BIN
static/assets/images/transientchat-blue.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 242 KiB |
259
static/assets/js/admin.js
Normal file
259
static/assets/js/admin.js
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
async function loadStats() {
|
||||
const res = await fetch('/admin/stats');
|
||||
const stats = await res.json();
|
||||
const div = document.getElementById('serverStats');
|
||||
|
||||
const formatTime = (s) => {
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
return `${h}h ${m}m`;
|
||||
};
|
||||
|
||||
div.innerHTML = `
|
||||
<div style="text-align: center;"><strong>Uptime</strong><br>${formatTime(stats.uptime)}</div>
|
||||
<div style="text-align: center;"><strong>Users</strong><br>${stats.active_users}</div>
|
||||
<div style="text-align: center;"><strong>Rooms</strong><br>${stats.active_rooms}</div>
|
||||
<div style="text-align: center;"><strong>Messages</strong><br>${stats.total_messages}</div>
|
||||
<div style="text-align: center;"><strong>Bans</strong><br>${stats.banned_count}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
const res = await fetch('/admin/users');
|
||||
const users = await res.json();
|
||||
const div = document.getElementById('userList');
|
||||
const countDisplay = document.getElementById('activeUserCount');
|
||||
div.innerHTML = '';
|
||||
users.forEach(user => {
|
||||
const item = document.createElement('div');
|
||||
const label = document.createElement('span');
|
||||
label.textContent = `${user.username} (${user.ip})`;
|
||||
|
||||
const banIpBtn = document.createElement('button');
|
||||
banIpBtn.textContent = 'Ban IP';
|
||||
banIpBtn.style.marginLeft = '10px';
|
||||
banIpBtn.onclick = () => banUser(user.username);
|
||||
|
||||
const banUserBtn = document.createElement('button');
|
||||
banUserBtn.textContent = 'Ban Username';
|
||||
banUserBtn.style.marginLeft = '5px';
|
||||
banUserBtn.onclick = () => banUsername(user.username);
|
||||
|
||||
const releaseUserBtn = document.createElement('button');
|
||||
releaseUserBtn.textContent = 'Release Username';
|
||||
releaseUserBtn.style.marginLeft = '5px';
|
||||
releaseUserBtn.onclick = () => releaseUser(user.username);
|
||||
|
||||
item.append(label, banIpBtn, banUserBtn, releaseUserBtn);
|
||||
div.appendChild(item);
|
||||
});
|
||||
|
||||
countDisplay.textContent = `(${users.length})`;
|
||||
}
|
||||
|
||||
async function loadRooms() {
|
||||
const res = await fetch('/admin/rooms');
|
||||
const rooms = await res.json();
|
||||
const div = document.getElementById('roomList');
|
||||
const countDisplay = document.getElementById('activeRoomCount');
|
||||
|
||||
const activeRooms = rooms.filter(r => r.users.length > 0); // Filter active only
|
||||
|
||||
div.innerHTML = ''; // Clear previous content
|
||||
|
||||
activeRooms.forEach(r => {
|
||||
const container = document.createElement('div');
|
||||
const link = document.createElement('a');
|
||||
link.href = r.type === 'watchparty'
|
||||
? `/watchparty_chat.html?room=${encodeURIComponent(r.name)}`
|
||||
: `/chat.html?room=${encodeURIComponent(r.name)}`;
|
||||
link.target = "_blank";
|
||||
link.textContent = r.name;
|
||||
|
||||
const details = document.createElement('span');
|
||||
details.style.fontSize = '0.9em';
|
||||
details.style.color = '#666';
|
||||
details.style.marginLeft = '10px';
|
||||
details.textContent = `Owner: ${r.owner} | Msgs: ${r.msg_count} | ${r.is_public ? 'Public' : 'Unlisted'} | Users: ${r.users.join(', ')}`;
|
||||
|
||||
const nukeBtn = document.createElement('button');
|
||||
nukeBtn.textContent = 'Nuke';
|
||||
nukeBtn.style.marginLeft = '10px';
|
||||
nukeBtn.style.backgroundColor = '#d9534f'; // Red warning color
|
||||
nukeBtn.onclick = () => nukeRoom(r.name);
|
||||
|
||||
container.append(link, details, nukeBtn);
|
||||
div.appendChild(container);
|
||||
});
|
||||
countDisplay.textContent = `(${activeRooms.length})`;
|
||||
}
|
||||
|
||||
async function banUser(username) {
|
||||
if (!confirm(`Ban user ${username}?`)) return;
|
||||
const res = await fetch('/admin/banip', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username })
|
||||
});
|
||||
const result = await res.json();
|
||||
alert(JSON.stringify(result));
|
||||
loadUsers();
|
||||
loadRooms();
|
||||
loadBanned();
|
||||
}
|
||||
|
||||
async function banUsername(username) {
|
||||
if (!confirm(`Ban username ${username}?`)) return;
|
||||
const res = await fetch('/admin/banusername', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username })
|
||||
});
|
||||
const result = await res.json();
|
||||
alert(JSON.stringify(result));
|
||||
loadUsers();
|
||||
loadRooms();
|
||||
loadBanned();
|
||||
}
|
||||
|
||||
async function loadBanned() {
|
||||
const res = await fetch('/admin/banned');
|
||||
const banned = await res.json();
|
||||
const div = document.getElementById('bannedList');
|
||||
div.innerHTML = '';
|
||||
banned.forEach(item => {
|
||||
const el = document.createElement('div');
|
||||
el.textContent = `${item.type}: ${item.value}`;
|
||||
div.appendChild(el);
|
||||
});
|
||||
}
|
||||
|
||||
async function bulkBanIPs() {
|
||||
const lines = document.getElementById('bulkInput').value
|
||||
.split('\n')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
for (const ip of lines) {
|
||||
const res = await fetch('/admin/banrawip', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ip })
|
||||
});
|
||||
|
||||
const result = await res.json();
|
||||
if (!res.ok) {
|
||||
console.warn(`Failed to ban IP: ${ip} — ${result.error}`);
|
||||
} else {
|
||||
console.log(`Banned IP: ${ip}`, result);
|
||||
}
|
||||
}
|
||||
|
||||
alert("Raw IP ban requests completed.");
|
||||
loadUsers();
|
||||
loadRooms();
|
||||
loadBanned();
|
||||
}
|
||||
|
||||
async function bulkBanUsernames() {
|
||||
const lines = document.getElementById('bulkInput').value.split('\n').map(s => s.trim()).filter(Boolean);
|
||||
for (const username of lines) {
|
||||
const res = await fetch('/admin/banusername', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username })
|
||||
});
|
||||
const result = await res.json();
|
||||
console.log(result);
|
||||
}
|
||||
alert("Username ban requests completed.");
|
||||
loadUsers();
|
||||
loadRooms();
|
||||
loadBanned();
|
||||
}
|
||||
|
||||
async function releaseUser(username) {
|
||||
if (!confirm(`Release user ${username}?`)) return;
|
||||
const res = await fetch('/admin/releaseuser', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username })
|
||||
});
|
||||
const result = await res.json();
|
||||
alert(JSON.stringify(result));
|
||||
loadUsers();
|
||||
loadRooms();
|
||||
loadBanned();
|
||||
}
|
||||
|
||||
async function broadcastSystemMessage() {
|
||||
const input = document.getElementById('broadcastInput');
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
|
||||
if (!confirm(`Broadcast to ALL rooms: "${text}"?`)) return;
|
||||
|
||||
const res = await fetch('/admin/broadcast', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text })
|
||||
});
|
||||
const result = await res.json();
|
||||
alert(`Broadcast sent to ${result.rooms_affected} rooms.`);
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
async function nukeRoom(room) {
|
||||
if (!confirm(`Are you sure you want to NUKE room: "${room}"? This will delete all history and kick users.`)) return;
|
||||
await fetch('/admin/nukeroom', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ room })
|
||||
});
|
||||
loadRooms();
|
||||
}
|
||||
|
||||
// Initial load
|
||||
loadUsers();
|
||||
loadRooms();
|
||||
loadBanned();
|
||||
loadStats();
|
||||
|
||||
// Refresh every 5 seconds
|
||||
setInterval(() => {
|
||||
loadUsers();
|
||||
loadRooms();
|
||||
loadBanned();
|
||||
loadStats();
|
||||
}, 5000);
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
document.querySelectorAll(".collapsible").forEach(header => {
|
||||
const countSpan = header.querySelector('span');
|
||||
const labelText = header.childNodes[0].textContent.trim(); // original text
|
||||
|
||||
// Set initial state
|
||||
if (countSpan) {
|
||||
header.childNodes[0].textContent = '▶ ' + labelText + ' ';
|
||||
} else {
|
||||
header.textContent = '▶ ' + labelText;
|
||||
}
|
||||
|
||||
const content = header.nextElementSibling;
|
||||
if (content && content.classList.contains("collapsible-content")) {
|
||||
content.style.display = 'none';
|
||||
|
||||
header.style.cursor = "pointer";
|
||||
header.addEventListener("click", () => {
|
||||
const isOpen = content.style.display === 'block';
|
||||
content.style.display = isOpen ? 'none' : 'block';
|
||||
|
||||
if (countSpan) {
|
||||
header.childNodes[0].textContent = (isOpen ? '▶ ' : '▼ ') + labelText + ' ';
|
||||
} else {
|
||||
header.textContent = (isOpen ? '▶ ' : '▼ ') + labelText;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
548
static/assets/js/chat.js
Normal file
548
static/assets/js/chat.js
Normal file
|
|
@ -0,0 +1,548 @@
|
|||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const room = params.get("room") || "Unknown Room";
|
||||
const username = localStorage.getItem("username");
|
||||
const userKey = localStorage.getItem("userKey");
|
||||
let firstLoad = true;
|
||||
let isRefreshing = false;
|
||||
|
||||
if (!room || !username || !userKey) {
|
||||
alert("Missing room or user credentials.");
|
||||
const url = `/index.html${room ? '?room=' + encodeURIComponent(room) : ''}`;
|
||||
window.location.href = url;
|
||||
return;
|
||||
}
|
||||
|
||||
document.title = room;
|
||||
|
||||
try {
|
||||
const res = await fetch('/validate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, key: userKey })
|
||||
});
|
||||
if (!res.ok) {
|
||||
localStorage.clear();
|
||||
alert("User validation failed.");
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Validation error:', err);
|
||||
localStorage.clear();
|
||||
alert("Validation failed.");
|
||||
const redirectUrl = `/index.html${room ? '?room=' + encodeURIComponent(room) : ''}`;
|
||||
window.location.href = redirectUrl;
|
||||
return;
|
||||
}
|
||||
|
||||
// 🔒 Security: Escape HTML to prevent XSS
|
||||
function escapeHtml(text) {
|
||||
if (!text) return text;
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
document.getElementById("room-title").innerHTML = `
|
||||
<div class="title-row">
|
||||
<a href="server_list.html">
|
||||
<img src="/assets/images/transientchat-blue.png" alt="Transient.chat" style="height: 60px;"/>
|
||||
<a/>
|
||||
Room: ${escapeHtml(room)}
|
||||
</div>
|
||||
`;
|
||||
|
||||
const messagesDiv = document.getElementById("messages");
|
||||
const form = document.getElementById("chat-form");
|
||||
const input = document.getElementById("message-input");
|
||||
const usersDiv = document.getElementById("user-list");
|
||||
const soundToggleBtn = document.getElementById("sound-toggle");
|
||||
const externalToggleBtn = document.getElementById("external-toggle");
|
||||
|
||||
// 🎤 Voice Message Setup
|
||||
const micBtn = document.createElement("button");
|
||||
micBtn.textContent = "🎤";
|
||||
micBtn.type = "button";
|
||||
micBtn.className = "mic-button";
|
||||
micBtn.title = "Hold to record (max 15s)";
|
||||
|
||||
// Insert mic button after input (between input and send button)
|
||||
if (input && input.parentNode) {
|
||||
input.parentNode.insertBefore(micBtn, input.nextSibling);
|
||||
}
|
||||
|
||||
// 🗣️ Autoplay Toggle
|
||||
const autoplayToggleBtn = document.createElement("button");
|
||||
autoplayToggleBtn.id = "autoplay-toggle";
|
||||
// Insert after external toggle
|
||||
if (externalToggleBtn && externalToggleBtn.parentNode) {
|
||||
externalToggleBtn.parentNode.insertBefore(autoplayToggleBtn, externalToggleBtn.nextSibling);
|
||||
}
|
||||
|
||||
// Audio setup
|
||||
const sounds = {
|
||||
message: new Audio('/assets/sounds/message.wav'),
|
||||
in: new Audio('/assets/sounds/user-in.wav'),
|
||||
out: new Audio('/assets/sounds/user-out.wav'),
|
||||
};
|
||||
|
||||
// Load saved preferences from localStorage
|
||||
soundEnabled = localStorage.getItem("soundEnabled") !== "false"; // default true
|
||||
externalContentEnabled = localStorage.getItem("externalContentEnabled") !== "false"; // default true
|
||||
let autoplayEnabled = localStorage.getItem("autoplayEnabled") !== "false"; // default true
|
||||
|
||||
soundToggleBtn.textContent = soundEnabled ? "🔊 Sound: On" : "🔇 Sound: Off";
|
||||
soundToggleBtn.classList.toggle("toggle-off", !soundEnabled);
|
||||
|
||||
const updateAutoplayToggleBtn = () => {
|
||||
autoplayToggleBtn.textContent = autoplayEnabled
|
||||
? "🗣️ Autoplay: On"
|
||||
: "🔇 Autoplay: Off";
|
||||
autoplayToggleBtn.classList.toggle("toggle-off", !autoplayEnabled);
|
||||
};
|
||||
updateAutoplayToggleBtn();
|
||||
|
||||
const updateExternalToggleBtn = () => {
|
||||
externalToggleBtn.textContent = externalContentEnabled
|
||||
? "🖥️ External Content: On"
|
||||
: "🔒 External Content: Off";
|
||||
externalToggleBtn.classList.toggle("toggle-off", !externalContentEnabled);
|
||||
};
|
||||
|
||||
updateExternalToggleBtn(); // Reset style on load
|
||||
|
||||
// Mobile Toggle for User List & Settings
|
||||
const userListContainer = document.getElementById("user-list-container");
|
||||
if (userListContainer) {
|
||||
const toggleBtn = document.createElement("button");
|
||||
toggleBtn.id = "user-list-toggle";
|
||||
toggleBtn.textContent = "Show Users & Settings";
|
||||
userListContainer.prepend(toggleBtn);
|
||||
|
||||
toggleBtn.addEventListener("click", () => {
|
||||
const isExpanded = userListContainer.classList.toggle("expanded");
|
||||
toggleBtn.textContent = isExpanded ? "Hide Users & Settings" : "Show Users & Settings";
|
||||
});
|
||||
}
|
||||
|
||||
soundToggleBtn.addEventListener("click", () => {
|
||||
soundEnabled = !soundEnabled;
|
||||
localStorage.setItem("soundEnabled", soundEnabled); // ✅ Persist
|
||||
soundToggleBtn.textContent = soundEnabled ? "🔊 Sound: On" : "🔇 Sound: Off";
|
||||
soundToggleBtn.classList.toggle("toggle-off", !soundEnabled);
|
||||
});
|
||||
|
||||
externalToggleBtn.addEventListener("click", () => {
|
||||
externalContentEnabled = !externalContentEnabled;
|
||||
localStorage.setItem("externalContentEnabled", externalContentEnabled); // ✅ Persist
|
||||
updateExternalToggleBtn();
|
||||
});
|
||||
|
||||
autoplayToggleBtn.addEventListener("click", () => {
|
||||
autoplayEnabled = !autoplayEnabled;
|
||||
localStorage.setItem("autoplayEnabled", autoplayEnabled);
|
||||
updateAutoplayToggleBtn();
|
||||
});
|
||||
|
||||
// Recording Logic
|
||||
let mediaRecorder;
|
||||
let audioChunks = [];
|
||||
let isRecording = false;
|
||||
let recordingTimeout;
|
||||
|
||||
async function startRecording() {
|
||||
if (isRecording) return;
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
// Low bitrate preference (16kbps)
|
||||
const options = { mimeType: 'audio/webm;codecs=opus', bitsPerSecond: 16000 };
|
||||
// Fallback if codec not supported
|
||||
if (!MediaRecorder.isTypeSupported(options.mimeType)) delete options.mimeType;
|
||||
|
||||
mediaRecorder = new MediaRecorder(stream, options);
|
||||
audioChunks = [];
|
||||
isRecording = true;
|
||||
micBtn.classList.add("recording");
|
||||
micBtn.textContent = "⏹️";
|
||||
|
||||
mediaRecorder.ondataavailable = e => audioChunks.push(e.data);
|
||||
mediaRecorder.onstop = () => {
|
||||
micBtn.classList.remove("recording");
|
||||
micBtn.textContent = "🎤";
|
||||
isRecording = false;
|
||||
clearTimeout(recordingTimeout);
|
||||
|
||||
const blob = new Blob(audioChunks, { type: 'audio/webm' });
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(blob);
|
||||
reader.onloadend = () => {
|
||||
const base64Audio = reader.result;
|
||||
// Send immediately
|
||||
fetch(`/chat/${encodeURIComponent(room)}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, text: "🎤 Voice Message", audio: base64Audio })
|
||||
}).then(() => refresh());
|
||||
};
|
||||
stream.getTracks().forEach(t => t.stop());
|
||||
};
|
||||
|
||||
mediaRecorder.start();
|
||||
recordingTimeout = setTimeout(() => {
|
||||
if (isRecording && mediaRecorder.state === "recording") mediaRecorder.stop();
|
||||
}, 15000); // 15s limit
|
||||
|
||||
} catch (err) {
|
||||
console.error("Mic error:", err);
|
||||
alert("Could not access microphone.");
|
||||
}
|
||||
}
|
||||
|
||||
function stopRecording() {
|
||||
if (isRecording && mediaRecorder && mediaRecorder.state === "recording") {
|
||||
mediaRecorder.stop();
|
||||
}
|
||||
}
|
||||
|
||||
micBtn.addEventListener("mousedown", startRecording);
|
||||
micBtn.addEventListener("mouseup", stopRecording);
|
||||
micBtn.addEventListener("mouseleave", stopRecording);
|
||||
micBtn.addEventListener("touchstart", (e) => { e.preventDefault(); startRecording(); });
|
||||
micBtn.addEventListener("touchend", (e) => { e.preventDefault(); stopRecording(); });
|
||||
|
||||
async function refreshUserPresence() {
|
||||
const res = await fetch(`/chat/${encodeURIComponent(room)}/users`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
alert("You were removed from the room.");
|
||||
window.location.href = "/server_list.html"; // ✅ go to server list
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let lastUserList = [];
|
||||
let lastTimestamp = null;
|
||||
const seenMessageTimestamps = new Set();
|
||||
|
||||
const audioQueue = [];
|
||||
let isPlayingAudio = false;
|
||||
|
||||
function processAudioQueue() {
|
||||
if (isPlayingAudio || audioQueue.length === 0) return;
|
||||
|
||||
const audio = audioQueue.shift();
|
||||
isPlayingAudio = true;
|
||||
|
||||
const onComplete = () => {
|
||||
isPlayingAudio = false;
|
||||
processAudioQueue();
|
||||
};
|
||||
|
||||
audio.addEventListener('ended', onComplete, { once: true });
|
||||
audio.addEventListener('error', () => {
|
||||
console.error("Audio playback error");
|
||||
onComplete();
|
||||
}, { once: true });
|
||||
|
||||
audio.play().catch(e => {
|
||||
console.log("Autoplay blocked or failed:", e);
|
||||
onComplete();
|
||||
});
|
||||
}
|
||||
|
||||
let windowFocused = true;
|
||||
let unreadCount = 0;
|
||||
|
||||
window.addEventListener('focus', () => {
|
||||
windowFocused = true;
|
||||
unreadCount = 0;
|
||||
document.title = `${room}`;
|
||||
});
|
||||
|
||||
window.addEventListener('blur', () => {
|
||||
windowFocused = false;
|
||||
});
|
||||
|
||||
function linkify(text) {
|
||||
// 1. Escape the raw text first to prevent XSS
|
||||
const safeText = escapeHtml(text);
|
||||
|
||||
// 2. Replace URLs in the escaped text with HTML
|
||||
return safeText.replace(/(https?:\/\/[^\s]+)/g, (url) => {
|
||||
const imagePattern = /\.(png|jpe?g|gif)$/i;
|
||||
const youtubeRegex = /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/watch\?v=|youtu\.be\/)([\w-]{11})/;
|
||||
|
||||
const id = `preview-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
// ✅ YouTube Video
|
||||
const ytMatch = url.match(youtubeRegex);
|
||||
if (ytMatch) {
|
||||
const videoId = ytMatch[1];
|
||||
const embedUrl = `https://www.youtube.com/embed/${videoId}`;
|
||||
|
||||
if (externalContentEnabled) {
|
||||
return `
|
||||
<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>
|
||||
<div style="padding: 0.5em; text-align: center;">
|
||||
<iframe
|
||||
src="${embedUrl}"
|
||||
title="YouTube video player"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
allowfullscreen
|
||||
style="width: 640px; height: 360px; max-width: 100%; border-radius: 4px;"
|
||||
></iframe>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
return `
|
||||
<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>
|
||||
<div class="external-image-placeholder" style="padding: 0.5em; text-align: center;">
|
||||
<button onclick="
|
||||
this.style.display='none';
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.src = '${embedUrl}';
|
||||
iframe.title = 'YouTube video player';
|
||||
iframe.frameBorder = 0;
|
||||
iframe.allow = 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share';
|
||||
iframe.referrerPolicy = 'strict-origin-when-cross-origin';
|
||||
iframe.allowFullscreen = true;
|
||||
iframe.style.width = '640px';
|
||||
iframe.style.height = '360px';
|
||||
iframe.style.maxWidth = '100%';
|
||||
iframe.style.borderRadius = '4px';
|
||||
document.getElementById('${id}').appendChild(iframe);
|
||||
">
|
||||
Show external content
|
||||
</button>
|
||||
<div id="${id}" style="margin-top: 0.5em;"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Image
|
||||
if (imagePattern.test(url)) {
|
||||
if (externalContentEnabled) {
|
||||
return `
|
||||
<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>
|
||||
<div style="padding: 0.5em; text-align: center;">
|
||||
<img src="${url}" style="width: 100%; height: auto; border-radius: 4px;" />
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
return `
|
||||
<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>
|
||||
<div class="external-image-placeholder" style="padding: 0.5em; text-align: center;">
|
||||
<button onclick="
|
||||
this.style.display='none';
|
||||
const img = document.createElement('img');
|
||||
img.src = '${url}';
|
||||
img.style.width = '100%';
|
||||
img.style.height = 'auto';
|
||||
img.style.borderRadius = '4px';
|
||||
document.getElementById('${id}').appendChild(img);
|
||||
">
|
||||
Show external content
|
||||
</button>
|
||||
<div id="${id}" style="margin-top: 0.5em;"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// 🔗 Default fallback: plain link
|
||||
return `<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`;
|
||||
});
|
||||
}
|
||||
|
||||
function cleanExpiredMessages() {
|
||||
const now = Date.now();
|
||||
const oneHourAgo = now - 60 * 60 * 1000;
|
||||
const messageDivs = document.querySelectorAll("#messages .message");
|
||||
|
||||
messageDivs.forEach(msgDiv => {
|
||||
const ts = msgDiv.getAttribute("data-timestamp");
|
||||
if (!ts) return;
|
||||
|
||||
const cleanedTs = ts.replace(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}).+$/, '$1Z');
|
||||
const parsedTimestamp = Date.parse(cleanedTs);
|
||||
if (isNaN(parsedTimestamp)) return;
|
||||
|
||||
if (parsedTimestamp < oneHourAgo) {
|
||||
msgDiv.remove();
|
||||
seenMessageTimestamps.delete(ts);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
const res = await fetch(`/chat/${encodeURIComponent(room)}/users`);
|
||||
const data = await res.json();
|
||||
|
||||
const currentList = data.users || [];
|
||||
const isOwner = data.owner === username;
|
||||
usersDiv.innerHTML = "";
|
||||
|
||||
const joined = currentList.filter(user => !lastUserList.includes(user));
|
||||
const left = lastUserList.filter(user => !currentList.includes(user));
|
||||
|
||||
if (!firstLoad && soundEnabled) {
|
||||
if (joined.length > 0) sounds.in.play();
|
||||
if (left.length > 0) sounds.out.play();
|
||||
}
|
||||
|
||||
lastUserList = currentList;
|
||||
|
||||
currentList.forEach(user => {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = user;
|
||||
|
||||
if (isOwner && user !== username) {
|
||||
const btn = document.createElement("button");
|
||||
btn.textContent = "Kick";
|
||||
btn.className = "kick-button";
|
||||
btn.onclick = async () => {
|
||||
if (confirm(`Kick ${user}?`)) {
|
||||
await fetch(`/chat/${encodeURIComponent(room)}/kick`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ owner: username, target: user }),
|
||||
});
|
||||
await loadUsers();
|
||||
}
|
||||
};
|
||||
div.appendChild(btn);
|
||||
}
|
||||
|
||||
usersDiv.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadMessages() {
|
||||
const url = `/chat/${encodeURIComponent(room)}` + (lastTimestamp ? `?since=${encodeURIComponent(lastTimestamp)}` : "");
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
|
||||
if (!Array.isArray(data)) return;
|
||||
|
||||
const now = Date.now();
|
||||
const oneHourAgo = now - 60 * 60 * 1000;
|
||||
|
||||
const newMessages = data.filter(msg => {
|
||||
const msgTime = new Date(msg.timestamp).getTime();
|
||||
if (isNaN(msgTime)) return false;
|
||||
if (firstLoad) return true;
|
||||
return msgTime >= oneHourAgo;
|
||||
});
|
||||
|
||||
newMessages.forEach(msg => {
|
||||
// Skip if already rendered
|
||||
if (seenMessageTimestamps.has(msg.timestamp)) return;
|
||||
|
||||
const div = document.createElement("div");
|
||||
div.classList.add("message");
|
||||
div.setAttribute("data-timestamp", msg.timestamp);
|
||||
seenMessageTimestamps.add(msg.timestamp);
|
||||
|
||||
if (msg.username === username) {
|
||||
div.classList.add("my-message");
|
||||
}
|
||||
div.innerHTML = `<strong>${escapeHtml(msg.username)}:</strong> ${linkify(msg.text)}`;
|
||||
|
||||
if (msg.audio) {
|
||||
const audioContainer = document.createElement("div");
|
||||
audioContainer.className = "audio-container";
|
||||
const audio = document.createElement("audio");
|
||||
audio.controls = true;
|
||||
audio.className = "voice-message-audio";
|
||||
|
||||
audio.src = msg.audio;
|
||||
|
||||
// Autoplay if enabled, not first load, and not my own message
|
||||
if (autoplayEnabled && !firstLoad && msg.username !== username) {
|
||||
audioQueue.push(audio);
|
||||
processAudioQueue();
|
||||
}
|
||||
|
||||
audioContainer.appendChild(audio);
|
||||
div.appendChild(audioContainer);
|
||||
}
|
||||
|
||||
messagesDiv.appendChild(div);
|
||||
});
|
||||
|
||||
if (newMessages.length > 0) {
|
||||
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
||||
lastTimestamp = newMessages[newMessages.length - 1].timestamp;
|
||||
}
|
||||
|
||||
if (!firstLoad && soundEnabled && newMessages.some(msg => msg.username !== username)) {
|
||||
sounds.message.play();
|
||||
}
|
||||
|
||||
if (!windowFocused && newMessages.length > 0) {
|
||||
unreadCount += newMessages.length;
|
||||
document.title = `${room} (${unreadCount})`;
|
||||
}
|
||||
}
|
||||
|
||||
form.addEventListener("submit", e => {
|
||||
e.preventDefault();
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
|
||||
fetch(`/chat/${encodeURIComponent(room)}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, text })
|
||||
}).then(() => {
|
||||
input.value = "";
|
||||
refresh(); // force fetch after post to ensure sync
|
||||
});
|
||||
});
|
||||
|
||||
async function refresh() {
|
||||
if (isRefreshing) return;
|
||||
isRefreshing = true;
|
||||
|
||||
try {
|
||||
// 🔒 Check if user is still valid
|
||||
const res = await fetch('/validate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, key: userKey })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
localStorage.clear();
|
||||
alert("Session expired or you were removed.");
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
|
||||
await refreshUserPresence();
|
||||
await loadMessages();
|
||||
await loadUsers();
|
||||
cleanExpiredMessages();
|
||||
} catch (e) {
|
||||
console.error("Refresh error:", e);
|
||||
} finally {
|
||||
firstLoad = false;
|
||||
isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
// First full load, then enable polling
|
||||
await refresh();
|
||||
setInterval(refresh, 1000);
|
||||
});
|
||||
87
static/assets/js/index.js
Normal file
87
static/assets/js/index.js
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const storedUsername = localStorage.getItem('username');
|
||||
const storedKey = localStorage.getItem('userKey');
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const room = params.get('room');
|
||||
const isWatchParty = params.has('watchparty'); // Use explicit watchparty URL flag
|
||||
|
||||
function redirectToRoom(roomName) {
|
||||
if (!roomName) {
|
||||
window.location.href = '/server_list.html';
|
||||
} else {
|
||||
window.location.href = isWatchParty
|
||||
? `/watchparty_chat.html?room=${encodeURIComponent(roomName)}`
|
||||
: `/chat.html?room=${encodeURIComponent(roomName)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-forward if valid session is stored
|
||||
if (storedUsername && storedKey) {
|
||||
try {
|
||||
const response = await fetch('/validate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: storedUsername, key: storedKey }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
redirectToRoom(room);
|
||||
return;
|
||||
} else {
|
||||
// Session expired or invalid; clear storage but pre-fill username
|
||||
localStorage.clear();
|
||||
const input = document.getElementById('username-input');
|
||||
if (input) input.value = storedUsername;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Validation error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
const startButton = document.getElementById('start-chat-button');
|
||||
const usernameInput = document.getElementById('username-input');
|
||||
|
||||
if (usernameInput) {
|
||||
usernameInput.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
startButton.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (startButton) {
|
||||
startButton.addEventListener('click', async () => {
|
||||
const username = usernameInput.value.trim();
|
||||
|
||||
if (!username) {
|
||||
alert('Please enter a username.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
alert(err.error || 'Username registration failed.');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
localStorage.setItem('username', data.username);
|
||||
localStorage.setItem('userKey', data.key);
|
||||
|
||||
redirectToRoom(room);
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
alert('An error occurred.');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
190
static/assets/js/server_list.js
Normal file
190
static/assets/js/server_list.js
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
const username = localStorage.getItem('username');
|
||||
const userKey = localStorage.getItem('userKey');
|
||||
|
||||
if (!username || !userKey) {
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/validate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, key: userKey })
|
||||
});
|
||||
if (!response.ok) {
|
||||
localStorage.clear();
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Validation failed:', err);
|
||||
localStorage.clear();
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
|
||||
const userInfoEl = document.getElementById('user-info');
|
||||
userInfoEl.textContent = `Logged in as: ${username}`;
|
||||
|
||||
document.querySelectorAll('.collapsible').forEach(heading => {
|
||||
heading.addEventListener('click', () => {
|
||||
const content = heading.nextElementSibling;
|
||||
const isOpen = content.style.display === 'block';
|
||||
content.style.display = isOpen ? 'none' : 'block';
|
||||
heading.textContent = (isOpen ? '▶' : '▼') + heading.textContent.slice(1);
|
||||
});
|
||||
});
|
||||
|
||||
fetch("/rooms")
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const { regional, topical, public: publicRooms, unique_users } = data;
|
||||
|
||||
const regionalList = document.getElementById('regional-rooms');
|
||||
const topicalList = document.getElementById('topical-rooms');
|
||||
const publicList = document.getElementById('public-rooms');
|
||||
const watchpartyList = document.getElementById('watchparty-rooms');
|
||||
|
||||
// Helper to create a room <li>
|
||||
function createRoomItem({ name, users, type }) {
|
||||
const li = document.createElement('li');
|
||||
const a = document.createElement('a');
|
||||
const isWatchParty = type && type.startsWith('watchparty');
|
||||
a.href = isWatchParty
|
||||
? `/watchparty_chat.html?room=${encodeURIComponent(name)}`
|
||||
: `/chat.html?room=${encodeURIComponent(name)}`;
|
||||
a.textContent = `${name} ( ${users} )`;
|
||||
li.appendChild(a);
|
||||
return { li, isWatchParty };
|
||||
}
|
||||
|
||||
// Add regional rooms
|
||||
regional.forEach(room => {
|
||||
regionalList.appendChild(createRoomItem(room).li);
|
||||
});
|
||||
|
||||
// Add topical rooms
|
||||
topical.forEach(room => {
|
||||
topicalList.appendChild(createRoomItem(room).li);
|
||||
});
|
||||
|
||||
// Add public/watchparty rooms
|
||||
publicRooms.forEach(room => {
|
||||
const { li, isWatchParty } = createRoomItem(room);
|
||||
if (isWatchParty) {
|
||||
watchpartyList.appendChild(li);
|
||||
} else {
|
||||
publicList.appendChild(li);
|
||||
}
|
||||
});
|
||||
|
||||
// Show total unique user count
|
||||
document.getElementById('user-count').textContent = `Users Chatting ( ${unique_users} )`;
|
||||
|
||||
// 🧠 Load and render Active Rooms (regional, topical, public w/ users)
|
||||
fetch("/rooms?activeOnly=true")
|
||||
.then(response => response.json())
|
||||
.then(({ regional, topical, public: publicRooms }) => {
|
||||
const activeList = document.getElementById("active-rooms");
|
||||
let count = 0;
|
||||
|
||||
function createActiveRoomItem({ name, users, type }) {
|
||||
const li = document.createElement("li");
|
||||
const a = document.createElement("a");
|
||||
|
||||
a.href = type === "watchparty"
|
||||
? `/watchparty_chat.html?room=${encodeURIComponent(name)}`
|
||||
: `/chat.html?room=${encodeURIComponent(name)}`;
|
||||
a.textContent = `${name} ( ${users} )`;
|
||||
li.appendChild(a);
|
||||
return li;
|
||||
}
|
||||
|
||||
[...regional, ...topical, ...publicRooms].forEach(room => {
|
||||
activeList.appendChild(createActiveRoomItem(room));
|
||||
count++;
|
||||
});
|
||||
|
||||
// Update Active Rooms heading with count
|
||||
const activeHeadingEl = document.getElementById("active-rooms-header");
|
||||
if (activeHeadingEl) {
|
||||
const baseText = activeHeadingEl.textContent.replace(/▶|▼|\(\d+\)/g, "").trim();
|
||||
activeHeadingEl.textContent = `▶ ${baseText} ( ${count} )`;
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Failed to load active rooms:", err);
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Failed to load rooms:", err);
|
||||
alert("Failed to load rooms.");
|
||||
});
|
||||
|
||||
document.getElementById('create-room-btn').addEventListener('click', () => {
|
||||
const roomName = document.getElementById('new-room-name').value.trim();
|
||||
if (!roomName) return alert('Room name cannot be empty.');
|
||||
|
||||
fetch('/rooms', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: roomName })
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('Room creation failed.');
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
window.location.href = `/chat.html?room=${encodeURIComponent(data.name)}`;
|
||||
})
|
||||
.catch(err => alert(err.message));
|
||||
});
|
||||
|
||||
document.getElementById('create-unlisted-room-btn').addEventListener('click', () => {
|
||||
const roomName = document.getElementById('new-room-name').value.trim();
|
||||
if (!roomName) return alert('Room name cannot be empty.');
|
||||
window.location.href = `/chat.html?room=${encodeURIComponent(roomName)}`;
|
||||
});
|
||||
|
||||
// 🔥 Watch Party creation
|
||||
document.getElementById('create-watchparty-btn').addEventListener('click', () => {
|
||||
const roomName = document.getElementById('new-room-name').value.trim();
|
||||
if (!roomName) return alert('Room name cannot be empty.');
|
||||
|
||||
fetch('/rooms', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: roomName })
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('Watch party creation failed.');
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
window.location.href = `/watchparty_chat.html?room=${encodeURIComponent(data.name)}`;
|
||||
})
|
||||
.catch(err => alert(err.message));
|
||||
});
|
||||
});
|
||||
|
||||
// Enable Enter key for room creation
|
||||
document.getElementById('new-room-name').addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
document.getElementById('create-room-btn').click();
|
||||
}
|
||||
});
|
||||
|
||||
// Enable Enter key for watch party creation
|
||||
document.getElementById('new-room-name').addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
document.getElementById('create-watchparty-btn').click();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('pageshow', (event) => {
|
||||
if (event.persisted) {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
224
static/assets/js/watchparty_chat.js
Normal file
224
static/assets/js/watchparty_chat.js
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const room = params.get("room");
|
||||
const username = localStorage.getItem("username");
|
||||
const userKey = localStorage.getItem("userKey");
|
||||
|
||||
if (!room || !username || !userKey) {
|
||||
alert("Missing room or user credentials.");
|
||||
window.location.href = `/index.html${room ? `?room=${encodeURIComponent(room)}&watchparty=1` : ""}`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Note: We rely on chat.js (loaded in the HTML) to handle the server-side
|
||||
// /validate call and the rendering of the #room-title header.
|
||||
// This script focuses solely on the video synchronization logic.
|
||||
|
||||
const videoControls = document.getElementById("video-controls");
|
||||
const videoInput = document.getElementById("video-url-input");
|
||||
const videoButton = document.getElementById("start-video-button");
|
||||
const pauseButton = document.getElementById("pause-video-button");
|
||||
const resumeButton = document.getElementById("resume-video-button");
|
||||
const clearButton = document.getElementById("clear-video-button");
|
||||
const iframe = document.getElementById("video-iframe");
|
||||
const pausedOverlay = document.getElementById("paused-overlay");
|
||||
|
||||
let videoExpireAt = null;
|
||||
let videoStarted = false;
|
||||
let lastVideoId = null;
|
||||
let isRoomOwner = false;
|
||||
|
||||
function extractYouTubeId(url) {
|
||||
const match = url.match(/(?:youtube\.com\/.*v=|youtu\.be\/)([\w-]{11})/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
async function fetchYouTubeDuration(videoId) {
|
||||
return 24 * 60 * 60; // 24 hours in seconds
|
||||
}
|
||||
|
||||
function loadYouTubeVideo(videoId, startSeconds = 0) {
|
||||
if (lastVideoId === videoId) return;
|
||||
iframe.src = `https://www.youtube.com/embed/${videoId}?autoplay=1&start=${startSeconds}`;
|
||||
iframe.classList.remove("hidden");
|
||||
lastVideoId = videoId;
|
||||
}
|
||||
|
||||
function clearVideoUI() {
|
||||
iframe.src = "";
|
||||
iframe.classList.add("hidden");
|
||||
videoExpireAt = null;
|
||||
videoStarted = false;
|
||||
lastVideoId = null;
|
||||
pausedOverlay.classList.add("hidden");
|
||||
updateControlVisibility({ hasVideo: false, isPaused: false, isOwner: isRoomOwner });
|
||||
}
|
||||
|
||||
function updateControlVisibility({ hasVideo, isPaused, isOwner }) {
|
||||
if (isOwner) {
|
||||
if (hasVideo) {
|
||||
videoInput.classList.add("hidden");
|
||||
videoButton.classList.add("hidden");
|
||||
clearButton.classList.remove("hidden");
|
||||
|
||||
if (isPaused) {
|
||||
pauseButton.classList.add("hidden");
|
||||
resumeButton.classList.remove("hidden");
|
||||
} else {
|
||||
pauseButton.classList.remove("hidden");
|
||||
resumeButton.classList.add("hidden");
|
||||
}
|
||||
} else {
|
||||
videoInput.classList.remove("hidden");
|
||||
videoButton.classList.remove("hidden");
|
||||
clearButton.classList.add("hidden");
|
||||
pauseButton.classList.add("hidden");
|
||||
resumeButton.classList.add("hidden");
|
||||
}
|
||||
videoControls.classList.remove("hidden");
|
||||
} else {
|
||||
videoControls.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
async function checkRoomOwnership() {
|
||||
try {
|
||||
const res = await fetch(`/chat/${encodeURIComponent(room)}/users`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username }),
|
||||
});
|
||||
const data = await res.json();
|
||||
isRoomOwner = data.owner && data.owner === username;
|
||||
} catch (err) {
|
||||
console.error("Failed to determine ownership", err);
|
||||
isRoomOwner = false;
|
||||
}
|
||||
}
|
||||
|
||||
videoButton.addEventListener("click", async () => {
|
||||
const url = videoInput.value.trim();
|
||||
const videoId = extractYouTubeId(url);
|
||||
if (!videoId) return alert("Invalid YouTube URL.");
|
||||
|
||||
await fetch(`/watchparty/${encodeURIComponent(room)}/video`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
|
||||
const duration = await fetchYouTubeDuration(videoId);
|
||||
videoExpireAt = Date.now() + duration * 1000;
|
||||
videoStarted = true;
|
||||
|
||||
loadYouTubeVideo(videoId);
|
||||
updateControlVisibility({ hasVideo: true, isPaused: false, isOwner: isRoomOwner });
|
||||
});
|
||||
|
||||
pauseButton.addEventListener("click", async () => {
|
||||
try {
|
||||
await fetch(`/watchparty/${encodeURIComponent(room)}/video/pause`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username }),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to pause video", err);
|
||||
}
|
||||
});
|
||||
|
||||
resumeButton.addEventListener("click", async () => {
|
||||
try {
|
||||
await fetch(`/watchparty/${encodeURIComponent(room)}/video/resume`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username }),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to resume video", err);
|
||||
}
|
||||
});
|
||||
|
||||
clearButton.addEventListener("click", async () => {
|
||||
try {
|
||||
await fetch(`/watchparty/${encodeURIComponent(room)}/video/clear`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username }),
|
||||
});
|
||||
|
||||
clearVideoUI();
|
||||
} catch (err) {
|
||||
console.error("Failed to clear video", err);
|
||||
}
|
||||
});
|
||||
|
||||
async function syncVideoFromServer() {
|
||||
try {
|
||||
const res = await fetch(`/watchparty/${encodeURIComponent(room)}/video`);
|
||||
if (!res.ok) throw new Error("video fetch failed");
|
||||
|
||||
const { url, elapsed, is_paused } = await res.json();
|
||||
const videoId = extractYouTubeId(url);
|
||||
if (!videoId) {
|
||||
clearVideoUI();
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_paused) {
|
||||
if (!iframe.classList.contains("hidden")) {
|
||||
iframe.classList.add("hidden");
|
||||
iframe.src = ""; // Stop audio
|
||||
pausedOverlay.classList.remove("hidden");
|
||||
lastVideoId = null; // Force reload on resume
|
||||
}
|
||||
videoStarted = true;
|
||||
updateControlVisibility({ hasVideo: true, isPaused: true, isOwner: isRoomOwner });
|
||||
return;
|
||||
}
|
||||
|
||||
pausedOverlay.classList.add("hidden");
|
||||
const duration = await fetchYouTubeDuration(videoId);
|
||||
videoExpireAt = Date.now() + ((duration - elapsed) * 1000);
|
||||
videoStarted = true;
|
||||
|
||||
loadYouTubeVideo(videoId, elapsed);
|
||||
updateControlVisibility({ hasVideo: true, isPaused: false, isOwner: isRoomOwner });
|
||||
} catch (err) {
|
||||
// Fallback: video was likely cleared
|
||||
if (videoStarted || !iframe.classList.contains("hidden")) {
|
||||
clearVideoUI();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setInterval(async () => {
|
||||
if (videoExpireAt && Date.now() > videoExpireAt) {
|
||||
clearVideoUI();
|
||||
|
||||
if (isRoomOwner) {
|
||||
try {
|
||||
await fetch(`/watchparty/${encodeURIComponent(room)}/video/clear`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username }),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to auto-clear video", err);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await syncVideoFromServer();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
async function initialize() {
|
||||
await checkRoomOwnership();
|
||||
await syncVideoFromServer();
|
||||
if (!videoStarted) {
|
||||
updateControlVisibility({ hasVideo: false, isPaused: false, isOwner: isRoomOwner });
|
||||
}
|
||||
}
|
||||
|
||||
await initialize();
|
||||
});
|
||||
BIN
static/assets/sounds/message.wav
Normal file
BIN
static/assets/sounds/message.wav
Normal file
Binary file not shown.
BIN
static/assets/sounds/user-in.wav
Normal file
BIN
static/assets/sounds/user-in.wav
Normal file
Binary file not shown.
BIN
static/assets/sounds/user-out.wav
Normal file
BIN
static/assets/sounds/user-out.wav
Normal file
Binary file not shown.
71
static/chat.html
Normal file
71
static/chat.html
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Transient.chat - Chat Room</title>
|
||||
<link rel="stylesheet" href="/assets/css/styles.css" />
|
||||
<link rel="stylesheet" href="/assets/css/chat.css" />
|
||||
<link rel="icon" href="/assets/images/favicon.ico" type="image/x-icon" />
|
||||
</head>
|
||||
<body>
|
||||
<h1 id="room-title">Chat Room</h1>
|
||||
<div id="page-container">
|
||||
<div id="chat-center">
|
||||
<div id="messages"></div>
|
||||
<form id="chat-form">
|
||||
<input
|
||||
type="text"
|
||||
class="inputfield"
|
||||
id="message-input"
|
||||
placeholder="Type a message, paste in a link, or url to an image or youtube video."
|
||||
autocomplete="off"
|
||||
required
|
||||
/>
|
||||
<button type="submit">Send</button>
|
||||
</form>
|
||||
</div>
|
||||
<div id="user-list-container">
|
||||
<a
|
||||
href="server_list.html"
|
||||
style="
|
||||
display: inline-block;
|
||||
margin-bottom: 10px;
|
||||
text-decoration: none;
|
||||
"
|
||||
>
|
||||
<button>Server List</button>
|
||||
</a>
|
||||
<br />
|
||||
<button
|
||||
id="sound-toggle"
|
||||
style="
|
||||
margin-bottom: 10px;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
text-align: left;
|
||||
"
|
||||
>
|
||||
🔊 Sound: On
|
||||
</button>
|
||||
<br />
|
||||
<button
|
||||
id="external-toggle"
|
||||
style="
|
||||
margin-bottom: 10px;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
text-align: left;
|
||||
"
|
||||
>
|
||||
🔒 External Content: Off
|
||||
</button>
|
||||
<br />
|
||||
<br />
|
||||
<h3>Users in room</h3>
|
||||
<div id="user-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/assets/js/chat.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
74
static/faq.html
Normal file
74
static/faq.html
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>FAQ - Transient.chat</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Frequently asked questions about using Transient.chat"
|
||||
/>
|
||||
<link rel="stylesheet" href="/assets/css/styles.css" />
|
||||
<link rel="icon" href="/assets/images/favicon.ico" type="image/x-icon" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="info-text">
|
||||
<div class="title-row">
|
||||
<a href="index.html">
|
||||
<img
|
||||
src="/assets/images/transientchat-blue.png"
|
||||
alt="Transient.chat"
|
||||
/>
|
||||
</a>
|
||||
<h1>Frequently Asked Questions</h1>
|
||||
</div>
|
||||
|
||||
<h2>How long do messages last?</h2>
|
||||
<p>All chat messages automatically disappear after 1 hour.</p>
|
||||
|
||||
<h2>How long do rooms last?</h2>
|
||||
<p>
|
||||
All chat rooms automatically disappear after all users leave the room.
|
||||
</p>
|
||||
|
||||
<h2>Do I need an account to use this?</h2>
|
||||
<p>
|
||||
No. Just pick a handle and start chatting. If you don't come back in 3
|
||||
days your handle is released.
|
||||
</p>
|
||||
|
||||
<h2>Is anything stored permanently?</h2>
|
||||
<p>
|
||||
No databases, no disk writes. Everything exists in memory and vanishes
|
||||
when inactive.
|
||||
</p>
|
||||
|
||||
<h2>What is a Watchroom?</h2>
|
||||
<p>
|
||||
A Watchroom lets you sync YouTube playback with others while chatting.
|
||||
Everyone sees the same video at the same time.
|
||||
</p>
|
||||
|
||||
<h2>Can I send voice messages?</h2>
|
||||
<p>
|
||||
Yes. You can record short voice clips (up to 15 seconds) by holding the microphone button.
|
||||
Enable <strong>Autoplay</strong> in the sidebar to hear messages automatically as they arrive.
|
||||
</p>
|
||||
|
||||
<h2>What is External Content?</h2>
|
||||
<p>
|
||||
External images and videos linked by other users. You can toggle <strong>External Content</strong> on to automatically show embedded media.
|
||||
</p>
|
||||
|
||||
<h2>Can users be removed?</h2>
|
||||
<p>Yes. Room owners can kick users. Admins can ban users by IP.</p>
|
||||
|
||||
<h2>Where can I find the source code?</h2>
|
||||
<p><a href="https://github.com/benjimanmiller/transient.chat">https://github.com/benjimanmiller/transient.chat</a></p>
|
||||
|
||||
<br /><br />
|
||||
<a href="index.html">← Back to Home</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
63
static/index.html
Normal file
63
static/index.html
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Transient.chat</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Live chats that vanish. No data. No history. Pure ephemerality."
|
||||
/>
|
||||
<link rel="stylesheet" href="/assets/css/styles.css" />
|
||||
<link rel="icon" href="/assets/images/favicon.ico" type="image/x-icon" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="info-text">
|
||||
<div class="title-row">
|
||||
<a href="index.html">
|
||||
<img
|
||||
src="/assets/images/transientchat-blue.png"
|
||||
alt="Transient.chat"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<h1>Chat without a trace!</h1>
|
||||
</div>
|
||||
<p>
|
||||
Real-time conversations that disappear after
|
||||
<strong>1 hour</strong>.<br />
|
||||
No accounts. No data trails. Nothing is saved... ever.<br />
|
||||
Everything is held in server memory and is never written to any disk or
|
||||
database. <br /><br />
|
||||
Join a room and talk freely. When the room empties, everything
|
||||
vanishes.<br />
|
||||
Want to sync up a video? Try a <strong>Watchroom</strong> and chat while
|
||||
watching YouTube together.<br /><br />
|
||||
<strong>Voice Messages:</strong> Hold the mic button to record and send ephemeral voice clips. Enable Autoplay to hear them live.<br /><br />
|
||||
<strong>No tracking • No storage • 100% ephemeral</strong><br /><br />
|
||||
If you're tired of feeds, filters, or forever — you're in the right
|
||||
place.
|
||||
<a href="faq.html">Learn More →</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="login-container">
|
||||
<input type="text" id="username-input" placeholder="Enter Your Handle" />
|
||||
<br />
|
||||
<br />
|
||||
<button id="start-chat-button">Start Chatting!</button>
|
||||
</div>
|
||||
|
||||
<div id="info-text">
|
||||
<p>
|
||||
Don't trust me? I don't blame you. Review the source and host your own
|
||||
server!<br />
|
||||
<a href="https://github.com/benjimanmiller/transient.chat">
|
||||
https://github.com/benjimanmiller/transient.chat
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<script src="/assets/js/index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
75
static/server_list.html
Normal file
75
static/server_list.html
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Transient.chat - Server List</title>
|
||||
<link rel="stylesheet" href="/assets/css/styles.css" />
|
||||
<link rel="icon" href="/assets/images/favicon.ico" type="image/x-icon" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="title-row">
|
||||
<a href="index.html">
|
||||
<img src="/assets/images/transientchat-blue.png" alt="Transient.chat" />
|
||||
</a>
|
||||
<h1>Server List</h1>
|
||||
</div>
|
||||
<div id="user-info"></div>
|
||||
|
||||
<div id="user-count" style="margin-top: 8px; font-weight: bold"></div>
|
||||
|
||||
<div id="chat-rooms">
|
||||
<h2 class="collapsible">▶ US Regional Rooms</h2>
|
||||
<ul
|
||||
id="regional-rooms"
|
||||
class="collapsible-content"
|
||||
style="display: none"
|
||||
></ul>
|
||||
|
||||
<h2 class="collapsible">▶ Topical Rooms</h2>
|
||||
<ul
|
||||
id="topical-rooms"
|
||||
class="collapsible-content"
|
||||
style="display: none"
|
||||
></ul>
|
||||
|
||||
<h2 class="collapsible">▶ User Created Public Rooms</h2>
|
||||
<ul
|
||||
id="public-rooms"
|
||||
class="collapsible-content"
|
||||
style="display: none"
|
||||
></ul>
|
||||
|
||||
<h2 class="collapsible">▶ Watch Party Rooms</h2>
|
||||
<ul
|
||||
id="watchparty-rooms"
|
||||
class="collapsible-content"
|
||||
style="display: none"
|
||||
></ul>
|
||||
|
||||
<h2 class="collapsible" id="active-rooms-header">▶ Active Rooms</h2>
|
||||
<ul
|
||||
id="active-rooms"
|
||||
class="collapsible-content"
|
||||
style="display: none"
|
||||
></ul>
|
||||
|
||||
<br />
|
||||
|
||||
<div id="create-room-form">
|
||||
<input
|
||||
type="text"
|
||||
id="new-room-name"
|
||||
placeholder="Enter New Room Name"
|
||||
/>
|
||||
<br />
|
||||
<div class="button-row">
|
||||
<button id="create-room-btn">Create Public Room</button>
|
||||
<button id="create-unlisted-room-btn">Create Unlisted Room</button>
|
||||
<button id="create-watchparty-btn">Create Watch Party</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/assets/js/server_list.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
109
static/watchparty_chat.html
Normal file
109
static/watchparty_chat.html
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Transient.chat - Watch Party</title>
|
||||
<link rel="stylesheet" href="/assets/css/styles.css" />
|
||||
<link rel="stylesheet" href="/assets/css/chat.css" />
|
||||
<link rel="stylesheet" href="/assets/css/watchparty.css" />
|
||||
<link rel="icon" href="/assets/images/favicon.ico" type="image/x-icon" />
|
||||
</head>
|
||||
<body class="watchparty">
|
||||
<h1 id="room-title">Watch Party Room</h1>
|
||||
|
||||
<div id="video-chat-container">
|
||||
<!-- Column 1: Video Section -->
|
||||
<div id="video-section">
|
||||
<div id="video-controls">
|
||||
<input
|
||||
type="text"
|
||||
id="video-url-input"
|
||||
placeholder="Paste YouTube video URL..."
|
||||
/>
|
||||
<button id="start-video-button">▶️ Play</button>
|
||||
<button id="pause-video-button" class="hidden">⏸ Pause</button>
|
||||
<button id="resume-video-button" class="hidden">▶ Resume</button>
|
||||
<button id="clear-video-button" class="hidden">
|
||||
🛑 Clear Video
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="video-wrapper">
|
||||
<iframe
|
||||
id="video-iframe"
|
||||
frameborder="0"
|
||||
allowfullscreen
|
||||
allow="autoplay; encrypted-media"
|
||||
class="hidden"
|
||||
></iframe>
|
||||
<img
|
||||
id="paused-overlay"
|
||||
src="/assets/images/paused.png"
|
||||
class="hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Column 2: Chat Section -->
|
||||
<div id="chat-center">
|
||||
<div id="messages"></div>
|
||||
<form id="chat-form">
|
||||
<input
|
||||
type="text"
|
||||
class="inputfield"
|
||||
id="message-input"
|
||||
placeholder="Type a message, paste in a link, or url to an image or YouTube video."
|
||||
autocomplete="off"
|
||||
required
|
||||
/>
|
||||
<button type="submit">Send</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Column 3: User List + Controls -->
|
||||
<div id="user-list-container">
|
||||
<a
|
||||
href="server_list.html"
|
||||
style="
|
||||
display: inline-block;
|
||||
margin-bottom: 10px;
|
||||
text-decoration: none;
|
||||
"
|
||||
>
|
||||
<button>Server List</button>
|
||||
</a>
|
||||
<br />
|
||||
<button
|
||||
id="sound-toggle"
|
||||
style="
|
||||
margin-bottom: 10px;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
text-align: left;
|
||||
"
|
||||
>
|
||||
🔊 Sound: On
|
||||
</button>
|
||||
<br />
|
||||
<button
|
||||
id="external-toggle"
|
||||
style="
|
||||
margin-bottom: 10px;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
text-align: left;
|
||||
"
|
||||
>
|
||||
🔒 External Content: Off
|
||||
</button>
|
||||
<br /><br />
|
||||
<h3>Users in room</h3>
|
||||
<div id="user-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/assets/js/chat.js"></script>
|
||||
<script src="/assets/js/watchparty_chat.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in a new issue