Compare commits
No commits in common. "main" and "0.0.2" have entirely different histories.
49 changed files with 1424 additions and 16082 deletions
10
.env.example
10
.env.example
|
|
@ -1,6 +1,5 @@
|
|||
DATABASE_URL=postgres://postgres:postgres@localhost:5432/chattz
|
||||
PORT=3000
|
||||
APP_BASE_URL=http://localhost:3000
|
||||
|
||||
# Authentik OIDC app values
|
||||
OIDC_CLIENT_ID=replace-me
|
||||
|
|
@ -17,9 +16,6 @@ TURN_URLS=turn:turn.example.com:3478?transport=udp,turn:turn.example.com:3478?tr
|
|||
TURN_USERNAME=replace-me
|
||||
TURN_PASSWORD=replace-me
|
||||
|
||||
# Cloudflare R2 media uploads
|
||||
R2_ACCOUNT_ID=replace-me
|
||||
R2_ACCESS_KEY_ID=replace-me
|
||||
R2_SECRET_ACCESS_KEY=replace-me
|
||||
R2_BUCKET=chattz-media
|
||||
MEDIA_BASE_URL=https://media.example.com
|
||||
# 32+ random chars; used to sign session cookies
|
||||
SESSION_SECRET=replace-with-long-random-secret
|
||||
COOKIE_SECURE=false
|
||||
|
|
|
|||
|
|
@ -11,51 +11,6 @@ jobs:
|
|||
with:
|
||||
node-version: 24
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Update version from tag
|
||||
run: |
|
||||
# Extract version from tag (e.g. v1.2.3 -> 1.2.3)
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
echo "Bumping version to $VERSION"
|
||||
npm version $VERSION --no-git-tag-version
|
||||
- run: npm ci
|
||||
- run: npm run dist:linux
|
||||
- name: Build Windows installer with 32-bit Wine prefix
|
||||
run: |
|
||||
export WINEARCH=win32
|
||||
export WINEPREFIX="$HOME/.wine32"
|
||||
wineboot -u
|
||||
npm run dist:win
|
||||
- name: Copy installers into static folder
|
||||
run: |
|
||||
mkdir -p static/installers
|
||||
find static/installers -mindepth 1 -maxdepth 1 -type f -delete
|
||||
copy_first() {
|
||||
pattern="$1"
|
||||
out="$2"
|
||||
file="$(find dist -maxdepth 1 -type f -name "$pattern" | head -n 1)"
|
||||
if [ -n "$file" ]; then
|
||||
cp "$file" "static/installers/$out"
|
||||
fi
|
||||
}
|
||||
# Copy versioned artifacts for electron-updater
|
||||
cp dist/*.AppImage static/installers/ || true
|
||||
cp dist/*.exe static/installers/ || true
|
||||
cp dist/*.rpm static/installers/ || true
|
||||
cp dist/*.deb static/installers/ || true
|
||||
cp dist/*.msi static/installers/ || true
|
||||
|
||||
# Maintain generic names for stable website links
|
||||
copy_first '*.rpm' 'chattz-linux.rpm'
|
||||
copy_first '*.deb' 'chattz-linux.deb'
|
||||
copy_first '*.AppImage' 'chattz-linux.AppImage'
|
||||
copy_first '*.exe' 'chattz-windows.exe'
|
||||
copy_first '*.msi' 'chattz-windows.msi'
|
||||
|
||||
# Metadata files for electron-updater
|
||||
copy_first 'latest-linux.yml' 'latest-linux.yml'
|
||||
copy_first 'latest.yml' 'latest.yml'
|
||||
- name: Cache Cargo registry
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
|
|
@ -79,10 +34,6 @@ jobs:
|
|||
export XDG_RUNTIME_DIR=/run/user/$(id -u)
|
||||
export DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u)/bus
|
||||
systemctl --user stop discord
|
||||
mkdir -p ~/discord
|
||||
cp target/release/chattz ~/discord/discord
|
||||
cp target/release/discord ~/discord/discord
|
||||
chmod +x ~/discord/discord
|
||||
mkdir -p ~/discord/static/uploads
|
||||
find ~/discord/static -mindepth 1 -maxdepth 1 ! -name 'uploads' -exec rm -rf {} +
|
||||
cp -r static/* ~/discord/static/
|
||||
systemctl --user start discord
|
||||
systemctl --user start discord
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -1,4 +1,2 @@
|
|||
/target
|
||||
.env
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
891
Cargo.lock
generated
891
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
11
Cargo.toml
11
Cargo.toml
|
|
@ -5,20 +5,17 @@ edition = "2024"
|
|||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
aws-config = { version = "1", default-features = false, features = ["behavior-version-latest", "rt-tokio", "rustls"] }
|
||||
aws-sdk-s3 = { version = "1", default-features = false, features = ["rt-tokio", "rustls"] }
|
||||
axum = { version = "0.8", features = ["macros", "ws", "multipart"] }
|
||||
axum = { version = "0.8", features = ["macros", "ws"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
dotenvy = "0.15"
|
||||
jsonwebtoken = "9"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
sea-orm = { version = "1.1", default-features = false, features = ["sqlx-postgres", "runtime-tokio-rustls", "macros", "with-chrono", "with-uuid", "mock"] }
|
||||
sea-orm = { version = "1.1", default-features = false, features = ["sqlx-postgres", "runtime-tokio-rustls", "macros", "with-chrono", "with-uuid"] }
|
||||
sea-orm-migration = { version = "1.1", default-features = false, features = ["sqlx-postgres", "runtime-tokio-rustls"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
futures-util = "0.3"
|
||||
tokio = { version = "1", features = ["fs", "io-util", "macros", "rt-multi-thread"] }
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||
tower-http = { version = "0.6", features = ["trace", "fs"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||
|
|
|
|||
11
README.md
11
README.md
|
|
@ -8,7 +8,7 @@ A simple single-instance Discord-style monolith in Rust using:
|
|||
## What this includes
|
||||
|
||||
- OIDC login flow (`/auth/login`, `/auth/callback`, `/auth/logout`)
|
||||
- HttpOnly session cookie auth
|
||||
- Signed session cookie auth
|
||||
- Channel voice chat over WebRTC (P2P mesh) with server WebSocket signaling
|
||||
- Guild invite codes (create + join)
|
||||
- Direct messages (DM) between users
|
||||
|
|
@ -41,11 +41,6 @@ For voice reliability on restrictive networks, configure TURN in `.env`:
|
|||
- `TURN_USERNAME`
|
||||
- `TURN_PASSWORD`
|
||||
|
||||
For production deployments:
|
||||
- `APP_BASE_URL` must be your public app origin and should use `https`
|
||||
- `MEDIA_BASE_URL` should be a separate media origin for user uploads
|
||||
- uploads and soundboard require R2/object storage to be configured
|
||||
|
||||
3. Run app:
|
||||
|
||||
```bash
|
||||
|
|
@ -60,7 +55,7 @@ Web UI is available at `http://localhost:${PORT}/`.
|
|||
## Authentik setup notes
|
||||
|
||||
Create an Authentik OAuth2/OIDC provider + application and set:
|
||||
- Redirect URI: `${APP_BASE_URL}/auth/callback`
|
||||
- Redirect URI: `http://localhost:3000/auth/callback`
|
||||
- Scopes including at least: `openid profile email`
|
||||
|
||||
If you change `PORT`, update `OIDC_REDIRECT_URL` and this redirect URI to match.
|
||||
|
|
@ -96,7 +91,6 @@ For Authentik these are commonly under `/application/o/...` for the app slug.
|
|||
- `GET /channels/:channel_id/voice/ws` (WebSocket signaling)
|
||||
|
||||
All endpoints except health and auth flow require the session cookie from successful login.
|
||||
Authenticated WebSocket connections (`/ws`, `/channels/:channel_id/voice/ws`) also use the same cookie session.
|
||||
|
||||
## Notes
|
||||
|
||||
|
|
@ -104,7 +98,6 @@ This is intentionally minimal and monolithic (single process, single Postgres in
|
|||
Voice is implemented as browser-to-browser WebRTC audio with signaling in this server.
|
||||
For two users behind strict NAT/firewall, you may need TURN for reliable connectivity.
|
||||
The web UI remembers the last selected guild in browser local storage and auto-selects it on reload.
|
||||
User uploads are served from the configured media origin, not from `/static`.
|
||||
|
||||
Mic filter modes in the UI:
|
||||
- `NSNet2 (Compat)`: always-on denoising mode (implemented using DeepFilterNet3 with lighter suppression preset)
|
||||
|
|
|
|||
BIN
build/icon.png
BIN
build/icon.png
Binary file not shown.
|
Before Width: | Height: | Size: 430 KiB |
|
|
@ -1 +0,0 @@
|
|||
import "../static/shared/app-core.js";
|
||||
|
|
@ -1,282 +0,0 @@
|
|||
<!-- Generated from shared-html/index.template.html. Do not edit directly. -->
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>Chattz</title>
|
||||
<link rel="stylesheet" href="../static/styles.css" />
|
||||
<!-- Lucide Icons -->
|
||||
<script src="../static/vendor/lucide.min.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="auth-screen" id="auth-screen">
|
||||
<div class="auth-card">
|
||||
<div class="brand-large">C</div>
|
||||
<h1>Chattz</h1>
|
||||
<p>Sign in to open your servers.</p>
|
||||
<button id="login-btn">Login with Authentik</button>
|
||||
<p id="status" class="status-line"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="shell hidden" id="main">
|
||||
<aside class="server-rail">
|
||||
<div class="brand" title="Home">
|
||||
<i data-lucide="message-square"></i>
|
||||
</div>
|
||||
<div class="separator"></div>
|
||||
<div id="guild-list" class="guild-list"></div>
|
||||
<button id="add-guild-btn" class="guild-pill action-pill" title="Add a Server">
|
||||
<i data-lucide="plus"></i>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<aside class="channel-sidebar">
|
||||
<header class="sidebar-header clickable" id="guild-header">
|
||||
<h2 id="guild-title">No server selected</h2>
|
||||
<div class="sidebar-header-actions">
|
||||
<span id="invite-copied-badge" class="copied-badge hidden">Copied!</span>
|
||||
<button id="create-invite-btn" class="header-action-btn" title="Create Invite Link">
|
||||
<i data-lucide="link-2"></i>
|
||||
</button>
|
||||
<i data-lucide="chevron-down"></i>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="sidebar-scroll">
|
||||
<section class="sidebar-group">
|
||||
<div class="group-title">
|
||||
<i data-lucide="chevron-down" class="group-toggle"></i>
|
||||
<span>Text Channels</span>
|
||||
<button id="add-text-btn" class="add-btn" type="button" title="Create Text Channel">
|
||||
<i data-lucide="plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="channel-list" class="channel-list"></div>
|
||||
</section>
|
||||
|
||||
<section class="sidebar-group">
|
||||
<div class="group-title">
|
||||
<i data-lucide="chevron-down" class="group-toggle"></i>
|
||||
<span>Voice Channels</span>
|
||||
<button id="add-voice-btn" class="add-btn" type="button" title="Create Voice Channel">
|
||||
<i data-lucide="plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="voice-channel-list" class="channel-list"></div>
|
||||
</section>
|
||||
|
||||
<section class="sidebar-group">
|
||||
<div class="group-title">
|
||||
<i data-lucide="chevron-down" class="group-toggle"></i>
|
||||
<span>Direct Messages</span>
|
||||
</div>
|
||||
<div id="dm-list" class="channel-list"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<div id="voice-connection" class="voice-connection hidden">
|
||||
<div class="vc-info">
|
||||
<i data-lucide="signal-high" class="vc-icon"></i>
|
||||
<div class="vc-text">
|
||||
<span class="vc-status">Voice Connected</span>
|
||||
<span id="vc-channel-name" class="vc-name">General</span>
|
||||
</div>
|
||||
<div class="vc-actions">
|
||||
<button id="voice-video-btn" title="Turn on Camera"><i data-lucide="video-off"></i></button>
|
||||
<button id="voice-screen-btn" title="Share Screen"><i data-lucide="monitor-off"></i></button>
|
||||
<button id="voice-mute-btn" title="Mute"><i data-lucide="mic"></i></button>
|
||||
<button id="voice-leave-btn" title="Disconnect"><i data-lucide="phone-off"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="soundboard" class="soundboard-container hidden">
|
||||
<div class="soundboard-header">
|
||||
<span>Sound Board</span>
|
||||
<span id="add-sound-btn" class="btn-add-sound">+ Add Sound</span>
|
||||
</div>
|
||||
<div id="soundboard-grid" class="soundboard-grid"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="user-panel">
|
||||
<div class="avatar-wrapper">
|
||||
<div id="user-avatar" class="avatar">U</div>
|
||||
<div class="status-dot online"></div>
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<div id="user-name" class="display-name">Username</div>
|
||||
<div class="user-status">Online</div>
|
||||
</div>
|
||||
<div class="user-actions">
|
||||
<div id="update-notifier" class="update-notifier hidden">
|
||||
<button id="update-download-btn" title="Download Update"><i data-lucide="download"></i></button>
|
||||
<button id="update-install-btn" title="Install Update" class="hidden"><i data-lucide="arrow-up-circle"></i></button>
|
||||
</div>
|
||||
<button id="user-settings-btn" title="Settings"><i data-lucide="settings"></i></button>
|
||||
<button id="logout-btn" title="Logout"><i data-lucide="log-out"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="chat-pane">
|
||||
<header class="chat-header">
|
||||
<button id="mobile-menu-btn" class="mobile-toggle-btn" title="Menu">
|
||||
<i data-lucide="menu"></i>
|
||||
</button>
|
||||
<i data-lucide="hash" class="header-icon"></i>
|
||||
<h3 id="channel-title">Select a channel</h3>
|
||||
<div style="flex:1"></div>
|
||||
<button id="mobile-members-btn" class="mobile-toggle-btn" title="Members">
|
||||
<i data-lucide="users"></i>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div id="video-grid" class="video-grid hidden"></div>
|
||||
<div id="message-list" class="message-list"></div>
|
||||
|
||||
<div class="chat-input-wrapper">
|
||||
<form id="message-form" class="message-form">
|
||||
<input id="media-file-input" type="file" class="hidden-file-input" accept="image/*,video/*,audio/*,.pdf,.txt,.zip,.rar,.7z,.tar,.gz,.json,.csv,.md" />
|
||||
<button type="button" id="media-upload-btn" class="input-action-btn" title="Upload File">
|
||||
<i data-lucide="paperclip"></i>
|
||||
</button>
|
||||
<button type="button" id="gif-btn" class="input-action-btn" title="Open GIF Picker">
|
||||
<i data-lucide="image"></i>
|
||||
</button>
|
||||
<input id="message-body" autocomplete="off" placeholder="Message #channel" maxlength="4000" required />
|
||||
<button type="submit" class="hidden"></button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<aside class="utility-sidebar" id="utility-sidebar">
|
||||
<header class="sidebar-header">
|
||||
<h2>Members</h2>
|
||||
</header>
|
||||
<div class="member-list-wrapper">
|
||||
<div id="member-list" class="member-list"></div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div id="overlay" class="overlay hidden"></div>
|
||||
|
||||
<!-- Modals -->
|
||||
<div id="gif-picker" class="modal-container hidden">
|
||||
<div class="modal gif-modal">
|
||||
<div class="modal-header">
|
||||
<h2>Select GIF</h2>
|
||||
<button type="button" class="close-btn" id="gif-picker-close">
|
||||
<i data-lucide="x"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="gif-search-wrapper">
|
||||
<input type="text" id="gif-search-input" placeholder="Search for GIFs..." autocomplete="off">
|
||||
<i data-lucide="search" class="search-icon"></i>
|
||||
</div>
|
||||
<div id="gif-results" class="gif-results-grid">
|
||||
<!-- GIF results will be injected here -->
|
||||
<div class="gif-loading hidden">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="modal-container" class="modal-container hidden">
|
||||
<div class="modal">
|
||||
<h2 id="modal-title">Create Server</h2>
|
||||
<form id="guild-form" class="modal-form">
|
||||
<div class="form-item">
|
||||
<label for="guild-name">SERVER NAME</label>
|
||||
<input id="guild-name" placeholder="My Awesome Server" maxlength="64" required />
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="cancel-btn" id="modal-cancel">Cancel</button>
|
||||
<button type="submit" class="submit-btn">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="channel-modal" class="modal-container hidden">
|
||||
<div class="modal">
|
||||
<h2>Create Channel</h2>
|
||||
<form id="channel-form" class="modal-form">
|
||||
<div class="form-item">
|
||||
<label>CHANNEL TYPE</label>
|
||||
<div class="radio-group">
|
||||
<label class="radio-item">
|
||||
<input type="radio" name="channel-kind" value="text" checked>
|
||||
<div class="radio-box">
|
||||
<i data-lucide="hash"></i>
|
||||
<div class="radio-text">
|
||||
<strong>Text</strong>
|
||||
<span>Send messages, images, and GIFs.</span>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="radio-item">
|
||||
<input type="radio" name="channel-kind" value="voice">
|
||||
<div class="radio-box">
|
||||
<i data-lucide="volume-2"></i>
|
||||
<div class="radio-text">
|
||||
<strong>Voice</strong>
|
||||
<span>Hang out together with voice and video.</span>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label for="channel-name">CHANNEL NAME</label>
|
||||
<input id="channel-name" placeholder="new-channel" maxlength="64" required />
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="cancel-btn" id="channel-modal-cancel">Cancel</button>
|
||||
<button type="submit" class="submit-btn">Create Channel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="sound-modal" class="modal-container hidden">
|
||||
<div class="modal">
|
||||
<h2>Add Sound</h2>
|
||||
<form id="sound-form" class="modal-form">
|
||||
<div class="form-item">
|
||||
<label for="sound-name">NAME</label>
|
||||
<input id="sound-name" placeholder="Quack" maxlength="32" required />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label for="sound-icon">ICON (Emoji or Initials)</label>
|
||||
<input id="sound-icon" placeholder="🦆" maxlength="4" required />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label for="sound-file">AUDIO FILE (MP3/WAV)</label>
|
||||
<input id="sound-file" type="file" accept="audio/*" required />
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="cancel-btn" id="sound-modal-cancel">Cancel</button>
|
||||
<button type="submit" class="submit-btn" id="sound-submit-btn">Add Sound</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="upload-limit-modal" class="modal-container hidden">
|
||||
<div class="modal">
|
||||
<h2>Upload Too Large</h2>
|
||||
<p id="upload-limit-message" class="modal-copy">Uploads are limited to 50 MB per file.</p>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="submit-btn" id="upload-limit-ok">OK</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="app.js?v=20260227-shared-core-1"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
function onIpc(channel, callback) {
|
||||
const listener = (_event, payload) => callback(payload);
|
||||
ipcRenderer.on(channel, listener);
|
||||
return () => ipcRenderer.removeListener(channel, listener);
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
copyToClipboard: (text) => ipcRenderer.invoke('clipboard-write', text),
|
||||
getUpdateState: () => ipcRenderer.invoke('get-update-state'),
|
||||
checkForUpdatesNow: () => ipcRenderer.invoke('check-for-updates-now'),
|
||||
checkForUpdates: () => ipcRenderer.send('check-for-updates'),
|
||||
downloadUpdate: () => ipcRenderer.send('download-update'),
|
||||
quitAndInstall: () => ipcRenderer.send('quit-and-install'),
|
||||
onUpdateState: (callback) => onIpc('update-state', callback),
|
||||
onUpdateAvailable: (callback) => onIpc('update-available', callback),
|
||||
onUpdateDownloaded: (callback) => onIpc('update-downloaded', callback),
|
||||
onUpdateError: (callback) => onIpc('update-error', callback)
|
||||
});
|
||||
Binary file not shown.
BIN
desktop/vendor/deepfilternet3/v2/pkg/df_bg.wasm
vendored
BIN
desktop/vendor/deepfilternet3/v2/pkg/df_bg.wasm
vendored
Binary file not shown.
313
main.js
313
main.js
|
|
@ -1,313 +0,0 @@
|
|||
const { app, BrowserWindow, session, desktopCapturer, ipcMain, clipboard } = require('electron');
|
||||
const { autoUpdater } = require('electron-updater');
|
||||
const path = require('path');
|
||||
const packageJson = require('./package.json');
|
||||
const PERIODIC_UPDATE_CHECK_INTERVAL_MS = 1 * 60 * 1000;
|
||||
|
||||
const updateState = {
|
||||
status: 'idle', // idle | checking | available | downloading | downloaded | installing | not-available | error
|
||||
info: null,
|
||||
error: null,
|
||||
};
|
||||
|
||||
function broadcastUpdateState() {
|
||||
const wins = BrowserWindow.getAllWindows();
|
||||
for (const win of wins) {
|
||||
if (!win.isDestroyed()) {
|
||||
win.webContents.send('update-state', updateState);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isNewerVersionAvailable(info) {
|
||||
const next = info && typeof info.version === 'string' ? info.version : '';
|
||||
return Boolean(next) && next !== app.getVersion();
|
||||
}
|
||||
|
||||
let updateCheckInProgress = false;
|
||||
let installInProgress = false;
|
||||
let periodicUpdateTimer = null;
|
||||
|
||||
function resolveBackendUrl() {
|
||||
const configuredUrl = (process.env.CHATTZ_URL || process.env.APP_BASE_URL || '').trim();
|
||||
if (configuredUrl) {
|
||||
return configuredUrl.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
const packagedFallback = typeof packageJson.homepage === 'string'
|
||||
? packageJson.homepage.trim()
|
||||
: '';
|
||||
if (app.isPackaged && packagedFallback) {
|
||||
return packagedFallback.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
return 'http://localhost:3000';
|
||||
}
|
||||
|
||||
function webContentsOrigin(webContents) {
|
||||
if (!webContents || typeof webContents.getURL !== 'function') {
|
||||
return null;
|
||||
}
|
||||
return safeOrigin(webContents.getURL());
|
||||
}
|
||||
|
||||
async function runUpdateCheck(reason = 'manual') {
|
||||
if (installInProgress) {
|
||||
console.log(`Update check skipped (${reason}): install already in progress`);
|
||||
return;
|
||||
}
|
||||
if (updateCheckInProgress) {
|
||||
console.log(`Update check skipped (${reason}): another check is already in progress`);
|
||||
return;
|
||||
}
|
||||
updateCheckInProgress = true;
|
||||
updateState.status = 'checking';
|
||||
updateState.error = null;
|
||||
try {
|
||||
const result = await autoUpdater.checkForUpdates();
|
||||
const info = result && result.updateInfo ? result.updateInfo : null;
|
||||
if (isNewerVersionAvailable(info)) {
|
||||
updateState.status = 'available';
|
||||
updateState.info = info;
|
||||
updateState.error = null;
|
||||
} else {
|
||||
updateState.status = 'not-available';
|
||||
updateState.info = info;
|
||||
updateState.error = null;
|
||||
}
|
||||
} catch (err) {
|
||||
updateState.status = 'error';
|
||||
updateState.error = err && err.message ? err.message : String(err);
|
||||
console.error(`Update check failed (${reason})`, err);
|
||||
} finally {
|
||||
updateCheckInProgress = false;
|
||||
broadcastUpdateState();
|
||||
}
|
||||
}
|
||||
|
||||
function installDownloadedUpdate() {
|
||||
if (installInProgress) {
|
||||
console.log('Update install already in progress');
|
||||
return;
|
||||
}
|
||||
|
||||
installInProgress = true;
|
||||
updateState.status = 'installing';
|
||||
updateState.error = null;
|
||||
broadcastUpdateState();
|
||||
|
||||
const wins = BrowserWindow.getAllWindows();
|
||||
for (const win of wins) {
|
||||
if (win.isDestroyed()) continue;
|
||||
try {
|
||||
win.removeAllListeners('close');
|
||||
win.destroy();
|
||||
} catch (err) {
|
||||
console.warn('Failed to destroy window before update install', err);
|
||||
}
|
||||
}
|
||||
|
||||
setImmediate(() => {
|
||||
try {
|
||||
autoUpdater.quitAndInstall(false, true);
|
||||
} catch (err) {
|
||||
installInProgress = false;
|
||||
updateState.status = 'error';
|
||||
updateState.error = err && err.message ? err.message : String(err);
|
||||
broadcastUpdateState();
|
||||
console.error('quitAndInstall failed', err);
|
||||
}
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (installInProgress) {
|
||||
console.warn('Update install is still waiting for app shutdown');
|
||||
}
|
||||
}, 15000);
|
||||
}
|
||||
|
||||
function startPeriodicUpdateChecks() {
|
||||
if (periodicUpdateTimer || !app.isPackaged) {
|
||||
return;
|
||||
}
|
||||
|
||||
periodicUpdateTimer = setInterval(() => {
|
||||
void runUpdateCheck('periodic');
|
||||
}, PERIODIC_UPDATE_CHECK_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
const sess = session.fromPartition('persist:chattz');
|
||||
const backendUrl = resolveBackendUrl();
|
||||
const backendOrigin = new URL(backendUrl).origin;
|
||||
console.log(`Desktop backend URL: ${backendUrl}`);
|
||||
|
||||
const win = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
title: "Chattz Desktop",
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
session: sess,
|
||||
preload: path.join(__dirname, 'desktop', 'preload.js')
|
||||
}
|
||||
});
|
||||
|
||||
win.setAutoHideMenuBar(true);
|
||||
win.setMenuBarVisibility(true);
|
||||
|
||||
sess.setPermissionCheckHandler((webContents, permission) => {
|
||||
const origin = webContentsOrigin(webContents);
|
||||
if (origin !== backendOrigin) return false;
|
||||
return permission === 'media' || permission === 'clipboard-write';
|
||||
});
|
||||
|
||||
sess.setPermissionRequestHandler((webContents, permission, callback) => {
|
||||
const origin = webContentsOrigin(webContents);
|
||||
if (origin === backendOrigin && (permission === 'media' || permission === 'clipboard-write')) {
|
||||
callback(true);
|
||||
} else {
|
||||
callback(false);
|
||||
}
|
||||
});
|
||||
|
||||
sess.setDisplayMediaRequestHandler((request, callback) => {
|
||||
const origin = safeOrigin(request.frame?.url || win.webContents.getURL());
|
||||
if (origin !== backendOrigin) {
|
||||
callback(null);
|
||||
return;
|
||||
}
|
||||
desktopCapturer.getSources({ types: ['screen', 'window'] }).then((sources) => {
|
||||
if (sources && sources.length > 0) {
|
||||
const screenSource = sources.find(s => s.id.startsWith('screen')) || sources[0];
|
||||
callback({ video: screenSource, audio: 'loopback' });
|
||||
} else {
|
||||
callback(null);
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error("Failed to get desktop sources for screen share", err);
|
||||
callback(null);
|
||||
});
|
||||
});
|
||||
|
||||
win.loadURL(backendUrl).catch((err) => {
|
||||
console.error(`Failed to load desktop app: ${err}`);
|
||||
});
|
||||
|
||||
// Ensure renderer receives latest updater state after any (re)load.
|
||||
// Delay broadcast by 300ms to give the renderer time to register its
|
||||
// onUpdateState IPC listener before we push state.
|
||||
win.webContents.on('did-finish-load', () => {
|
||||
setTimeout(() => {
|
||||
if (!win.isDestroyed()) broadcastUpdateState();
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// Uncomment to debug
|
||||
// win.webContents.openDevTools();
|
||||
}
|
||||
|
||||
app.commandLine.appendSwitch('disable-webrtc-hw-encoding'); // Sometime helps resolve codec mismatch behavior
|
||||
app.commandLine.appendSwitch('log-level', '3'); // Silences standard Chromium warning logs (like SRTP transport unprotect logs which are benign timing issues)
|
||||
|
||||
app.whenReady().then(() => {
|
||||
ipcMain.handle('clipboard-write', (event, text) => {
|
||||
clipboard.writeText(text);
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('get-update-state', () => ({ ...updateState }));
|
||||
ipcMain.handle('check-for-updates-now', async () => {
|
||||
await runUpdateCheck('renderer-direct');
|
||||
return { ...updateState };
|
||||
});
|
||||
|
||||
createWindow();
|
||||
|
||||
// Configure Auto-Updater
|
||||
autoUpdater.autoDownload = false;
|
||||
autoUpdater.logger = console;
|
||||
|
||||
autoUpdater.on('update-available', (info) => {
|
||||
updateState.status = 'available';
|
||||
updateState.info = info;
|
||||
updateState.error = null;
|
||||
broadcastUpdateState();
|
||||
const wins = BrowserWindow.getAllWindows();
|
||||
if (wins.length > 0) wins[0].webContents.send('update-available', info);
|
||||
});
|
||||
|
||||
autoUpdater.on('update-not-available', (info) => {
|
||||
updateState.status = 'not-available';
|
||||
updateState.info = info || null;
|
||||
updateState.error = null;
|
||||
broadcastUpdateState();
|
||||
});
|
||||
|
||||
autoUpdater.on('update-downloaded', (info) => {
|
||||
updateState.status = 'downloaded';
|
||||
updateState.info = info;
|
||||
updateState.error = null;
|
||||
broadcastUpdateState();
|
||||
const wins = BrowserWindow.getAllWindows();
|
||||
if (wins.length > 0) wins[0].webContents.send('update-downloaded', info);
|
||||
});
|
||||
|
||||
autoUpdater.on('error', (err) => {
|
||||
updateState.status = 'error';
|
||||
updateState.error = err.message;
|
||||
broadcastUpdateState();
|
||||
const wins = BrowserWindow.getAllWindows();
|
||||
if (wins.length > 0) wins[0].webContents.send('update-error', err.message);
|
||||
});
|
||||
|
||||
ipcMain.on('check-for-updates', () => {
|
||||
void runUpdateCheck('manual');
|
||||
});
|
||||
|
||||
ipcMain.on('download-update', () => {
|
||||
if (installInProgress) return;
|
||||
updateState.status = 'downloading';
|
||||
updateState.error = null;
|
||||
broadcastUpdateState();
|
||||
autoUpdater.downloadUpdate();
|
||||
});
|
||||
|
||||
ipcMain.on('quit-and-install', () => {
|
||||
installDownloadedUpdate();
|
||||
});
|
||||
|
||||
// Check once on startup
|
||||
setTimeout(() => {
|
||||
void runUpdateCheck('startup');
|
||||
}, 5000);
|
||||
startPeriodicUpdateChecks();
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('before-quit', () => {
|
||||
if (periodicUpdateTimer) {
|
||||
clearInterval(periodicUpdateTimer);
|
||||
periodicUpdateTimer = null;
|
||||
}
|
||||
});
|
||||
|
||||
function safeOrigin(value) {
|
||||
try {
|
||||
return new URL(value).origin;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
5307
package-lock.json
generated
5307
package-lock.json
generated
File diff suppressed because it is too large
Load diff
61
package.json
61
package.json
|
|
@ -1,61 +0,0 @@
|
|||
{
|
||||
"name": "chattz-electron",
|
||||
"version": "0.0.50",
|
||||
"description": "Electron frontend for Chattz",
|
||||
"author": "Pavel Flegr <pavelflegr@gmail.com>",
|
||||
"homepage": "https://discord.flegr.me",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.flegr.me/pavel/discord.git"
|
||||
},
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"build:html": "node scripts/generate-html.js",
|
||||
"start": "npm run build:html && electron .",
|
||||
"dev": "npm run build:html && electron .",
|
||||
"dist": "npm run build:html && electron-builder --publish never",
|
||||
"dist:linux": "npm run build:html && electron-builder --linux --publish never",
|
||||
"dist:win": "npm run build:html && electron-builder --win --publish never"
|
||||
},
|
||||
"build": {
|
||||
"appId": "me.flegr.chattz",
|
||||
"productName": "Chattz",
|
||||
"linux": {
|
||||
"target": [
|
||||
"AppImage",
|
||||
"rpm"
|
||||
],
|
||||
"category": "Chat",
|
||||
"icon": "build/icon.png"
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
"nsis"
|
||||
]
|
||||
},
|
||||
"publish": [
|
||||
{
|
||||
"provider": "generic",
|
||||
"url": "https://discord.flegr.me/static/installers",
|
||||
"channel": "latest"
|
||||
}
|
||||
],
|
||||
"files": [
|
||||
"main.js",
|
||||
"package.json",
|
||||
"desktop/",
|
||||
"static/",
|
||||
"!dist"
|
||||
],
|
||||
"directories": {
|
||||
"output": "dist"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^34.5.8",
|
||||
"electron-builder": "^25.1.8"
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-updater": "^6.8.3"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const templatePath = path.join(root, 'shared-html', 'index.template.html');
|
||||
const template = fs.readFileSync(templatePath, 'utf8');
|
||||
|
||||
const updaterMarkup = `<div id="update-notifier" class="update-notifier hidden">
|
||||
<button id="update-download-btn" title="Download Update"><i data-lucide="download"></i></button>
|
||||
<button id="update-install-btn" title="Install Update" class="hidden"><i data-lucide="arrow-up-circle"></i></button>
|
||||
</div>`;
|
||||
|
||||
const variants = {
|
||||
web: {
|
||||
styles_href: '/static/styles.css',
|
||||
lucide_src: '/static/vendor/lucide.min.js',
|
||||
app_src: '/static/app.js?v=20260227-shared-core-1',
|
||||
web_downloads: `<div class="auth-downloads">
|
||||
<span>Desktop downloads:</span>
|
||||
<a href="/static/installers/chattz-windows.exe">Windows</a>
|
||||
<a href="/static/installers/chattz-linux.rpm">Linux (RPM)</a>
|
||||
</div>`,
|
||||
desktop_updater: updaterMarkup,
|
||||
outPath: path.join(root, 'static', 'index.html'),
|
||||
},
|
||||
desktop: {
|
||||
styles_href: '../static/styles.css',
|
||||
lucide_src: '../static/vendor/lucide.min.js',
|
||||
app_src: 'app.js?v=20260227-shared-core-1',
|
||||
web_downloads: '',
|
||||
desktop_updater: updaterMarkup,
|
||||
outPath: path.join(root, 'desktop', 'index.html'),
|
||||
},
|
||||
};
|
||||
|
||||
for (const variant of Object.values(variants)) {
|
||||
let output = template;
|
||||
for (const [key, value] of Object.entries(variant)) {
|
||||
if (key === 'outPath') continue;
|
||||
const token = `{{${key}}}`;
|
||||
if (value === '') {
|
||||
output = output.replace(new RegExp(`^[ \\t]*\\{\\{${key}\\}\\}[ \\t]*\\n?`, 'm'), '');
|
||||
continue;
|
||||
}
|
||||
output = output.replaceAll(token, value);
|
||||
}
|
||||
|
||||
if (/\{\{[a-z_]+\}\}/i.test(output)) {
|
||||
throw new Error(`Unresolved template placeholders remain in ${variant.outPath}`);
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
variant.outPath,
|
||||
`<!-- Generated from shared-html/index.template.html. Do not edit directly. -->\n${output}`,
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
|
@ -1,279 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>Chattz</title>
|
||||
<link rel="stylesheet" href="{{styles_href}}" />
|
||||
<!-- Lucide Icons -->
|
||||
<script src="{{lucide_src}}"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="auth-screen" id="auth-screen">
|
||||
<div class="auth-card">
|
||||
<div class="brand-large">C</div>
|
||||
<h1>Chattz</h1>
|
||||
<p>Sign in to open your servers.</p>
|
||||
<button id="login-btn">Login with Authentik</button>
|
||||
{{web_downloads}}
|
||||
<p id="status" class="status-line"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="shell hidden" id="main">
|
||||
<aside class="server-rail">
|
||||
<div class="brand" title="Home">
|
||||
<i data-lucide="message-square"></i>
|
||||
</div>
|
||||
<div class="separator"></div>
|
||||
<div id="guild-list" class="guild-list"></div>
|
||||
<button id="add-guild-btn" class="guild-pill action-pill" title="Add a Server">
|
||||
<i data-lucide="plus"></i>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<aside class="channel-sidebar">
|
||||
<header class="sidebar-header clickable" id="guild-header">
|
||||
<h2 id="guild-title">No server selected</h2>
|
||||
<div class="sidebar-header-actions">
|
||||
<span id="invite-copied-badge" class="copied-badge hidden">Copied!</span>
|
||||
<button id="create-invite-btn" class="header-action-btn" title="Create Invite Link">
|
||||
<i data-lucide="link-2"></i>
|
||||
</button>
|
||||
<i data-lucide="chevron-down"></i>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="sidebar-scroll">
|
||||
<section class="sidebar-group">
|
||||
<div class="group-title">
|
||||
<i data-lucide="chevron-down" class="group-toggle"></i>
|
||||
<span>Text Channels</span>
|
||||
<button id="add-text-btn" class="add-btn" type="button" title="Create Text Channel">
|
||||
<i data-lucide="plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="channel-list" class="channel-list"></div>
|
||||
</section>
|
||||
|
||||
<section class="sidebar-group">
|
||||
<div class="group-title">
|
||||
<i data-lucide="chevron-down" class="group-toggle"></i>
|
||||
<span>Voice Channels</span>
|
||||
<button id="add-voice-btn" class="add-btn" type="button" title="Create Voice Channel">
|
||||
<i data-lucide="plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="voice-channel-list" class="channel-list"></div>
|
||||
</section>
|
||||
|
||||
<section class="sidebar-group">
|
||||
<div class="group-title">
|
||||
<i data-lucide="chevron-down" class="group-toggle"></i>
|
||||
<span>Direct Messages</span>
|
||||
</div>
|
||||
<div id="dm-list" class="channel-list"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<div id="voice-connection" class="voice-connection hidden">
|
||||
<div class="vc-info">
|
||||
<i data-lucide="signal-high" class="vc-icon"></i>
|
||||
<div class="vc-text">
|
||||
<span class="vc-status">Voice Connected</span>
|
||||
<span id="vc-channel-name" class="vc-name">General</span>
|
||||
</div>
|
||||
<div class="vc-actions">
|
||||
<button id="voice-video-btn" title="Turn on Camera"><i data-lucide="video-off"></i></button>
|
||||
<button id="voice-screen-btn" title="Share Screen"><i data-lucide="monitor-off"></i></button>
|
||||
<button id="voice-mute-btn" title="Mute"><i data-lucide="mic"></i></button>
|
||||
<button id="voice-leave-btn" title="Disconnect"><i data-lucide="phone-off"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="soundboard" class="soundboard-container hidden">
|
||||
<div class="soundboard-header">
|
||||
<span>Sound Board</span>
|
||||
<span id="add-sound-btn" class="btn-add-sound">+ Add Sound</span>
|
||||
</div>
|
||||
<div id="soundboard-grid" class="soundboard-grid"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="user-panel">
|
||||
<div class="avatar-wrapper">
|
||||
<div id="user-avatar" class="avatar">U</div>
|
||||
<div class="status-dot online"></div>
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<div id="user-name" class="display-name">Username</div>
|
||||
<div class="user-status">Online</div>
|
||||
</div>
|
||||
<div class="user-actions">
|
||||
{{desktop_updater}}
|
||||
<button id="user-settings-btn" title="Settings"><i data-lucide="settings"></i></button>
|
||||
<button id="logout-btn" title="Logout"><i data-lucide="log-out"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="chat-pane">
|
||||
<header class="chat-header">
|
||||
<button id="mobile-menu-btn" class="mobile-toggle-btn" title="Menu">
|
||||
<i data-lucide="menu"></i>
|
||||
</button>
|
||||
<i data-lucide="hash" class="header-icon"></i>
|
||||
<h3 id="channel-title">Select a channel</h3>
|
||||
<div style="flex:1"></div>
|
||||
<button id="mobile-members-btn" class="mobile-toggle-btn" title="Members">
|
||||
<i data-lucide="users"></i>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div id="video-grid" class="video-grid hidden"></div>
|
||||
<div id="message-list" class="message-list"></div>
|
||||
|
||||
<div class="chat-input-wrapper">
|
||||
<form id="message-form" class="message-form">
|
||||
<input id="media-file-input" type="file" class="hidden-file-input" accept="image/*,video/*,audio/*,.pdf,.txt,.zip,.rar,.7z,.tar,.gz,.json,.csv,.md" />
|
||||
<button type="button" id="media-upload-btn" class="input-action-btn" title="Upload File">
|
||||
<i data-lucide="paperclip"></i>
|
||||
</button>
|
||||
<button type="button" id="gif-btn" class="input-action-btn" title="Open GIF Picker">
|
||||
<i data-lucide="image"></i>
|
||||
</button>
|
||||
<input id="message-body" autocomplete="off" placeholder="Message #channel" maxlength="4000" required />
|
||||
<button type="submit" class="hidden"></button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<aside class="utility-sidebar" id="utility-sidebar">
|
||||
<header class="sidebar-header">
|
||||
<h2>Members</h2>
|
||||
</header>
|
||||
<div class="member-list-wrapper">
|
||||
<div id="member-list" class="member-list"></div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div id="overlay" class="overlay hidden"></div>
|
||||
|
||||
<!-- Modals -->
|
||||
<div id="gif-picker" class="modal-container hidden">
|
||||
<div class="modal gif-modal">
|
||||
<div class="modal-header">
|
||||
<h2>Select GIF</h2>
|
||||
<button type="button" class="close-btn" id="gif-picker-close">
|
||||
<i data-lucide="x"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="gif-search-wrapper">
|
||||
<input type="text" id="gif-search-input" placeholder="Search for GIFs..." autocomplete="off">
|
||||
<i data-lucide="search" class="search-icon"></i>
|
||||
</div>
|
||||
<div id="gif-results" class="gif-results-grid">
|
||||
<!-- GIF results will be injected here -->
|
||||
<div class="gif-loading hidden">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="modal-container" class="modal-container hidden">
|
||||
<div class="modal">
|
||||
<h2 id="modal-title">Create Server</h2>
|
||||
<form id="guild-form" class="modal-form">
|
||||
<div class="form-item">
|
||||
<label for="guild-name">SERVER NAME</label>
|
||||
<input id="guild-name" placeholder="My Awesome Server" maxlength="64" required />
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="cancel-btn" id="modal-cancel">Cancel</button>
|
||||
<button type="submit" class="submit-btn">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="channel-modal" class="modal-container hidden">
|
||||
<div class="modal">
|
||||
<h2>Create Channel</h2>
|
||||
<form id="channel-form" class="modal-form">
|
||||
<div class="form-item">
|
||||
<label>CHANNEL TYPE</label>
|
||||
<div class="radio-group">
|
||||
<label class="radio-item">
|
||||
<input type="radio" name="channel-kind" value="text" checked>
|
||||
<div class="radio-box">
|
||||
<i data-lucide="hash"></i>
|
||||
<div class="radio-text">
|
||||
<strong>Text</strong>
|
||||
<span>Send messages, images, and GIFs.</span>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="radio-item">
|
||||
<input type="radio" name="channel-kind" value="voice">
|
||||
<div class="radio-box">
|
||||
<i data-lucide="volume-2"></i>
|
||||
<div class="radio-text">
|
||||
<strong>Voice</strong>
|
||||
<span>Hang out together with voice and video.</span>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label for="channel-name">CHANNEL NAME</label>
|
||||
<input id="channel-name" placeholder="new-channel" maxlength="64" required />
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="cancel-btn" id="channel-modal-cancel">Cancel</button>
|
||||
<button type="submit" class="submit-btn">Create Channel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="sound-modal" class="modal-container hidden">
|
||||
<div class="modal">
|
||||
<h2>Add Sound</h2>
|
||||
<form id="sound-form" class="modal-form">
|
||||
<div class="form-item">
|
||||
<label for="sound-name">NAME</label>
|
||||
<input id="sound-name" placeholder="Quack" maxlength="32" required />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label for="sound-icon">ICON (Emoji or Initials)</label>
|
||||
<input id="sound-icon" placeholder="🦆" maxlength="4" required />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label for="sound-file">AUDIO FILE (MP3/WAV)</label>
|
||||
<input id="sound-file" type="file" accept="audio/*" required />
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="cancel-btn" id="sound-modal-cancel">Cancel</button>
|
||||
<button type="submit" class="submit-btn" id="sound-submit-btn">Add Sound</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="upload-limit-modal" class="modal-container hidden">
|
||||
<div class="modal">
|
||||
<h2>Upload Too Large</h2>
|
||||
<p id="upload-limit-message" class="modal-copy">Uploads are limited to 50 MB per file.</p>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="submit-btn" id="upload-limit-ok">OK</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="{{app_src}}"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
258
src/auth.rs
258
src/auth.rs
|
|
@ -1,25 +1,32 @@
|
|||
use anyhow::{Result, anyhow};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{FromRef, FromRequestParts},
|
||||
http::{HeaderMap, StatusCode, request::Parts},
|
||||
http::{StatusCode, request::Parts},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use chrono::{Duration, Utc};
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{AppState, db};
|
||||
|
||||
pub const OAUTH_STATE_COOKIE: &str = "chattz_oauth_state";
|
||||
pub const SESSION_COOKIE: &str = "chattz_session";
|
||||
const SESSION_TTL_DAYS: i64 = 30;
|
||||
const SESSION_COOKIE: &str = "chattz_session";
|
||||
const OAUTH_STATE_COOKIE: &str = "chattz_oauth_state";
|
||||
const SESSION_TTL_SECS: u64 = 60 * 60 * 24 * 7;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
struct SessionClaims {
|
||||
sub: String,
|
||||
exp: usize,
|
||||
iat: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthUser {
|
||||
pub id: Uuid,
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
|
@ -36,13 +43,6 @@ impl ApiError {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn forbidden(msg: &str) -> Self {
|
||||
Self {
|
||||
status: StatusCode::FORBIDDEN,
|
||||
message: msg.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bad_request(msg: &str) -> Self {
|
||||
Self {
|
||||
status: StatusCode::BAD_REQUEST,
|
||||
|
|
@ -56,13 +56,6 @@ impl ApiError {
|
|||
message: msg.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn service_unavailable(msg: &str) -> Self {
|
||||
Self {
|
||||
status: StatusCode::SERVICE_UNAVAILABLE,
|
||||
message: msg.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
|
@ -72,13 +65,7 @@ struct ErrorBody<'a> {
|
|||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
(
|
||||
self.status,
|
||||
Json(ErrorBody {
|
||||
error: &self.message,
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
(self.status, Json(ErrorBody { error: &self.message })).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -90,25 +77,25 @@ impl From<anyhow::Error> for ApiError {
|
|||
|
||||
impl<S> FromRequestParts<S> for AuthUser
|
||||
where
|
||||
AppState: FromRef<S>,
|
||||
AppState: axum::extract::FromRef<S>,
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = ApiError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||
let app = AppState::from_ref(state);
|
||||
let session_id = read_cookie_from_headers(&parts.headers, SESSION_COOKIE)
|
||||
.ok_or_else(|| ApiError::unauthorized("missing session cookie"))?;
|
||||
let token = read_cookie(parts, SESSION_COOKIE).ok_or_else(|| ApiError::unauthorized("missing session"))?;
|
||||
let user_id = verify_session(&token, &app.settings.session_secret)
|
||||
.map_err(|_| ApiError::unauthorized("invalid session"))?;
|
||||
|
||||
let user_id = db::touch_active_session(&app.db, &session_id)
|
||||
let exists = db::user_exists(&app.db, user_id)
|
||||
.await
|
||||
.map_err(|_| ApiError::unauthorized("invalid or expired session"))?
|
||||
.ok_or_else(|| ApiError::unauthorized("invalid or expired session"))?;
|
||||
.map_err(|_| ApiError::unauthorized("session user not found"))?;
|
||||
if !exists {
|
||||
return Err(ApiError::unauthorized("session user not found"));
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
id: user_id,
|
||||
session_id,
|
||||
})
|
||||
Ok(Self { id: user_id })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -116,14 +103,6 @@ pub fn new_oauth_state() -> String {
|
|||
Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
pub fn new_session_id() -> String {
|
||||
Uuid::new_v4().simple().to_string()
|
||||
}
|
||||
|
||||
pub fn session_expiry() -> chrono::DateTime<chrono::FixedOffset> {
|
||||
(Utc::now() + Duration::days(SESSION_TTL_DAYS)).fixed_offset()
|
||||
}
|
||||
|
||||
pub fn make_oauth_state_cookie(value: &str, secure: bool) -> String {
|
||||
format!(
|
||||
"{name}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=600{secure_flag}",
|
||||
|
|
@ -133,32 +112,69 @@ pub fn make_oauth_state_cookie(value: &str, secure: bool) -> String {
|
|||
}
|
||||
|
||||
pub fn clear_oauth_state_cookie(secure: bool) -> String {
|
||||
clear_cookie(OAUTH_STATE_COOKIE, secure, "Lax")
|
||||
}
|
||||
|
||||
pub fn make_session_cookie(value: &str, secure: bool) -> String {
|
||||
format!(
|
||||
"{name}={value}; Path=/; HttpOnly; SameSite=Strict; Max-Age={ttl}{secure_flag}",
|
||||
name = SESSION_COOKIE,
|
||||
ttl = Duration::days(SESSION_TTL_DAYS).num_seconds(),
|
||||
"{name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0{secure_flag}",
|
||||
name = OAUTH_STATE_COOKIE,
|
||||
secure_flag = if secure { "; Secure" } else { "" }
|
||||
)
|
||||
}
|
||||
|
||||
pub fn clear_session_cookie(secure: bool) -> String {
|
||||
clear_cookie(SESSION_COOKIE, secure, "Strict")
|
||||
}
|
||||
|
||||
pub fn read_cookie_from_headers(headers: &HeaderMap, cookie_name: &str) -> Option<String> {
|
||||
pub fn read_oauth_state_from_headers(headers: &axum::http::HeaderMap) -> Option<String> {
|
||||
let raw = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
|
||||
raw.split(';').find_map(|pair| {
|
||||
let mut kv = pair.trim().splitn(2, '=');
|
||||
let key = kv.next()?;
|
||||
let value = kv.next()?;
|
||||
(key == cookie_name).then(|| value.to_string())
|
||||
(key == OAUTH_STATE_COOKIE).then(|| value.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn new_session_cookie(user_id: Uuid, secret: &str, secure: bool) -> Result<String> {
|
||||
let now = now_ts();
|
||||
let claims = SessionClaims {
|
||||
sub: user_id.to_string(),
|
||||
iat: now as usize,
|
||||
exp: (now + SESSION_TTL_SECS) as usize,
|
||||
};
|
||||
|
||||
let token = encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(secret.as_bytes()),
|
||||
)
|
||||
.context("failed to encode session token")?;
|
||||
|
||||
Ok(format!(
|
||||
"{name}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={ttl}{secure_flag}",
|
||||
name = SESSION_COOKIE,
|
||||
ttl = SESSION_TTL_SECS,
|
||||
secure_flag = if secure { "; Secure" } else { "" }
|
||||
))
|
||||
}
|
||||
|
||||
pub fn clear_session_cookie(secure: bool) -> String {
|
||||
format!(
|
||||
"{name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0{secure_flag}",
|
||||
name = SESSION_COOKIE,
|
||||
secure_flag = if secure { "; Secure" } else { "" }
|
||||
)
|
||||
}
|
||||
|
||||
fn verify_session(token: &str, secret: &str) -> Result<Uuid> {
|
||||
let mut validation = Validation::default();
|
||||
validation.validate_exp = true;
|
||||
|
||||
let data = decode::<SessionClaims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(secret.as_bytes()),
|
||||
&validation,
|
||||
)
|
||||
.context("failed to decode session token")?;
|
||||
|
||||
let user_id = Uuid::parse_str(&data.claims.sub).context("invalid sub in session token")?;
|
||||
Ok(user_id)
|
||||
}
|
||||
|
||||
pub fn validate_oauth_state(expected_cookie: Option<String>, query_state: &str) -> Result<()> {
|
||||
let expected = expected_cookie.ok_or_else(|| anyhow!("missing oauth state cookie"))?;
|
||||
if expected != query_state {
|
||||
|
|
@ -167,113 +183,19 @@ pub fn validate_oauth_state(expected_cookie: Option<String>, query_state: &str)
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub fn user_agent_hash(headers: &HeaderMap) -> Option<String> {
|
||||
header_hash(headers, axum::http::header::USER_AGENT.as_str())
|
||||
fn read_cookie(parts: &Parts, name: &str) -> Option<String> {
|
||||
let raw = parts.headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
|
||||
raw.split(';').find_map(|pair| {
|
||||
let mut kv = pair.trim().splitn(2, '=');
|
||||
let key = kv.next()?;
|
||||
let value = kv.next()?;
|
||||
(key == name).then(|| value.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn ip_hash(headers: &HeaderMap) -> Option<String> {
|
||||
headers
|
||||
.get("x-forwarded-for")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|raw| raw.split(',').next().map(str::trim))
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(hash_string)
|
||||
.or_else(|| header_hash(headers, "x-real-ip"))
|
||||
}
|
||||
|
||||
pub fn origin_matches(headers: &HeaderMap, expected_origin: &str) -> bool {
|
||||
headers
|
||||
.get(axum::http::header::ORIGIN)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(|origin| origin == expected_origin)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn header_hash(headers: &HeaderMap, header_name: &str) -> Option<String> {
|
||||
headers
|
||||
.get(header_name)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(hash_string)
|
||||
}
|
||||
|
||||
fn hash_string(value: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(value.as_bytes());
|
||||
let digest = hasher.finalize();
|
||||
let mut out = String::with_capacity(digest.len() * 2);
|
||||
for byte in digest {
|
||||
out.push(nibble_to_hex(byte >> 4));
|
||||
out.push(nibble_to_hex(byte & 0x0f));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn nibble_to_hex(value: u8) -> char {
|
||||
match value {
|
||||
0..=9 => (b'0' + value) as char,
|
||||
10..=15 => (b'a' + (value - 10)) as char,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_cookie(name: &str, secure: bool, same_site: &str) -> String {
|
||||
format!(
|
||||
"{name}=; Path=/; HttpOnly; SameSite={same_site}; Max-Age=0{secure_flag}",
|
||||
secure_flag = if secure { "; Secure" } else { "" }
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
SESSION_COOKIE, clear_session_cookie, hash_string, make_session_cookie, origin_matches,
|
||||
read_cookie_from_headers,
|
||||
};
|
||||
use axum::http::{HeaderMap, header};
|
||||
|
||||
#[test]
|
||||
fn hash_string_is_stable() {
|
||||
assert_eq!(
|
||||
hash_string("example"),
|
||||
"50d858e0985ecc7f60418aaf0cc5ab587f42c2570a884095a9e8ccacd0f6545c"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_named_cookie_from_header() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header::COOKIE,
|
||||
"other=value; chattz_session=session-123; another=ok"
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
read_cookie_from_headers(&headers, SESSION_COOKIE),
|
||||
Some("session-123".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn origin_match_requires_exact_origin() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(header::ORIGIN, "http://localhost:3000".parse().unwrap());
|
||||
|
||||
assert!(origin_matches(&headers, "http://localhost:3000"));
|
||||
assert!(!origin_matches(&headers, "https://localhost:3000"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_cookie_has_strict_policy_and_clear_cookie_expires() {
|
||||
let session_cookie = make_session_cookie("session-123", false);
|
||||
let cleared_cookie = clear_session_cookie(false);
|
||||
|
||||
assert!(session_cookie.contains("HttpOnly"));
|
||||
assert!(session_cookie.contains("SameSite=Strict"));
|
||||
assert!(session_cookie.contains("Max-Age="));
|
||||
assert!(cleared_cookie.contains("SameSite=Strict"));
|
||||
assert!(cleared_cookie.contains("Max-Age=0"));
|
||||
}
|
||||
fn now_ts() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_else(|_| Duration::from_secs(0))
|
||||
.as_secs()
|
||||
}
|
||||
|
|
|
|||
354
src/chat.rs
354
src/chat.rs
|
|
@ -1,354 +0,0 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use axum::extract::ws::{Message, WebSocket};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::{RwLock, mpsc};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{AppState, db};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ChatClient {
|
||||
tx: mpsc::UnboundedSender<ServerEvent>,
|
||||
is_idle: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
|
||||
pub struct OnlineUser {
|
||||
pub user_id: Uuid,
|
||||
pub online: bool,
|
||||
pub idle: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ChatHub {
|
||||
// user_id -> connection_id -> client
|
||||
clients: RwLock<HashMap<Uuid, HashMap<Uuid, ChatClient>>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ServerEvent {
|
||||
MessageCreated {
|
||||
channel_id: Uuid,
|
||||
message: serde_json::Value,
|
||||
},
|
||||
DmCreated {
|
||||
other_user_id: Uuid,
|
||||
message: serde_json::Value,
|
||||
},
|
||||
UserPresence {
|
||||
user_id: Uuid,
|
||||
online: bool,
|
||||
idle: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
enum ClientEvent {
|
||||
Ping,
|
||||
SetIdleStatus { is_idle: bool },
|
||||
}
|
||||
|
||||
impl ChatHub {
|
||||
pub async fn add_client(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
connection_id: Uuid,
|
||||
tx: mpsc::UnboundedSender<ServerEvent>,
|
||||
) -> Option<OnlineUser> {
|
||||
let mut clients = self.clients.write().await;
|
||||
let previous = aggregate_presence(clients.get(&user_id), user_id);
|
||||
|
||||
clients
|
||||
.entry(user_id)
|
||||
.or_default()
|
||||
.insert(connection_id, ChatClient { tx, is_idle: false });
|
||||
|
||||
let current = aggregate_presence(clients.get(&user_id), user_id);
|
||||
presence_delta(previous, current)
|
||||
}
|
||||
|
||||
pub async fn remove_client(&self, user_id: Uuid, connection_id: Uuid) -> Option<OnlineUser> {
|
||||
let mut clients = self.clients.write().await;
|
||||
let previous = aggregate_presence(clients.get(&user_id), user_id);
|
||||
|
||||
if let Some(connections) = clients.get_mut(&user_id) {
|
||||
connections.remove(&connection_id);
|
||||
if connections.is_empty() {
|
||||
clients.remove(&user_id);
|
||||
}
|
||||
}
|
||||
|
||||
let current = aggregate_presence(clients.get(&user_id), user_id);
|
||||
presence_delta(previous, current)
|
||||
}
|
||||
|
||||
pub async fn get_online_users_for(&self, visible_user_ids: &[Uuid]) -> Vec<OnlineUser> {
|
||||
let clients = self.clients.read().await;
|
||||
visible_user_ids
|
||||
.iter()
|
||||
.filter_map(|user_id| aggregate_presence(clients.get(user_id), *user_id))
|
||||
.filter(|presence| presence.online)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn broadcast_to_many(&self, user_ids: Vec<Uuid>, event: ServerEvent) {
|
||||
let clients = self.clients.read().await;
|
||||
for user_id in user_ids {
|
||||
if let Some(connections) = clients.get(&user_id) {
|
||||
for client in connections.values() {
|
||||
let _ = client.tx.send(event.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn broadcast_to_user(&self, user_id: Uuid, event: ServerEvent) {
|
||||
let clients = self.clients.read().await;
|
||||
if let Some(connections) = clients.get(&user_id) {
|
||||
for client in connections.values() {
|
||||
let _ = client.tx.send(event.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn set_idle_status(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
connection_id: Uuid,
|
||||
is_idle: bool,
|
||||
) -> Option<OnlineUser> {
|
||||
let mut clients = self.clients.write().await;
|
||||
let previous = aggregate_presence(clients.get(&user_id), user_id);
|
||||
|
||||
if let Some(connections) = clients.get_mut(&user_id)
|
||||
&& let Some(client) = connections.get_mut(&connection_id)
|
||||
{
|
||||
client.is_idle = is_idle;
|
||||
}
|
||||
|
||||
let current = aggregate_presence(clients.get(&user_id), user_id);
|
||||
presence_delta(previous, current)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_socket(state: AppState, socket: WebSocket, user_id: Uuid) {
|
||||
let (mut ws_sender, mut ws_receiver) = socket.split();
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<ServerEvent>();
|
||||
let connection_id = Uuid::new_v4();
|
||||
|
||||
if let Some(presence) = state.chat.add_client(user_id, connection_id, tx).await {
|
||||
if let Ok(visible_user_ids) = db::list_visible_user_ids(&state.db, user_id).await {
|
||||
state
|
||||
.chat
|
||||
.broadcast_to_many(
|
||||
visible_user_ids,
|
||||
ServerEvent::UserPresence {
|
||||
user_id: presence.user_id,
|
||||
online: presence.online,
|
||||
idle: presence.idle,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
let send_task = tokio::spawn(async move {
|
||||
while let Some(event) = rx.recv().await {
|
||||
let Ok(payload) = serde_json::to_string(&event) else {
|
||||
continue;
|
||||
};
|
||||
if ws_sender.send(Message::Text(payload.into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
while let Some(Ok(msg)) = ws_receiver.next().await {
|
||||
match msg {
|
||||
Message::Close(_) => break,
|
||||
Message::Text(text) => {
|
||||
if let Ok(ClientEvent::SetIdleStatus { is_idle }) =
|
||||
serde_json::from_str::<ClientEvent>(&text)
|
||||
&& let Some(presence) = state
|
||||
.chat
|
||||
.set_idle_status(user_id, connection_id, is_idle)
|
||||
.await
|
||||
&& let Ok(visible_user_ids) =
|
||||
db::list_visible_user_ids(&state.db, user_id).await
|
||||
{
|
||||
state
|
||||
.chat
|
||||
.broadcast_to_many(
|
||||
visible_user_ids,
|
||||
ServerEvent::UserPresence {
|
||||
user_id: presence.user_id,
|
||||
online: presence.online,
|
||||
idle: presence.idle,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
send_task.abort();
|
||||
if let Some(presence) = state.chat.remove_client(user_id, connection_id).await
|
||||
&& let Ok(visible_user_ids) = db::list_visible_user_ids(&state.db, user_id).await
|
||||
{
|
||||
state
|
||||
.chat
|
||||
.broadcast_to_many(
|
||||
visible_user_ids,
|
||||
ServerEvent::UserPresence {
|
||||
user_id: presence.user_id,
|
||||
online: presence.online,
|
||||
idle: presence.idle,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
fn aggregate_presence(
|
||||
connections: Option<&HashMap<Uuid, ChatClient>>,
|
||||
user_id: Uuid,
|
||||
) -> Option<OnlineUser> {
|
||||
let connections = connections?;
|
||||
if connections.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let idle = connections.values().all(|client| client.is_idle);
|
||||
Some(OnlineUser {
|
||||
user_id,
|
||||
online: true,
|
||||
idle,
|
||||
})
|
||||
}
|
||||
|
||||
fn presence_delta(previous: Option<OnlineUser>, current: Option<OnlineUser>) -> Option<OnlineUser> {
|
||||
match (previous, current) {
|
||||
(None, None) => None,
|
||||
(Some(prev), Some(curr)) if prev == curr => None,
|
||||
(Some(prev), None) => Some(OnlineUser {
|
||||
user_id: prev.user_id,
|
||||
online: false,
|
||||
idle: false,
|
||||
}),
|
||||
(_, Some(curr)) => Some(curr),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{ChatHub, OnlineUser, ServerEvent};
|
||||
use serde_json::json;
|
||||
use tokio::sync::mpsc;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[tokio::test]
|
||||
async fn multiple_connections_keep_user_online_until_last_disconnect() {
|
||||
let hub = ChatHub::default();
|
||||
let user_id = Uuid::new_v4();
|
||||
let first_connection = Uuid::new_v4();
|
||||
let second_connection = Uuid::new_v4();
|
||||
let (tx1, _rx1) = mpsc::unbounded_channel();
|
||||
let (tx2, _rx2) = mpsc::unbounded_channel();
|
||||
|
||||
let first_presence = hub.add_client(user_id, first_connection, tx1).await;
|
||||
let second_presence = hub.add_client(user_id, second_connection, tx2).await;
|
||||
let after_first_disconnect = hub.remove_client(user_id, first_connection).await;
|
||||
let after_last_disconnect = hub.remove_client(user_id, second_connection).await;
|
||||
|
||||
assert_eq!(
|
||||
first_presence,
|
||||
Some(OnlineUser {
|
||||
user_id,
|
||||
online: true,
|
||||
idle: false,
|
||||
})
|
||||
);
|
||||
assert_eq!(second_presence, None);
|
||||
assert_eq!(after_first_disconnect, None);
|
||||
assert_eq!(
|
||||
after_last_disconnect,
|
||||
Some(OnlineUser {
|
||||
user_id,
|
||||
online: false,
|
||||
idle: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn idle_status_only_flips_when_all_connections_are_idle() {
|
||||
let hub = ChatHub::default();
|
||||
let user_id = Uuid::new_v4();
|
||||
let first_connection = Uuid::new_v4();
|
||||
let second_connection = Uuid::new_v4();
|
||||
let (tx1, _rx1) = mpsc::unbounded_channel();
|
||||
let (tx2, _rx2) = mpsc::unbounded_channel();
|
||||
|
||||
hub.add_client(user_id, first_connection, tx1).await;
|
||||
hub.add_client(user_id, second_connection, tx2).await;
|
||||
|
||||
let first_idle = hub.set_idle_status(user_id, first_connection, true).await;
|
||||
let second_idle = hub.set_idle_status(user_id, second_connection, true).await;
|
||||
let active_again = hub.set_idle_status(user_id, first_connection, false).await;
|
||||
|
||||
assert_eq!(first_idle, None);
|
||||
assert_eq!(
|
||||
second_idle,
|
||||
Some(OnlineUser {
|
||||
user_id,
|
||||
online: true,
|
||||
idle: true,
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
active_again,
|
||||
Some(OnlineUser {
|
||||
user_id,
|
||||
online: true,
|
||||
idle: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn broadcast_to_user_reaches_all_active_connections() {
|
||||
let hub = ChatHub::default();
|
||||
let user_id = Uuid::new_v4();
|
||||
let first_connection = Uuid::new_v4();
|
||||
let second_connection = Uuid::new_v4();
|
||||
let (tx1, mut rx1) = mpsc::unbounded_channel();
|
||||
let (tx2, mut rx2) = mpsc::unbounded_channel();
|
||||
|
||||
hub.add_client(user_id, first_connection, tx1).await;
|
||||
hub.add_client(user_id, second_connection, tx2).await;
|
||||
|
||||
hub.broadcast_to_user(
|
||||
user_id,
|
||||
ServerEvent::DmCreated {
|
||||
other_user_id: Uuid::new_v4(),
|
||||
message: json!({ "body": "hello" }),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(matches!(
|
||||
rx1.try_recv(),
|
||||
Ok(ServerEvent::DmCreated { message, .. }) if message["body"] == "hello"
|
||||
));
|
||||
assert!(matches!(
|
||||
rx2.try_recv(),
|
||||
Ok(ServerEvent::DmCreated { message, .. }) if message["body"] == "hello"
|
||||
));
|
||||
}
|
||||
}
|
||||
169
src/config.rs
169
src/config.rs
|
|
@ -1,11 +1,8 @@
|
|||
use anyhow::{Context, Result, anyhow};
|
||||
use reqwest::Url;
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Settings {
|
||||
pub port: u16,
|
||||
pub app_base_url: String,
|
||||
pub app_origin: String,
|
||||
pub database_url: String,
|
||||
pub oidc_client_id: String,
|
||||
pub oidc_client_secret: String,
|
||||
|
|
@ -14,36 +11,21 @@ pub struct Settings {
|
|||
pub oidc_userinfo_url: String,
|
||||
pub oidc_redirect_url: String,
|
||||
pub oidc_scopes: String,
|
||||
pub session_cookie_secure: bool,
|
||||
pub session_secret: String,
|
||||
pub cookie_secure: bool,
|
||||
pub stun_urls: Vec<String>,
|
||||
pub turn_urls: Vec<String>,
|
||||
pub turn_username: Option<String>,
|
||||
pub turn_password: Option<String>,
|
||||
pub media: Option<MediaSettings>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MediaSettings {
|
||||
pub account_id: String,
|
||||
pub access_key_id: String,
|
||||
pub secret_access_key: String,
|
||||
pub bucket: String,
|
||||
pub public_base_url: String,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
pub fn from_env() -> Result<Self> {
|
||||
let app_base_url = required("APP_BASE_URL")?;
|
||||
let app_url = Url::parse(&app_base_url).context("APP_BASE_URL must be a valid URL")?;
|
||||
let app_origin = origin_from_url(&app_url)?;
|
||||
|
||||
Ok(Self {
|
||||
port: std::env::var("PORT")
|
||||
.unwrap_or_else(|_| "3000".into())
|
||||
.parse()
|
||||
.context("PORT must be a valid u16")?,
|
||||
app_base_url: trim_trailing_slash(&app_base_url),
|
||||
app_origin,
|
||||
database_url: required("DATABASE_URL")?,
|
||||
oidc_client_id: required("OIDC_CLIENT_ID")?,
|
||||
oidc_client_secret: required("OIDC_CLIENT_SECRET")?,
|
||||
|
|
@ -51,51 +33,20 @@ impl Settings {
|
|||
oidc_token_url: required("OIDC_TOKEN_URL")?,
|
||||
oidc_userinfo_url: required("OIDC_USERINFO_URL")?,
|
||||
oidc_redirect_url: required("OIDC_REDIRECT_URL")?,
|
||||
oidc_scopes: std::env::var("OIDC_SCOPES")
|
||||
.unwrap_or_else(|_| "openid profile email".to_string()),
|
||||
session_cookie_secure: requires_secure_cookie(&app_url)?,
|
||||
oidc_scopes: std::env::var("OIDC_SCOPES").unwrap_or_else(|_| "openid profile email".to_string()),
|
||||
session_secret: required("SESSION_SECRET")?,
|
||||
cookie_secure: std::env::var("COOKIE_SECURE")
|
||||
.unwrap_or_else(|_| "false".into())
|
||||
.parse()
|
||||
.context("COOKIE_SECURE must be true/false")?,
|
||||
stun_urls: parse_csv_env("STUN_URLS", "stun:stun.l.google.com:19302"),
|
||||
turn_urls: parse_csv_env("TURN_URLS", ""),
|
||||
turn_username: optional("TURN_USERNAME"),
|
||||
turn_password: optional("TURN_PASSWORD"),
|
||||
media: MediaSettings::from_env()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl MediaSettings {
|
||||
fn from_env() -> Result<Option<Self>> {
|
||||
let account_id = optional("R2_ACCOUNT_ID");
|
||||
let access_key_id = optional("R2_ACCESS_KEY_ID");
|
||||
let secret_access_key = optional("R2_SECRET_ACCESS_KEY");
|
||||
let bucket = optional("R2_BUCKET");
|
||||
let public_base_url = optional("MEDIA_BASE_URL").or_else(|| optional("R2_PUBLIC_BASE_URL"));
|
||||
|
||||
if account_id.is_none()
|
||||
&& access_key_id.is_none()
|
||||
&& secret_access_key.is_none()
|
||||
&& bucket.is_none()
|
||||
&& public_base_url.is_none()
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(Self {
|
||||
account_id: account_id.context("missing env var R2_ACCOUNT_ID")?,
|
||||
access_key_id: access_key_id.context("missing env var R2_ACCESS_KEY_ID")?,
|
||||
secret_access_key: secret_access_key.context("missing env var R2_SECRET_ACCESS_KEY")?,
|
||||
bucket: bucket.context("missing env var R2_BUCKET")?,
|
||||
public_base_url: trim_trailing_slash(
|
||||
&public_base_url.context("missing env var MEDIA_BASE_URL")?,
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn endpoint_url(&self) -> String {
|
||||
format!("https://{}.r2.cloudflarestorage.com", self.account_id)
|
||||
}
|
||||
}
|
||||
|
||||
fn required(name: &str) -> Result<String> {
|
||||
std::env::var(name).with_context(|| format!("missing env var {name}"))
|
||||
}
|
||||
|
|
@ -115,105 +66,3 @@ fn parse_csv_env(name: &str, default_value: &str) -> Vec<String> {
|
|||
.map(ToString::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn origin_from_url(url: &Url) -> Result<String> {
|
||||
let host = url
|
||||
.host_str()
|
||||
.ok_or_else(|| anyhow!("APP_BASE_URL must include a host"))?;
|
||||
let mut origin = format!("{}://{}", url.scheme(), host);
|
||||
if let Some(port) = url.port() {
|
||||
origin.push(':');
|
||||
origin.push_str(&port.to_string());
|
||||
}
|
||||
Ok(origin)
|
||||
}
|
||||
|
||||
fn requires_secure_cookie(url: &Url) -> Result<bool> {
|
||||
match url.scheme() {
|
||||
"https" => Ok(true),
|
||||
"http" => {
|
||||
let host = url
|
||||
.host_str()
|
||||
.ok_or_else(|| anyhow!("APP_BASE_URL must include a host"))?;
|
||||
if matches!(host, "localhost" | "127.0.0.1" | "::1") {
|
||||
Ok(false)
|
||||
} else {
|
||||
Err(anyhow!(
|
||||
"APP_BASE_URL must use https outside localhost when session cookies are enabled"
|
||||
))
|
||||
}
|
||||
}
|
||||
other => Err(anyhow!("APP_BASE_URL scheme {other} is not supported")),
|
||||
}
|
||||
}
|
||||
|
||||
fn trim_trailing_slash(value: &str) -> String {
|
||||
value.trim_end_matches('/').to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{MediaSettings, origin_from_url, requires_secure_cookie, trim_trailing_slash};
|
||||
use reqwest::Url;
|
||||
|
||||
#[test]
|
||||
fn origin_from_url_preserves_explicit_port() {
|
||||
let url = Url::parse("https://chat.example.com:8443/app").unwrap();
|
||||
let origin = origin_from_url(&url).unwrap();
|
||||
|
||||
assert_eq!(origin, "https://chat.example.com:8443");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secure_cookie_is_required_for_https_origins() {
|
||||
let url = Url::parse("https://chat.example.com").unwrap();
|
||||
|
||||
assert_eq!(requires_secure_cookie(&url).unwrap(), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn localhost_http_is_allowed_without_secure_cookie() {
|
||||
let url = Url::parse("http://localhost:3000").unwrap();
|
||||
|
||||
assert_eq!(requires_secure_cookie(&url).unwrap(), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_localhost_http_is_rejected() {
|
||||
let url = Url::parse("http://chat.example.com").unwrap();
|
||||
let err = requires_secure_cookie(&url).unwrap_err();
|
||||
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("APP_BASE_URL must use https outside localhost")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_trailing_slash_removes_only_suffix_slashes() {
|
||||
assert_eq!(
|
||||
trim_trailing_slash("https://chat.example.com///"),
|
||||
"https://chat.example.com"
|
||||
);
|
||||
assert_eq!(
|
||||
trim_trailing_slash("https://chat.example.com/app"),
|
||||
"https://chat.example.com/app"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_endpoint_url_uses_account_id() {
|
||||
let media = MediaSettings {
|
||||
account_id: "acct123".to_string(),
|
||||
access_key_id: "key".to_string(),
|
||||
secret_access_key: "secret".to_string(),
|
||||
bucket: "bucket".to_string(),
|
||||
public_base_url: "https://media.example.com".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
media.endpoint_url(),
|
||||
"https://acct123.r2.cloudflarestorage.com"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,66 +0,0 @@
|
|||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "attachments")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub channel_message_id: Option<Uuid>,
|
||||
pub direct_message_id: Option<Uuid>,
|
||||
pub uploader_user_id: Uuid,
|
||||
pub object_key: String,
|
||||
pub media_url: String,
|
||||
pub mime_type: String,
|
||||
pub size_bytes: i64,
|
||||
pub original_filename: String,
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::messages::Entity",
|
||||
from = "Column::ChannelMessageId",
|
||||
to = "super::messages::Column::Id",
|
||||
on_update = "NoAction",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
Messages,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::direct_messages::Entity",
|
||||
from = "Column::DirectMessageId",
|
||||
to = "super::direct_messages::Column::Id",
|
||||
on_update = "NoAction",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
DirectMessages,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::users::Entity",
|
||||
from = "Column::UploaderUserId",
|
||||
to = "super::users::Column::Id",
|
||||
on_update = "NoAction",
|
||||
on_delete = "NoAction"
|
||||
)]
|
||||
Users,
|
||||
}
|
||||
|
||||
impl Related<super::messages::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Messages.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::direct_messages::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::DirectMessages.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::users::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Users.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
|
|
@ -1,10 +1,7 @@
|
|||
pub mod attachments;
|
||||
pub mod channels;
|
||||
pub mod direct_messages;
|
||||
pub mod guild_members;
|
||||
pub mod guilds;
|
||||
pub mod invites;
|
||||
pub mod messages;
|
||||
pub mod sessions;
|
||||
pub mod soundboard_sounds;
|
||||
pub mod users;
|
||||
|
|
|
|||
|
|
@ -1,35 +0,0 @@
|
|||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "sessions")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: String,
|
||||
pub user_id: Uuid,
|
||||
pub expires_at: DateTimeWithTimeZone,
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
pub last_seen_at: DateTimeWithTimeZone,
|
||||
pub revoked_at: Option<DateTimeWithTimeZone>,
|
||||
pub user_agent_hash: Option<String>,
|
||||
pub ip_hash: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::users::Entity",
|
||||
from = "Column::UserId",
|
||||
to = "super::users::Column::Id",
|
||||
on_update = "NoAction",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
Users,
|
||||
}
|
||||
|
||||
impl Related<super::users::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Users.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "soundboard_sounds")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub guild_id: Uuid,
|
||||
pub name: String,
|
||||
pub icon: String,
|
||||
pub object_key: Option<String>,
|
||||
pub media_url: String,
|
||||
pub mime_type: Option<String>,
|
||||
pub size_bytes: Option<i64>,
|
||||
pub file_path: Option<String>,
|
||||
pub created_by_user_id: Uuid,
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::guilds::Entity",
|
||||
from = "Column::GuildId",
|
||||
to = "super::guilds::Column::Id",
|
||||
on_update = "NoAction",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
Guilds,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::users::Entity",
|
||||
from = "Column::CreatedByUserId",
|
||||
to = "super::users::Column::Id",
|
||||
on_update = "NoAction",
|
||||
on_delete = "NoAction"
|
||||
)]
|
||||
Users,
|
||||
}
|
||||
|
||||
impl Related<super::guilds::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Guilds.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::users::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Users.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
853
src/handlers.rs
853
src/handlers.rs
File diff suppressed because it is too large
Load diff
1129
src/main.rs
1129
src/main.rs
File diff suppressed because it is too large
Load diff
87
src/media.rs
87
src/media.rs
|
|
@ -1,87 +0,0 @@
|
|||
use anyhow::{Result, anyhow};
|
||||
use aws_config::BehaviorVersion;
|
||||
use aws_sdk_s3::{
|
||||
Client,
|
||||
config::{Credentials, Region},
|
||||
primitives::ByteStream,
|
||||
};
|
||||
|
||||
use crate::config::MediaSettings;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct MediaStorage {
|
||||
client: Client,
|
||||
bucket: String,
|
||||
public_base_url: String,
|
||||
}
|
||||
|
||||
impl MediaStorage {
|
||||
pub async fn new(settings: &MediaSettings) -> Result<Self> {
|
||||
let shared_config = aws_config::defaults(BehaviorVersion::latest())
|
||||
.region(Region::new("auto"))
|
||||
.credentials_provider(Credentials::new(
|
||||
settings.access_key_id.clone(),
|
||||
settings.secret_access_key.clone(),
|
||||
None,
|
||||
None,
|
||||
"chattz-r2",
|
||||
))
|
||||
.load()
|
||||
.await;
|
||||
|
||||
let config = aws_sdk_s3::config::Builder::from(&shared_config)
|
||||
.endpoint_url(settings.endpoint_url())
|
||||
.force_path_style(true)
|
||||
.build();
|
||||
|
||||
Ok(Self {
|
||||
client: Client::from_conf(config),
|
||||
bucket: settings.bucket.clone(),
|
||||
public_base_url: settings.public_base_url.trim_end_matches('/').to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn upload_object(
|
||||
&self,
|
||||
object_key: &str,
|
||||
bytes: Vec<u8>,
|
||||
content_type: &str,
|
||||
original_filename: &str,
|
||||
inline: bool,
|
||||
) -> Result<String> {
|
||||
self.client
|
||||
.put_object()
|
||||
.bucket(&self.bucket)
|
||||
.key(object_key)
|
||||
.body(ByteStream::from(bytes))
|
||||
.content_type(content_type)
|
||||
.content_disposition(format!(
|
||||
"{}; filename=\"{}\"",
|
||||
if inline { "inline" } else { "attachment" },
|
||||
sanitize_header_value(original_filename)
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| anyhow!("failed to upload object to R2: {e}"))?;
|
||||
|
||||
Ok(format!("{}/{}", self.public_base_url, object_key))
|
||||
}
|
||||
|
||||
pub async fn delete_object(&self, object_key: &str) -> Result<()> {
|
||||
self.client
|
||||
.delete_object()
|
||||
.bucket(&self.bucket)
|
||||
.key(object_key)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| anyhow!("failed to delete object from R2: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_header_value(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.filter(|c| *c != '\\' && *c != '"' && !c.is_control())
|
||||
.collect()
|
||||
}
|
||||
|
|
@ -11,13 +11,13 @@ impl MigrationTrait for Migration {
|
|||
Table::create()
|
||||
.table(Users::Table)
|
||||
.if_not_exists()
|
||||
.col(ColumnDef::new(Users::Id).uuid().not_null().primary_key())
|
||||
.col(
|
||||
ColumnDef::new(Users::OidcSub)
|
||||
.string()
|
||||
ColumnDef::new(Users::Id)
|
||||
.uuid()
|
||||
.not_null()
|
||||
.unique_key(),
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Users::OidcSub).string().not_null().unique_key())
|
||||
.col(ColumnDef::new(Users::Email).string())
|
||||
.col(ColumnDef::new(Users::DisplayName).string().not_null())
|
||||
.col(ColumnDef::new(Users::AvatarUrl).string())
|
||||
|
|
@ -42,7 +42,12 @@ impl MigrationTrait for Migration {
|
|||
Table::create()
|
||||
.table(Guilds::Table)
|
||||
.if_not_exists()
|
||||
.col(ColumnDef::new(Guilds::Id).uuid().not_null().primary_key())
|
||||
.col(
|
||||
ColumnDef::new(Guilds::Id)
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Guilds::Name).string().not_null())
|
||||
.col(ColumnDef::new(Guilds::OwnerUserId).uuid().not_null())
|
||||
.col(
|
||||
|
|
@ -103,7 +108,12 @@ impl MigrationTrait for Migration {
|
|||
Table::create()
|
||||
.table(Channels::Table)
|
||||
.if_not_exists()
|
||||
.col(ColumnDef::new(Channels::Id).uuid().not_null().primary_key())
|
||||
.col(
|
||||
ColumnDef::new(Channels::Id)
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Channels::GuildId).uuid().not_null())
|
||||
.col(ColumnDef::new(Channels::Name).string().not_null())
|
||||
.col(
|
||||
|
|
@ -128,7 +138,12 @@ impl MigrationTrait for Migration {
|
|||
Table::create()
|
||||
.table(Messages::Table)
|
||||
.if_not_exists()
|
||||
.col(ColumnDef::new(Messages::Id).uuid().not_null().primary_key())
|
||||
.col(
|
||||
ColumnDef::new(Messages::Id)
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Messages::ChannelId).uuid().not_null())
|
||||
.col(ColumnDef::new(Messages::AuthorUserId).uuid().not_null())
|
||||
.col(ColumnDef::new(Messages::Body).text().not_null())
|
||||
|
|
|
|||
|
|
@ -11,12 +11,7 @@ impl MigrationTrait for Migration {
|
|||
Table::create()
|
||||
.table(Invites::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Invites::Code)
|
||||
.string()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Invites::Code).string().not_null().primary_key())
|
||||
.col(ColumnDef::new(Invites::GuildId).uuid().not_null())
|
||||
.col(ColumnDef::new(Invites::CreatedByUserId).uuid().not_null())
|
||||
.col(
|
||||
|
|
|
|||
|
|
@ -17,16 +17,8 @@ impl MigrationTrait for Migration {
|
|||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(DirectMessages::SenderUserId)
|
||||
.uuid()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(DirectMessages::RecipientUserId)
|
||||
.uuid()
|
||||
.not_null(),
|
||||
)
|
||||
.col(ColumnDef::new(DirectMessages::SenderUserId).uuid().not_null())
|
||||
.col(ColumnDef::new(DirectMessages::RecipientUserId).uuid().not_null())
|
||||
.col(ColumnDef::new(DirectMessages::Body).text().not_null())
|
||||
.col(
|
||||
ColumnDef::new(DirectMessages::CreatedAt)
|
||||
|
|
|
|||
|
|
@ -1,86 +0,0 @@
|
|||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(SoundboardSounds::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(SoundboardSounds::Id)
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(SoundboardSounds::GuildId).uuid().not_null())
|
||||
.col(ColumnDef::new(SoundboardSounds::Name).string().not_null())
|
||||
.col(ColumnDef::new(SoundboardSounds::Icon).string().not_null())
|
||||
.col(
|
||||
ColumnDef::new(SoundboardSounds::FilePath)
|
||||
.string()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(SoundboardSounds::CreatedByUserId)
|
||||
.uuid()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(SoundboardSounds::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_soundboard_sounds_guild")
|
||||
.from(SoundboardSounds::Table, SoundboardSounds::GuildId)
|
||||
.to(Guilds::Table, Guilds::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_soundboard_sounds_user")
|
||||
.from(SoundboardSounds::Table, SoundboardSounds::CreatedByUserId)
|
||||
.to(Users::Table, Users::Id),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(SoundboardSounds::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum SoundboardSounds {
|
||||
Table,
|
||||
Id,
|
||||
GuildId,
|
||||
Name,
|
||||
Icon,
|
||||
FilePath,
|
||||
CreatedByUserId,
|
||||
CreatedAt,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Users {
|
||||
Table,
|
||||
Id,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Guilds {
|
||||
Table,
|
||||
Id,
|
||||
}
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Attachments::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Attachments::Id)
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Attachments::ChannelMessageId).uuid().null())
|
||||
.col(ColumnDef::new(Attachments::DirectMessageId).uuid().null())
|
||||
.col(ColumnDef::new(Attachments::UploaderUserId).uuid().not_null())
|
||||
.col(ColumnDef::new(Attachments::ObjectKey).string().not_null())
|
||||
.col(ColumnDef::new(Attachments::MediaUrl).string().not_null())
|
||||
.col(ColumnDef::new(Attachments::MimeType).string().not_null())
|
||||
.col(ColumnDef::new(Attachments::SizeBytes).big_integer().not_null())
|
||||
.col(ColumnDef::new(Attachments::OriginalFilename).string().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Attachments::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_attachments_channel_message")
|
||||
.from(Attachments::Table, Attachments::ChannelMessageId)
|
||||
.to(Messages::Table, Messages::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_attachments_direct_message")
|
||||
.from(Attachments::Table, Attachments::DirectMessageId)
|
||||
.to(DirectMessages::Table, DirectMessages::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_attachments_uploader")
|
||||
.from(Attachments::Table, Attachments::UploaderUserId)
|
||||
.to(Users::Table, Users::Id),
|
||||
)
|
||||
.check(
|
||||
Expr::cust(
|
||||
"(channel_message_id IS NOT NULL AND direct_message_id IS NULL) OR (channel_message_id IS NULL AND direct_message_id IS NOT NULL)",
|
||||
),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_attachments_channel_message")
|
||||
.table(Attachments::Table)
|
||||
.col(Attachments::ChannelMessageId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_attachments_direct_message")
|
||||
.table(Attachments::Table)
|
||||
.col(Attachments::DirectMessageId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(Attachments::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Attachments {
|
||||
Table,
|
||||
Id,
|
||||
ChannelMessageId,
|
||||
DirectMessageId,
|
||||
UploaderUserId,
|
||||
ObjectKey,
|
||||
MediaUrl,
|
||||
MimeType,
|
||||
SizeBytes,
|
||||
OriginalFilename,
|
||||
CreatedAt,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Messages {
|
||||
Table,
|
||||
Id,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum DirectMessages {
|
||||
Table,
|
||||
Id,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Users {
|
||||
Table,
|
||||
Id,
|
||||
}
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Sessions::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Sessions::Id)
|
||||
.string()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Sessions::UserId).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Sessions::ExpiresAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Sessions::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Sessions::LastSeenAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(ColumnDef::new(Sessions::RevokedAt).timestamp_with_time_zone())
|
||||
.col(ColumnDef::new(Sessions::UserAgentHash).string())
|
||||
.col(ColumnDef::new(Sessions::IpHash).string())
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_sessions_user")
|
||||
.from(Sessions::Table, Sessions::UserId)
|
||||
.to(Users::Table, Users::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_sessions_user_id")
|
||||
.table(Sessions::Table)
|
||||
.col(Sessions::UserId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_sessions_expires_at")
|
||||
.table(Sessions::Table)
|
||||
.col(Sessions::ExpiresAt)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_sessions_revoked_at")
|
||||
.table(Sessions::Table)
|
||||
.col(Sessions::RevokedAt)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(Sessions::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Sessions {
|
||||
Table,
|
||||
Id,
|
||||
UserId,
|
||||
ExpiresAt,
|
||||
CreatedAt,
|
||||
LastSeenAt,
|
||||
RevokedAt,
|
||||
UserAgentHash,
|
||||
IpHash,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Users {
|
||||
Table,
|
||||
Id,
|
||||
}
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(SoundboardSounds::Table)
|
||||
.add_column(ColumnDef::new(SoundboardSounds::ObjectKey).string().null())
|
||||
.add_column(ColumnDef::new(SoundboardSounds::MediaUrl).string().null())
|
||||
.add_column(ColumnDef::new(SoundboardSounds::MimeType).string().null())
|
||||
.add_column(
|
||||
ColumnDef::new(SoundboardSounds::SizeBytes)
|
||||
.big_integer()
|
||||
.null(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.get_connection()
|
||||
.execute_unprepared(
|
||||
r#"
|
||||
UPDATE soundboard_sounds
|
||||
SET media_url = file_path
|
||||
WHERE media_url IS NULL
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(SoundboardSounds::Table)
|
||||
.modify_column(
|
||||
ColumnDef::new(SoundboardSounds::MediaUrl)
|
||||
.string()
|
||||
.not_null(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(SoundboardSounds::Table)
|
||||
.drop_column(SoundboardSounds::ObjectKey)
|
||||
.drop_column(SoundboardSounds::MediaUrl)
|
||||
.drop_column(SoundboardSounds::MimeType)
|
||||
.drop_column(SoundboardSounds::SizeBytes)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum SoundboardSounds {
|
||||
Table,
|
||||
ObjectKey,
|
||||
MediaUrl,
|
||||
MimeType,
|
||||
SizeBytes,
|
||||
}
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(SoundboardSounds::Table)
|
||||
.modify_column(ColumnDef::new(SoundboardSounds::FilePath).string().null())
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.get_connection()
|
||||
.execute_unprepared(
|
||||
r#"
|
||||
UPDATE soundboard_sounds
|
||||
SET file_path = media_url
|
||||
WHERE file_path IS NULL
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(SoundboardSounds::Table)
|
||||
.modify_column(
|
||||
ColumnDef::new(SoundboardSounds::FilePath)
|
||||
.string()
|
||||
.not_null(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum SoundboardSounds {
|
||||
Table,
|
||||
FilePath,
|
||||
}
|
||||
|
|
@ -4,11 +4,6 @@ mod m20260213_000001_init;
|
|||
mod m20260213_000002_invites;
|
||||
mod m20260213_000003_channel_kind;
|
||||
mod m20260213_000004_direct_messages;
|
||||
mod m20260224_000005_soundboard;
|
||||
mod m20260227_000006_attachments;
|
||||
mod m20260227_000007_sessions;
|
||||
mod m20260227_000008_soundboard_media;
|
||||
mod m20260227_000009_soundboard_file_path_nullable;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
|
|
@ -20,11 +15,6 @@ impl MigratorTrait for Migrator {
|
|||
Box::new(m20260213_000002_invites::Migration),
|
||||
Box::new(m20260213_000003_channel_kind::Migration),
|
||||
Box::new(m20260213_000004_direct_messages::Migration),
|
||||
Box::new(m20260224_000005_soundboard::Migration),
|
||||
Box::new(m20260227_000006_attachments::Migration),
|
||||
Box::new(m20260227_000007_sessions::Migration),
|
||||
Box::new(m20260227_000008_soundboard_media::Migration),
|
||||
Box::new(m20260227_000009_soundboard_file_path_nullable::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,12 +31,12 @@ pub struct Channel {
|
|||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Attachment {
|
||||
pub struct Message {
|
||||
pub id: Uuid,
|
||||
pub media_url: String,
|
||||
pub mime_type: String,
|
||||
pub size_bytes: i64,
|
||||
pub original_filename: String,
|
||||
pub channel_id: Uuid,
|
||||
pub author_user_id: Uuid,
|
||||
pub body: String,
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
|
|
@ -46,14 +46,13 @@ pub struct BasicUser {
|
|||
pub avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[derive(Debug, Clone, Serialize, sea_orm::FromQueryResult)]
|
||||
pub struct MessageWithAuthor {
|
||||
pub id: Uuid,
|
||||
pub channel_id: Uuid,
|
||||
pub author_user_id: Uuid,
|
||||
pub author_display_name: String,
|
||||
pub body: String,
|
||||
pub attachments: Vec<Attachment>,
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
}
|
||||
|
||||
|
|
@ -72,7 +71,6 @@ pub struct DmMessageWithAuthor {
|
|||
pub recipient_user_id: Uuid,
|
||||
pub author_display_name: String,
|
||||
pub body: String,
|
||||
pub attachments: Vec<Attachment>,
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
}
|
||||
|
||||
|
|
@ -86,16 +84,3 @@ pub struct Invite {
|
|||
pub max_uses: Option<i32>,
|
||||
pub use_count: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SoundboardSound {
|
||||
pub id: Uuid,
|
||||
pub guild_id: Uuid,
|
||||
pub name: String,
|
||||
pub icon: String,
|
||||
pub media_url: String,
|
||||
pub mime_type: Option<String>,
|
||||
pub size_bytes: Option<i64>,
|
||||
pub created_by_user_id: Uuid,
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
}
|
||||
|
|
|
|||
557
src/voice.rs
557
src/voice.rs
|
|
@ -10,16 +10,12 @@ use crate::{AppState, db};
|
|||
|
||||
#[derive(Default)]
|
||||
pub struct VoiceHub {
|
||||
rooms: RwLock<HashMap<Uuid, HashMap<Uuid, HashMap<Uuid, ClientHandle>>>>,
|
||||
rooms: RwLock<HashMap<Uuid, HashMap<Uuid, ClientHandle>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ClientHandle {
|
||||
display_name: String,
|
||||
is_sharing_video: bool,
|
||||
is_sharing_screen: bool,
|
||||
is_speaking: bool,
|
||||
is_muted: bool,
|
||||
tx: mpsc::UnboundedSender<ServerEvent>,
|
||||
}
|
||||
|
||||
|
|
@ -27,53 +23,20 @@ struct ClientHandle {
|
|||
pub struct VoiceParticipant {
|
||||
pub user_id: Uuid,
|
||||
pub display_name: String,
|
||||
pub is_sharing_video: bool,
|
||||
pub is_sharing_screen: bool,
|
||||
pub is_speaking: bool,
|
||||
pub is_muted: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
enum ServerEvent {
|
||||
Peers {
|
||||
peers: Vec<VoiceParticipant>,
|
||||
},
|
||||
PeerJoined {
|
||||
user_id: Uuid,
|
||||
display_name: String,
|
||||
},
|
||||
PeerLeft {
|
||||
user_id: Uuid,
|
||||
},
|
||||
VideoStatusChanged {
|
||||
user_id: Uuid,
|
||||
is_sharing_video: bool,
|
||||
},
|
||||
ScreenStatusChanged {
|
||||
user_id: Uuid,
|
||||
is_sharing_screen: bool,
|
||||
},
|
||||
SpeakingStatusChanged {
|
||||
user_id: Uuid,
|
||||
is_speaking: bool,
|
||||
},
|
||||
MuteStatusChanged {
|
||||
user_id: Uuid,
|
||||
is_muted: bool,
|
||||
},
|
||||
Peers { peers: Vec<VoiceParticipant> },
|
||||
PeerJoined { user_id: Uuid, display_name: String },
|
||||
PeerLeft { user_id: Uuid },
|
||||
Signal {
|
||||
from_user_id: Uuid,
|
||||
kind: String,
|
||||
data: serde_json::Value,
|
||||
},
|
||||
Error {
|
||||
message: String,
|
||||
},
|
||||
PlaySound {
|
||||
user_id: Uuid,
|
||||
media_url: String,
|
||||
},
|
||||
Error { message: String },
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
|
@ -84,31 +47,6 @@ enum ClientEvent {
|
|||
kind: String,
|
||||
data: serde_json::Value,
|
||||
},
|
||||
SetVideoStatus {
|
||||
is_sharing_video: bool,
|
||||
},
|
||||
SetScreenStatus {
|
||||
is_sharing_screen: bool,
|
||||
},
|
||||
SetSpeakingStatus {
|
||||
is_speaking: bool,
|
||||
},
|
||||
SetMuteStatus {
|
||||
is_muted: bool,
|
||||
},
|
||||
PlaySound {
|
||||
sound_id: Uuid,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct VoiceStateChange {
|
||||
joined: Option<VoiceParticipant>,
|
||||
left_user_id: Option<Uuid>,
|
||||
video_changed: Option<(Uuid, bool)>,
|
||||
screen_changed: Option<(Uuid, bool)>,
|
||||
speaking_changed: Option<(Uuid, bool)>,
|
||||
mute_changed: Option<(Uuid, bool)>,
|
||||
}
|
||||
|
||||
impl VoiceHub {
|
||||
|
|
@ -119,7 +57,10 @@ impl VoiceHub {
|
|||
};
|
||||
|
||||
room.iter()
|
||||
.filter_map(|(user_id, connections)| aggregate_participant(Some(connections), *user_id))
|
||||
.map(|(user_id, handle)| VoiceParticipant {
|
||||
user_id: *user_id,
|
||||
display_name: handle.display_name.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
|
@ -127,58 +68,58 @@ impl VoiceHub {
|
|||
&self,
|
||||
room_id: Uuid,
|
||||
user_id: Uuid,
|
||||
connection_id: Uuid,
|
||||
display_name: String,
|
||||
tx: mpsc::UnboundedSender<ServerEvent>,
|
||||
) -> (Vec<VoiceParticipant>, VoiceStateChange) {
|
||||
) -> Vec<VoiceParticipant> {
|
||||
let mut rooms = self.rooms.write().await;
|
||||
let room = rooms.entry(room_id).or_default();
|
||||
|
||||
let peers = room
|
||||
.iter()
|
||||
.filter_map(|(peer_id, connections)| aggregate_participant(Some(connections), *peer_id))
|
||||
.map(|(peer_id, peer)| VoiceParticipant {
|
||||
user_id: *peer_id,
|
||||
display_name: peer.display_name.clone(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let previous = aggregate_participant(room.get(&user_id), user_id);
|
||||
room.entry(user_id).or_default().insert(
|
||||
connection_id,
|
||||
room.insert(
|
||||
user_id,
|
||||
ClientHandle {
|
||||
display_name,
|
||||
is_sharing_video: false,
|
||||
is_sharing_screen: false,
|
||||
is_speaking: false,
|
||||
is_muted: false,
|
||||
display_name: display_name.clone(),
|
||||
tx,
|
||||
},
|
||||
);
|
||||
let current = aggregate_participant(room.get(&user_id), user_id);
|
||||
|
||||
(peers, diff_voice_state(previous, current))
|
||||
}
|
||||
|
||||
async fn leave(&self, room_id: Uuid, user_id: Uuid, connection_id: Uuid) -> VoiceStateChange {
|
||||
let mut rooms = self.rooms.write().await;
|
||||
let Some(room) = rooms.get_mut(&room_id) else {
|
||||
return VoiceStateChange::default();
|
||||
};
|
||||
|
||||
let previous = aggregate_participant(room.get(&user_id), user_id);
|
||||
if let Some(connections) = room.get_mut(&user_id) {
|
||||
connections.remove(&connection_id);
|
||||
if connections.is_empty() {
|
||||
room.remove(&user_id);
|
||||
for (peer_id, peer) in room.iter() {
|
||||
if *peer_id != user_id {
|
||||
let _ = peer.tx.send(ServerEvent::PeerJoined {
|
||||
user_id,
|
||||
display_name: display_name.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
let current = aggregate_participant(room.get(&user_id), user_id);
|
||||
|
||||
peers
|
||||
}
|
||||
|
||||
async fn leave(&self, room_id: Uuid, user_id: Uuid) {
|
||||
let mut rooms = self.rooms.write().await;
|
||||
let Some(room) = rooms.get_mut(&room_id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
room.remove(&user_id);
|
||||
|
||||
for peer in room.values() {
|
||||
let _ = peer.tx.send(ServerEvent::PeerLeft { user_id });
|
||||
}
|
||||
|
||||
if room.is_empty() {
|
||||
rooms.remove(&room_id);
|
||||
}
|
||||
|
||||
diff_voice_state(previous, current)
|
||||
}
|
||||
|
||||
pub async fn relay_signal(
|
||||
async fn relay_signal(
|
||||
&self,
|
||||
room_id: Uuid,
|
||||
from_user_id: Uuid,
|
||||
|
|
@ -191,10 +132,7 @@ impl VoiceHub {
|
|||
return;
|
||||
};
|
||||
|
||||
if let Some(target) = room
|
||||
.get(&to_user_id)
|
||||
.and_then(|connections| connections.values().next())
|
||||
{
|
||||
if let Some(target) = room.get(&to_user_id) {
|
||||
let _ = target.tx.send(ServerEvent::Signal {
|
||||
from_user_id,
|
||||
kind,
|
||||
|
|
@ -202,214 +140,21 @@ impl VoiceHub {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_video_status(
|
||||
&self,
|
||||
room_id: Uuid,
|
||||
user_id: Uuid,
|
||||
connection_id: Uuid,
|
||||
is_sharing_video: bool,
|
||||
) -> VoiceStateChange {
|
||||
let mut rooms = self.rooms.write().await;
|
||||
let Some(room) = rooms.get_mut(&room_id) else {
|
||||
return VoiceStateChange::default();
|
||||
};
|
||||
|
||||
let previous = aggregate_participant(room.get(&user_id), user_id);
|
||||
if let Some(handle) = room
|
||||
.get_mut(&user_id)
|
||||
.and_then(|connections| connections.get_mut(&connection_id))
|
||||
{
|
||||
handle.is_sharing_video = is_sharing_video;
|
||||
}
|
||||
let current = aggregate_participant(room.get(&user_id), user_id);
|
||||
diff_voice_state(previous, current)
|
||||
}
|
||||
|
||||
async fn set_screen_status(
|
||||
&self,
|
||||
room_id: Uuid,
|
||||
user_id: Uuid,
|
||||
connection_id: Uuid,
|
||||
is_sharing_screen: bool,
|
||||
) -> VoiceStateChange {
|
||||
let mut rooms = self.rooms.write().await;
|
||||
let Some(room) = rooms.get_mut(&room_id) else {
|
||||
return VoiceStateChange::default();
|
||||
};
|
||||
|
||||
let previous = aggregate_participant(room.get(&user_id), user_id);
|
||||
if let Some(handle) = room
|
||||
.get_mut(&user_id)
|
||||
.and_then(|connections| connections.get_mut(&connection_id))
|
||||
{
|
||||
handle.is_sharing_screen = is_sharing_screen;
|
||||
}
|
||||
let current = aggregate_participant(room.get(&user_id), user_id);
|
||||
diff_voice_state(previous, current)
|
||||
}
|
||||
|
||||
async fn set_speaking_status(
|
||||
&self,
|
||||
room_id: Uuid,
|
||||
user_id: Uuid,
|
||||
connection_id: Uuid,
|
||||
is_speaking: bool,
|
||||
) -> VoiceStateChange {
|
||||
let mut rooms = self.rooms.write().await;
|
||||
let Some(room) = rooms.get_mut(&room_id) else {
|
||||
return VoiceStateChange::default();
|
||||
};
|
||||
|
||||
let previous = aggregate_participant(room.get(&user_id), user_id);
|
||||
if let Some(handle) = room
|
||||
.get_mut(&user_id)
|
||||
.and_then(|connections| connections.get_mut(&connection_id))
|
||||
{
|
||||
handle.is_speaking = is_speaking;
|
||||
}
|
||||
let current = aggregate_participant(room.get(&user_id), user_id);
|
||||
diff_voice_state(previous, current)
|
||||
}
|
||||
|
||||
async fn set_mute_status(
|
||||
&self,
|
||||
room_id: Uuid,
|
||||
user_id: Uuid,
|
||||
connection_id: Uuid,
|
||||
is_muted: bool,
|
||||
) -> VoiceStateChange {
|
||||
let mut rooms = self.rooms.write().await;
|
||||
let Some(room) = rooms.get_mut(&room_id) else {
|
||||
return VoiceStateChange::default();
|
||||
};
|
||||
|
||||
let previous = aggregate_participant(room.get(&user_id), user_id);
|
||||
if let Some(handle) = room
|
||||
.get_mut(&user_id)
|
||||
.and_then(|connections| connections.get_mut(&connection_id))
|
||||
{
|
||||
handle.is_muted = is_muted;
|
||||
}
|
||||
let current = aggregate_participant(room.get(&user_id), user_id);
|
||||
diff_voice_state(previous, current)
|
||||
}
|
||||
|
||||
pub async fn play_sound(&self, room_id: Uuid, user_id: Uuid, media_url: String) {
|
||||
let rooms = self.rooms.read().await;
|
||||
let Some(room) = rooms.get(&room_id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
for (peer_id, connections) in room.iter() {
|
||||
if *peer_id == user_id {
|
||||
continue;
|
||||
}
|
||||
for peer in connections.values() {
|
||||
let _ = peer.tx.send(ServerEvent::PlaySound {
|
||||
user_id,
|
||||
media_url: media_url.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn emit_change(&self, room_id: Uuid, user_id: Uuid, change: VoiceStateChange) {
|
||||
if is_voice_state_change_empty(&change) {
|
||||
return;
|
||||
}
|
||||
|
||||
let rooms = self.rooms.read().await;
|
||||
let Some(room) = rooms.get(&room_id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some(participant) = change.joined {
|
||||
broadcast_voice_event(
|
||||
room,
|
||||
user_id,
|
||||
ServerEvent::PeerJoined {
|
||||
user_id: participant.user_id,
|
||||
display_name: participant.display_name,
|
||||
},
|
||||
);
|
||||
}
|
||||
if let Some(left_user_id) = change.left_user_id {
|
||||
broadcast_voice_event(
|
||||
room,
|
||||
user_id,
|
||||
ServerEvent::PeerLeft {
|
||||
user_id: left_user_id,
|
||||
},
|
||||
);
|
||||
}
|
||||
if let Some((changed_user_id, is_sharing_video)) = change.video_changed {
|
||||
broadcast_voice_event(
|
||||
room,
|
||||
user_id,
|
||||
ServerEvent::VideoStatusChanged {
|
||||
user_id: changed_user_id,
|
||||
is_sharing_video,
|
||||
},
|
||||
);
|
||||
}
|
||||
if let Some((changed_user_id, is_sharing_screen)) = change.screen_changed {
|
||||
broadcast_voice_event(
|
||||
room,
|
||||
user_id,
|
||||
ServerEvent::ScreenStatusChanged {
|
||||
user_id: changed_user_id,
|
||||
is_sharing_screen,
|
||||
},
|
||||
);
|
||||
}
|
||||
if let Some((changed_user_id, is_speaking)) = change.speaking_changed {
|
||||
broadcast_voice_event(
|
||||
room,
|
||||
user_id,
|
||||
ServerEvent::SpeakingStatusChanged {
|
||||
user_id: changed_user_id,
|
||||
is_speaking,
|
||||
},
|
||||
);
|
||||
}
|
||||
if let Some((changed_user_id, is_muted)) = change.mute_changed {
|
||||
broadcast_voice_event(
|
||||
room,
|
||||
user_id,
|
||||
ServerEvent::MuteStatusChanged {
|
||||
user_id: changed_user_id,
|
||||
is_muted,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_socket(state: AppState, socket: WebSocket, room_id: Uuid, user_id: Uuid) {
|
||||
let Some(user) = db::get_user_by_id(&state.db, user_id).await.ok().flatten() else {
|
||||
return;
|
||||
};
|
||||
let Ok(Some(guild_id)) = db::guild_id_for_channel(&state.db, room_id).await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let (mut ws_sender, mut ws_receiver) = socket.split();
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<ServerEvent>();
|
||||
let connection_id = Uuid::new_v4();
|
||||
|
||||
let (peers, change) = state
|
||||
let peers = state
|
||||
.voice
|
||||
.join(
|
||||
room_id,
|
||||
user_id,
|
||||
connection_id,
|
||||
user.display_name.clone(),
|
||||
tx.clone(),
|
||||
)
|
||||
.join(room_id, user_id, user.display_name.clone(), tx.clone())
|
||||
.await;
|
||||
let _ = tx.send(ServerEvent::Peers { peers });
|
||||
state.voice.emit_change(room_id, user_id, change).await;
|
||||
|
||||
let send_task = tokio::spawn(async move {
|
||||
while let Some(event) = rx.recv().await {
|
||||
|
|
@ -437,44 +182,6 @@ pub async fn handle_socket(state: AppState, socket: WebSocket, room_id: Uuid, us
|
|||
.relay_signal(room_id, user_id, to_user_id, kind, data)
|
||||
.await;
|
||||
}
|
||||
Ok(ClientEvent::SetVideoStatus { is_sharing_video }) => {
|
||||
let change = state
|
||||
.voice
|
||||
.set_video_status(room_id, user_id, connection_id, is_sharing_video)
|
||||
.await;
|
||||
state.voice.emit_change(room_id, user_id, change).await;
|
||||
}
|
||||
Ok(ClientEvent::SetScreenStatus { is_sharing_screen }) => {
|
||||
let change = state
|
||||
.voice
|
||||
.set_screen_status(room_id, user_id, connection_id, is_sharing_screen)
|
||||
.await;
|
||||
state.voice.emit_change(room_id, user_id, change).await;
|
||||
}
|
||||
Ok(ClientEvent::SetSpeakingStatus { is_speaking }) => {
|
||||
let change = state
|
||||
.voice
|
||||
.set_speaking_status(room_id, user_id, connection_id, is_speaking)
|
||||
.await;
|
||||
state.voice.emit_change(room_id, user_id, change).await;
|
||||
}
|
||||
Ok(ClientEvent::SetMuteStatus { is_muted }) => {
|
||||
let change = state
|
||||
.voice
|
||||
.set_mute_status(room_id, user_id, connection_id, is_muted)
|
||||
.await;
|
||||
state.voice.emit_change(room_id, user_id, change).await;
|
||||
}
|
||||
Ok(ClientEvent::PlaySound { sound_id }) => {
|
||||
if let Ok(Some(sound)) = db::get_sound_by_id(&state.db, sound_id).await
|
||||
&& sound.guild_id == guild_id
|
||||
{
|
||||
state
|
||||
.voice
|
||||
.play_sound(room_id, user_id, sound.media_url)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = tx.send(ServerEvent::Error {
|
||||
message: format!("invalid voice message: {err}"),
|
||||
|
|
@ -488,183 +195,5 @@ pub async fn handle_socket(state: AppState, socket: WebSocket, room_id: Uuid, us
|
|||
}
|
||||
|
||||
send_task.abort();
|
||||
let change = state.voice.leave(room_id, user_id, connection_id).await;
|
||||
state.voice.emit_change(room_id, user_id, change).await;
|
||||
}
|
||||
|
||||
fn aggregate_participant(
|
||||
connections: Option<&HashMap<Uuid, ClientHandle>>,
|
||||
user_id: Uuid,
|
||||
) -> Option<VoiceParticipant> {
|
||||
let connections = connections?;
|
||||
if connections.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut handles = connections.values();
|
||||
let first = handles.next()?;
|
||||
Some(VoiceParticipant {
|
||||
user_id,
|
||||
display_name: first.display_name.clone(),
|
||||
is_sharing_video: connections.values().any(|handle| handle.is_sharing_video),
|
||||
is_sharing_screen: connections.values().any(|handle| handle.is_sharing_screen),
|
||||
is_speaking: connections.values().any(|handle| handle.is_speaking),
|
||||
is_muted: connections.values().all(|handle| handle.is_muted),
|
||||
})
|
||||
}
|
||||
|
||||
fn diff_voice_state(
|
||||
previous: Option<VoiceParticipant>,
|
||||
current: Option<VoiceParticipant>,
|
||||
) -> VoiceStateChange {
|
||||
match (previous, current) {
|
||||
(None, None) => VoiceStateChange::default(),
|
||||
(None, Some(current)) => VoiceStateChange {
|
||||
joined: Some(current),
|
||||
..Default::default()
|
||||
},
|
||||
(Some(previous), None) => VoiceStateChange {
|
||||
left_user_id: Some(previous.user_id),
|
||||
..Default::default()
|
||||
},
|
||||
(Some(previous), Some(current)) => VoiceStateChange {
|
||||
video_changed: (previous.is_sharing_video != current.is_sharing_video)
|
||||
.then_some((current.user_id, current.is_sharing_video)),
|
||||
screen_changed: (previous.is_sharing_screen != current.is_sharing_screen)
|
||||
.then_some((current.user_id, current.is_sharing_screen)),
|
||||
speaking_changed: (previous.is_speaking != current.is_speaking)
|
||||
.then_some((current.user_id, current.is_speaking)),
|
||||
mute_changed: (previous.is_muted != current.is_muted)
|
||||
.then_some((current.user_id, current.is_muted)),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn broadcast_voice_event(
|
||||
room: &HashMap<Uuid, HashMap<Uuid, ClientHandle>>,
|
||||
source_user_id: Uuid,
|
||||
event: ServerEvent,
|
||||
) {
|
||||
for (peer_id, connections) in room {
|
||||
if *peer_id == source_user_id {
|
||||
continue;
|
||||
}
|
||||
for peer in connections.values() {
|
||||
let _ = peer.tx.send(event.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_voice_state_change_empty(change: &VoiceStateChange) -> bool {
|
||||
change.joined.is_none()
|
||||
&& change.left_user_id.is_none()
|
||||
&& change.video_changed.is_none()
|
||||
&& change.screen_changed.is_none()
|
||||
&& change.speaking_changed.is_none()
|
||||
&& change.mute_changed.is_none()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{VoiceHub, aggregate_participant};
|
||||
use std::collections::HashMap;
|
||||
use tokio::sync::mpsc;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[tokio::test]
|
||||
async fn multiple_connections_keep_voice_participant_until_last_leave() {
|
||||
let hub = VoiceHub::default();
|
||||
let room_id = Uuid::new_v4();
|
||||
let user_id = Uuid::new_v4();
|
||||
let first_connection = Uuid::new_v4();
|
||||
let second_connection = Uuid::new_v4();
|
||||
let (tx1, _rx1) = mpsc::unbounded_channel();
|
||||
let (tx2, _rx2) = mpsc::unbounded_channel();
|
||||
|
||||
let (_peers, first_join) = hub
|
||||
.join(room_id, user_id, first_connection, "User".to_string(), tx1)
|
||||
.await;
|
||||
let (_peers, second_join) = hub
|
||||
.join(room_id, user_id, second_connection, "User".to_string(), tx2)
|
||||
.await;
|
||||
let first_leave = hub.leave(room_id, user_id, first_connection).await;
|
||||
let second_leave = hub.leave(room_id, user_id, second_connection).await;
|
||||
|
||||
assert!(first_join.joined.is_some());
|
||||
assert!(second_join.joined.is_none());
|
||||
assert!(first_leave.left_user_id.is_none());
|
||||
assert_eq!(second_leave.left_user_id, Some(user_id));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn voice_mute_state_only_flips_when_all_connections_are_muted() {
|
||||
let hub = VoiceHub::default();
|
||||
let room_id = Uuid::new_v4();
|
||||
let user_id = Uuid::new_v4();
|
||||
let first_connection = Uuid::new_v4();
|
||||
let second_connection = Uuid::new_v4();
|
||||
let (tx1, _rx1) = mpsc::unbounded_channel();
|
||||
let (tx2, _rx2) = mpsc::unbounded_channel();
|
||||
|
||||
let _ = hub
|
||||
.join(room_id, user_id, first_connection, "User".to_string(), tx1)
|
||||
.await;
|
||||
let _ = hub
|
||||
.join(room_id, user_id, second_connection, "User".to_string(), tx2)
|
||||
.await;
|
||||
|
||||
let first_mute = hub
|
||||
.set_mute_status(room_id, user_id, first_connection, true)
|
||||
.await;
|
||||
let second_mute = hub
|
||||
.set_mute_status(room_id, user_id, second_connection, true)
|
||||
.await;
|
||||
let unmute = hub
|
||||
.set_mute_status(room_id, user_id, first_connection, false)
|
||||
.await;
|
||||
|
||||
assert!(first_mute.mute_changed.is_none());
|
||||
assert_eq!(second_mute.mute_changed, Some((user_id, true)));
|
||||
assert_eq!(unmute.mute_changed, Some((user_id, false)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregate_participant_combines_connection_state() {
|
||||
let user_id = Uuid::new_v4();
|
||||
let first_connection = Uuid::new_v4();
|
||||
let second_connection = Uuid::new_v4();
|
||||
let mut connections = HashMap::new();
|
||||
let (tx1, _rx1) = mpsc::unbounded_channel();
|
||||
let (tx2, _rx2) = mpsc::unbounded_channel();
|
||||
|
||||
connections.insert(
|
||||
first_connection,
|
||||
super::ClientHandle {
|
||||
display_name: "User".to_string(),
|
||||
is_sharing_video: true,
|
||||
is_sharing_screen: false,
|
||||
is_speaking: false,
|
||||
is_muted: true,
|
||||
tx: tx1,
|
||||
},
|
||||
);
|
||||
connections.insert(
|
||||
second_connection,
|
||||
super::ClientHandle {
|
||||
display_name: "User".to_string(),
|
||||
is_sharing_video: false,
|
||||
is_sharing_screen: true,
|
||||
is_speaking: true,
|
||||
is_muted: false,
|
||||
tx: tx2,
|
||||
},
|
||||
);
|
||||
|
||||
let participant = aggregate_participant(Some(&connections), user_id).unwrap();
|
||||
assert!(participant.is_sharing_video);
|
||||
assert!(participant.is_sharing_screen);
|
||||
assert!(participant.is_speaking);
|
||||
assert!(!participant.is_muted);
|
||||
}
|
||||
state.voice.leave(room_id, user_id).await;
|
||||
}
|
||||
|
|
|
|||
863
static/app.js
863
static/app.js
|
|
@ -1 +1,862 @@
|
|||
import "./shared/app-core.js";
|
||||
const state = {
|
||||
me: null,
|
||||
guilds: [],
|
||||
channels: [],
|
||||
dmConversations: [],
|
||||
members: [],
|
||||
voicePresence: new Map(),
|
||||
selectedGuildId: null,
|
||||
selectedTextChannelId: null,
|
||||
selectedDmUserId: null,
|
||||
selectedDmDisplayName: null,
|
||||
selectedVoiceChannelId: null,
|
||||
voice: {
|
||||
ws: null,
|
||||
joinedChannelId: null,
|
||||
localStream: null,
|
||||
rawStream: null,
|
||||
audioContext: null,
|
||||
denoiserNode: null,
|
||||
deepFilterCore: null,
|
||||
peerConnections: new Map(),
|
||||
muted: false,
|
||||
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
|
||||
},
|
||||
voicePresencePollId: null,
|
||||
};
|
||||
|
||||
const el = {
|
||||
authScreen: document.getElementById("auth-screen"),
|
||||
loginBtn: document.getElementById("login-btn"),
|
||||
main: document.getElementById("main"),
|
||||
status: document.getElementById("status"),
|
||||
|
||||
// Guilds
|
||||
guildList: document.getElementById("guild-list"),
|
||||
addGuildBtn: document.getElementById("add-guild-btn"),
|
||||
guildTitle: document.getElementById("guild-title"),
|
||||
createInviteBtn: document.getElementById("create-invite-btn"),
|
||||
|
||||
// Channels
|
||||
channelList: document.getElementById("channel-list"),
|
||||
voiceChannelList: document.getElementById("voice-channel-list"),
|
||||
addTextBtn: document.getElementById("add-text-btn"),
|
||||
addVoiceBtn: document.getElementById("add-voice-btn"),
|
||||
dmList: document.getElementById("dm-list"),
|
||||
channelTitle: document.getElementById("channel-title"),
|
||||
|
||||
// Messages
|
||||
messageList: document.getElementById("message-list"),
|
||||
messageForm: document.getElementById("message-form"),
|
||||
messageBody: document.getElementById("message-body"),
|
||||
|
||||
// User Panel
|
||||
userName: document.getElementById("user-name"),
|
||||
userAvatar: document.getElementById("user-avatar"),
|
||||
logoutBtn: document.getElementById("logout-btn"),
|
||||
|
||||
// Voice Connection
|
||||
voiceConnection: document.getElementById("voice-connection"),
|
||||
vcChannelName: document.getElementById("vc-channel-name"),
|
||||
voiceMuteBtn: document.getElementById("voice-mute-btn"),
|
||||
voiceLeaveBtn: document.getElementById("voice-leave-btn"),
|
||||
|
||||
// Members
|
||||
memberList: document.getElementById("member-list"),
|
||||
|
||||
// Modals
|
||||
modalContainer: document.getElementById("modal-container"),
|
||||
guildForm: document.getElementById("guild-form"),
|
||||
guildName: document.getElementById("guild-name"),
|
||||
modalCancel: document.getElementById("modal-cancel"),
|
||||
|
||||
channelModal: document.getElementById("channel-modal"),
|
||||
channelForm: document.getElementById("channel-form"),
|
||||
channelName: document.getElementById("channel-name"),
|
||||
channelModalCancel: document.getElementById("channel-modal-cancel"),
|
||||
};
|
||||
|
||||
// --- API Helpers ---
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const res = await fetch(path, {
|
||||
...options,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(options.headers || {}),
|
||||
},
|
||||
credentials: "same-origin",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let detail = "request failed";
|
||||
try {
|
||||
const body = await res.json();
|
||||
detail = body.error || detail;
|
||||
} catch {}
|
||||
throw new Error(`${res.status}: ${detail}`);
|
||||
}
|
||||
|
||||
if (res.status === 204) return null;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// --- Utils ---
|
||||
|
||||
function shortName(name) {
|
||||
if (!name || typeof name !== 'string') return "?";
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return "?";
|
||||
const words = trimmed.split(/\s+/).filter(w => w.length > 0).slice(0, 2);
|
||||
if (words.length === 0) return "?";
|
||||
if (words.length === 1) return words[0].substring(0, 2).toUpperCase();
|
||||
return words.map((w) => w[0]?.toUpperCase() || "").join("");
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
if (s === null || s === undefined) return "";
|
||||
return String(s)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function formatDate(isoString) {
|
||||
const d = new Date(isoString);
|
||||
const now = new Date();
|
||||
const isToday = d.toDateString() === now.toDateString();
|
||||
|
||||
if (isToday) {
|
||||
return `Today at ${d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
|
||||
}
|
||||
return d.toLocaleDateString();
|
||||
}
|
||||
|
||||
const DEEPFILTERNET_LIB_PATH = "/static/vendor/deepfilternet3/index.esm.js";
|
||||
const NSNET2_COMPAT_SUPPRESSION = 56;
|
||||
|
||||
let deepFilterLibPromise = null;
|
||||
const LAST_GUILD_STORAGE_KEY = "chattz:lastGuildId";
|
||||
|
||||
// --- Renderers ---
|
||||
|
||||
function renderGuilds() {
|
||||
el.guildList.innerHTML = "";
|
||||
|
||||
for (const guild of state.guilds) {
|
||||
const btn = document.createElement("button");
|
||||
btn.className = `guild-pill ${state.selectedGuildId === guild.id ? "active" : ""}`;
|
||||
btn.title = guild.name;
|
||||
btn.textContent = shortName(guild.name);
|
||||
btn.onclick = async () => {
|
||||
state.selectedGuildId = guild.id;
|
||||
state.selectedTextChannelId = null;
|
||||
state.selectedVoiceChannelId = null;
|
||||
state.selectedDmUserId = null;
|
||||
state.selectedDmDisplayName = null;
|
||||
localStorage.setItem(LAST_GUILD_STORAGE_KEY, guild.id);
|
||||
renderGuilds();
|
||||
renderDMs();
|
||||
await loadChannels();
|
||||
await loadGuildMembers();
|
||||
await refreshVoicePresence();
|
||||
startVoicePresencePolling();
|
||||
renderMessages([]);
|
||||
updateHeaderLabels();
|
||||
};
|
||||
el.guildList.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
function renderChannels() {
|
||||
el.channelList.innerHTML = "";
|
||||
el.voiceChannelList.innerHTML = "";
|
||||
|
||||
for (const channel of state.channels) {
|
||||
const row = document.createElement("button");
|
||||
row.className = `channel-row ${
|
||||
(channel.kind === 'text' && state.selectedTextChannelId === channel.id) ||
|
||||
(channel.kind === 'voice' && state.selectedVoiceChannelId === channel.id) ? "active" : ""
|
||||
}`;
|
||||
|
||||
const iconName = channel.kind === 'text' ? 'hash' : 'volume-2';
|
||||
row.innerHTML = `<i data-lucide="${iconName}"></i> <span>${escapeHtml(channel.name)}</span>`;
|
||||
|
||||
row.onclick = async () => {
|
||||
if (channel.kind === 'text') {
|
||||
state.selectedDmUserId = null;
|
||||
state.selectedDmDisplayName = null;
|
||||
state.selectedTextChannelId = channel.id;
|
||||
renderChannels();
|
||||
renderDMs();
|
||||
updateHeaderLabels();
|
||||
const messages = await api(`/channels/${channel.id}/messages?limit=100`);
|
||||
renderMessages(messages);
|
||||
} else {
|
||||
state.selectedDmUserId = null;
|
||||
state.selectedDmDisplayName = null;
|
||||
state.selectedVoiceChannelId = channel.id;
|
||||
renderChannels();
|
||||
renderDMs();
|
||||
joinVoice();
|
||||
}
|
||||
};
|
||||
|
||||
if (channel.kind === 'text') {
|
||||
el.channelList.appendChild(row);
|
||||
} else {
|
||||
el.voiceChannelList.appendChild(row);
|
||||
|
||||
const participants = state.voicePresence.get(channel.id) || [];
|
||||
if (participants.length > 0) {
|
||||
const pList = document.createElement("div");
|
||||
pList.className = "voice-row-members channel-list";
|
||||
pList.style.paddingLeft = "24px";
|
||||
for (const p of participants) {
|
||||
const pRow = document.createElement("div");
|
||||
pRow.className = "channel-row";
|
||||
pRow.style.padding = "2px 8px";
|
||||
pRow.innerHTML = `<div class="avatar" style="width:20px;height:20px;font-size:10px">${shortName(p.display_name)}</div> <span>${escapeHtml(p.display_name)}</span>`;
|
||||
pList.appendChild(pRow);
|
||||
}
|
||||
el.voiceChannelList.appendChild(pList);
|
||||
}
|
||||
}
|
||||
}
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
function renderDMs() {
|
||||
el.dmList.innerHTML = "";
|
||||
for (const dm of state.dmConversations) {
|
||||
const row = document.createElement("button");
|
||||
row.className = `channel-row ${state.selectedDmUserId === dm.user_id ? "active" : ""}`;
|
||||
row.innerHTML = `<i data-lucide="message-circle"></i> <span>${escapeHtml(dm.display_name)}</span>`;
|
||||
row.onclick = async () => {
|
||||
state.selectedDmUserId = dm.user_id;
|
||||
state.selectedDmDisplayName = dm.display_name;
|
||||
state.selectedTextChannelId = null;
|
||||
state.selectedVoiceChannelId = null;
|
||||
renderChannels();
|
||||
renderDMs();
|
||||
updateHeaderLabels();
|
||||
const messages = await api(`/dms/${dm.user_id}/messages?limit=100`);
|
||||
renderMessages(messages);
|
||||
};
|
||||
el.dmList.appendChild(row);
|
||||
}
|
||||
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
function renderMessages(messages) {
|
||||
el.messageList.innerHTML = "";
|
||||
let lastAuthorId = null;
|
||||
let lastTime = null;
|
||||
|
||||
for (const m of messages.slice().reverse()) {
|
||||
const row = document.createElement("div");
|
||||
const mDate = new Date(m.created_at);
|
||||
const isGrouped = lastAuthorId === m.author_user_id &&
|
||||
lastTime && (mDate - lastTime < 300000); // 5 minutes
|
||||
|
||||
row.className = `msg ${isGrouped ? "msg-grouped" : ""}`;
|
||||
|
||||
const displayName = m.author_display_name || "Unknown User";
|
||||
|
||||
if (isGrouped) {
|
||||
row.innerHTML = `<div class="msg-content"><div class="msg-body">${escapeHtml(m.body)}</div></div>`;
|
||||
} else {
|
||||
row.innerHTML = `
|
||||
<div class="msg-avatar">${shortName(displayName)}</div>
|
||||
<div class="msg-content">
|
||||
<div class="msg-header">
|
||||
<span class="msg-author">${escapeHtml(displayName)}</span>
|
||||
<span class="msg-time">${formatDate(m.created_at)}</span>
|
||||
</div>
|
||||
<div class="msg-body">${escapeHtml(m.body)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
el.messageList.appendChild(row);
|
||||
lastAuthorId = m.author_user_id;
|
||||
lastTime = mDate;
|
||||
}
|
||||
|
||||
el.messageList.scrollTop = el.messageList.scrollHeight;
|
||||
}
|
||||
|
||||
function renderMembers() {
|
||||
el.memberList.innerHTML = "";
|
||||
for (const m of state.members) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "member-row";
|
||||
row.innerHTML = `
|
||||
<div class="member-avatar">${shortName(m.display_name)}</div>
|
||||
<div class="member-name">${escapeHtml(m.display_name)}</div>
|
||||
`;
|
||||
row.style.cursor = "pointer";
|
||||
row.onclick = async () => {
|
||||
if (state.me && m.id === state.me.id) return;
|
||||
state.selectedDmUserId = m.id;
|
||||
state.selectedDmDisplayName = m.display_name;
|
||||
state.selectedTextChannelId = null;
|
||||
state.selectedVoiceChannelId = null;
|
||||
renderChannels();
|
||||
renderDMs();
|
||||
updateHeaderLabels();
|
||||
const messages = await api(`/dms/${m.id}/messages?limit=100`);
|
||||
renderMessages(messages);
|
||||
};
|
||||
el.memberList.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
function updateHeaderLabels() {
|
||||
const guild = state.guilds.find((g) => g.id === state.selectedGuildId);
|
||||
el.guildTitle.textContent = guild ? guild.name : "No server selected";
|
||||
|
||||
if (state.selectedDmUserId) {
|
||||
const dmFromConversations = state.dmConversations.find((u) => u.user_id === state.selectedDmUserId);
|
||||
const dmFromMembers = state.members.find((m) => m.id === state.selectedDmUserId);
|
||||
const displayName = dmFromConversations?.display_name || dmFromMembers?.display_name || state.selectedDmDisplayName;
|
||||
const dmName = displayName ? `@${displayName}` : "Direct Message";
|
||||
el.channelTitle.textContent = dmName;
|
||||
el.messageBody.placeholder = displayName
|
||||
? `Message @${displayName}`
|
||||
: "Message";
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = state.channels.find((c) => c.id === state.selectedTextChannelId);
|
||||
el.channelTitle.textContent = channel ? channel.name : "Select a channel";
|
||||
el.messageBody.placeholder = channel ? `Message #${channel.name}` : "Select a channel";
|
||||
}
|
||||
|
||||
async function createInviteLink() {
|
||||
if (!state.selectedGuildId) {
|
||||
alert("Select a server first.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const invite = await api(`/guilds/${state.selectedGuildId}/invites`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ max_uses: 50, expires_in_hours: 24 }),
|
||||
});
|
||||
const link = `${location.origin}/?invite=${encodeURIComponent(invite.code)}`;
|
||||
try {
|
||||
await navigator.clipboard.writeText(link);
|
||||
alert(`Invite link copied:\n${link}`);
|
||||
} catch {
|
||||
prompt("Copy invite link:", link);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Logic ---
|
||||
|
||||
async function loadGuilds() {
|
||||
state.guilds = await api("/guilds");
|
||||
renderGuilds();
|
||||
}
|
||||
|
||||
async function loadDMConversations() {
|
||||
state.dmConversations = await api("/dms");
|
||||
renderDMs();
|
||||
}
|
||||
|
||||
async function loadGuildMembers() {
|
||||
if (!state.selectedGuildId) {
|
||||
state.members = [];
|
||||
renderMembers();
|
||||
return;
|
||||
}
|
||||
state.members = await api(`/guilds/${state.selectedGuildId}/members`);
|
||||
renderMembers();
|
||||
}
|
||||
|
||||
async function loadChannels() {
|
||||
if (!state.selectedGuildId) {
|
||||
state.channels = [];
|
||||
renderChannels();
|
||||
return;
|
||||
}
|
||||
|
||||
state.channels = await api(`/guilds/${state.selectedGuildId}/channels`);
|
||||
renderChannels();
|
||||
}
|
||||
|
||||
async function refreshVoicePresence() {
|
||||
if (!state.selectedGuildId) {
|
||||
state.voicePresence.clear();
|
||||
renderChannels();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await api(`/guilds/${state.selectedGuildId}/voice-presence`);
|
||||
state.voicePresence.clear();
|
||||
for (const entry of res.channels || []) {
|
||||
state.voicePresence.set(entry.channel_id, entry.participants || []);
|
||||
}
|
||||
renderChannels();
|
||||
} catch (err) {
|
||||
console.warn("voice presence failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
function startVoicePresencePolling() {
|
||||
if (state.voicePresencePollId) clearInterval(state.voicePresencePollId);
|
||||
state.voicePresencePollId = setInterval(() => {
|
||||
refreshVoicePresence().catch(() => {});
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// --- Voice ---
|
||||
|
||||
function getVoiceWsUrl(channelId) {
|
||||
const proto = location.protocol === "https:" ? "wss" : "ws";
|
||||
return `${proto}://${location.host}/channels/${channelId}/voice/ws`;
|
||||
}
|
||||
|
||||
function shouldInitiateOffer(peerId) {
|
||||
if (!state.me || !state.me.id) return false;
|
||||
return state.me.id > peerId;
|
||||
}
|
||||
|
||||
async function loadDeepFilterLib() {
|
||||
if (!deepFilterLibPromise) {
|
||||
deepFilterLibPromise = import(DEEPFILTERNET_LIB_PATH);
|
||||
}
|
||||
return deepFilterLibPromise;
|
||||
}
|
||||
|
||||
async function buildDenoiserNode(audioContext) {
|
||||
if (!audioContext.audioWorklet) {
|
||||
throw new Error("AudioWorklet is not supported in this browser");
|
||||
}
|
||||
|
||||
const df = await loadDeepFilterLib();
|
||||
const core = new df.DeepFilterNet3Core({
|
||||
sampleRate: 48000,
|
||||
noiseReductionLevel: NSNET2_COMPAT_SUPPRESSION,
|
||||
assetConfig: {
|
||||
cdnUrl: "/static/vendor/deepfilternet3",
|
||||
},
|
||||
});
|
||||
await core.initialize();
|
||||
const workletNode = await core.createAudioWorkletNode(audioContext);
|
||||
state.voice.deepFilterCore = core;
|
||||
return workletNode;
|
||||
}
|
||||
|
||||
function stopAndClearAudioPipeline() {
|
||||
if (state.voice.deepFilterCore) {
|
||||
state.voice.deepFilterCore.destroy();
|
||||
state.voice.deepFilterCore = null;
|
||||
}
|
||||
if (state.voice.denoiserNode && typeof state.voice.denoiserNode.destroy === "function") {
|
||||
state.voice.denoiserNode.destroy();
|
||||
}
|
||||
if (state.voice.localStream) {
|
||||
for (const track of state.voice.localStream.getTracks()) track.stop();
|
||||
}
|
||||
if (state.voice.rawStream && state.voice.rawStream !== state.voice.localStream) {
|
||||
for (const track of state.voice.rawStream.getTracks()) track.stop();
|
||||
}
|
||||
if (state.voice.audioContext) {
|
||||
state.voice.audioContext.close().catch(() => {});
|
||||
}
|
||||
state.voice.localStream = null;
|
||||
state.voice.rawStream = null;
|
||||
state.voice.audioContext = null;
|
||||
state.voice.denoiserNode = null;
|
||||
state.voice.deepFilterCore = null;
|
||||
}
|
||||
|
||||
async function buildAudioPipeline(rawStream) {
|
||||
const audioContext = new AudioContext();
|
||||
const source = audioContext.createMediaStreamSource(rawStream);
|
||||
const destination = audioContext.createMediaStreamDestination();
|
||||
state.voice.audioContext = audioContext;
|
||||
|
||||
let head = source;
|
||||
|
||||
const denoiserNode = await buildDenoiserNode(audioContext);
|
||||
if (denoiserNode) {
|
||||
head.connect(denoiserNode);
|
||||
head = denoiserNode;
|
||||
}
|
||||
head.connect(destination);
|
||||
|
||||
state.voice.denoiserNode = denoiserNode;
|
||||
return destination.stream;
|
||||
}
|
||||
|
||||
async function createLocalVoiceStream() {
|
||||
const constraints = {
|
||||
audio: {
|
||||
channelCount: 1,
|
||||
sampleRate: 48000,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: false,
|
||||
autoGainControl: false,
|
||||
},
|
||||
video: false,
|
||||
};
|
||||
const rawStream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
const localStream = await buildAudioPipeline(rawStream);
|
||||
state.voice.rawStream = rawStream;
|
||||
state.voice.localStream = localStream;
|
||||
if (state.voice.muted) {
|
||||
state.voice.localStream.getAudioTracks().forEach((t) => {
|
||||
t.enabled = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function ensurePeerConnection(peerId) {
|
||||
if (state.voice.peerConnections.has(peerId)) {
|
||||
return state.voice.peerConnections.get(peerId);
|
||||
}
|
||||
|
||||
const pc = new RTCPeerConnection({ iceServers: state.voice.iceServers });
|
||||
|
||||
if (state.voice.localStream) {
|
||||
for (const track of state.voice.localStream.getTracks()) {
|
||||
pc.addTrack(track, state.voice.localStream);
|
||||
}
|
||||
}
|
||||
|
||||
pc.onicecandidate = (event) => {
|
||||
if (!event.candidate || !state.voice.ws) return;
|
||||
state.voice.ws.send(JSON.stringify({
|
||||
type: "signal",
|
||||
to_user_id: peerId,
|
||||
kind: "ice",
|
||||
data: event.candidate,
|
||||
}));
|
||||
};
|
||||
|
||||
pc.ontrack = (event) => {
|
||||
let audio = document.getElementById(`audio-${peerId}`);
|
||||
if (!audio) {
|
||||
audio = document.createElement("audio");
|
||||
audio.id = `audio-${peerId}`;
|
||||
audio.autoplay = true;
|
||||
audio.playsInline = true;
|
||||
document.body.appendChild(audio);
|
||||
}
|
||||
audio.srcObject = event.streams[0];
|
||||
};
|
||||
|
||||
state.voice.peerConnections.set(peerId, pc);
|
||||
return pc;
|
||||
}
|
||||
|
||||
async function sendOffer(peerId) {
|
||||
const pc = ensurePeerConnection(peerId);
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
state.voice.ws.send(JSON.stringify({
|
||||
type: "signal",
|
||||
to_user_id: peerId,
|
||||
kind: "offer",
|
||||
data: offer,
|
||||
}));
|
||||
}
|
||||
|
||||
async function handleSignal(fromPeerId, kind, data) {
|
||||
const pc = ensurePeerConnection(fromPeerId);
|
||||
|
||||
if (kind === "offer") {
|
||||
await pc.setRemoteDescription(new RTCSessionDescription(data));
|
||||
const answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(answer);
|
||||
state.voice.ws.send(JSON.stringify({
|
||||
type: "signal",
|
||||
to_user_id: fromPeerId,
|
||||
kind: "answer",
|
||||
data: answer,
|
||||
}));
|
||||
} else if (kind === "answer") {
|
||||
await pc.setRemoteDescription(new RTCSessionDescription(data));
|
||||
} else if (kind === "ice") {
|
||||
try {
|
||||
await pc.addIceCandidate(data ? new RTCIceCandidate(data) : null);
|
||||
} catch (err) {
|
||||
console.warn("failed to add ice candidate", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function joinVoice() {
|
||||
if (!state.selectedVoiceChannelId) return;
|
||||
if (state.voice.joinedChannelId === state.selectedVoiceChannelId) return;
|
||||
|
||||
await leaveVoice();
|
||||
|
||||
try {
|
||||
await createLocalVoiceStream();
|
||||
} catch (err) {
|
||||
console.error("microphone denied", err);
|
||||
return;
|
||||
}
|
||||
|
||||
const ws = new WebSocket(getVoiceWsUrl(state.selectedVoiceChannelId));
|
||||
state.voice.ws = ws;
|
||||
state.voice.joinedChannelId = state.selectedVoiceChannelId;
|
||||
|
||||
ws.onopen = () => {
|
||||
const channel = state.channels.find(c => c.id === state.selectedVoiceChannelId);
|
||||
el.vcChannelName.textContent = channel ? channel.name : "Voice";
|
||||
el.voiceConnection.classList.remove("hidden");
|
||||
refreshVoicePresence().catch(() => {});
|
||||
};
|
||||
|
||||
ws.onmessage = async (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === "peers") {
|
||||
for (const peer of msg.peers) {
|
||||
if (shouldInitiateOffer(peer.user_id)) await sendOffer(peer.user_id);
|
||||
}
|
||||
} else if (msg.type === "peer_joined") {
|
||||
if (shouldInitiateOffer(msg.user_id)) await sendOffer(msg.user_id);
|
||||
} else if (msg.type === "peer_left") {
|
||||
const pc = state.voice.peerConnections.get(msg.user_id);
|
||||
if (pc) {
|
||||
pc.close();
|
||||
state.voice.peerConnections.delete(msg.user_id);
|
||||
}
|
||||
document.getElementById(`audio-${msg.user_id}`)?.remove();
|
||||
} else if (msg.type === "signal") {
|
||||
await handleSignal(msg.from_user_id, msg.kind, msg.data);
|
||||
}
|
||||
refreshVoicePresence().catch(() => {});
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
el.voiceConnection.classList.add("hidden");
|
||||
for (const pc of state.voice.peerConnections.values()) pc.close();
|
||||
state.voice.peerConnections.clear();
|
||||
stopAndClearAudioPipeline();
|
||||
state.voice.joinedChannelId = null;
|
||||
state.voice.ws = null;
|
||||
refreshVoicePresence().catch(() => {});
|
||||
};
|
||||
}
|
||||
|
||||
async function leaveVoice() {
|
||||
if (state.voice.ws) state.voice.ws.close();
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
if (!state.voice.localStream) return;
|
||||
state.voice.muted = !state.voice.muted;
|
||||
state.voice.localStream.getAudioTracks().forEach((t) => {
|
||||
t.enabled = !state.voice.muted;
|
||||
});
|
||||
el.voiceMuteBtn.innerHTML = state.voice.muted ? '<i data-lucide="mic-off"></i>' : '<i data-lucide="mic"></i>';
|
||||
el.voiceMuteBtn.style.color = state.voice.muted ? 'var(--danger)' : 'var(--text-muted)';
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
// --- Initialization ---
|
||||
|
||||
async function init() {
|
||||
lucide.createIcons();
|
||||
|
||||
el.loginBtn.onclick = () => { location.href = "/auth/login"; };
|
||||
|
||||
el.logoutBtn.onclick = async () => {
|
||||
await leaveVoice();
|
||||
await api("/auth/logout", { method: "POST" });
|
||||
location.reload();
|
||||
};
|
||||
|
||||
el.addGuildBtn.onclick = () => { el.modalContainer.classList.remove("hidden"); };
|
||||
el.createInviteBtn.onclick = createInviteLink;
|
||||
el.modalCancel.onclick = () => { el.modalContainer.classList.add("hidden"); };
|
||||
|
||||
el.addTextBtn.onclick = () => {
|
||||
if (!state.selectedGuildId) {
|
||||
alert("Create or select a server first.");
|
||||
return;
|
||||
}
|
||||
document.querySelector('input[name="channel-kind"][value="text"]').checked = true;
|
||||
el.channelModal.classList.remove("hidden");
|
||||
};
|
||||
el.addVoiceBtn.onclick = () => {
|
||||
if (!state.selectedGuildId) {
|
||||
alert("Create or select a server first.");
|
||||
return;
|
||||
}
|
||||
document.querySelector('input[name="channel-kind"][value="voice"]').checked = true;
|
||||
el.channelModal.classList.remove("hidden");
|
||||
};
|
||||
el.channelModalCancel.onclick = () => { el.channelModal.classList.add("hidden"); };
|
||||
|
||||
el.guildForm.onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const guild = await api("/guilds", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name: el.guildName.value }),
|
||||
});
|
||||
el.guildName.value = "";
|
||||
el.modalContainer.classList.add("hidden");
|
||||
state.guilds.push(guild);
|
||||
state.selectedGuildId = guild.id;
|
||||
state.selectedTextChannelId = null;
|
||||
state.selectedVoiceChannelId = null;
|
||||
state.selectedDmUserId = null;
|
||||
state.selectedDmDisplayName = null;
|
||||
localStorage.setItem(LAST_GUILD_STORAGE_KEY, guild.id);
|
||||
renderGuilds();
|
||||
await loadChannels();
|
||||
await loadGuildMembers();
|
||||
await refreshVoicePresence();
|
||||
renderMessages([]);
|
||||
updateHeaderLabels();
|
||||
} catch (err) { alert(err.message); }
|
||||
};
|
||||
|
||||
el.channelForm.onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!state.selectedGuildId) {
|
||||
alert("Select a server first.");
|
||||
return;
|
||||
}
|
||||
const kindInput = document.querySelector('input[name="channel-kind"]:checked');
|
||||
const kind = kindInput ? kindInput.value : "text";
|
||||
const channelName = el.channelName.value.trim();
|
||||
if (!channelName) {
|
||||
alert("Channel name is required.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const created = await api("/channels", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
guild_id: state.selectedGuildId,
|
||||
name: channelName,
|
||||
kind: kind,
|
||||
}),
|
||||
});
|
||||
el.channelName.value = "";
|
||||
el.channelModal.classList.add("hidden");
|
||||
await loadChannels();
|
||||
if (created.kind === "text") {
|
||||
state.selectedTextChannelId = created.id;
|
||||
state.selectedVoiceChannelId = null;
|
||||
state.selectedDmUserId = null;
|
||||
state.selectedDmDisplayName = null;
|
||||
renderChannels();
|
||||
renderDMs();
|
||||
updateHeaderLabels();
|
||||
const messages = await api(`/channels/${created.id}/messages?limit=100`);
|
||||
renderMessages(messages);
|
||||
} else {
|
||||
state.selectedVoiceChannelId = created.id;
|
||||
state.selectedTextChannelId = null;
|
||||
state.selectedDmUserId = null;
|
||||
state.selectedDmDisplayName = null;
|
||||
renderChannels();
|
||||
renderDMs();
|
||||
updateHeaderLabels();
|
||||
}
|
||||
} catch (err) { alert(err.message); }
|
||||
};
|
||||
|
||||
el.messageForm.onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const body = el.messageBody.value.trim();
|
||||
if (!body) return;
|
||||
|
||||
try {
|
||||
if (state.selectedTextChannelId) {
|
||||
await api(`/channels/${state.selectedTextChannelId}/messages`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ body }),
|
||||
});
|
||||
} else if (state.selectedDmUserId) {
|
||||
await api(`/dms/${state.selectedDmUserId}/messages`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ body }),
|
||||
});
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
el.messageBody.value = "";
|
||||
const messages = state.selectedTextChannelId
|
||||
? await api(`/channels/${state.selectedTextChannelId}/messages?limit=100`)
|
||||
: await api(`/dms/${state.selectedDmUserId}/messages?limit=100`);
|
||||
renderMessages(messages);
|
||||
await loadDMConversations();
|
||||
renderDMs();
|
||||
} catch (err) { alert(err.message); }
|
||||
};
|
||||
|
||||
el.voiceMuteBtn.onclick = toggleMute;
|
||||
el.voiceLeaveBtn.onclick = leaveVoice;
|
||||
|
||||
try {
|
||||
state.me = await api("/me");
|
||||
if (state.me && state.me.display_name) {
|
||||
el.userName.textContent = state.me.display_name;
|
||||
el.userAvatar.textContent = shortName(state.me.display_name);
|
||||
}
|
||||
el.authScreen.classList.add("hidden");
|
||||
el.main.classList.remove("hidden");
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const inviteCode = params.get("invite");
|
||||
if (inviteCode) {
|
||||
try {
|
||||
const joinedGuild = await api(`/invites/${encodeURIComponent(inviteCode)}/join`, {
|
||||
method: "POST",
|
||||
});
|
||||
localStorage.setItem(LAST_GUILD_STORAGE_KEY, joinedGuild.id);
|
||||
} catch (err) {
|
||||
alert(`Failed to join invite: ${err.message}`);
|
||||
} finally {
|
||||
params.delete("invite");
|
||||
const nextQuery = params.toString();
|
||||
const nextUrl = `${location.pathname}${nextQuery ? `?${nextQuery}` : ""}`;
|
||||
history.replaceState(null, "", nextUrl);
|
||||
}
|
||||
}
|
||||
|
||||
await loadGuilds();
|
||||
await loadDMConversations();
|
||||
|
||||
if (state.guilds.length > 0) {
|
||||
const lastGuildId = localStorage.getItem(LAST_GUILD_STORAGE_KEY);
|
||||
const guild = state.guilds.find((g) => g.id === lastGuildId) || state.guilds[0];
|
||||
state.selectedGuildId = guild.id;
|
||||
localStorage.setItem(LAST_GUILD_STORAGE_KEY, guild.id);
|
||||
renderGuilds();
|
||||
await loadChannels();
|
||||
await loadGuildMembers();
|
||||
await refreshVoicePresence();
|
||||
updateHeaderLabels();
|
||||
} else {
|
||||
state.members = [];
|
||||
renderMembers();
|
||||
updateHeaderLabels();
|
||||
}
|
||||
|
||||
startVoicePresencePolling();
|
||||
lucide.createIcons();
|
||||
} catch (err) {
|
||||
console.error("init failed", err);
|
||||
el.authScreen.classList.remove("hidden");
|
||||
el.main.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
|
|
|
|||
|
|
@ -1,16 +1,13 @@
|
|||
<!-- Generated from shared-html/index.template.html. Do not edit directly. -->
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Chattz</title>
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
<!-- Lucide Icons -->
|
||||
<script src="/static/vendor/lucide.min.js"></script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="auth-screen" id="auth-screen">
|
||||
<div class="auth-card">
|
||||
|
|
@ -18,11 +15,6 @@
|
|||
<h1>Chattz</h1>
|
||||
<p>Sign in to open your servers.</p>
|
||||
<button id="login-btn">Login with Authentik</button>
|
||||
<div class="auth-downloads">
|
||||
<span>Desktop downloads:</span>
|
||||
<a href="/static/installers/chattz-windows.exe">Windows</a>
|
||||
<a href="/static/installers/chattz-linux.rpm">Linux (RPM)</a>
|
||||
</div>
|
||||
<p id="status" class="status-line"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -43,7 +35,6 @@
|
|||
<header class="sidebar-header clickable" id="guild-header">
|
||||
<h2 id="guild-title">No server selected</h2>
|
||||
<div class="sidebar-header-actions">
|
||||
<span id="invite-copied-badge" class="copied-badge hidden">Copied!</span>
|
||||
<button id="create-invite-btn" class="header-action-btn" title="Create Invite Link">
|
||||
<i data-lucide="link-2"></i>
|
||||
</button>
|
||||
|
|
@ -92,21 +83,12 @@
|
|||
<span id="vc-channel-name" class="vc-name">General</span>
|
||||
</div>
|
||||
<div class="vc-actions">
|
||||
<button id="voice-video-btn" title="Turn on Camera"><i data-lucide="video-off"></i></button>
|
||||
<button id="voice-screen-btn" title="Share Screen"><i data-lucide="monitor-off"></i></button>
|
||||
<button id="voice-mute-btn" title="Mute"><i data-lucide="mic"></i></button>
|
||||
<button id="voice-leave-btn" title="Disconnect"><i data-lucide="phone-off"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="soundboard" class="soundboard-container hidden">
|
||||
<div class="soundboard-header">
|
||||
<span>Sound Board</span>
|
||||
<span id="add-sound-btn" class="btn-add-sound">+ Add Sound</span>
|
||||
</div>
|
||||
<div id="soundboard-grid" class="soundboard-grid"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="user-panel">
|
||||
<div class="avatar-wrapper">
|
||||
<div id="user-avatar" class="avatar">U</div>
|
||||
|
|
@ -117,10 +99,6 @@
|
|||
<div class="user-status">Online</div>
|
||||
</div>
|
||||
<div class="user-actions">
|
||||
<div id="update-notifier" class="update-notifier hidden">
|
||||
<button id="update-download-btn" title="Download Update"><i data-lucide="download"></i></button>
|
||||
<button id="update-install-btn" title="Install Update" class="hidden"><i data-lucide="arrow-up-circle"></i></button>
|
||||
</div>
|
||||
<button id="user-settings-btn" title="Settings"><i data-lucide="settings"></i></button>
|
||||
<button id="logout-btn" title="Logout"><i data-lucide="log-out"></i></button>
|
||||
</div>
|
||||
|
|
@ -130,36 +108,21 @@
|
|||
|
||||
<main class="chat-pane">
|
||||
<header class="chat-header">
|
||||
<button id="mobile-menu-btn" class="mobile-toggle-btn" title="Menu">
|
||||
<i data-lucide="menu"></i>
|
||||
</button>
|
||||
<i data-lucide="hash" class="header-icon"></i>
|
||||
<h3 id="channel-title">Select a channel</h3>
|
||||
<div style="flex:1"></div>
|
||||
<button id="mobile-members-btn" class="mobile-toggle-btn" title="Members">
|
||||
<i data-lucide="users"></i>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div id="video-grid" class="video-grid hidden"></div>
|
||||
<div id="message-list" class="message-list"></div>
|
||||
|
||||
<div class="chat-input-wrapper">
|
||||
<form id="message-form" class="message-form">
|
||||
<input id="media-file-input" type="file" class="hidden-file-input" accept="image/*,video/*,audio/*,.pdf,.txt,.zip,.rar,.7z,.tar,.gz,.json,.csv,.md" />
|
||||
<button type="button" id="media-upload-btn" class="input-action-btn" title="Upload File">
|
||||
<i data-lucide="paperclip"></i>
|
||||
</button>
|
||||
<button type="button" id="gif-btn" class="input-action-btn" title="Open GIF Picker">
|
||||
<i data-lucide="image"></i>
|
||||
</button>
|
||||
<input id="message-body" autocomplete="off" placeholder="Message #channel" maxlength="4000" required />
|
||||
<button type="submit" class="hidden"></button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<aside class="utility-sidebar" id="utility-sidebar">
|
||||
<aside class="utility-sidebar">
|
||||
<header class="sidebar-header">
|
||||
<h2>Members</h2>
|
||||
</header>
|
||||
|
|
@ -169,27 +132,7 @@
|
|||
</aside>
|
||||
</div>
|
||||
|
||||
<div id="overlay" class="overlay hidden"></div>
|
||||
|
||||
<!-- Modals -->
|
||||
<div id="gif-picker" class="modal-container hidden">
|
||||
<div class="modal gif-modal">
|
||||
<div class="modal-header">
|
||||
<h2>Select GIF</h2>
|
||||
<button type="button" class="close-btn" id="gif-picker-close">
|
||||
<i data-lucide="x"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="gif-search-wrapper">
|
||||
<input type="text" id="gif-search-input" placeholder="Search for GIFs..." autocomplete="off">
|
||||
<i data-lucide="search" class="search-icon"></i>
|
||||
</div>
|
||||
<div id="gif-results" class="gif-results-grid">
|
||||
<!-- GIF results will be injected here -->
|
||||
<div class="gif-loading hidden">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="modal-container" class="modal-container hidden">
|
||||
<div class="modal">
|
||||
<h2 id="modal-title">Create Server</h2>
|
||||
|
|
@ -247,41 +190,6 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div id="sound-modal" class="modal-container hidden">
|
||||
<div class="modal">
|
||||
<h2>Add Sound</h2>
|
||||
<form id="sound-form" class="modal-form">
|
||||
<div class="form-item">
|
||||
<label for="sound-name">NAME</label>
|
||||
<input id="sound-name" placeholder="Quack" maxlength="32" required />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label for="sound-icon">ICON (Emoji or Initials)</label>
|
||||
<input id="sound-icon" placeholder="🦆" maxlength="4" required />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label for="sound-file">AUDIO FILE (MP3/WAV)</label>
|
||||
<input id="sound-file" type="file" accept="audio/*" required />
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="cancel-btn" id="sound-modal-cancel">Cancel</button>
|
||||
<button type="submit" class="submit-btn" id="sound-submit-btn">Add Sound</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="upload-limit-modal" class="modal-container hidden">
|
||||
<div class="modal">
|
||||
<h2>Upload Too Large</h2>
|
||||
<p id="upload-limit-message" class="modal-copy">Uploads are limited to 50 MB per file.</p>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="submit-btn" id="upload-limit-ok">OK</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/static/app.js?v=20260227-shared-core-1"></script>
|
||||
<script src="/static/app.js" defer></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
1555
static/styles.css
1555
static/styles.css
File diff suppressed because it is too large
Load diff
249
static/vendor/deepfilternet3-noise-filter.esm.js
vendored
249
static/vendor/deepfilternet3-noise-filter.esm.js
vendored
File diff suppressed because one or more lines are too long
BIN
static/vendor/deepfilternet3/v2/pkg/df_bg.wasm
vendored
BIN
static/vendor/deepfilternet3/v2/pkg/df_bg.wasm
vendored
Binary file not shown.
12
static/vendor/lucide.min.js
vendored
12
static/vendor/lucide.min.js
vendored
File diff suppressed because one or more lines are too long
33
test.html
33
test.html
|
|
@ -1,33 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" href="static/styles.css" />
|
||||
<style>
|
||||
body { margin: 0; height: 100vh; display: flex; }
|
||||
.chat-pane { flex: 1; position: relative; background: #333; }
|
||||
.chat-header { height: 48px; background: #222; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="chat-pane">
|
||||
<div class="chat-header">Header</div>
|
||||
<div id="video-grid" class="video-grid">
|
||||
<div class="video-item" onclick="toggle(this)" style="display:flex; justify-content:center; align-items:center; color:white;">Click me</div>
|
||||
<div class="video-item" style="color:white;">Another</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
function toggle(el) {
|
||||
const grid = document.getElementById('video-grid');
|
||||
const isFullscreen = el.classList.contains('fullscreen');
|
||||
document.querySelectorAll('.video-item').forEach(e => e.classList.remove('fullscreen'));
|
||||
if (isFullscreen) {
|
||||
grid.classList.remove('has-fullscreen');
|
||||
} else {
|
||||
el.classList.add('fullscreen');
|
||||
grid.classList.add('has-fullscreen');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
|
||||
> chattz-electron@0.1.0 start
|
||||
> electron .
|
||||
|
||||
Loading desktop app from: file:///home/pavel/chattz/desktop/index.html?backend=https%3A%2F%2Fdiscord.flegr.me
|
||||
Loading…
Add table
Add a link
Reference in a new issue