Initial commit: EifelDC - Discord-like Matrix chat platform
Some checks failed
CI / Rust Check (push) Has been cancelled
CI / Rust Tests (push) Has been cancelled
CI / Frontend Check (push) Has been cancelled
CI / Build Tauri (macOS) (push) Has been cancelled
CI / Build Tauri (macOS Intel) (push) Has been cancelled
CI / Build Tauri (Linux) (push) Has been cancelled
Some checks failed
CI / Rust Check (push) Has been cancelled
CI / Rust Tests (push) Has been cancelled
CI / Frontend Check (push) Has been cancelled
CI / Build Tauri (macOS) (push) Has been cancelled
CI / Build Tauri (macOS Intel) (push) Has been cancelled
CI / Build Tauri (Linux) (push) Has been cancelled
Includes server (Rust/Axum API proxy with voice management), Tauri desktop client with Svelte UI, bot-sdk, Docker infra (Synapse, PostgreSQL, Coturn, Nginx), and CI/CD pipeline.
This commit is contained in:
28
.dockerignore
Normal file
28
.dockerignore
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
target/
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.svelte-kit/
|
||||||
|
*.log
|
||||||
|
*.pid
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
.git/
|
||||||
|
.github/
|
||||||
|
synapse-data/
|
||||||
|
synapse-venv/
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.crt
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
*.db-wal
|
||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.cargo/
|
||||||
|
infra/
|
||||||
230
.github/workflows/ci.yml
vendored
Normal file
230
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, develop]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
RUST_BACKTRACE: 1
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-rust:
|
||||||
|
name: Rust Check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust stable
|
||||||
|
uses: dtolnay/rust-action/setup@v1
|
||||||
|
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev libssl-dev libgtksourceview-3.0-dev
|
||||||
|
|
||||||
|
- name: Cache Cargo
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: ${{ runner.os }}-cargo-
|
||||||
|
|
||||||
|
- name: Cargo check (client)
|
||||||
|
run: cargo check -p eifeldc-client
|
||||||
|
|
||||||
|
- name: Cargo check (bot-sdk)
|
||||||
|
run: cargo check -p eifeldc-bot-sdk
|
||||||
|
|
||||||
|
- name: Cargo check (server)
|
||||||
|
run: cargo check -p eifeldc-server
|
||||||
|
|
||||||
|
- name: Cargo clippy
|
||||||
|
run: cargo clippy --workspace -- -D warnings
|
||||||
|
|
||||||
|
test-rust:
|
||||||
|
name: Rust Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: check-rust
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust stable
|
||||||
|
uses: dtolnay/rust-action/setup@v1
|
||||||
|
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev libssl-dev
|
||||||
|
|
||||||
|
- name: Cache Cargo
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: ${{ runner.os }}-cargo-
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: cargo test --workspace
|
||||||
|
|
||||||
|
check-frontend:
|
||||||
|
name: Frontend Check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: client/src-ui
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: client/src-ui/package.json
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Svelte check
|
||||||
|
run: npm run check
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
build-tauri-macos:
|
||||||
|
name: Build Tauri (macOS)
|
||||||
|
runs-on: macos-latest
|
||||||
|
needs: [check-rust, check-frontend]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust stable
|
||||||
|
uses: dtolnay/rust-action/setup@v1
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: client/src-ui/package.json
|
||||||
|
|
||||||
|
- name: Install frontend deps
|
||||||
|
run: npm ci
|
||||||
|
working-directory: client/src-ui
|
||||||
|
|
||||||
|
- name: Build Tauri (macOS)
|
||||||
|
uses: tauri-apps/tauri-action@v0
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
with:
|
||||||
|
projectPath: client
|
||||||
|
tagName: v__VERSION__
|
||||||
|
releaseName: "EifelDC v__VERSION__"
|
||||||
|
releaseBody: "EifelDC Release"
|
||||||
|
releaseDraft: true
|
||||||
|
args: --target aarch64-apple-darwin
|
||||||
|
|
||||||
|
- name: Upload macOS .dmg
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: EifelDC-macos-aarch64
|
||||||
|
path: client/src-tauri/target/release/bundle/dmg/*.dmg
|
||||||
|
|
||||||
|
build-tauri-macos-intel:
|
||||||
|
name: Build Tauri (macOS Intel)
|
||||||
|
runs-on: macos-latest
|
||||||
|
needs: [check-rust, check-frontend]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust stable
|
||||||
|
uses: dtolnay/rust-action/setup@v1
|
||||||
|
with:
|
||||||
|
targets: x86_64-apple-darwin
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: client/src-ui/package.json
|
||||||
|
|
||||||
|
- name: Install frontend deps
|
||||||
|
run: npm ci
|
||||||
|
working-directory: client/src-ui
|
||||||
|
|
||||||
|
- name: Build Tauri (macOS Intel)
|
||||||
|
uses: tauri-apps/tauri-action@v0
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
with:
|
||||||
|
projectPath: client
|
||||||
|
tagName: v__VERSION__
|
||||||
|
releaseName: "EifelDC v__VERSION__"
|
||||||
|
releaseBody: "EifelDC Release"
|
||||||
|
releaseDraft: true
|
||||||
|
args: --target x86_64-apple-darwin
|
||||||
|
|
||||||
|
- name: Upload macOS Intel .dmg
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: EifelDC-macos-x86_64
|
||||||
|
path: client/src-tauri/target/x86_64-apple-darwin/release/bundle/dmg/*.dmg
|
||||||
|
|
||||||
|
build-tauri-linux:
|
||||||
|
name: Build Tauri (Linux)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [check-rust, check-frontend]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust stable
|
||||||
|
uses: dtolnay/rust-action/setup@v1
|
||||||
|
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev libssl-dev libgtksourceview-3.0-dev
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: client/src-ui/package.json
|
||||||
|
|
||||||
|
- name: Install frontend deps
|
||||||
|
run: npm ci
|
||||||
|
working-directory: client/src-ui
|
||||||
|
|
||||||
|
- name: Build Tauri (Linux)
|
||||||
|
uses: tauri-apps/tauri-action@v0
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
with:
|
||||||
|
projectPath: client
|
||||||
|
tagName: v__VERSION__
|
||||||
|
releaseName: "EifelDC v__VERSION__"
|
||||||
|
releaseBody: "EifelDC Release"
|
||||||
|
releaseDraft: true
|
||||||
|
|
||||||
|
- name: Upload Linux .deb
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: EifelDC-linux-deb
|
||||||
|
path: client/src-tauri/target/release/bundle/deb/*.deb
|
||||||
|
|
||||||
|
- name: Upload Linux .AppImage
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: EifelDC-linux-appimage
|
||||||
|
path: client/src-tauri/target/release/bundle/appimage/*.AppImage
|
||||||
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/target/
|
||||||
|
**/target/
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.svelte-kit/
|
||||||
|
*.log
|
||||||
|
*.pid
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
synapse-data/
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
*.db-wal
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.crt
|
||||||
|
venv/
|
||||||
|
synapse-venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.cargo/
|
||||||
4396
Cargo.lock
generated
Normal file
4396
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
Cargo.toml
Normal file
18
Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
|
members = [
|
||||||
|
"server",
|
||||||
|
]
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
matrix-sdk = { version = "0.7", features = ["e2e-encryption", "sqlite"] }
|
||||||
|
matrix-sdk-base = "0.7"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
anyhow = "1"
|
||||||
|
thiserror = "1"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
reqwest = { version = "0.12", features = ["json", "multipart"] }
|
||||||
|
url = "2"
|
||||||
37
Dockerfile
Normal file
37
Dockerfile
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
FROM node:20-bookworm AS frontend-builder
|
||||||
|
|
||||||
|
WORKDIR /app/client/src-ui
|
||||||
|
COPY client/src-ui/package.json client/src-ui/package-lock.json* ./
|
||||||
|
RUN npm install
|
||||||
|
COPY client/src-ui/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM rust:1.82-bookworm AS server-builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
COPY server/Cargo.toml server/Cargo.toml
|
||||||
|
RUN mkdir -p server/src && echo "fn main() {}" > server/src/main.rs
|
||||||
|
RUN cargo build --release -p eifeldc-server && rm -rf server/src
|
||||||
|
|
||||||
|
COPY server/src server/src
|
||||||
|
RUN touch server/src/main.rs && cargo build --release -p eifeldc-server
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y ca-certificates libssl3 && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN useradd -m -d /home/eifeldc eifeldc
|
||||||
|
|
||||||
|
COPY --from=server-builder /app/target/release/eifeldc-server /usr/local/bin/eifeldc-server
|
||||||
|
COPY --from=frontend-builder /app/client/src-ui/dist /usr/share/eifeldc/client
|
||||||
|
|
||||||
|
USER eifeldc
|
||||||
|
|
||||||
|
ENV EIFELDC_STATIC_DIR=/usr/share/eifeldc/client
|
||||||
|
ENV RUST_LOG=eifeldc_server=info,tower_http=info
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["eifeldc-server"]
|
||||||
22
bot-sdk/Cargo.toml
Normal file
22
bot-sdk/Cargo.toml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
[package]
|
||||||
|
name = "eifeldc-bot-sdk"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
matrix-sdk = { workspace = true }
|
||||||
|
matrix-sdk-base = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
tracing-subscriber = { workspace = true }
|
||||||
|
reqwest = { workspace = true }
|
||||||
|
url = { workspace = true }
|
||||||
|
async-trait = "0.1"
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
mockito = "1"
|
||||||
24
bot-sdk/src/auth.rs
Normal file
24
bot-sdk/src/auth.rs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
pub struct BotAuth {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BotAuth {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
username: String::new(),
|
||||||
|
password: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_credentials(username: &str, password: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
username: username.to_string(),
|
||||||
|
password: password.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_configured(&self) -> bool {
|
||||||
|
!self.username.is_empty() && !self.password.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
132
bot-sdk/src/client.rs
Normal file
132
bot-sdk/src/client.rs
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
use matrix_sdk::Client;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
use crate::auth::BotAuth;
|
||||||
|
use crate::commands::CommandRegistry;
|
||||||
|
use crate::event::EventHandler;
|
||||||
|
use crate::room::RoomManager;
|
||||||
|
|
||||||
|
pub struct BotClient {
|
||||||
|
client: Arc<RwLock<Option<Client>>>,
|
||||||
|
auth: Arc<RwLock<BotAuth>>,
|
||||||
|
commands: Arc<RwLock<CommandRegistry>>,
|
||||||
|
event_handler: Arc<RwLock<EventHandler>>,
|
||||||
|
room_manager: Arc<RwLock<RoomManager>>,
|
||||||
|
homeserver: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BotClient {
|
||||||
|
pub fn new(homeserver: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
client: Arc::new(RwLock::new(None)),
|
||||||
|
auth: Arc::new(RwLock::new(BotAuth::new())),
|
||||||
|
commands: Arc::new(RwLock::new(CommandRegistry::new())),
|
||||||
|
event_handler: Arc::new(RwLock::new(EventHandler::new())),
|
||||||
|
room_manager: Arc::new(RwLock::new(RoomManager::new())),
|
||||||
|
homeserver: homeserver.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_auth(self, username: &str, password: &str) -> Self {
|
||||||
|
let auth = BotAuth::with_credentials(username, password);
|
||||||
|
self.auth = Arc::new(RwLock::new(auth));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start(&self) -> anyhow::Result<()> {
|
||||||
|
let client = Client::builder()
|
||||||
|
.homeserver_url(&self.homeserver)
|
||||||
|
.build()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut guard = self.client.write().await;
|
||||||
|
*guard = Some(client.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let auth = self.auth.read().await;
|
||||||
|
client
|
||||||
|
.matrix_auth()
|
||||||
|
.login_username(&auth.username, &auth.password)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tracing::info!("Bot logged in as {}", auth.username);
|
||||||
|
drop(auth);
|
||||||
|
|
||||||
|
let mut sync_token: Option<String> = None;
|
||||||
|
loop {
|
||||||
|
let mut settings = matrix_sdk::config::SyncSettings::new();
|
||||||
|
if let Some(token) = sync_token.as_ref() {
|
||||||
|
settings = settings.token(token.clone());
|
||||||
|
}
|
||||||
|
match client.sync_once(settings).await {
|
||||||
|
Ok(response) => {
|
||||||
|
sync_token = Some(response.next_batch);
|
||||||
|
|
||||||
|
let handler = self.event_handler.read().await;
|
||||||
|
handler.dispatch("sync");
|
||||||
|
drop(handler);
|
||||||
|
|
||||||
|
let rooms = client.joined_rooms();
|
||||||
|
let mut room_mgr = self.room_manager.write().await;
|
||||||
|
for room in rooms {
|
||||||
|
let name = room.display_name().await.map(|n| n.to_string()).unwrap_or_default();
|
||||||
|
room_mgr.add_room(crate::room::RoomInfo {
|
||||||
|
room_id: room.room_id().to_string(),
|
||||||
|
name,
|
||||||
|
is_encrypted: room.is_encrypted().await.unwrap_or(false),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Sync error: {}", e);
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn stop(&self) -> anyhow::Result<()> {
|
||||||
|
let mut guard = self.client.write().await;
|
||||||
|
*guard = None;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send_message(&self, room_id: &str, message: &str) -> anyhow::Result<()> {
|
||||||
|
let guard = self.client.read().await;
|
||||||
|
let client = guard.as_ref().ok_or(anyhow::anyhow!("Not connected"))?;
|
||||||
|
|
||||||
|
let rid = matrix_sdk::ruma::room_id!(room_id)
|
||||||
|
.map_err(|_| anyhow::anyhow!("Invalid room ID"))?;
|
||||||
|
let room = client.get_room(&rid)
|
||||||
|
.ok_or(anyhow::anyhow!("Room not found"))?;
|
||||||
|
|
||||||
|
let content = matrix_sdk::ruma::events::room::message::RoomMessageEventContent::text_plain(message);
|
||||||
|
let txn_id = matrix_sdk::ruma::TransactionId::new();
|
||||||
|
room.send(content, Some(&txn_id)).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn on_command(&self, name: &str, handler: Box<dyn Fn(&str, &str) + Send + Sync>) {
|
||||||
|
let mut commands = self.commands.write().await;
|
||||||
|
commands.register(name, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn on_event(&self, handler: Box<dyn Fn(&str) + Send + Sync>) {
|
||||||
|
let mut event_handler = self.event_handler.write().await;
|
||||||
|
event_handler.add_handler(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_rooms(&self) -> Vec<crate::room::RoomInfo> {
|
||||||
|
let room_mgr = self.room_manager.read().await;
|
||||||
|
room_mgr.list_rooms().into_iter().cloned().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_message(&self, room_id: &str, sender: &str, body: &str) {
|
||||||
|
let commands = self.commands.read().await;
|
||||||
|
commands.parse_and_execute(body, sender);
|
||||||
|
}
|
||||||
|
}
|
||||||
57
bot-sdk/src/commands.rs
Normal file
57
bot-sdk/src/commands.rs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
type CommandHandler = Box<dyn Fn(&str, &str) + Send + Sync>;
|
||||||
|
|
||||||
|
pub struct CommandRegistry {
|
||||||
|
commands: HashMap<String, CommandHandler>,
|
||||||
|
prefix: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommandRegistry {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
commands: HashMap::new(),
|
||||||
|
prefix: "!".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_prefix(mut self, prefix: &str) -> Self {
|
||||||
|
self.prefix = prefix.to_string();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_prefix(&mut self, prefix: &str) {
|
||||||
|
self.prefix = prefix.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn register(&mut self, name: &str, handler: CommandHandler) {
|
||||||
|
self.commands.insert(name.to_string(), handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unregister(&mut self, name: &str) {
|
||||||
|
self.commands.remove(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_and_execute(&self, message: &str, sender: &str) {
|
||||||
|
if !message.starts_with(&self.prefix) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = &message[self.prefix.len()..];
|
||||||
|
let parts: Vec<&str> = content.splitn(2, ' ').collect();
|
||||||
|
let command = parts[0];
|
||||||
|
let args = parts.get(1).unwrap_or(&"");
|
||||||
|
|
||||||
|
if let Some(handler) = self.commands.get(command) {
|
||||||
|
handler(args, sender);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_commands(&self) -> Vec<String> {
|
||||||
|
self.commands.keys().cloned().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_command(&self, name: &str) -> bool {
|
||||||
|
self.commands.contains_key(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
25
bot-sdk/src/event.rs
Normal file
25
bot-sdk/src/event.rs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
type EventCallback = Box<dyn Fn(&str) + Send + Sync>;
|
||||||
|
|
||||||
|
pub struct EventHandler {
|
||||||
|
handlers: Vec<EventCallback>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventHandler {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { handlers: Vec::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_handler(&mut self, handler: EventCallback) {
|
||||||
|
self.handlers.push(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dispatch(&self, event: &str) {
|
||||||
|
for handler in &self.handlers {
|
||||||
|
handler(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handler_count(&self) -> usize {
|
||||||
|
self.handlers.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
8
bot-sdk/src/lib.rs
Normal file
8
bot-sdk/src/lib.rs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
pub mod client;
|
||||||
|
pub mod commands;
|
||||||
|
pub mod event;
|
||||||
|
pub mod room;
|
||||||
|
pub mod auth;
|
||||||
|
|
||||||
|
pub use client::BotClient;
|
||||||
|
pub use auth::BotAuth;
|
||||||
38
bot-sdk/src/room.rs
Normal file
38
bot-sdk/src/room.rs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct RoomInfo {
|
||||||
|
pub room_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub is_encrypted: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RoomManager {
|
||||||
|
rooms: HashMap<String, RoomInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RoomManager {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { rooms: HashMap::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_room(&mut self, room: RoomInfo) {
|
||||||
|
self.rooms.insert(room.room_id.clone(), room);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_room(&mut self, room_id: &str) {
|
||||||
|
self.rooms.remove(room_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_room(&self, room_id: &str) -> Option<&RoomInfo> {
|
||||||
|
self.rooms.get(room_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_rooms(&self) -> Vec<&RoomInfo> {
|
||||||
|
self.rooms.values().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn room_count(&self) -> usize {
|
||||||
|
self.rooms.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
29
client/Cargo.toml
Normal file
29
client/Cargo.toml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
[package]
|
||||||
|
name = "eifeldc-client"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
matrix-sdk = { workspace = true }
|
||||||
|
matrix-sdk-base = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
tracing-subscriber = { workspace = true }
|
||||||
|
reqwest = { workspace = true }
|
||||||
|
url = { workspace = true }
|
||||||
|
tauri = { version = "1", features = ["shell-open", "dialog-all"] }
|
||||||
|
tauri-plugin-oauth = "2.0"
|
||||||
|
sled = "0.34"
|
||||||
|
futures = "0.3"
|
||||||
|
async-trait = "0.1"
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
rand = "0.8"
|
||||||
|
base64 = "0.22"
|
||||||
|
sha2 = "0.10"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "1", features = [] }
|
||||||
3
client/build.rs
Normal file
3
client/build.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
42
client/scripts/build-macos.sh
Executable file
42
client/scripts/build-macos.sh
Executable file
@@ -0,0 +1,42 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
|
||||||
|
TARGET="${1:-}"
|
||||||
|
if [ -n "$TARGET" ]; then
|
||||||
|
TARGET_ARG="--target $TARGET"
|
||||||
|
else
|
||||||
|
TARGET_ARG=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Building EifelDC for macOS ==="
|
||||||
|
echo "Target: ${TARGET:-native}"
|
||||||
|
|
||||||
|
echo "[1/3] Installing frontend dependencies..."
|
||||||
|
cd "${PROJECT_DIR}/client/src-ui"
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
echo "[2/3] Building frontend..."
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
echo "[3/3] Building Tauri app..."
|
||||||
|
cd "${PROJECT_DIR}/client"
|
||||||
|
cargo tauri build $TARGET_ARG
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Build complete! ==="
|
||||||
|
|
||||||
|
if [ -n "$TARGET" ]; then
|
||||||
|
BUNDLE_DIR="${PROJECT_DIR}/client/src-tauri/target/${TARGET}/release/bundle"
|
||||||
|
else
|
||||||
|
BUNDLE_DIR="${PROJECT_DIR}/client/src-tauri/target/release/bundle"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -d "${BUNDLE_DIR}/dmg" ]; then
|
||||||
|
echo "DMG: $(find "${BUNDLE_DIR}/dmg" -name '*.dmg' 2>/dev/null || echo 'not found')"
|
||||||
|
fi
|
||||||
|
if [ -d "${BUNDLE_DIR}/macos" ]; then
|
||||||
|
echo "App: $(find "${BUNDLE_DIR}/macos" -name '*.app' 2>/dev/null || echo 'not found')"
|
||||||
|
fi
|
||||||
BIN
client/src-tauri/icons/128x128.png
Normal file
BIN
client/src-tauri/icons/128x128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 505 B |
BIN
client/src-tauri/icons/128x128@2x.png
Normal file
BIN
client/src-tauri/icons/128x128@2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
client/src-tauri/icons/32x32.png
Normal file
BIN
client/src-tauri/icons/32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 164 B |
BIN
client/src-tauri/icons/icon.icns
Normal file
BIN
client/src-tauri/icons/icon.icns
Normal file
Binary file not shown.
BIN
client/src-tauri/icons/icon.ico
Normal file
BIN
client/src-tauri/icons/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 186 B |
20
client/src-tauri/macos/EifelDC.entitlements
Normal file
20
client/src-tauri/macos/EifelDC.entitlements
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.app-sandbox</key>
|
||||||
|
<false/>
|
||||||
|
<key>com.apple.security.network.client</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.network.server</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.device.audio-input</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.device.camera</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.files.user-selected.read-only</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.files.user-selected.read-write</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
32
client/src-tauri/macos/Info.plist
Normal file
32
client/src-tauri/macos/Info.plist
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>EifelDC</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>EifelDC</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>de.eifeldc</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>EifelDC</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>0.1.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>10.15</string>
|
||||||
|
<key>NSHighResolutionCapable</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>EifelDC needs microphone access for voice channels.</string>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>EifelDC needs camera access for video calls.</string>
|
||||||
|
<key>LSApplicationCategoryType</key>
|
||||||
|
<string>public.app-category.social-networking</string>
|
||||||
|
<key>NSHumanReadableCopyright</key>
|
||||||
|
<string>Copyright 2024 EifelDC. All rights reserved.</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
69
client/src-tauri/tauri.conf.json
Normal file
69
client/src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
{
|
||||||
|
"build": {
|
||||||
|
"beforeDevCommand": "npm run dev",
|
||||||
|
"beforeBuildCommand": "npm run build",
|
||||||
|
"devPath": "http://localhost:5173",
|
||||||
|
"distDir": "../src-ui/dist"
|
||||||
|
},
|
||||||
|
"package": {
|
||||||
|
"productName": "EifelDC",
|
||||||
|
"version": "0.1.0"
|
||||||
|
},
|
||||||
|
"tauri": {
|
||||||
|
"allowlist": {
|
||||||
|
"all": false,
|
||||||
|
"shell": { "open": true },
|
||||||
|
"dialog": { "all": true },
|
||||||
|
"window": { "all": true }
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"identifier": "de.eifeldc",
|
||||||
|
"icon": [
|
||||||
|
"icons/32x32.png",
|
||||||
|
"icons/128x128.png",
|
||||||
|
"icons/128x128@2x.png",
|
||||||
|
"icons/icon.icns",
|
||||||
|
"icons/icon.ico"
|
||||||
|
],
|
||||||
|
"resources": [],
|
||||||
|
"copyright": "",
|
||||||
|
"category": "SocialNetworking",
|
||||||
|
"shortDescription": "EifelDC - Matrix Chat Client",
|
||||||
|
"longDescription": "EifelDC is a Discord-like Matrix chat client built with Tauri, Svelte and Rust.",
|
||||||
|
"windows": {
|
||||||
|
"certificateThumbprint": null,
|
||||||
|
"digestAlgorithm": "sha256",
|
||||||
|
"timestampUrl": ""
|
||||||
|
},
|
||||||
|
"macOS": {
|
||||||
|
"frameworks": [],
|
||||||
|
"minimumSystemVersion": "10.15",
|
||||||
|
"exceptionDomain": "",
|
||||||
|
"signingIdentity": null,
|
||||||
|
"entitlements": "macos/EifelDC.entitlements",
|
||||||
|
"providerShortName": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"csp": "default-src 'self'; connect-src https: wss: http://localhost:*; img-src https: data:; style-src 'self' 'unsafe-inline'; script-src 'self'"
|
||||||
|
},
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"title": "EifelDC",
|
||||||
|
"width": 1280,
|
||||||
|
"height": 720,
|
||||||
|
"minWidth": 800,
|
||||||
|
"minHeight": 600,
|
||||||
|
"resizable": true,
|
||||||
|
"fullscreen": false,
|
||||||
|
"decorations": true,
|
||||||
|
"transparent": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"systemTray": {
|
||||||
|
"iconPath": "icons/32x32.png",
|
||||||
|
"iconAsTemplate": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
client/src-ui/index.html
Normal file
13
client/src-ui/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>EifelDC</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1877
client/src-ui/package-lock.json
generated
Normal file
1877
client/src-ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
client/src-ui/package.json
Normal file
22
client/src-ui/package.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "eifeldc-ui",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"check": "svelte-check --tsconfig ./tsconfig.json"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^3",
|
||||||
|
"@tauri-apps/api": "^1",
|
||||||
|
"svelte": "^4",
|
||||||
|
"svelte-check": "^3",
|
||||||
|
"svelte-preprocess": "^5.1.4",
|
||||||
|
"tslib": "^2",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
113
client/src-ui/src/App.svelte
Normal file
113
client/src-ui/src/App.svelte
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import LoginScreen from './components/LoginScreen.svelte';
|
||||||
|
import ServerSidebar from './components/ServerSidebar.svelte';
|
||||||
|
import ChannelSidebar from './components/ChannelSidebar.svelte';
|
||||||
|
import ChatArea from './components/ChatArea.svelte';
|
||||||
|
import MemberList from './components/MemberList.svelte';
|
||||||
|
import VoicePanel from './components/VoicePanel.svelte';
|
||||||
|
import { currentUser, refreshChannels } from './lib/store';
|
||||||
|
|
||||||
|
let loggedIn = false;
|
||||||
|
let loading = true;
|
||||||
|
|
||||||
|
function isTauri(): boolean {
|
||||||
|
return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
if (isTauri()) {
|
||||||
|
try {
|
||||||
|
const { invoke } = await import('@tauri-apps/api/tauri');
|
||||||
|
const user: string | null = await invoke('get_current_user');
|
||||||
|
if (user) {
|
||||||
|
loggedIn = true;
|
||||||
|
currentUser.set({
|
||||||
|
id: user,
|
||||||
|
username: user.split(':')[0].replace('@', ''),
|
||||||
|
avatarUrl: null,
|
||||||
|
status: 'online',
|
||||||
|
});
|
||||||
|
await refreshChannels();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
loggedIn = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const token = localStorage.getItem('eifeldc_token');
|
||||||
|
if (token) {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/current-user', {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const userId: string | null = await res.json();
|
||||||
|
if (userId) {
|
||||||
|
loggedIn = true;
|
||||||
|
currentUser.set({
|
||||||
|
id: userId,
|
||||||
|
username: userId.split(':')[0].replace('@', ''),
|
||||||
|
avatarUrl: null,
|
||||||
|
status: 'online',
|
||||||
|
});
|
||||||
|
await refreshChannels();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
localStorage.removeItem('eifeldc_token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="splash">
|
||||||
|
<div class="splash-logo">
|
||||||
|
<svg viewBox="0 0 100 100" width="80" height="80">
|
||||||
|
<circle cx="50" cy="50" r="45" fill="var(--accent)" />
|
||||||
|
<text x="50" y="58" text-anchor="middle" fill="white" font-size="30" font-weight="bold">E</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p>Loading EifelDC...</p>
|
||||||
|
</div>
|
||||||
|
{:else if !loggedIn}
|
||||||
|
<LoginScreen on:login={() => (loggedIn = true)} />
|
||||||
|
{:else}
|
||||||
|
<div class="app-layout">
|
||||||
|
<ServerSidebar />
|
||||||
|
<ChannelSidebar />
|
||||||
|
<div class="main-content">
|
||||||
|
<ChatArea />
|
||||||
|
</div>
|
||||||
|
<MemberList />
|
||||||
|
</div>
|
||||||
|
<VoicePanel />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.splash {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-layout {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
595
client/src-ui/src/components/ChannelSidebar.svelte
Normal file
595
client/src-ui/src/components/ChannelSidebar.svelte
Normal file
@@ -0,0 +1,595 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { currentServer, channels, currentChannel, textChannels, voiceChannels, currentUser, voiceState, refreshChannels } from '../lib/store';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { getJoinedRooms, joinVoiceChannel, createRoom, joinRoom, leaveRoom, logout, setPresence, type VoiceStateInfo, type RoomInfo } from '../lib/api';
|
||||||
|
|
||||||
|
let showCreateRoom = false;
|
||||||
|
let showJoinRoom = false;
|
||||||
|
let newRoomName = '';
|
||||||
|
let newRoomTopic = '';
|
||||||
|
let joinRoomInput = '';
|
||||||
|
let createError = '';
|
||||||
|
let joinError = '';
|
||||||
|
let showSettings = false;
|
||||||
|
let presenceStatus: 'online' | 'idle' | 'dnd' | 'offline' = 'online';
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const rooms: RoomInfo[] = await getJoinedRooms();
|
||||||
|
channels.set(rooms.map((r: RoomInfo) => ({
|
||||||
|
id: r.room_id,
|
||||||
|
name: r.name || r.room_id,
|
||||||
|
type: 'text' as const,
|
||||||
|
topic: r.topic || null,
|
||||||
|
parentId: null,
|
||||||
|
})));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load rooms', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleCreateRoom() {
|
||||||
|
createError = '';
|
||||||
|
try {
|
||||||
|
await createRoom(newRoomName, newRoomTopic || undefined);
|
||||||
|
showCreateRoom = false;
|
||||||
|
newRoomName = '';
|
||||||
|
newRoomTopic = '';
|
||||||
|
await refreshChannels();
|
||||||
|
} catch (e: any) {
|
||||||
|
createError = e.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleJoinRoom() {
|
||||||
|
joinError = '';
|
||||||
|
try {
|
||||||
|
await joinRoom(joinRoomInput);
|
||||||
|
showJoinRoom = false;
|
||||||
|
joinRoomInput = '';
|
||||||
|
await refreshChannels();
|
||||||
|
} catch (e: any) {
|
||||||
|
joinError = e.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleJoinVoice(channel: any) {
|
||||||
|
try {
|
||||||
|
const result: VoiceStateInfo = await joinVoiceChannel(channel.id);
|
||||||
|
voiceState.set({
|
||||||
|
channelId: result.room_id,
|
||||||
|
muted: result.muted,
|
||||||
|
deafened: result.deafened,
|
||||||
|
streaming: result.streaming,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to join voice', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLeaveRoom(roomId: string) {
|
||||||
|
try {
|
||||||
|
await leaveRoom(roomId);
|
||||||
|
await refreshChannels();
|
||||||
|
if ($currentChannel?.id === roomId) {
|
||||||
|
currentChannel.set(null);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to leave room', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
try {
|
||||||
|
await logout();
|
||||||
|
window.location.reload();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to logout', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePresenceChange(status: string) {
|
||||||
|
try {
|
||||||
|
await setPresence(status);
|
||||||
|
presenceStatus = status as any;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to set presence', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractUsername(userId: string): string {
|
||||||
|
return userId.split(':')[0].replace('@', '');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="channel-sidebar">
|
||||||
|
<div class="server-header">
|
||||||
|
<span class="server-name">{$currentServer?.name || 'EifelDC'}</span>
|
||||||
|
<button class="btn-icon" on:click={() => showSettings = !showSettings}>
|
||||||
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M7 10l5 5 5-5z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showSettings}
|
||||||
|
<div class="settings-popup">
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="settings-label">Status</div>
|
||||||
|
<button class="status-option" on:click={() => handlePresenceChange('online')}>
|
||||||
|
<span class="status-indicator" style="background: var(--online)"></span> Online
|
||||||
|
</button>
|
||||||
|
<button class="status-option" on:click={() => handlePresenceChange('away')}>
|
||||||
|
<span class="status-indicator" style="background: var(--idle)"></span> Abwesend
|
||||||
|
</button>
|
||||||
|
<button class="status-option" on:click={() => handlePresenceChange('unavailable')}>
|
||||||
|
<span class="status-indicator" style="background: var(--dnd)"></span> Nicht stören
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<hr />
|
||||||
|
<button class="settings-option" on:click={handleLogout}>Abmelden</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="channel-list">
|
||||||
|
<div class="channel-category">
|
||||||
|
<div class="category-header" on:click={() => showCreateRoom = !showCreateRoom}>
|
||||||
|
<svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor"><path d="M7 10l5 5 5-5z"/></svg>
|
||||||
|
<span>Textkanäle</span>
|
||||||
|
<span class="category-add" on:click|stopPropagation={() => { showCreateRoom = true; showJoinRoom = false; }}>+</span>
|
||||||
|
</div>
|
||||||
|
{#each $textChannels as channel}
|
||||||
|
<button
|
||||||
|
class="channel-item"
|
||||||
|
class:active={$currentChannel?.id === channel.id}
|
||||||
|
on:click={() => currentChannel.set(channel)}
|
||||||
|
on:contextmenu|preventDefault={() => handleLeaveRoom(channel.id)}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor" class="channel-icon">
|
||||||
|
<path d="M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.89044C10.2015 3 10.4371 3.28107 10.3827 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8904C18.2015 3 18.4371 3.28107 18.3827 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.755C20.0656 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8696C13.5585 21 13.3229 20.7189 13.3773 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41045 9L8.35045 15H14.3504L15.4104 9H9.41045Z"/>
|
||||||
|
</svg>
|
||||||
|
<span>{channel.name}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="channel-category">
|
||||||
|
<div class="category-header">
|
||||||
|
<svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor"><path d="M7 10l5 5 5-5z"/></svg>
|
||||||
|
<span>Sprachkanäle</span>
|
||||||
|
</div>
|
||||||
|
{#each $voiceChannels as channel}
|
||||||
|
<button
|
||||||
|
class="channel-item voice"
|
||||||
|
class:active={$currentChannel?.id === channel.id}
|
||||||
|
on:click={() => handleJoinVoice(channel)}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor" class="channel-icon">
|
||||||
|
<path d="M12 3c-4.97 0-9 4.03-9 9v7c0 1.66 1.34 3 3 3h3v-8H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-4v8h3c1.66 0 3-1.34 3-3v-7c0-4.97-4.03-9-9-9z"/>
|
||||||
|
</svg>
|
||||||
|
<span>{channel.name}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="add-channel-btn" on:click={() => { showJoinRoom = true; showCreateRoom = false; }}>
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z"/></svg>
|
||||||
|
Raum beitreten
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showCreateRoom}
|
||||||
|
<div class="modal-overlay" on:click={() => showCreateRoom = false}>
|
||||||
|
<div class="modal" on:click|stopPropagation>
|
||||||
|
<h3>Raum erstellen</h3>
|
||||||
|
{#if createError}
|
||||||
|
<div class="modal-error">{createError}</div>
|
||||||
|
{/if}
|
||||||
|
<div class="modal-field">
|
||||||
|
<label>Name</label>
|
||||||
|
<input type="text" bind:value={newRoomName} placeholder="Raumname" />
|
||||||
|
</div>
|
||||||
|
<div class="modal-field">
|
||||||
|
<label>Thema (optional)</label>
|
||||||
|
<input type="text" bind:value={newRoomTopic} placeholder="Thema" />
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn-secondary" on:click={() => showCreateRoom = false}>Abbrechen</button>
|
||||||
|
<button class="btn-primary" on:click={handleCreateRoom} disabled={!newRoomName}>Erstellen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showJoinRoom}
|
||||||
|
<div class="modal-overlay" on:click={() => showJoinRoom = false}>
|
||||||
|
<div class="modal" on:click|stopPropagation>
|
||||||
|
<h3>Raum beitreten</h3>
|
||||||
|
{#if joinError}
|
||||||
|
<div class="modal-error">{joinError}</div>
|
||||||
|
{/if}
|
||||||
|
<div class="modal-field">
|
||||||
|
<label>Raum-ID oder Alias</label>
|
||||||
|
<input type="text" bind:value={joinRoomInput} placeholder="#raum:matrix.org" />
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn-secondary" on:click={() => showJoinRoom = false}>Abbrechen</button>
|
||||||
|
<button class="btn-primary" on:click={handleJoinRoom} disabled={!joinRoomInput}>Beitreten</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="user-panel">
|
||||||
|
<div class="user-info">
|
||||||
|
<div class="avatar-small">{$currentUser?.username?.charAt(0).toUpperCase() || '?'}</div>
|
||||||
|
<div class="user-details">
|
||||||
|
<span class="username">{$currentUser?.username || 'Benutzer'}</span>
|
||||||
|
<span class="status-text">{presenceStatus === 'online' ? 'Online' : presenceStatus === 'idle' ? 'Abwesend' : presenceStatus === 'dnd' ? 'Nicht stören' : 'Offline'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="user-actions">
|
||||||
|
<button class="btn-icon" title="Mikrofon" on:click={() => { if ($voiceState.channelId) voiceState.update(s => ({ ...s, muted: !s.muted })); }}>
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5z"/><path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="btn-icon" title="Kopfhörer" on:click={() => { if ($voiceState.channelId) voiceState.update(s => ({ ...s, deafened: !s.deafened })); }}>
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M12 3c-4.97 0-9 4.03-9 9v7c0 1.66 1.34 3 3 3h3v-8H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-4v8h3c1.66 0 3-1.34 3-3v-7c0-4.97-4.03-9-9-9z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="btn-icon" title="Einstellungen" on:click={() => showSettings = !showSettings}>⚙</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.channel-sidebar {
|
||||||
|
width: 240px;
|
||||||
|
min-width: 240px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-header {
|
||||||
|
height: 48px;
|
||||||
|
padding: 0 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-bottom: 1px solid var(--bg-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-category {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-header:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-add {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-add:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 16px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-item:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-item.active {
|
||||||
|
background: var(--bg-active);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-channel-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--success);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-channel-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-panel {
|
||||||
|
height: 52px;
|
||||||
|
padding: 0 8px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-small {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-popup {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 52px;
|
||||||
|
left: 8px;
|
||||||
|
right: 8px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 8px;
|
||||||
|
z-index: 10;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-option:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-option {
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--danger);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-option:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0,0,0,0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 24px;
|
||||||
|
width: 400px;
|
||||||
|
max-width: 90vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal h3 {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-field {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-field label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-field input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-field input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-error {
|
||||||
|
background: rgba(237, 66, 69, 0.15);
|
||||||
|
color: var(--danger);
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
284
client/src-ui/src/components/ChatArea.svelte
Normal file
284
client/src-ui/src/components/ChatArea.svelte
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { messages, currentChannel, members } from '../lib/store';
|
||||||
|
import { getRoomMessages, sendMessage, getRoomMembers } from '../lib/api';
|
||||||
|
import { onMount, afterUpdate } from 'svelte';
|
||||||
|
|
||||||
|
let inputText = '';
|
||||||
|
let chatContainer: HTMLElement;
|
||||||
|
let loading = false;
|
||||||
|
|
||||||
|
async function loadMessages() {
|
||||||
|
if (!$currentChannel) return;
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
const roomId = $currentChannel.id;
|
||||||
|
const msgs = await getRoomMessages(roomId, 50);
|
||||||
|
messages.set(msgs.map(m => ({
|
||||||
|
id: m.event_id,
|
||||||
|
sender: m.sender,
|
||||||
|
content: m.body,
|
||||||
|
timestamp: m.timestamp,
|
||||||
|
replyTo: m.reply_to,
|
||||||
|
isBot: false,
|
||||||
|
reactions: {},
|
||||||
|
})));
|
||||||
|
const memberIds = await getRoomMembers(roomId);
|
||||||
|
members.set(memberIds.map(id => ({
|
||||||
|
id,
|
||||||
|
username: id.split(':')[0].replace('@', ''),
|
||||||
|
avatarUrl: null,
|
||||||
|
status: 'online' as const,
|
||||||
|
roles: [],
|
||||||
|
})));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load messages', e);
|
||||||
|
}
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$: if ($currentChannel) loadMessages();
|
||||||
|
|
||||||
|
async function handleSend() {
|
||||||
|
if (!inputText.trim() || !$currentChannel) return;
|
||||||
|
const text = inputText.trim();
|
||||||
|
inputText = '';
|
||||||
|
try {
|
||||||
|
await sendMessage($currentChannel.id, text);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to send message', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(ts: number): string {
|
||||||
|
return new Date(ts).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractUsername(userId: string): string {
|
||||||
|
return userId.split(':')[0].replace('@', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
afterUpdate(() => {
|
||||||
|
if (chatContainer) chatContainer.scrollTop = chatContainer.scrollHeight;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="chat-area">
|
||||||
|
<div class="chat-header">
|
||||||
|
<span class="channel-hash">#</span>
|
||||||
|
<span class="channel-name">{$currentChannel?.name || 'Kanal auswählen'}</span>
|
||||||
|
{#if $currentChannel?.topic}
|
||||||
|
<span class="divider">|</span>
|
||||||
|
<span class="channel-topic">{$currentChannel.topic}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="messages" bind:this={chatContainer}>
|
||||||
|
{#if loading}
|
||||||
|
<div class="loading">Nachrichten werden geladen...</div>
|
||||||
|
{:else if $messages.length === 0}
|
||||||
|
<div class="empty">Noch keine Nachrichten. Sei der Erste!</div>
|
||||||
|
{:else}
|
||||||
|
{#each $messages as msg (msg.id)}
|
||||||
|
<div class="message">
|
||||||
|
<div class="message-avatar">
|
||||||
|
<div class="avatar">{extractUsername(msg.sender).charAt(0).toUpperCase()}</div>
|
||||||
|
</div>
|
||||||
|
<div class="message-content">
|
||||||
|
<div class="message-header">
|
||||||
|
<span class="message-author">{extractUsername(msg.sender)}</span>
|
||||||
|
<span class="message-time">{formatTime(msg.timestamp)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="message-body">{msg.content}</div>
|
||||||
|
{#if Object.keys(msg.reactions).length > 0}
|
||||||
|
<div class="reactions">
|
||||||
|
{#each Object.entries(msg.reactions) as [emoji, users]}
|
||||||
|
<span class="reaction">{emoji} {users.length}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-area">
|
||||||
|
{#if $currentChannel}
|
||||||
|
<div class="input-container">
|
||||||
|
<button class="input-addon" title="Datei anhängen">📎</button>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={inputText}
|
||||||
|
placeholder="Nachricht in #{$currentChannel?.name} senden"
|
||||||
|
on:keydown={(e) => { if (e.key === 'Enter' && !e.shiftKey) handleSend(); }}
|
||||||
|
/>
|
||||||
|
<button class="input-addon" title="Emoji">😀</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="no-channel">Wähle einen Kanal aus</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.chat-area {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header {
|
||||||
|
height: 48px;
|
||||||
|
padding: 0 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-hash {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-name {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
color: var(--border);
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-topic {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 4px 0;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-avatar .avatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-author {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-time {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-body {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reactions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction {
|
||||||
|
background: var(--bg-input);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-area {
|
||||||
|
padding: 0 16px 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-container input {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
padding: 8px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-addon {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-addon:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading, .empty, .no-channel {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
201
client/src-ui/src/components/LoginScreen.svelte
Normal file
201
client/src-ui/src/components/LoginScreen.svelte
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import { login, register } from '../lib/api';
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
let mode = 'login';
|
||||||
|
let homeserver = 'https://matrix.org';
|
||||||
|
let username = '';
|
||||||
|
let password = '';
|
||||||
|
let confirmPassword = '';
|
||||||
|
let error = '';
|
||||||
|
let loading = false;
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
error = '';
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
if (mode === 'login') {
|
||||||
|
const result = await login(homeserver, username, password);
|
||||||
|
if (result.success) {
|
||||||
|
dispatch('login', { userId: result.user_id });
|
||||||
|
} else {
|
||||||
|
error = result.error || 'Login fehlgeschlagen';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
error = 'Passwörter stimmen nicht überein';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await register(homeserver, username, password);
|
||||||
|
if (result.success) {
|
||||||
|
dispatch('login', { userId: result.user_id });
|
||||||
|
} else {
|
||||||
|
error = result.error || 'Registrierung fehlgeschlagen';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
error = e.toString();
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="login-container">
|
||||||
|
<div class="login-box">
|
||||||
|
<div class="logo">
|
||||||
|
<svg viewBox="0 0 100 100" width="72" height="72">
|
||||||
|
<circle cx="50" cy="50" r="45" fill="var(--accent)" />
|
||||||
|
<text x="50" y="58" text-anchor="middle" fill="white" font-size="30" font-weight="bold">E</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1>{mode === 'login' ? 'Willkommen zurück' : 'Account erstellen'}</h1>
|
||||||
|
<p class="subtitle">EifelDC — Matrix Client</p>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="error">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form on:submit|preventDefault={handleSubmit}>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="homeserver">Homeserver</label>
|
||||||
|
<input id="homeserver" type="url" bind:value={homeserver} placeholder="https://matrix.org" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Benutzername</label>
|
||||||
|
<input id="username" type="text" bind:value={username} placeholder="Nutzername" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Passwort</label>
|
||||||
|
<input id="password" type="password" bind:value={password} placeholder="********" />
|
||||||
|
</div>
|
||||||
|
{#if mode === 'register'}
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="confirm-password">Passwort bestätigen</label>
|
||||||
|
<input id="confirm-password" type="password" bind:value={confirmPassword} placeholder="********" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<button type="submit" class="btn-primary" disabled={loading}>
|
||||||
|
{loading ? '...' : mode === 'login' ? 'Anmelden' : 'Registrieren'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="switch-mode">
|
||||||
|
{#if mode === 'login'}
|
||||||
|
Noch kein Account? <button on:click={() => { mode = 'register'; error = ''; }}>Registrieren</button>
|
||||||
|
{:else}
|
||||||
|
Bereits registriert? <button on:click={() => { mode = 'login'; error = ''; }}>Anmelden</button>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.login-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
width: 420px;
|
||||||
|
max-width: 90vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background: rgba(237, 66, 69, 0.15);
|
||||||
|
color: var(--danger);
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.7rem;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1rem;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-mode {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 1rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-mode button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
148
client/src-ui/src/components/MemberList.svelte
Normal file
148
client/src-ui/src/components/MemberList.svelte
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { sortedMembers, currentChannel } from '../lib/store';
|
||||||
|
|
||||||
|
function statusColor(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'online': return 'var(--online)';
|
||||||
|
case 'idle': return 'var(--idle)';
|
||||||
|
case 'dnd': return 'var(--dnd)';
|
||||||
|
default: return 'var(--offline)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'online': return 'Online';
|
||||||
|
case 'idle': return 'Abwesend';
|
||||||
|
case 'dnd': return 'Nicht stören';
|
||||||
|
default: return 'Offline';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractUsername(id: string): string {
|
||||||
|
return id.split(':')[0].replace('@', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupByStatus(members: any[]): Record<string, any[]> {
|
||||||
|
const groups: Record<string, any[]> = { online: [], idle: [], dnd: [], offline: [] };
|
||||||
|
for (const m of members) {
|
||||||
|
(groups[m.status] || groups.offline).push(m);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
$: grouped = groupByStatus($sortedMembers);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="member-list">
|
||||||
|
{#each [
|
||||||
|
{ key: 'online', label: 'Online' },
|
||||||
|
{ key: 'idle', label: 'Abwesend' },
|
||||||
|
{ key: 'dnd', label: 'Nicht stören' },
|
||||||
|
{ key: 'offline', label: 'Offline' }
|
||||||
|
] as group}
|
||||||
|
{#if grouped[group.key]?.length > 0}
|
||||||
|
<div class="member-group">
|
||||||
|
<div class="group-header">
|
||||||
|
{group.label} — {grouped[group.key].length}
|
||||||
|
</div>
|
||||||
|
{#each grouped[group.key] as member}
|
||||||
|
<div class="member-item">
|
||||||
|
<div class="member-avatar">
|
||||||
|
<div class="avatar-circle">
|
||||||
|
{extractUsername(member.id).charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div class="status-dot" style="background: {statusColor(member.status)}"></div>
|
||||||
|
</div>
|
||||||
|
<div class="member-info">
|
||||||
|
<span class="member-name">{extractUsername(member.id)}</span>
|
||||||
|
{#if member.status !== 'offline'}
|
||||||
|
<span class="member-status">{statusLabel(member.status)}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.member-list {
|
||||||
|
width: 240px;
|
||||||
|
min-width: 240px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 8px 8px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-group {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-header {
|
||||||
|
padding: 8px 8px 4px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-item:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-avatar {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-circle {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -1px;
|
||||||
|
right: -1px;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-name {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-status {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
139
client/src-ui/src/components/ServerSidebar.svelte
Normal file
139
client/src-ui/src/components/ServerSidebar.svelte
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { servers, currentServer } from '../lib/store';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
let showAddServer = false;
|
||||||
|
|
||||||
|
function selectServer(server: any) {
|
||||||
|
currentServer.set(server);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="server-sidebar">
|
||||||
|
<div class="server-list">
|
||||||
|
<button class="server-icon home" on:click={() => currentServer.set(null)}>
|
||||||
|
<svg viewBox="0 0 24 24" width="28" height="28" fill="currentColor">
|
||||||
|
<path d="M12 2L2 12h3v8h6v-6h2v6h6v-8h3L12 2z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="separator"></div>
|
||||||
|
|
||||||
|
{#each $servers as server}
|
||||||
|
<button
|
||||||
|
class="server-icon"
|
||||||
|
class:active={$currentServer?.id === server.id}
|
||||||
|
on:click={() => selectServer(server)}
|
||||||
|
title={server.name}
|
||||||
|
>
|
||||||
|
{#if server.iconUrl}
|
||||||
|
<img src={server.iconUrl} alt={server.name} />
|
||||||
|
{:else}
|
||||||
|
<span class="server-initial">{server.name.charAt(0).toUpperCase()}</span>
|
||||||
|
{/if}
|
||||||
|
<div class="pill"></div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<button class="server-icon add" on:click={() => showAddServer = !showAddServer} title="Server beitreten">
|
||||||
|
<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
|
||||||
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.server-sidebar {
|
||||||
|
width: 72px;
|
||||||
|
min-width: 72px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator {
|
||||||
|
width: 32px;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 1px;
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-radius 0.2s, background 0.2s;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-icon:hover {
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-icon.active {
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-icon img {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-initial {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add:hover {
|
||||||
|
background: var(--success);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
position: absolute;
|
||||||
|
left: -4px;
|
||||||
|
width: 4px;
|
||||||
|
height: 0;
|
||||||
|
background: white;
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
transition: height 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-icon.active .pill,
|
||||||
|
.server-icon:hover .pill {
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
130
client/src-ui/src/components/VoicePanel.svelte
Normal file
130
client/src-ui/src/components/VoicePanel.svelte
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { voiceState } from '../lib/store';
|
||||||
|
import { leaveVoiceChannel, toggleMute, toggleDeafen } from '../lib/api';
|
||||||
|
|
||||||
|
async function handleLeave() {
|
||||||
|
if (!$voiceState.channelId) return;
|
||||||
|
try {
|
||||||
|
await leaveVoiceChannel($voiceState.channelId);
|
||||||
|
voiceState.set({ channelId: null, muted: false, deafened: false, streaming: false });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to leave voice', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMute() {
|
||||||
|
if (!$voiceState.channelId) return;
|
||||||
|
try {
|
||||||
|
const newMuted = await toggleMute($voiceState.channelId);
|
||||||
|
voiceState.update(s => ({ ...s, muted: newMuted }));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to toggle mute', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeafen() {
|
||||||
|
if (!$voiceState.channelId) return;
|
||||||
|
try {
|
||||||
|
const newDeaf = await toggleDeafen($voiceState.channelId);
|
||||||
|
voiceState.update(s => ({ ...s, deafened: newDeaf }));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to toggle deafen', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if $voiceState.channelId}
|
||||||
|
<div class="voice-panel">
|
||||||
|
<div class="voice-info">
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16" fill="var(--success)">
|
||||||
|
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z"/>
|
||||||
|
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="voice-label">Sprachverbunden</span>
|
||||||
|
</div>
|
||||||
|
<div class="voice-actions">
|
||||||
|
<button class="voice-btn" class:active={$voiceState.muted} on:click={handleMute} title="Mikrofon stumm">
|
||||||
|
{#if $voiceState.muted}
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M19 11h-1.7c0 .74-.16 1.43-.43 2.05l1.23 1.23c.56-.98.9-2.09.9-3.28zm-4.6.6L16 13.6V11c0-1.66-1.34-3-3-3-.36 0-.71.07-1.03.19l1.43 1.41zm3.6-6.8L3.86 16.14 5.27 17.55l2.62-2.62c.82.66 1.83 1.07 2.93 1.07h.18v3.08c-3.39.49-6 3.39-6 6.92h2c0-2.76 2.24-5 5-5h1v-4.77l5.27 5.27 1.41-1.41z"/></svg>
|
||||||
|
{:else}
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z"/><path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/></svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
<button class="voice-btn" class:active={$voiceState.deafened} on:click={handleDeafen} title="Kopfhörer stumm">
|
||||||
|
{#if $voiceState.deafened}
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M3.63 3.63a.996.996 0 000 1.41L7.29 8.7 7 9H4c-.55 0-1 .45-1 1v4c0 .55.45 1 1 1h3l4.29 4.29c.63.63 1.71.18 1.71-.71v-3.17l5.67 5.67a.996.996 0 101.41-1.41L5.04 3.63a.996.996 0 00-1.41 0zM19 12c0 .82-.33 1.56-.85 2.12l1.42 1.42A4.978 4.978 0 0021 12c0-2.12-1.31-3.93-3.17-4.68L17 8.62C18.23 9.24 19 10.53 19 12zm-4-6c0-2.76-2.24-5-5-5-.71 0-1.38.15-2 .42l1.46 1.46C9.96 2.54 10.96 2 12 2c1.66 0 3 1.34 3 3v4.17l2 2V6z"/></svg>
|
||||||
|
{:else}
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M12 3c-4.97 0-9 4.03-9 9v7c0 1.66 1.34 3 3 3h3v-8H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-4v8h3c1.66 0 3-1.34 3-3v-7c0-4.97-4.03-9-9-9z"/></svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
<button class="voice-btn disconnect" on:click={handleLeave} title="Verbindung trennen">
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M12 9c-1.6 0-3.15.25-4.6.72v3.1c0 .39-.23.74-.56.9-.98.49-1.87 1.12-2.66 1.85-.18.18-.43.28-.7.28-.28 0-.53-.11-.71-.29L.29 13.08a.956.956 0 01-.29-.7c0-.28.11-.53.29-.71C3.09 8.27 7.33 6 12 6s8.91 2.27 11.71 5.67c.18.18.29.43.29.71 0 .28-.11.53-.29.71l-2.48 2.48c-.18.18-.43.29-.71.29-.27 0-.52-.11-.7-.28a11.27 11.27 0 00-2.67-1.85.996.996 0 01-.56-.9v-3.1C15.15 9.25 13.6 9 12 9z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.voice-panel {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 312px;
|
||||||
|
right: 240px;
|
||||||
|
height: 48px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--success);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-btn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: none;
|
||||||
|
background: var(--bg-input);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-btn.active {
|
||||||
|
background: var(--danger);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-btn.disconnect {
|
||||||
|
background: var(--danger);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-btn.disconnect:hover {
|
||||||
|
background: #c03033;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
253
client/src-ui/src/lib/api.ts
Normal file
253
client/src-ui/src/lib/api.ts
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
export interface LoginResult {
|
||||||
|
success: boolean;
|
||||||
|
user_id: string;
|
||||||
|
error: string | null;
|
||||||
|
token?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoomInfo {
|
||||||
|
room_id: string;
|
||||||
|
name: string;
|
||||||
|
avatar_url: string | null;
|
||||||
|
is_encrypted: boolean;
|
||||||
|
member_count: number;
|
||||||
|
topic: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageInfo {
|
||||||
|
event_id: string;
|
||||||
|
sender: string;
|
||||||
|
body: string;
|
||||||
|
timestamp: number;
|
||||||
|
reply_to: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoleInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
permissions: string[];
|
||||||
|
position: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PermissionsInfo {
|
||||||
|
can_send_messages: boolean;
|
||||||
|
can_delete_messages: boolean;
|
||||||
|
can_manage_channels: boolean;
|
||||||
|
can_manage_roles: boolean;
|
||||||
|
can_kick: boolean;
|
||||||
|
can_ban: boolean;
|
||||||
|
can_manage_emoji: boolean;
|
||||||
|
can_manage_threads: boolean;
|
||||||
|
can_voice_connect: boolean;
|
||||||
|
can_voice_stream: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoiceStateInfo {
|
||||||
|
room_id: string;
|
||||||
|
muted: boolean;
|
||||||
|
deafened: boolean;
|
||||||
|
streaming: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PresenceInfo {
|
||||||
|
user_id: string;
|
||||||
|
status: string;
|
||||||
|
status_msg: string | null;
|
||||||
|
last_active: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTauri(): boolean {
|
||||||
|
return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getApiBase(): string {
|
||||||
|
return window.location.origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToken(): string | null {
|
||||||
|
return localStorage.getItem('eifeldc_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setToken(token: string) {
|
||||||
|
localStorage.setItem('eifeldc_token', token);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearToken() {
|
||||||
|
localStorage.removeItem('eifeldc_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tauriInvoke(cmd: string, args: Record<string, unknown>): Promise<any> {
|
||||||
|
const { invoke } = await import('@tauri-apps/api/tauri');
|
||||||
|
return invoke(cmd, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function httpGet(path: string): Promise<any> {
|
||||||
|
const res = await fetch(`${getApiBase()}${path}`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${getToken()}` },
|
||||||
|
});
|
||||||
|
if (res.status === 401) { clearToken(); throw new Error('Unauthorized'); }
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function httpPost(path: string, body?: unknown): Promise<any> {
|
||||||
|
const res = await fetch(`${getApiBase()}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${getToken()}`,
|
||||||
|
},
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
if (res.status === 401) { clearToken(); throw new Error('Unauthorized'); }
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(homeserver: string, username: string, password: string): Promise<LoginResult> {
|
||||||
|
if (isTauri()) {
|
||||||
|
return tauriInvoke('login', { homeserver, username, password });
|
||||||
|
}
|
||||||
|
const result = await httpPost('/api/login', { homeserver, username, password });
|
||||||
|
if (result.token) { setToken(result.token); }
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout(): Promise<boolean> {
|
||||||
|
if (isTauri()) {
|
||||||
|
return tauriInvoke('logout', {});
|
||||||
|
}
|
||||||
|
clearToken();
|
||||||
|
return httpPost('/api/logout');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function register(homeserver: string, username: string, password: string): Promise<LoginResult> {
|
||||||
|
if (isTauri()) {
|
||||||
|
return tauriInvoke('register', { homeserver, username, password });
|
||||||
|
}
|
||||||
|
const result = await httpPost('/api/register', { homeserver, username, password });
|
||||||
|
if (result.token) { setToken(result.token); }
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getJoinedRooms(): Promise<RoomInfo[]> {
|
||||||
|
if (isTauri()) {
|
||||||
|
return tauriInvoke('get_joined_rooms', {});
|
||||||
|
}
|
||||||
|
return httpGet('/api/rooms');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRoomMessages(roomId: string, limit: number, from?: string): Promise<MessageInfo[]> {
|
||||||
|
if (isTauri()) {
|
||||||
|
return tauriInvoke('get_room_messages', { roomId, limit, from });
|
||||||
|
}
|
||||||
|
let url = `/api/rooms/${encodeURIComponent(roomId)}/messages?limit=${limit}`;
|
||||||
|
if (from) url += `&from=${encodeURIComponent(from)}`;
|
||||||
|
return httpGet(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendMessage(roomId: string, message: string): Promise<string> {
|
||||||
|
if (isTauri()) {
|
||||||
|
return tauriInvoke('send_message', { roomId, message });
|
||||||
|
}
|
||||||
|
return httpPost(`/api/rooms/${encodeURIComponent(roomId)}/send`, { message });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRoom(name: string, topic?: string, visibility?: string): Promise<string> {
|
||||||
|
if (isTauri()) {
|
||||||
|
return tauriInvoke('create_room', { name, topic, visibility: visibility || 'private' });
|
||||||
|
}
|
||||||
|
const result = await httpPost('/api/rooms/create', { name, topic, visibility: visibility || 'private' });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function joinRoom(roomIdOrAlias: string): Promise<string> {
|
||||||
|
if (isTauri()) {
|
||||||
|
return tauriInvoke('join_room', { roomIdOrAlias });
|
||||||
|
}
|
||||||
|
const result = await httpPost('/api/rooms/join', { room_id_or_alias: roomIdOrAlias });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function leaveRoom(roomId: string): Promise<boolean> {
|
||||||
|
if (isTauri()) {
|
||||||
|
return tauriInvoke('leave_room', { roomId });
|
||||||
|
}
|
||||||
|
return httpPost(`/api/rooms/${encodeURIComponent(roomId)}/leave`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRoomMembers(roomId: string): Promise<string[]> {
|
||||||
|
if (isTauri()) {
|
||||||
|
return tauriInvoke('get_room_members', { roomId });
|
||||||
|
}
|
||||||
|
return httpGet(`/api/rooms/${encodeURIComponent(roomId)}/members`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setPresence(status: string, statusMsg?: string): Promise<boolean> {
|
||||||
|
if (isTauri()) {
|
||||||
|
return tauriInvoke('set_presence', { status, statusMsg });
|
||||||
|
}
|
||||||
|
return httpPost('/api/presence/set', { status, status_msg: statusMsg });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPresence(userId: string): Promise<PresenceInfo> {
|
||||||
|
if (isTauri()) {
|
||||||
|
return tauriInvoke('get_presence', { userId });
|
||||||
|
}
|
||||||
|
return httpGet(`/api/presence/${encodeURIComponent(userId)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function joinVoiceChannel(roomId: string): Promise<VoiceStateInfo> {
|
||||||
|
if (isTauri()) {
|
||||||
|
return tauriInvoke('join_voice_channel', { roomId });
|
||||||
|
}
|
||||||
|
return httpPost('/api/voice/join', { room_id: roomId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function leaveVoiceChannel(roomId: string): Promise<boolean> {
|
||||||
|
if (isTauri()) {
|
||||||
|
return tauriInvoke('leave_voice_channel', { roomId });
|
||||||
|
}
|
||||||
|
return httpPost('/api/voice/leave', { room_id: roomId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleMute(roomId: string): Promise<boolean> {
|
||||||
|
if (isTauri()) {
|
||||||
|
return tauriInvoke('toggle_mute', { roomId });
|
||||||
|
}
|
||||||
|
return httpPost('/api/voice/toggle-mute', { room_id: roomId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleDeafen(roomId: string): Promise<boolean> {
|
||||||
|
if (isTauri()) {
|
||||||
|
return tauriInvoke('toggle_deafen', { roomId });
|
||||||
|
}
|
||||||
|
return httpPost('/api/voice/toggle-deafen', { room_id: roomId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRoles(roomId: string): Promise<RoleInfo[]> {
|
||||||
|
if (isTauri()) {
|
||||||
|
return tauriInvoke('get_roles', { roomId });
|
||||||
|
}
|
||||||
|
return httpGet(`/api/rooms/${encodeURIComponent(roomId)}/roles`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assignRole(roomId: string, userId: string, roleId: string): Promise<boolean> {
|
||||||
|
if (isTauri()) {
|
||||||
|
return tauriInvoke('assign_role', { roomId, userId, roleId });
|
||||||
|
}
|
||||||
|
return httpPost(`/api/rooms/${encodeURIComponent(roomId)}/roles/assign`, { user_id: userId, role_id: roleId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeRole(roomId: string, userId: string, roleId: string): Promise<boolean> {
|
||||||
|
if (isTauri()) {
|
||||||
|
return tauriInvoke('remove_role', { roomId, userId, roleId });
|
||||||
|
}
|
||||||
|
return httpPost(`/api/rooms/${encodeURIComponent(roomId)}/roles/remove`, { user_id: userId, role_id: roleId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPermissions(roomId: string, userId: string): Promise<PermissionsInfo> {
|
||||||
|
if (isTauri()) {
|
||||||
|
return tauriInvoke('get_permissions', { roomId, userId });
|
||||||
|
}
|
||||||
|
return httpGet(`/api/rooms/${encodeURIComponent(roomId)}/permissions/${encodeURIComponent(userId)}`);
|
||||||
|
}
|
||||||
111
client/src-ui/src/lib/store.ts
Normal file
111
client/src-ui/src/lib/store.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { writable, derived } from 'svelte/store';
|
||||||
|
import { invoke } from '@tauri-apps/api/tauri';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
status: 'online' | 'idle' | 'dnd' | 'offline';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Room {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
unreadCount: number;
|
||||||
|
isVoice: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Channel {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: 'text' | 'voice' | 'announcement';
|
||||||
|
topic: string | null;
|
||||||
|
parentId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
id: string;
|
||||||
|
sender: string;
|
||||||
|
content: string;
|
||||||
|
timestamp: number;
|
||||||
|
replyTo: string | null;
|
||||||
|
isBot: boolean;
|
||||||
|
reactions: Record<string, string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Server {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
iconUrl: string | null;
|
||||||
|
roles: Role[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Role {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
permissions: string[];
|
||||||
|
position: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Member {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
status: 'online' | 'idle' | 'dnd' | 'offline';
|
||||||
|
roles: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const currentUser = writable<User | null>(null);
|
||||||
|
export const currentServer = writable<Server | null>(null);
|
||||||
|
export const currentChannel = writable<Channel | null>(null);
|
||||||
|
export const servers = writable<Server[]>([]);
|
||||||
|
export const channels = writable<Channel[]>([]);
|
||||||
|
export const messages = writable<Message[]>([]);
|
||||||
|
export const members = writable<Member[]>([]);
|
||||||
|
export const voiceState = writable<{
|
||||||
|
channelId: string | null;
|
||||||
|
muted: boolean;
|
||||||
|
deafened: boolean;
|
||||||
|
streaming: boolean;
|
||||||
|
}>({
|
||||||
|
channelId: null,
|
||||||
|
muted: false,
|
||||||
|
deafened: false,
|
||||||
|
streaming: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sortedMembers = derived(members, ($members) => {
|
||||||
|
return [...$members].sort((a, b) => {
|
||||||
|
const statusOrder = { online: 0, idle: 1, dnd: 2, offline: 3 };
|
||||||
|
return statusOrder[a.status] - statusOrder[b.status];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export const textChannels = derived(channels, ($channels) =>
|
||||||
|
$channels.filter((c) => c.type === 'text')
|
||||||
|
);
|
||||||
|
|
||||||
|
export const voiceChannels = derived(channels, ($channels) =>
|
||||||
|
$channels.filter((c) => c.type === 'voice')
|
||||||
|
);
|
||||||
|
|
||||||
|
export async function getJoinedRooms(): Promise<import('./api').RoomInfo[]> {
|
||||||
|
return invoke('get_joined_rooms');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshChannels() {
|
||||||
|
try {
|
||||||
|
const rooms = await getJoinedRooms();
|
||||||
|
channels.set(rooms.map(r => ({
|
||||||
|
id: r.room_id,
|
||||||
|
name: r.name || r.room_id,
|
||||||
|
type: 'text' as const,
|
||||||
|
topic: null,
|
||||||
|
parentId: null,
|
||||||
|
})));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to refresh channels', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
6
client/src-ui/src/main.ts
Normal file
6
client/src-ui/src/main.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import App from './App.svelte';
|
||||||
|
import './styles/global.css';
|
||||||
|
|
||||||
|
const app = new App({ target: document.getElementById('app')! });
|
||||||
|
|
||||||
|
export default app;
|
||||||
81
client/src-ui/src/styles/global.css
Normal file
81
client/src-ui/src/styles/global.css
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
:root {
|
||||||
|
--bg-primary: #1a1a2e;
|
||||||
|
--bg-secondary: #16213e;
|
||||||
|
--bg-tertiary: #0f3460;
|
||||||
|
--bg-channel: #1e2a4a;
|
||||||
|
--bg-chat: #1a1a2e;
|
||||||
|
--bg-input: #253254;
|
||||||
|
--bg-hover: #2a3a5c;
|
||||||
|
--bg-active: #3a4f7a;
|
||||||
|
--text-primary: #e0e0e0;
|
||||||
|
--text-secondary: #8e8e8e;
|
||||||
|
--text-muted: #6a6a6a;
|
||||||
|
--accent: #5865f2;
|
||||||
|
--accent-hover: #4752c4;
|
||||||
|
--success: #57f287;
|
||||||
|
--warning: #fee75c;
|
||||||
|
--danger: #ed4245;
|
||||||
|
--online: #3ba55c;
|
||||||
|
--idle: #faa61a;
|
||||||
|
--dnd: #ed4245;
|
||||||
|
--offline: #747f8d;
|
||||||
|
--border: #2a3a5c;
|
||||||
|
--scrollbar: #2a3a5c;
|
||||||
|
--scrollbar-hover: #3a4f7a;
|
||||||
|
--radius-sm: 4px;
|
||||||
|
--radius-md: 8px;
|
||||||
|
--radius-lg: 16px;
|
||||||
|
--font-primary: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-primary);
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--scrollbar);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--scrollbar-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
input, textarea, button {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
16
client/src-ui/tsconfig.json
Normal file
16
client/src-ui/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||||
|
"strict": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
29
client/src-ui/vite.config.ts
Normal file
29
client/src-ui/vite.config.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import sveltePreprocess from 'svelte-preprocess';
|
||||||
|
|
||||||
|
const host = process.env.TAURI_DEV_HOST;
|
||||||
|
const isTauri = !!process.env.TAURI_DEV_HOST || !!process.env.TAURI_ENV_PLATFORM;
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [svelte({
|
||||||
|
preprocess: sveltePreprocess(),
|
||||||
|
})],
|
||||||
|
clearScreen: false,
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
strictPort: true,
|
||||||
|
host: host || false,
|
||||||
|
hmr: host ? { protocol: 'ws', host, port: 5174 } : undefined,
|
||||||
|
watch: isTauri ? { ignored: ['**/src-tauri/**'] } : undefined,
|
||||||
|
proxy: isTauri ? undefined : {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
},
|
||||||
|
});
|
||||||
107
client/src/commands/auth.rs
Normal file
107
client/src/commands/auth.rs
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
use matrix_sdk::Client;
|
||||||
|
use serde::Serialize;
|
||||||
|
use tauri::State;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct LoginResult {
|
||||||
|
success: bool,
|
||||||
|
user_id: String,
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn login(
|
||||||
|
state: State<'_, crate::state::AppState>,
|
||||||
|
homeserver: String,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
) -> Result<LoginResult, String> {
|
||||||
|
let client = Client::builder()
|
||||||
|
.homeserver_url(&homeserver)
|
||||||
|
.build()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
client
|
||||||
|
.matrix_auth()
|
||||||
|
.login_username(&username, &password)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let user_id = client
|
||||||
|
.user_id()
|
||||||
|
.map(|u| u.to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let mut s = state.write().await;
|
||||||
|
s.client = Some(client);
|
||||||
|
s.logged_in = true;
|
||||||
|
s.user_id = Some(user_id.clone());
|
||||||
|
|
||||||
|
Ok(LoginResult {
|
||||||
|
success: true,
|
||||||
|
user_id,
|
||||||
|
error: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn logout(state: State<'_, crate::state::AppState>) -> Result<bool, String> {
|
||||||
|
let mut s = state.write().await;
|
||||||
|
if let Some(client) = s.client.take() {
|
||||||
|
let _ = client.matrix_auth().logout().await;
|
||||||
|
}
|
||||||
|
s.logged_in = false;
|
||||||
|
s.user_id = None;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn register(
|
||||||
|
state: State<'_, crate::state::AppState>,
|
||||||
|
homeserver: String,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
) -> Result<LoginResult, String> {
|
||||||
|
let client = Client::builder()
|
||||||
|
.homeserver_url(&homeserver)
|
||||||
|
.build()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let mut request = matrix_sdk::ruma::api::client::account::register::v3::Request::new();
|
||||||
|
request.username = Some(username);
|
||||||
|
request.password = Some(password);
|
||||||
|
|
||||||
|
client
|
||||||
|
.matrix_auth()
|
||||||
|
.register(request)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let user_id = client
|
||||||
|
.user_id()
|
||||||
|
.map(|u| u.to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let mut s = state.write().await;
|
||||||
|
s.client = Some(client);
|
||||||
|
s.logged_in = true;
|
||||||
|
s.user_id = Some(user_id.clone());
|
||||||
|
|
||||||
|
Ok(LoginResult {
|
||||||
|
success: true,
|
||||||
|
user_id,
|
||||||
|
error: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_current_user(
|
||||||
|
state: State<'_, crate::state::AppState>,
|
||||||
|
) -> Result<Option<String>, String> {
|
||||||
|
let s = state.read().await;
|
||||||
|
Ok(s.user_id.clone())
|
||||||
|
}
|
||||||
93
client/src/commands/emoji.rs
Normal file
93
client/src/commands/emoji.rs
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
use matrix_sdk::ruma::room_id;
|
||||||
|
use serde::Serialize;
|
||||||
|
use tauri::State;
|
||||||
|
use crate::state::AppState;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct CustomEmoji {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
url: String,
|
||||||
|
category: String,
|
||||||
|
animated: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct StickerPack {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
stickers: Vec<Sticker>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct Sticker {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_custom_emoji(
|
||||||
|
state: State<'_, crate::state::AppState>,
|
||||||
|
room_id: String,
|
||||||
|
) -> Result<Vec<CustomEmoji>, String> {
|
||||||
|
let s = state.read().await;
|
||||||
|
let client = s.client.as_ref().ok_or("Not logged in")?;
|
||||||
|
|
||||||
|
let rid = room_id!(room_id.as_str()).map_err(|e: matrix_sdk::ruma::IdParseError| e.to_string())?;
|
||||||
|
let _room = client.get_room(&rid).ok_or("Room not found")?;
|
||||||
|
|
||||||
|
let _emojis = Vec::new();
|
||||||
|
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn upload_emoji(
|
||||||
|
state: State<'_, crate::state::AppState>,
|
||||||
|
room_id: String,
|
||||||
|
name: String,
|
||||||
|
image_path: String,
|
||||||
|
) -> Result<CustomEmoji, String> {
|
||||||
|
let s = state.read().await;
|
||||||
|
let client = s.client.as_ref().ok_or("Not logged in")?;
|
||||||
|
|
||||||
|
let path = Path::new(&image_path);
|
||||||
|
if !path.exists() {
|
||||||
|
return Err("Image file not found".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mime_type = match path.extension().and_then(|e| e.to_str()) {
|
||||||
|
Some("png") => "image/png",
|
||||||
|
Some("jpg") | Some("jpeg") => "image/jpeg",
|
||||||
|
Some("gif") => "image/gif",
|
||||||
|
Some("webp") => "image/webp",
|
||||||
|
_ => "image/png",
|
||||||
|
};
|
||||||
|
|
||||||
|
let data = std::fs::read(path).map_err(|e| e.to_string())?;
|
||||||
|
let content_type = mime_type.parse::<matrix_sdk::ruma::mime::Mime>().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.media()
|
||||||
|
.upload(&content_type, data)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(CustomEmoji {
|
||||||
|
id: format!("emoji_{}", chrono::Utc::now().timestamp()),
|
||||||
|
name,
|
||||||
|
url: response.content_uri.to_string(),
|
||||||
|
category: "custom".to_string(),
|
||||||
|
animated: mime_type == "image/gif",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_sticker_packs(
|
||||||
|
state: State<'_, crate::state::AppState>,
|
||||||
|
) -> Result<Vec<StickerPack>, String> {
|
||||||
|
let _s = state.read().await;
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
7
client/src/commands/mod.rs
Normal file
7
client/src/commands/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
pub mod auth;
|
||||||
|
pub mod rooms;
|
||||||
|
pub mod threads;
|
||||||
|
pub mod presence;
|
||||||
|
pub mod voice;
|
||||||
|
pub mod emoji;
|
||||||
|
pub mod roles;
|
||||||
75
client/src/commands/presence.rs
Normal file
75
client/src/commands/presence.rs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
use matrix_sdk::ruma::user_id;
|
||||||
|
use serde::Serialize;
|
||||||
|
use tauri::State;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct PresenceInfo {
|
||||||
|
user_id: String,
|
||||||
|
status: String,
|
||||||
|
status_msg: Option<String>,
|
||||||
|
last_active: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_presence(
|
||||||
|
state: State<'_, crate::state::AppState>,
|
||||||
|
status: String,
|
||||||
|
status_msg: Option<String>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let s = state.read().await;
|
||||||
|
let client = s.client.as_ref().ok_or("Not logged in")?;
|
||||||
|
|
||||||
|
let presence_state = match status.as_str() {
|
||||||
|
"online" => matrix_sdk::ruma::presence::PresenceState::Online,
|
||||||
|
"away" => matrix_sdk::ruma::presence::PresenceState::Away,
|
||||||
|
"unavailable" => matrix_sdk::ruma::presence::PresenceState::Unavailable,
|
||||||
|
_ => matrix_sdk::ruma::presence::PresenceState::Online,
|
||||||
|
};
|
||||||
|
|
||||||
|
let request = matrix_sdk::ruma::api::client::presence::set_presence::v3::Request::new(
|
||||||
|
client.user_id().ok_or("No user ID")?.to_owned(),
|
||||||
|
);
|
||||||
|
let mut request = request;
|
||||||
|
request.presence = presence_state;
|
||||||
|
request.status_msg = status_msg.clone();
|
||||||
|
|
||||||
|
client
|
||||||
|
.send(request, None)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_presence(
|
||||||
|
state: State<'_, crate::state::AppState>,
|
||||||
|
user_id_str: String,
|
||||||
|
) -> Result<PresenceInfo, String> {
|
||||||
|
let s = state.read().await;
|
||||||
|
let client = s.client.as_ref().ok_or("Not logged in")?;
|
||||||
|
|
||||||
|
let uid = user_id!(user_id_str.as_str()).map_err(|e: matrix_sdk::ruma::IdParseError| e.to_string())?;
|
||||||
|
|
||||||
|
let request = matrix_sdk::ruma::api::client::presence::get_presence::v3::Request::new(uid.to_owned());
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.send(request, None)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let status_str = match response.presence {
|
||||||
|
matrix_sdk::ruma::presence::PresenceState::Online => "online",
|
||||||
|
matrix_sdk::ruma::presence::PresenceState::Away => "away",
|
||||||
|
matrix_sdk::ruma::presence::PresenceState::Unavailable => "unavailable",
|
||||||
|
_ => "offline",
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(PresenceInfo {
|
||||||
|
user_id: user_id_str,
|
||||||
|
status: status_str.to_string(),
|
||||||
|
status_msg: response.status_msg,
|
||||||
|
last_active: response.last_active_ago.map(|d| d.as_secs()),
|
||||||
|
})
|
||||||
|
}
|
||||||
181
client/src/commands/roles.rs
Normal file
181
client/src/commands/roles.rs
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
use matrix_sdk::ruma::room_id;
|
||||||
|
use matrix_sdk::ruma::user_id;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::State;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
|
pub struct Role {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
color: String,
|
||||||
|
permissions: Vec<String>,
|
||||||
|
position: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct Permissions {
|
||||||
|
can_send_messages: bool,
|
||||||
|
can_delete_messages: bool,
|
||||||
|
can_manage_channels: bool,
|
||||||
|
can_manage_roles: bool,
|
||||||
|
can_kick: bool,
|
||||||
|
can_ban: bool,
|
||||||
|
can_manage_emoji: bool,
|
||||||
|
can_manage_threads: bool,
|
||||||
|
can_voice_connect: bool,
|
||||||
|
can_voice_stream: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn power_level_to_permissions(power_level: i64) -> Permissions {
|
||||||
|
Permissions {
|
||||||
|
can_send_messages: power_level >= 0,
|
||||||
|
can_delete_messages: power_level >= 50,
|
||||||
|
can_manage_channels: power_level >= 50,
|
||||||
|
can_manage_roles: power_level >= 75,
|
||||||
|
can_kick: power_level >= 50,
|
||||||
|
can_ban: power_level >= 75,
|
||||||
|
can_manage_emoji: power_level >= 50,
|
||||||
|
can_manage_threads: power_level >= 0,
|
||||||
|
can_voice_connect: power_level >= 0,
|
||||||
|
can_voice_stream: power_level >= 25,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_roles() -> Vec<Role> {
|
||||||
|
vec![
|
||||||
|
Role {
|
||||||
|
id: "admin".to_string(),
|
||||||
|
name: "Admin".to_string(),
|
||||||
|
color: "#ed4245".to_string(),
|
||||||
|
permissions: vec!["*".to_string()],
|
||||||
|
position: 100,
|
||||||
|
},
|
||||||
|
Role {
|
||||||
|
id: "moderator".to_string(),
|
||||||
|
name: "Moderator".to_string(),
|
||||||
|
color: "#fee75c".to_string(),
|
||||||
|
permissions: vec!["kick".to_string(), "ban".to_string(), "manage_channels".to_string()],
|
||||||
|
position: 50,
|
||||||
|
},
|
||||||
|
Role {
|
||||||
|
id: "member".to_string(),
|
||||||
|
name: "Mitglied".to_string(),
|
||||||
|
color: "#5865f2".to_string(),
|
||||||
|
permissions: vec!["send_messages".to_string(), "voice_connect".to_string()],
|
||||||
|
position: 0,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_roles(
|
||||||
|
state: State<'_, crate::state::AppState>,
|
||||||
|
room_id: String,
|
||||||
|
) -> Result<Vec<Role>, String> {
|
||||||
|
let s = state.read().await;
|
||||||
|
let client = s.client.as_ref().ok_or("Not logged in")?;
|
||||||
|
let rid = room_id!(room_id.as_str()).map_err(|e: matrix_sdk::ruma::IdParseError| e.to_string())?;
|
||||||
|
let room = client.get_room(&rid).ok_or("Room not found")?;
|
||||||
|
|
||||||
|
let mut roles = Vec::new();
|
||||||
|
|
||||||
|
if let Ok(power_levels) = room.power_levels().await {
|
||||||
|
for (uid, power_level) in &power_levels.users {
|
||||||
|
roles.push(Role {
|
||||||
|
id: format!("role_{}", uid),
|
||||||
|
name: uid.to_string(),
|
||||||
|
color: if *power_level >= 100 { "#ed4245".to_string() } else if *power_level >= 50 { "#fee75c".to_string() } else { "#5865f2".to_string() },
|
||||||
|
permissions: vec![],
|
||||||
|
position: *power_level as i32,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if roles.is_empty() {
|
||||||
|
roles = default_roles();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(roles)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn assign_role(
|
||||||
|
state: State<'_, crate::state::AppState>,
|
||||||
|
room_id: String,
|
||||||
|
user_id: String,
|
||||||
|
role_id: String,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let s = state.read().await;
|
||||||
|
let client = s.client.as_ref().ok_or("Not logged in")?;
|
||||||
|
let rid = room_id!(room_id.as_str()).map_err(|e: matrix_sdk::ruma::IdParseError| e.to_string())?;
|
||||||
|
let room = client.get_room(&rid).ok_or("Room not found")?;
|
||||||
|
let uid = user_id!(user_id.as_str()).map_err(|e: matrix_sdk::ruma::IdParseError| e.to_string())?;
|
||||||
|
|
||||||
|
let power_level: i64 = match role_id.as_str() {
|
||||||
|
"admin" => 100,
|
||||||
|
"moderator" => 50,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let content = matrix_sdk::ruma::events::room::power_levels::RoomPowerLevelsEventContent::new();
|
||||||
|
let mut content = content;
|
||||||
|
content.users.insert(uid.to_owned(), power_level.into());
|
||||||
|
|
||||||
|
room.send_state_event(content)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn remove_role(
|
||||||
|
state: State<'_, crate::state::AppState>,
|
||||||
|
room_id: String,
|
||||||
|
user_id: String,
|
||||||
|
_role_id: String,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let s = state.read().await;
|
||||||
|
let client = s.client.as_ref().ok_or("Not logged in")?;
|
||||||
|
let rid = room_id!(room_id.as_str()).map_err(|e: matrix_sdk::ruma::IdParseError| e.to_string())?;
|
||||||
|
let room = client.get_room(&rid).ok_or("Room not found")?;
|
||||||
|
let uid = user_id!(user_id.as_str()).map_err(|e: matrix_sdk::ruma::IdParseError| e.to_string())?;
|
||||||
|
|
||||||
|
if let Ok(mut power_levels) = room.power_levels().await {
|
||||||
|
power_levels.users.remove(&uid);
|
||||||
|
|
||||||
|
let content = matrix_sdk::ruma::events::room::power_levels::RoomPowerLevelsEventContent::from(power_levels);
|
||||||
|
room.send_state_event(content)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_permissions(
|
||||||
|
state: State<'_, crate::state::AppState>,
|
||||||
|
room_id: String,
|
||||||
|
user_id: String,
|
||||||
|
) -> Result<Permissions, String> {
|
||||||
|
let s = state.read().await;
|
||||||
|
let client = s.client.as_ref().ok_or("Not logged in")?;
|
||||||
|
let rid = room_id!(room_id.as_str()).map_err(|e: matrix_sdk::ruma::IdParseError| e.to_string())?;
|
||||||
|
let room = client.get_room(&rid).ok_or("Room not found")?;
|
||||||
|
let uid = user_id!(user_id.as_str()).map_err(|e: matrix_sdk::ruma::IdParseError| e.to_string())?;
|
||||||
|
|
||||||
|
let user_power = if let Ok(power_levels) = room.power_levels().await {
|
||||||
|
power_levels
|
||||||
|
.users
|
||||||
|
.get(&uid)
|
||||||
|
.copied()
|
||||||
|
.map(|p| p.into())
|
||||||
|
.unwrap_or(power_levels.users_default as i64)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(power_level_to_permissions(user_power))
|
||||||
|
}
|
||||||
177
client/src/commands/rooms.rs
Normal file
177
client/src/commands/rooms.rs
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
use matrix_sdk::ruma::room_id;
|
||||||
|
use serde::Serialize;
|
||||||
|
use tauri::State;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct RoomInfo {
|
||||||
|
room_id: String,
|
||||||
|
name: String,
|
||||||
|
avatar_url: Option<String>,
|
||||||
|
is_encrypted: bool,
|
||||||
|
member_count: u64,
|
||||||
|
topic: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct MessageInfo {
|
||||||
|
event_id: String,
|
||||||
|
sender: String,
|
||||||
|
body: String,
|
||||||
|
timestamp: u64,
|
||||||
|
reply_to: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_joined_rooms(
|
||||||
|
state: State<'_, crate::state::AppState>,
|
||||||
|
) -> Result<Vec<RoomInfo>, String> {
|
||||||
|
let s = state.read().await;
|
||||||
|
let client = s.client.as_ref().ok_or("Not logged in")?;
|
||||||
|
|
||||||
|
let rooms = client.joined_rooms();
|
||||||
|
let mut result = Vec::new();
|
||||||
|
|
||||||
|
for room in rooms {
|
||||||
|
let name = room.display_name().await.map(|n| n.to_string()).unwrap_or_default();
|
||||||
|
let avatar_url = room.avatar_url().map(|u| u.to_string());
|
||||||
|
let member_count = room.joined_members().len() as u64;
|
||||||
|
let topic = room.topic().map(|t| t.to_string());
|
||||||
|
let is_encrypted = room.is_encrypted().await.unwrap_or(false);
|
||||||
|
|
||||||
|
result.push(RoomInfo {
|
||||||
|
room_id: room.room_id().to_string(),
|
||||||
|
name,
|
||||||
|
avatar_url,
|
||||||
|
is_encrypted,
|
||||||
|
member_count,
|
||||||
|
topic,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_room_messages(
|
||||||
|
state: State<'_, crate::state::AppState>,
|
||||||
|
room_id: String,
|
||||||
|
limit: u32,
|
||||||
|
from: Option<String>,
|
||||||
|
) -> Result<Vec<MessageInfo>, String> {
|
||||||
|
let s = state.read().await;
|
||||||
|
let client = s.client.as_ref().ok_or("Not logged in")?;
|
||||||
|
let rid = room_id!(room_id.as_str()).map_err(|e: matrix_sdk::ruma::IdParseError| e.to_string())?;
|
||||||
|
let room = client.get_room(&rid).ok_or("Room not found")?;
|
||||||
|
|
||||||
|
let mut options = matrix_sdk::ruma::api::client::message::get_message_events::v3::Request::new();
|
||||||
|
options.limit = limit.into();
|
||||||
|
options.from = from.map(|t| t.into()).or_else(|| None);
|
||||||
|
|
||||||
|
let messages = room
|
||||||
|
.messages(options)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let mut result = Vec::new();
|
||||||
|
for msg in messages.chunk {
|
||||||
|
if let matrix_sdk::ruma::events::AnySyncMessageLikeEvent::RoomMessage(ev) = msg {
|
||||||
|
let body = ev.content().body().to_string();
|
||||||
|
result.push(MessageInfo {
|
||||||
|
event_id: ev.event_id().to_string(),
|
||||||
|
sender: ev.sender().to_string(),
|
||||||
|
body,
|
||||||
|
timestamp: ev.origin_server_ts().0,
|
||||||
|
reply_to: ev.content().in_reply_to().map(|r| r.event_id.to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn send_message(
|
||||||
|
state: State<'_, crate::state::AppState>,
|
||||||
|
room_id: String,
|
||||||
|
message: String,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let s = state.read().await;
|
||||||
|
let client = s.client.as_ref().ok_or("Not logged in")?;
|
||||||
|
let rid = room_id!(room_id.as_str()).map_err(|e: matrix_sdk::ruma::IdParseError| e.to_string())?;
|
||||||
|
let room = client.get_room(&rid).ok_or("Room not found")?;
|
||||||
|
|
||||||
|
let content = matrix_sdk::ruma::events::room::message::RoomMessageEventContent::text_plain(&message);
|
||||||
|
let txn_id = matrix_sdk::ruma::TransactionId::new();
|
||||||
|
let response = room.send(content, Some(&txn_id)).await.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(response.event_id.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn create_room(
|
||||||
|
state: State<'_, crate::state::AppState>,
|
||||||
|
name: String,
|
||||||
|
topic: Option<String>,
|
||||||
|
visibility: String,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let s = state.read().await;
|
||||||
|
let client = s.client.as_ref().ok_or("Not logged in")?;
|
||||||
|
|
||||||
|
let vis = match visibility.as_str() {
|
||||||
|
"public" => matrix_sdk::ruma::Space::Public,
|
||||||
|
_ => matrix_sdk::ruma::Space::Private,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut request = matrix_sdk::ruma::api::client::room::create_room::v3::Request::new();
|
||||||
|
request.name = Some(name);
|
||||||
|
request.topic = topic;
|
||||||
|
request.visibility = vis;
|
||||||
|
|
||||||
|
let response = client.create_room(request).await.map_err(|e| e.to_string())?;
|
||||||
|
Ok(response.room_id.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn join_room(
|
||||||
|
state: State<'_, crate::state::AppState>,
|
||||||
|
room_id_or_alias: String,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let s = state.read().await;
|
||||||
|
let client = s.client.as_ref().ok_or("Not logged in")?;
|
||||||
|
|
||||||
|
let room_id = client.join_room_by_id_or_alias(
|
||||||
|
&room_id_or_alias.try_into().map_err(|e: matrix_sdk::ruma::IdParseError| e.to_string())?,
|
||||||
|
&[]
|
||||||
|
).await.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(room_id.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn leave_room(
|
||||||
|
state: State<'_, crate::state::AppState>,
|
||||||
|
room_id: String,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let s = state.read().await;
|
||||||
|
let client = s.client.as_ref().ok_or("Not logged in")?;
|
||||||
|
let rid = room_id!(room_id.as_str()).map_err(|e: matrix_sdk::ruma::IdParseError| e.to_string())?;
|
||||||
|
let room = client.get_room(&rid).ok_or("Room not found")?;
|
||||||
|
|
||||||
|
room.leave().await.map_err(|e| e.to_string())?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_room_members(
|
||||||
|
state: State<'_, crate::state::AppState>,
|
||||||
|
room_id: String,
|
||||||
|
) -> Result<Vec<String>, String> {
|
||||||
|
let s = state.read().await;
|
||||||
|
let client = s.client.as_ref().ok_or("Not logged in")?;
|
||||||
|
let rid = room_id!(room_id.as_str()).map_err(|e: matrix_sdk::ruma::IdParseError| e.to_string())?;
|
||||||
|
let room = client.get_room(&rid).ok_or("Room not found")?;
|
||||||
|
|
||||||
|
let members = room.joined_members();
|
||||||
|
Ok(members.iter().map(|m| m.user_id().to_string()).collect())
|
||||||
|
}
|
||||||
87
client/src/commands/threads.rs
Normal file
87
client/src/commands/threads.rs
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
use matrix_sdk::ruma::room_id;
|
||||||
|
use serde::Serialize;
|
||||||
|
use tauri::State;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct ThreadInfo {
|
||||||
|
thread_id: String,
|
||||||
|
root_event_id: String,
|
||||||
|
sender: String,
|
||||||
|
body: String,
|
||||||
|
reply_count: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_thread_messages(
|
||||||
|
state: State<'_, crate::state::AppState>,
|
||||||
|
room_id: String,
|
||||||
|
thread_id: String,
|
||||||
|
limit: u32,
|
||||||
|
) -> Result<Vec<ThreadInfo>, String> {
|
||||||
|
let s = state.read().await;
|
||||||
|
let client = s.client.as_ref().ok_or("Not logged in")?;
|
||||||
|
let rid = room_id!(room_id.as_str()).map_err(|e: matrix_sdk::ruma::IdParseError| e.to_string())?;
|
||||||
|
let room = client.get_room(&rid).ok_or("Room not found")?;
|
||||||
|
|
||||||
|
let mut options = matrix_sdk::ruma::api::client::message::get_message_events::v3::Request::new();
|
||||||
|
options.limit = limit.into();
|
||||||
|
|
||||||
|
let messages = room.messages(options).await.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let mut result = Vec::new();
|
||||||
|
for msg in messages.chunk {
|
||||||
|
if let matrix_sdk::ruma::events::AnySyncMessageLikeEvent::RoomMessage(ev) = msg {
|
||||||
|
let relates_to = ev.content().relates_to();
|
||||||
|
let is_thread = relates_to.as_ref().map_or(false, |r| {
|
||||||
|
matches!(r, matrix_sdk::ruma::events::room::message::Relation::Thread(_))
|
||||||
|
});
|
||||||
|
if is_thread {
|
||||||
|
if let Some(matrix_sdk::ruma::events::room::message::Relation::Thread(thread)) = relates_to {
|
||||||
|
result.push(ThreadInfo {
|
||||||
|
thread_id: thread.event_id.to_string(),
|
||||||
|
root_event_id: thread.event_id.to_string(),
|
||||||
|
sender: ev.sender().to_string(),
|
||||||
|
body: ev.content().body().to_string(),
|
||||||
|
reply_count: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn send_thread_reply(
|
||||||
|
state: State<'_, crate::state::AppState>,
|
||||||
|
room_id: String,
|
||||||
|
thread_id: String,
|
||||||
|
message: String,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let s = state.read().await;
|
||||||
|
let client = s.client.as_ref().ok_or("Not logged in")?;
|
||||||
|
let rid = room_id!(room_id.as_str()).map_err(|e: matrix_sdk::ruma::IdParseError| e.to_string())?;
|
||||||
|
let room = client.get_room(&rid).ok_or("Room not found")?;
|
||||||
|
|
||||||
|
let content = matrix_sdk::ruma::events::room::message::RoomMessageEventContent::text_plain(&message);
|
||||||
|
let txn_id = matrix_sdk::ruma::TransactionId::new();
|
||||||
|
let response = room.send(content, Some(&txn_id)).await.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(response.event_id.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn create_thread(
|
||||||
|
state: State<'_, crate::state::AppState>,
|
||||||
|
room_id: String,
|
||||||
|
root_event_id: String,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let s = state.read().await;
|
||||||
|
let client = s.client.as_ref().ok_or("Not logged in")?;
|
||||||
|
let rid = room_id!(room_id.as_str()).map_err(|e: matrix_sdk::ruma::IdParseError| e.to_string())?;
|
||||||
|
let _room = client.get_room(&rid).ok_or("Room not found")?;
|
||||||
|
|
||||||
|
Ok(root_event_id)
|
||||||
|
}
|
||||||
68
client/src/commands/voice.rs
Normal file
68
client/src/commands/voice.rs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
use tauri::State;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct VoiceState {
|
||||||
|
room_id: String,
|
||||||
|
muted: bool,
|
||||||
|
deafened: bool,
|
||||||
|
streaming: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn join_voice_channel(
|
||||||
|
state: State<'_, crate::state::AppState>,
|
||||||
|
room_id: String,
|
||||||
|
) -> Result<VoiceState, String> {
|
||||||
|
let s = state.read().await;
|
||||||
|
let user_id = s.user_id.clone().ok_or("Not logged in")?;
|
||||||
|
|
||||||
|
let mut s = state.write().await;
|
||||||
|
s.voice_manager.join_channel(room_id.clone(), user_id);
|
||||||
|
|
||||||
|
Ok(VoiceState {
|
||||||
|
room_id,
|
||||||
|
muted: false,
|
||||||
|
deafened: false,
|
||||||
|
streaming: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn leave_voice_channel(
|
||||||
|
state: State<'_, crate::state::AppState>,
|
||||||
|
room_id: String,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let s = state.read().await;
|
||||||
|
let user_id = s.user_id.clone().ok_or("Not logged in")?;
|
||||||
|
|
||||||
|
let mut s = state.write().await;
|
||||||
|
Ok(s.voice_manager.leave_channel(&room_id, &user_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn toggle_mute(
|
||||||
|
state: State<'_, crate::state::AppState>,
|
||||||
|
room_id: String,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let s = state.read().await;
|
||||||
|
let user_id = s.user_id.clone().ok_or("Not logged in")?;
|
||||||
|
|
||||||
|
let mut s = state.write().await;
|
||||||
|
s.voice_manager.toggle_mute(&room_id, &user_id)
|
||||||
|
.ok_or("Not in voice channel".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn toggle_deafen(
|
||||||
|
state: State<'_, crate::state::AppState>,
|
||||||
|
room_id: String,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let s = state.read().await;
|
||||||
|
let user_id = s.user_id.clone().ok_or("Not logged in")?;
|
||||||
|
|
||||||
|
let mut s = state.write().await;
|
||||||
|
s.voice_manager.toggle_deafen(&room_id, &user_id)
|
||||||
|
.ok_or("Not in voice channel".to_string())
|
||||||
|
}
|
||||||
41
client/src/main.rs
Normal file
41
client/src/main.rs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
pub mod matrix;
|
||||||
|
pub mod commands;
|
||||||
|
pub mod state;
|
||||||
|
|
||||||
|
use state::AppState;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
.manage(AppState::new())
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
commands::auth::login,
|
||||||
|
commands::auth::logout,
|
||||||
|
commands::auth::register,
|
||||||
|
commands::auth::get_current_user,
|
||||||
|
commands::rooms::get_joined_rooms,
|
||||||
|
commands::rooms::get_room_messages,
|
||||||
|
commands::rooms::send_message,
|
||||||
|
commands::rooms::create_room,
|
||||||
|
commands::rooms::join_room,
|
||||||
|
commands::rooms::leave_room,
|
||||||
|
commands::rooms::get_room_members,
|
||||||
|
commands::threads::get_thread_messages,
|
||||||
|
commands::threads::send_thread_reply,
|
||||||
|
commands::threads::create_thread,
|
||||||
|
commands::presence::set_presence,
|
||||||
|
commands::presence::get_presence,
|
||||||
|
commands::voice::join_voice_channel,
|
||||||
|
commands::voice::leave_voice_channel,
|
||||||
|
commands::voice::toggle_mute,
|
||||||
|
commands::voice::toggle_deafen,
|
||||||
|
commands::emoji::get_custom_emoji,
|
||||||
|
commands::emoji::upload_emoji,
|
||||||
|
commands::emoji::get_sticker_packs,
|
||||||
|
commands::roles::get_roles,
|
||||||
|
commands::roles::assign_role,
|
||||||
|
commands::roles::remove_role,
|
||||||
|
commands::roles::get_permissions,
|
||||||
|
])
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("error while running tauri application");
|
||||||
|
}
|
||||||
75
client/src/matrix/client.rs
Normal file
75
client/src/matrix/client.rs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use matrix_sdk::Client;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
use super::room::RoomManager;
|
||||||
|
use super::event::EventHandler;
|
||||||
|
use super::sync::SyncService;
|
||||||
|
use super::presence::PresenceManager;
|
||||||
|
use super::voice::VoiceManager;
|
||||||
|
|
||||||
|
pub struct MatrixClient {
|
||||||
|
pub client: Arc<RwLock<Option<Client>>>,
|
||||||
|
pub room_manager: Arc<RwLock<RoomManager>>,
|
||||||
|
pub event_handler: EventHandler,
|
||||||
|
pub sync_service: Arc<RwLock<SyncService>>,
|
||||||
|
pub presence_manager: Arc<RwLock<PresenceManager>>,
|
||||||
|
pub voice_manager: Arc<RwLock<VoiceManager>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MatrixClient {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
client: Arc::new(RwLock::new(None)),
|
||||||
|
room_manager: Arc::new(RwLock::new(RoomManager::new())),
|
||||||
|
event_handler: EventHandler::new(),
|
||||||
|
sync_service: Arc::new(RwLock::new(SyncService::new())),
|
||||||
|
presence_manager: Arc::new(RwLock::new(PresenceManager::new())),
|
||||||
|
voice_manager: Arc::new(RwLock::new(VoiceManager::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn connect(&self, homeserver: &str) -> Result<()> {
|
||||||
|
let client = Client::builder()
|
||||||
|
.homeserver_url(homeserver)
|
||||||
|
.build()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut guard = self.client.write().await;
|
||||||
|
*guard = Some(client);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn login(&self, username: &str, password: &str) -> Result<String> {
|
||||||
|
let guard = self.client.read().await;
|
||||||
|
let client = guard.as_ref().ok_or(anyhow::anyhow!("Not connected"))?;
|
||||||
|
|
||||||
|
client
|
||||||
|
.matrix_auth()
|
||||||
|
.login_username(username, password)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let user_id = client
|
||||||
|
.user_id()
|
||||||
|
.ok_or(anyhow::anyhow!("No user ID"))?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
Ok(user_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start_sync(&self) -> Result<()> {
|
||||||
|
let guard = self.client.read().await;
|
||||||
|
let client = guard.as_ref().ok_or(anyhow::anyhow!("Not connected"))?;
|
||||||
|
|
||||||
|
let mut sync = self.sync_service.write().await;
|
||||||
|
sync.start(client.clone()).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn stop_sync(&self) -> Result<()> {
|
||||||
|
let mut sync = self.sync_service.write().await;
|
||||||
|
sync.stop().await
|
||||||
|
}
|
||||||
|
}
|
||||||
68
client/src/matrix/event.rs
Normal file
68
client/src/matrix/event.rs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub enum ClientEvent {
|
||||||
|
NewMessage {
|
||||||
|
room_id: String,
|
||||||
|
sender: String,
|
||||||
|
body: String,
|
||||||
|
event_id: String,
|
||||||
|
},
|
||||||
|
RoomJoined {
|
||||||
|
room_id: String,
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
RoomLeft {
|
||||||
|
room_id: String,
|
||||||
|
},
|
||||||
|
Typing {
|
||||||
|
room_id: String,
|
||||||
|
user_id: String,
|
||||||
|
},
|
||||||
|
PresenceChange {
|
||||||
|
user_id: String,
|
||||||
|
status: String,
|
||||||
|
},
|
||||||
|
MemberJoined {
|
||||||
|
room_id: String,
|
||||||
|
user_id: String,
|
||||||
|
},
|
||||||
|
MemberLeft {
|
||||||
|
room_id: String,
|
||||||
|
user_id: String,
|
||||||
|
},
|
||||||
|
Receipt {
|
||||||
|
room_id: String,
|
||||||
|
event_id: String,
|
||||||
|
user_id: String,
|
||||||
|
},
|
||||||
|
VoiceStateChange {
|
||||||
|
room_id: String,
|
||||||
|
user_id: String,
|
||||||
|
joined: bool,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct EventHandler {
|
||||||
|
sender: mpsc::UnboundedSender<ClientEvent>,
|
||||||
|
receiver: tokio::sync::Mutex<mpsc::UnboundedReceiver<ClientEvent>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventHandler {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let (sender, receiver) = mpsc::unbounded_channel();
|
||||||
|
Self {
|
||||||
|
sender,
|
||||||
|
receiver: tokio::sync::Mutex::new(receiver),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn emit(&self, event: ClientEvent) {
|
||||||
|
let _ = self.sender.send(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn next(&self) -> Option<ClientEvent> {
|
||||||
|
let mut guard = self.receiver.lock().await;
|
||||||
|
guard.recv().await
|
||||||
|
}
|
||||||
|
}
|
||||||
8
client/src/matrix/mod.rs
Normal file
8
client/src/matrix/mod.rs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
pub mod client;
|
||||||
|
pub mod room;
|
||||||
|
pub mod event;
|
||||||
|
pub mod sync;
|
||||||
|
pub mod voice;
|
||||||
|
pub mod presence;
|
||||||
|
|
||||||
|
pub use client::MatrixClient;
|
||||||
39
client/src/matrix/presence.rs
Normal file
39
client/src/matrix/presence.rs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
use matrix_sdk::ruma::presence::PresenceState;
|
||||||
|
|
||||||
|
pub struct PresenceManager {
|
||||||
|
current_presence: PresenceState,
|
||||||
|
status_msg: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PresenceManager {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
current_presence: PresenceState::Online,
|
||||||
|
status_msg: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_online(&mut self) {
|
||||||
|
self.current_presence = PresenceState::Online;
|
||||||
|
self.status_msg = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_away(&mut self, msg: Option<String>) {
|
||||||
|
self.current_presence = PresenceState::Away;
|
||||||
|
self.status_msg = msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_unavailable(&mut self) {
|
||||||
|
self.current_presence = PresenceState::Unavailable;
|
||||||
|
self.status_msg = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_status(&self) -> (PresenceState, &Option<String>) {
|
||||||
|
(self.current_presence, &self.status_msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_presence(&mut self, presence: PresenceState, status_msg: Option<String>) {
|
||||||
|
self.current_presence = presence;
|
||||||
|
self.status_msg = status_msg;
|
||||||
|
}
|
||||||
|
}
|
||||||
67
client/src/matrix/room.rs
Normal file
67
client/src/matrix/room.rs
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use matrix_sdk::Client;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
pub struct RoomManager {
|
||||||
|
rooms: HashMap<String, RoomState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct RoomState {
|
||||||
|
pub room_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub unread_count: u32,
|
||||||
|
pub is_voice: bool,
|
||||||
|
pub topic: Option<String>,
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
pub member_count: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RoomManager {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
rooms: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn refresh_rooms(&mut self, client: &Client) -> Result<()> {
|
||||||
|
self.rooms.clear();
|
||||||
|
let joined = client.joined_rooms();
|
||||||
|
for room in joined {
|
||||||
|
let name = room.display_name().await.map(|n| n.to_string()).unwrap_or_default();
|
||||||
|
let avatar_url = room.avatar_url().map(|u| u.to_string());
|
||||||
|
let member_count = room.joined_members().len() as u64;
|
||||||
|
let topic = room.topic().map(|t| t.to_string());
|
||||||
|
|
||||||
|
self.rooms.insert(
|
||||||
|
room.room_id().to_string(),
|
||||||
|
RoomState {
|
||||||
|
room_id: room.room_id().to_string(),
|
||||||
|
name,
|
||||||
|
unread_count: 0,
|
||||||
|
is_voice: false,
|
||||||
|
topic,
|
||||||
|
avatar_url,
|
||||||
|
member_count,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_rooms(&self) -> Vec<&RoomState> {
|
||||||
|
let mut rooms: Vec<&RoomState> = self.rooms.values().collect();
|
||||||
|
rooms.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
rooms
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_room(&self, room_id: &str) -> Option<&RoomState> {
|
||||||
|
self.rooms.get(room_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_unread(&mut self, room_id: &str, count: u32) {
|
||||||
|
if let Some(room) = self.rooms.get_mut(room_id) {
|
||||||
|
room.unread_count = count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
44
client/src/matrix/sync.rs
Normal file
44
client/src/matrix/sync.rs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use matrix_sdk::Client;
|
||||||
|
use matrix_sdk::config::SyncSettings;
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
|
|
||||||
|
pub struct SyncService {
|
||||||
|
handle: Option<JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SyncService {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { handle: None }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start(&mut self, client: Client) -> Result<()> {
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
let mut sync_token: Option<String> = None;
|
||||||
|
loop {
|
||||||
|
let mut settings = SyncSettings::new();
|
||||||
|
if let Some(token) = sync_token.as_ref() {
|
||||||
|
settings = settings.token(token.clone());
|
||||||
|
}
|
||||||
|
match client.sync_once(settings).await {
|
||||||
|
Ok(response) => {
|
||||||
|
sync_token = Some(response.next_batch);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Sync error: {}", e);
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
self.handle = Some(handle);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn stop(&mut self) -> Result<()> {
|
||||||
|
if let Some(handle) = self.handle.take() {
|
||||||
|
handle.abort();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
100
client/src/matrix/voice.rs
Normal file
100
client/src/matrix/voice.rs
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
pub struct VoiceManager {
|
||||||
|
channels: HashMap<String, VoiceChannel>,
|
||||||
|
active_channel: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct VoiceChannel {
|
||||||
|
pub room_id: String,
|
||||||
|
pub participants: Vec<VoiceParticipant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct VoiceParticipant {
|
||||||
|
pub user_id: String,
|
||||||
|
pub muted: bool,
|
||||||
|
pub deafened: bool,
|
||||||
|
pub streaming: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VoiceManager {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
channels: HashMap::new(),
|
||||||
|
active_channel: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn join_channel(&mut self, room_id: String, user_id: String) -> bool {
|
||||||
|
if let Some(ref old_channel) = self.active_channel {
|
||||||
|
self.leave_channel_internal(old_channel, &user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
let channel = self.channels.entry(room_id.clone()).or_insert_with(|| VoiceChannel {
|
||||||
|
room_id: room_id.clone(),
|
||||||
|
participants: Vec::new(),
|
||||||
|
});
|
||||||
|
if channel.participants.iter().any(|p| p.user_id == user_id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
channel.participants.push(VoiceParticipant {
|
||||||
|
user_id,
|
||||||
|
muted: false,
|
||||||
|
deafened: false,
|
||||||
|
streaming: false,
|
||||||
|
});
|
||||||
|
self.active_channel = Some(room_id);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn leave_channel_internal(&mut self, room_id: &str, user_id: &str) {
|
||||||
|
if let Some(channel) = self.channels.get_mut(room_id) {
|
||||||
|
channel.participants.retain(|p| p.user_id != user_id);
|
||||||
|
if channel.participants.is_empty() {
|
||||||
|
self.channels.remove(room_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn leave_channel(&mut self, room_id: &str, user_id: &str) -> bool {
|
||||||
|
self.leave_channel_internal(room_id, user_id);
|
||||||
|
if self.active_channel.as_deref() == Some(room_id) {
|
||||||
|
self.active_channel = None;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle_mute(&mut self, room_id: &str, user_id: &str) -> Option<bool> {
|
||||||
|
if let Some(channel) = self.channels.get_mut(room_id) {
|
||||||
|
if let Some(participant) = channel.participants.iter_mut().find(|p| p.user_id == user_id) {
|
||||||
|
participant.muted = !participant.muted;
|
||||||
|
return Some(participant.muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle_deafen(&mut self, room_id: &str, user_id: &str) -> Option<bool> {
|
||||||
|
if let Some(channel) = self.channels.get_mut(room_id) {
|
||||||
|
if let Some(participant) = channel.participants.iter_mut().find(|p| p.user_id == user_id) {
|
||||||
|
participant.deafened = !participant.deafened;
|
||||||
|
if participant.deafened {
|
||||||
|
participant.muted = true;
|
||||||
|
}
|
||||||
|
return Some(participant.deafened);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_active_channel(&self) -> Option<&str> {
|
||||||
|
self.active_channel.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_participants(&self, room_id: &str) -> Vec<&VoiceParticipant> {
|
||||||
|
self.channels
|
||||||
|
.get(room_id)
|
||||||
|
.map(|c| c.participants.iter().collect())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
}
|
||||||
21
client/src/state/mod.rs
Normal file
21
client/src/state/mod.rs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
use matrix_sdk::Client;
|
||||||
|
|
||||||
|
use crate::matrix::voice::VoiceManager;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct AppStateInner {
|
||||||
|
pub client: Option<Client>,
|
||||||
|
pub logged_in: bool,
|
||||||
|
pub user_id: Option<String>,
|
||||||
|
pub voice_manager: VoiceManager,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type AppState = Arc<RwLock<AppStateInner>>;
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Arc::new(RwLock::new(AppStateInner::default()))
|
||||||
|
}
|
||||||
|
}
|
||||||
5
infra/.env.example
Normal file
5
infra/.env.example
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
DOMAIN=eifeldc.local
|
||||||
|
POSTGRES_PASSWORD=changeme_postgres_password
|
||||||
|
TURN_SECRET=changeme_turn_secret
|
||||||
|
MACAROON_SECRET=changeme_macaroon_secret
|
||||||
|
FORM_SECRET=changeme_form_secret
|
||||||
17
infra/coturn/turnserver.conf
Normal file
17
infra/coturn/turnserver.conf
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
listening-port=3478
|
||||||
|
tls-listening-port=5349
|
||||||
|
realm=eifeldc.local
|
||||||
|
server-name=eifeldc.local
|
||||||
|
fingerprint
|
||||||
|
lt-cred-mech
|
||||||
|
use-auth-secret
|
||||||
|
static-auth-secret=CHANGE_ME_GENERATE_A_SECRET
|
||||||
|
total-quota=100
|
||||||
|
bps-capacity=0
|
||||||
|
stale-nonce=600
|
||||||
|
cert=/etc/letsencrypt/live/eifeldc.local/cert.pem
|
||||||
|
pkey=/etc/letsencrypt/live/eifeldc.local/privkey.pem
|
||||||
|
no-multicast-peers
|
||||||
|
no-cli
|
||||||
|
log-file=/var/log/turnserver/turnserver.log
|
||||||
|
simple-log
|
||||||
134
infra/docker-compose.yml
Normal file
134
infra/docker-compose.yml
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
version: "3.8"
|
||||||
|
|
||||||
|
services:
|
||||||
|
eifeldc:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: eifeldc-server
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- EIFELDC_STATIC_DIR=/usr/share/eifeldc/client
|
||||||
|
- RUST_LOG=eifeldc_server=info,tower_http=info
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:3000/api/current-user"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
networks:
|
||||||
|
- eifeldc
|
||||||
|
|
||||||
|
synapse:
|
||||||
|
image: matrixdotorg/synapse:latest
|
||||||
|
container_name: eifeldc-synapse
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- synapse-data:/data
|
||||||
|
- ./synapse/log.config:/data/log.config:ro
|
||||||
|
environment:
|
||||||
|
- SYNAPSE_SERVER_NAME=${DOMAIN:-eifeldc.local}
|
||||||
|
- SYNAPSE_REPORT_STATS=no
|
||||||
|
ports:
|
||||||
|
- "8008:8008"
|
||||||
|
- "8448:8448"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8008/_matrix/client/versions"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
networks:
|
||||||
|
- eifeldc
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: eifeldc-postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: synapse
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme_postgres_password}
|
||||||
|
POSTGRES_DB: synapse
|
||||||
|
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --lc-collate=C --lc-ctype=C"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U synapse"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
networks:
|
||||||
|
- eifeldc
|
||||||
|
|
||||||
|
coturn:
|
||||||
|
image: coturn/coturn:latest
|
||||||
|
container_name: eifeldc-coturn
|
||||||
|
restart: unless-stopped
|
||||||
|
network_mode: host
|
||||||
|
volumes:
|
||||||
|
- ./coturn/turnserver.conf:/etc/turnserver.conf:ro
|
||||||
|
- turn-certs:/etc/letsencrypt
|
||||||
|
command: ["-c", "/etc/turnserver.conf"]
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: eifeldc-nginx
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./nginx/eifeldc.conf:/etc/nginx/conf.d/eifeldc.conf:ro
|
||||||
|
- nginx-certs:/etc/letsencrypt:ro
|
||||||
|
depends_on:
|
||||||
|
eifeldc:
|
||||||
|
condition: service_healthy
|
||||||
|
synapse:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:80/"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
networks:
|
||||||
|
- eifeldc
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-data:
|
||||||
|
turn-certs:
|
||||||
|
nginx-certs:
|
||||||
|
synapse-data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
eifeldc:
|
||||||
|
driver: bridge
|
||||||
79
infra/nginx/eifeldc.conf
Normal file
79
infra/nginx/eifeldc.conf
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
upstream eifeldc_server {
|
||||||
|
server eifeldc:3000;
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream synapse_server {
|
||||||
|
server synapse:8008;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name eifeldc.local;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name eifeldc.local;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/eifeldc.local/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/eifeldc.local/privkey.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
ssl_prefer_server_ciphers on;
|
||||||
|
ssl_session_cache shared:SSL:10m;
|
||||||
|
ssl_session_timeout 10m;
|
||||||
|
|
||||||
|
client_max_body_size 50M;
|
||||||
|
|
||||||
|
add_header X-Frame-Options SAMEORIGIN;
|
||||||
|
add_header X-Content-Type-Options nosniff;
|
||||||
|
add_header X-XSS-Protection "1; mode=block";
|
||||||
|
add_header Referrer-Policy strict-origin-when-cross-origin;
|
||||||
|
|
||||||
|
location /.well-known/matrix/server {
|
||||||
|
default_type application/json;
|
||||||
|
return 200 '{"m.server": "eifeldc.local:443"}';
|
||||||
|
}
|
||||||
|
|
||||||
|
location /.well-known/matrix/client {
|
||||||
|
default_type application/json;
|
||||||
|
return 200 '{"m.homeserver": {"base_url": "https://eifeldc.local"}}';
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ ^/_matrix/(client|media|federation|key|v2) {
|
||||||
|
proxy_pass http://synapse_server;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_read_timeout 600s;
|
||||||
|
proxy_send_timeout 600s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /_synapse {
|
||||||
|
proxy_pass http://synapse_server;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://eifeldc_server;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://eifeldc_server;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
59
infra/scripts/backup.sh
Executable file
59
infra/scripts/backup.sh
Executable file
@@ -0,0 +1,59 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
INFRA_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
BACKUP_DIR="${INFRA_DIR}/backups"
|
||||||
|
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||||
|
BACKUP_PATH="${BACKUP_DIR}/${TIMESTAMP}"
|
||||||
|
|
||||||
|
mkdir -p "${BACKUP_PATH}"
|
||||||
|
|
||||||
|
echo "=== EifelDC Backup ==="
|
||||||
|
echo "Backup directory: ${BACKUP_PATH}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if command -v docker &>/dev/null && docker compose -f "${INFRA_DIR}/docker-compose.yml" ps &>/dev/null 2>&1; then
|
||||||
|
echo "[Docker Mode]"
|
||||||
|
|
||||||
|
echo "[1/4] Backing up PostgreSQL..."
|
||||||
|
docker compose -f "${INFRA_DIR}/docker-compose.yml" exec -T postgres \
|
||||||
|
pg_dump -U synapse synapse > "${BACKUP_PATH}/synapse_db.sql"
|
||||||
|
|
||||||
|
echo "[2/4] Backing up Synapse media..."
|
||||||
|
docker cp eifeldc-synapse:/data/media_store "${BACKUP_PATH}/media_store" 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "[3/4] Backing up Synapse config..."
|
||||||
|
docker cp eifeldc-synapse:/data/homeserver.yaml "${BACKUP_PATH}/homeserver.yaml" 2>/dev/null || true
|
||||||
|
docker cp eifeldc-synapse:/data/signing.key "${BACKUP_PATH}/signing.key" 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "[4/4] Backing up environment config..."
|
||||||
|
cp "${INFRA_DIR}/.env" "${BACKUP_PATH}/.env" 2>/dev/null || true
|
||||||
|
else
|
||||||
|
echo "[Native Mode]"
|
||||||
|
|
||||||
|
echo "[1/3] Backing up PostgreSQL..."
|
||||||
|
sudo -u postgres pg_dump synapse > "${BACKUP_PATH}/synapse_db.sql" 2>/dev/null || echo "Warning: Could not dump database"
|
||||||
|
|
||||||
|
echo "[2/3] Backing up Synapse data..."
|
||||||
|
SYNAPSE_DIR="${SYNAPSE_DIR:-/opt/eifeldc/synapse-data}"
|
||||||
|
cp -r "${SYNAPSE_DIR}/homeserver.yaml" "${BACKUP_PATH}/" 2>/dev/null || true
|
||||||
|
cp -r "${SYNAPSE_DIR}/signing.key" "${BACKUP_PATH}/" 2>/dev/null || true
|
||||||
|
cp -r "${SYNAPSE_DIR}/media_store" "${BACKUP_PATH}/" 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "[3/3] Backing up environment config..."
|
||||||
|
cp "${INFRA_DIR}/.env" "${BACKUP_PATH}/.env" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
COMPRESSED="${BACKUP_DIR}/eifeldc_backup_${TIMESTAMP}.tar.gz"
|
||||||
|
tar -czf "${COMPRESSED}" -C "${BACKUP_DIR}" "${TIMESTAMP}"
|
||||||
|
rm -rf "${BACKUP_PATH}"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Backup saved: ${COMPRESSED}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
KEEP_COUNT=7
|
||||||
|
ls -t "${BACKUP_DIR}"/eifeldc_backup_*.tar.gz 2>/dev/null | tail -n +$((KEEP_COUNT + 1)) | xargs -r rm --
|
||||||
|
echo "Old backups cleaned (keeping latest ${KEEP_COUNT})"
|
||||||
|
echo "Done!"
|
||||||
35
infra/scripts/generate-certs.sh
Executable file
35
infra/scripts/generate-certs.sh
Executable file
@@ -0,0 +1,35 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
INFRA_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
CERT_DIR="${INFRA_DIR}/certs"
|
||||||
|
DOMAIN="${1:-eifeldc.local}"
|
||||||
|
|
||||||
|
echo "=== Generating self-signed SSL certificate for ${DOMAIN} ==="
|
||||||
|
|
||||||
|
rm -rf "${CERT_DIR}"
|
||||||
|
mkdir -p "${CERT_DIR}/live/${DOMAIN}"
|
||||||
|
mkdir -p "${CERT_DIR}/archive"
|
||||||
|
|
||||||
|
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
||||||
|
-keyout "${CERT_DIR}/live/${DOMAIN}/privkey.pem" \
|
||||||
|
-out "${CERT_DIR}/live/${DOMAIN}/fullchain.pem" \
|
||||||
|
-subj "/CN=${DOMAIN}" \
|
||||||
|
-addext "subjectAltName=DNS:${DOMAIN},DNS:localhost,IP:127.0.0.1"
|
||||||
|
|
||||||
|
cp "${CERT_DIR}/live/${DOMAIN}/fullchain.pem" "${CERT_DIR}/live/${DOMAIN}/cert.pem"
|
||||||
|
|
||||||
|
cp "${CERT_DIR}/live/${DOMAIN}/fullchain.pem" "${CERT_DIR}/archive/${DOMAIN}-fullchain.pem"
|
||||||
|
cp "${CERT_DIR}/live/${DOMAIN}/privkey.pem" "${CERT_DIR}/archive/${DOMAIN}-privkey.pem"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Certificates generated:"
|
||||||
|
echo " Cert: ${CERT_DIR}/live/${DOMAIN}/fullchain.pem"
|
||||||
|
echo " Key: ${CERT_DIR}/live/${DOMAIN}/privkey.pem"
|
||||||
|
echo ""
|
||||||
|
echo "For Docker deployment, copy certs to the nginx-certs volume:"
|
||||||
|
echo " docker volume inspect infra_nginx-certs"
|
||||||
|
echo " sudo cp -r ${CERT_DIR}/live/${DOMAIN}/* <volume_mountpoint>/live/${DOMAIN}/"
|
||||||
|
echo ""
|
||||||
|
echo "Or for local dev, update nginx config to point to ${CERT_DIR}/live/${DOMAIN}/"
|
||||||
76
infra/scripts/healthcheck.sh
Executable file
76
infra/scripts/healthcheck.sh
Executable file
@@ -0,0 +1,76 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
INFRA_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
|
||||||
|
echo "=== EifelDC Health Check ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
OK=true
|
||||||
|
|
||||||
|
check_service() {
|
||||||
|
local name=$1
|
||||||
|
local url=$2
|
||||||
|
local expected_status=${3:-200}
|
||||||
|
|
||||||
|
if curl -sf -o /dev/null -w "%{http_code}" "${url}" 2>/dev/null | grep -q "${expected_status}"; then
|
||||||
|
echo " [OK] ${name}"
|
||||||
|
else
|
||||||
|
echo " [FAIL] ${name} - ${url}"
|
||||||
|
OK=false
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if command -v docker &>/dev/null && [ -f "${INFRA_DIR}/docker-compose.yml" ]; then
|
||||||
|
echo "[Docker Mode]"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "Container Status:"
|
||||||
|
cd "${INFRA_DIR}"
|
||||||
|
docker compose ps
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "Service Health:"
|
||||||
|
check_service "EifelDC Server" "http://localhost:3000/api/current-user" "401"
|
||||||
|
check_service "Synapse" "http://localhost:8008/_matrix/client/versions"
|
||||||
|
check_service "PostgreSQL" "localhost:5432"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Container Logs (last 5 lines):"
|
||||||
|
for svc in eifeldc synapse postgres nginx; do
|
||||||
|
if docker compose ps --services --filter "status=running" | grep -q "${svc}"; then
|
||||||
|
echo "--- ${svc} ---"
|
||||||
|
docker compose logs --tail=5 "${svc}" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
else
|
||||||
|
echo "[Native Mode]"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "Service Health:"
|
||||||
|
check_service "EifelDC Server" "http://localhost:3000/api/current-user" "401"
|
||||||
|
check_service "Synapse" "http://localhost:8008/_matrix/client/versions"
|
||||||
|
check_service "Nginx" "http://localhost:80/"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Systemd Status:"
|
||||||
|
for svc in eifeldc-synapse eifeldc-server coturn nginx; do
|
||||||
|
status=$(systemctl is-active "${svc}" 2>/dev/null || echo "unknown")
|
||||||
|
if [ "$status" = "active" ]; then
|
||||||
|
echo " [OK] ${svc}: ${status}"
|
||||||
|
else
|
||||||
|
echo " [FAIL] ${svc}: ${status}"
|
||||||
|
OK=false
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
if [ "$OK" = true ]; then
|
||||||
|
echo "=== All services healthy! ==="
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
echo "=== Some services are not healthy! ==="
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
281
infra/scripts/setup.sh
Executable file
281
infra/scripts/setup.sh
Executable file
@@ -0,0 +1,281 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DOMAIN="${1:-eifeldc.local}"
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
INFRA_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
|
||||||
|
echo "=== EifelDC Infrastructure Setup ==="
|
||||||
|
echo "Domain: ${DOMAIN}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
read -p "Use Docker? (y/N): " USE_DOCKER
|
||||||
|
if [[ "${USE_DOCKER,,}" == "y" ]]; then
|
||||||
|
echo ""
|
||||||
|
echo "[Docker Mode]"
|
||||||
|
|
||||||
|
echo "[1/5] Generating secrets..."
|
||||||
|
TURN_SECRET=$(openssl rand -hex 16)
|
||||||
|
MACAROON_SECRET=$(openssl rand -hex 32)
|
||||||
|
FORM_SECRET=$(openssl rand -hex 32)
|
||||||
|
POSTGRES_PASSWORD=$(openssl rand -hex 16)
|
||||||
|
|
||||||
|
echo "[2/5] Writing .env file..."
|
||||||
|
cat > "${INFRA_DIR}/.env" <<EOF
|
||||||
|
DOMAIN=${DOMAIN}
|
||||||
|
POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||||
|
TURN_SECRET=${TURN_SECRET}
|
||||||
|
MACAROON_SECRET=${MACAROON_SECRET}
|
||||||
|
FORM_SECRET=${FORM_SECRET}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "[3/5] Generating Synapse config..."
|
||||||
|
if [ ! -d "${INFRA_DIR}/synapse-data" ]; then
|
||||||
|
mkdir -p "${INFRA_DIR}/synapse-data"
|
||||||
|
docker run -it --rm \
|
||||||
|
-v "$(pwd)/synapse-data:/data" \
|
||||||
|
-e SYNAPSE_SERVER_NAME="${DOMAIN}" \
|
||||||
|
-e SYNAPSE_REPORT_STATS=no \
|
||||||
|
matrixdotorg/synapse:latest generate
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[4/5] Patching Synapse config with secrets..."
|
||||||
|
if [ -f "${INFRA_DIR}/synapse-data/homeserver.yaml" ]; then
|
||||||
|
sed -i "s/synapse_password/${POSTGRES_PASSWORD}/g" "${INFRA_DIR}/synapse-data/homeserver.yaml"
|
||||||
|
sed -i "s/CHANGE_ME_GENERATE_A_RANDOM_STRING/${MACAROON_SECRET}/g" "${INFRA_DIR}/synapse-data/homeserver.yaml"
|
||||||
|
sed -i "s/CHANGE_ME_GENERATE_ANOTHER_RANDOM_STRING/${FORM_SECRET}/g" "${INFRA_DIR}/synapse-data/homeserver.yaml"
|
||||||
|
sed -i "s/CHANGE_ME_MATCH_COTURN_SECRET/${TURN_SECRET}/g" "${INFRA_DIR}/synapse-data/homeserver.yaml"
|
||||||
|
sed -i "s/eifeldc.local/${DOMAIN}/g" "${INFRA_DIR}/synapse-data/homeserver.yaml"
|
||||||
|
fi
|
||||||
|
|
||||||
|
sed -i "s/CHANGE_ME_GENERATE_A_SECRET/${TURN_SECRET}/g" "${INFRA_DIR}/coturn/turnserver.conf" 2>/dev/null || true
|
||||||
|
sed -i "s/eifeldc.local/${DOMAIN}/g" "${INFRA_DIR}/coturn/turnserver.conf" 2>/dev/null || true
|
||||||
|
sed -i "s/eifeldc.local/${DOMAIN}/g" "${INFRA_DIR}/nginx/eifeldc.conf" 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "[5/5] Generating self-signed certs and starting containers..."
|
||||||
|
"${SCRIPT_DIR}/generate-certs.sh" "${DOMAIN}"
|
||||||
|
cd "${INFRA_DIR}"
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Waiting for Synapse..."
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if curl -sf "http://localhost:8008/_matrix/client/versions" > /dev/null 2>&1; then
|
||||||
|
echo "Synapse is ready!"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Docker Setup Complete! ==="
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo "[Native Mode]"
|
||||||
|
SYNAPSE_DIR="/opt/eifeldc/synapse-data"
|
||||||
|
NGINX_CONF="/etc/nginx/sites-available/eifeldc"
|
||||||
|
COTURN_CONF="/etc/turnserver.conf"
|
||||||
|
|
||||||
|
echo "[1/6] Generating secrets..."
|
||||||
|
TURN_SECRET=$(openssl rand -hex 16)
|
||||||
|
MACAROON_SECRET=$(openssl rand -hex 32)
|
||||||
|
FORM_SECRET=$(openssl rand -hex 32)
|
||||||
|
POSTGRES_PASSWORD=$(openssl rand -hex 16)
|
||||||
|
|
||||||
|
echo "[2/6] Writing .env file..."
|
||||||
|
cat > "${INFRA_DIR}/.env" <<EOF
|
||||||
|
DOMAIN=${DOMAIN}
|
||||||
|
POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||||
|
TURN_SECRET=${TURN_SECRET}
|
||||||
|
MACAROON_SECRET=${MACAROON_SECRET}
|
||||||
|
FORM_SECRET=${FORM_SECRET}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "[3/6] Installing system dependencies..."
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y \
|
||||||
|
python3-pip python3-venv nginx coturn certbot \
|
||||||
|
libffi-dev libssl-dev libjpeg-dev libxml2-dev libxslt1-dev \
|
||||||
|
postgresql postgresql-contrib curl
|
||||||
|
|
||||||
|
echo "[4/6] Setting up PostgreSQL..."
|
||||||
|
sudo -u postgres psql -c "CREATE USER synapse WITH PASSWORD '${POSTGRES_PASSWORD}';" 2>/dev/null || true
|
||||||
|
sudo -u postgres psql -c "CREATE DATABASE synapse ENCODING 'UTF8' LC_COLLATE='C' LC_CTYPE='C' TEMPLATE template0 OWNER synapse;" 2>/dev/null || true
|
||||||
|
sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE synapse TO synapse;" 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "[5/6] Installing and configuring Synapse..."
|
||||||
|
mkdir -p "${SYNAPSE_DIR}"
|
||||||
|
if [ ! -d "${SYNAPSE_DIR}/venv" ]; then
|
||||||
|
python3 -m venv "${SYNAPSE_DIR}/venv"
|
||||||
|
"${SYNAPSE_DIR}/venv/bin/pip" install --upgrade pip
|
||||||
|
"${SYNAPSE_DIR}/venv/bin/pip" install matrix-synapse
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "${SYNAPSE_DIR}/homeserver.yaml" ]; then
|
||||||
|
"${SYNAPSE_DIR}/venv/bin/python" -m synapse.app.homeserver \
|
||||||
|
--server-name "${DOMAIN}" \
|
||||||
|
--config-path "${SYNAPSE_DIR}/homeserver.yaml" \
|
||||||
|
--generate-config \
|
||||||
|
--report-stats=no
|
||||||
|
fi
|
||||||
|
|
||||||
|
sed -i "s/synapse_password/${POSTGRES_PASSWORD}/g" "${SYNAPSE_DIR}/homeserver.yaml" 2>/dev/null || true
|
||||||
|
sed -i "s/CHANGE_ME_GENERATE_A_RANDOM_STRING/${MACAROON_SECRET}/g" "${SYNAPSE_DIR}/homeserver.yaml" 2>/dev/null || true
|
||||||
|
sed -i "s/CHANGE_ME_GENERATE_ANOTHER_RANDOM_STRING/${FORM_SECRET}/g" "${SYNAPSE_DIR}/homeserver.yaml" 2>/dev/null || true
|
||||||
|
sed -i "s/CHANGE_ME_MATCH_COTURN_SECRET/${TURN_SECRET}/g" "${SYNAPSE_DIR}/homeserver.yaml" 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "[6/6] Configuring services..."
|
||||||
|
|
||||||
|
sudo tee "${COTURN_CONF}" > /dev/null <<EOF
|
||||||
|
listening-port=3478
|
||||||
|
tls-listening-port=5349
|
||||||
|
realm=${DOMAIN}
|
||||||
|
server-name=${DOMAIN}
|
||||||
|
fingerprint
|
||||||
|
lt-cred-mech
|
||||||
|
use-auth-secret
|
||||||
|
static-auth-secret=${TURN_SECRET}
|
||||||
|
total-quota=100
|
||||||
|
stale-nonce=600
|
||||||
|
cert=/etc/letsencrypt/live/${DOMAIN}/cert.pem
|
||||||
|
pkey=/etc/letsencrypt/live/${DOMAIN}/privkey.pem
|
||||||
|
no-multicast-peers
|
||||||
|
no-cli
|
||||||
|
log-file=/var/log/turnserver/turnserver.log
|
||||||
|
simple-log
|
||||||
|
EOF
|
||||||
|
|
||||||
|
sudo sed -i 's/#TURNSERVER_ENABLED=1/TURNSERVER_ENABLED=1/' /etc/default/coturn 2>/dev/null || true
|
||||||
|
|
||||||
|
sudo tee "${NGINX_CONF}" > /dev/null <<'NGINXEOF'
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name DOMAIN_PLACEHOLDER;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name DOMAIN_PLACEHOLDER;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/DOMAIN_PLACEHOLDER/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/DOMAIN_PLACEHOLDER/privkey.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
ssl_prefer_server_ciphers on;
|
||||||
|
ssl_session_cache shared:SSL:10m;
|
||||||
|
ssl_session_timeout 10m;
|
||||||
|
|
||||||
|
client_max_body_size 50M;
|
||||||
|
|
||||||
|
add_header X-Frame-Options SAMEORIGIN;
|
||||||
|
add_header X-Content-Type-Options nosniff;
|
||||||
|
add_header X-XSS-Protection "1; mode=block";
|
||||||
|
|
||||||
|
location /.well-known/matrix/server {
|
||||||
|
default_type application/json;
|
||||||
|
return 200 '{"m.server": "DOMAIN_PLACEHOLDER:443"}';
|
||||||
|
}
|
||||||
|
|
||||||
|
location /.well-known/matrix/client {
|
||||||
|
default_type application/json;
|
||||||
|
return 200 '{"m.homeserver": {"base_url": "https://DOMAIN_PLACEHOLDER"}}';
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ ^/_matrix/(client|media|federation|key|v2) {
|
||||||
|
proxy_pass http://127.0.0.1:8008;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_read_timeout 600s;
|
||||||
|
proxy_send_timeout 600s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /_synapse {
|
||||||
|
proxy_pass http://127.0.0.1:8008;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:3000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:3000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NGINXEOF
|
||||||
|
|
||||||
|
sudo sed -i "s/DOMAIN_PLACEHOLDER/${DOMAIN}/g" "${NGINX_CONF}"
|
||||||
|
sudo ln -sf "${NGINX_CONF}" /etc/nginx/sites-enabled/eifeldc 2>/dev/null || true
|
||||||
|
sudo nginx -t && sudo systemctl reload nginx || true
|
||||||
|
|
||||||
|
sudo tee /etc/systemd/system/eifeldc-synapse.service > /dev/null <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=EifelDC Synapse Homeserver
|
||||||
|
After=network.target postgresql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=synapse
|
||||||
|
Group=synapse
|
||||||
|
WorkingDirectory=${SYNAPSE_DIR}
|
||||||
|
ExecStart=${SYNAPSE_DIR}/venv/bin/python -m synapse.app.homeserver --config-path ${SYNAPSE_DIR}/homeserver.yaml
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
sudo tee /etc/systemd/system/eifeldc-server.service > /dev/null <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=EifelDC Web Server
|
||||||
|
After=network.target eifeldc-synapse.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=/opt/eifeldc
|
||||||
|
Environment=EIFELDC_STATIC_DIR=/opt/eifeldc/client/src-ui/dist
|
||||||
|
Environment=RUST_LOG=eifeldc_server=info,tower_http=info
|
||||||
|
ExecStart=/opt/eifeldc/target/release/eifeldc-server
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable eifeldc-synapse eifeldc-server coturn
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Native Setup Complete! ==="
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Next steps:"
|
||||||
|
echo " 1. Get SSL certs:"
|
||||||
|
echo " Docker: ${SCRIPT_DIR}/generate-certs.sh ${DOMAIN}"
|
||||||
|
echo " Native: sudo certbot certonly --nginx -d ${DOMAIN}"
|
||||||
|
echo " 2. Start services:"
|
||||||
|
echo " Docker: ${SCRIPT_DIR}/start.sh"
|
||||||
|
echo " Native: sudo systemctl start eifeldc-synapse coturn nginx eifeldc-server"
|
||||||
|
echo " 3. Build EifelDC server: cd /opt/eifeldc && cargo build --release -p eifeldc-server"
|
||||||
|
echo " 4. Build EifelDC UI: cd /opt/eifeldc/client/src-ui && npm ci && npm run build"
|
||||||
|
echo " 5. Register first user via the EifelDC client"
|
||||||
|
echo ""
|
||||||
34
infra/scripts/start.sh
Normal file
34
infra/scripts/start.sh
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
INFRA_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
|
||||||
|
if command -v docker &>/dev/null && [ -f "${INFRA_DIR}/docker-compose.yml" ]; then
|
||||||
|
echo "=== Starting EifelDC Infrastructure (Docker) ==="
|
||||||
|
cd "${INFRA_DIR}"
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Waiting for services..."
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Container status:"
|
||||||
|
docker compose ps
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Running health check..."
|
||||||
|
"${SCRIPT_DIR}/healthcheck.sh" || true
|
||||||
|
else
|
||||||
|
echo "=== Starting EifelDC Infrastructure (Native) ==="
|
||||||
|
sudo systemctl start postgresql
|
||||||
|
sudo systemctl start eifeldc-synapse
|
||||||
|
sudo systemctl start coturn
|
||||||
|
sudo systemctl start nginx
|
||||||
|
sudo systemctl start eifeldc-server
|
||||||
|
|
||||||
|
echo "All services started."
|
||||||
|
echo ""
|
||||||
|
"${SCRIPT_DIR}/healthcheck.sh" || true
|
||||||
|
fi
|
||||||
19
infra/scripts/stop.sh
Normal file
19
infra/scripts/stop.sh
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
INFRA_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
|
||||||
|
if command -v docker &>/dev/null && [ -f "${INFRA_DIR}/docker-compose.yml" ]; then
|
||||||
|
echo "=== Stopping EifelDC Infrastructure (Docker) ==="
|
||||||
|
cd "${INFRA_DIR}"
|
||||||
|
docker compose down
|
||||||
|
echo "All containers stopped."
|
||||||
|
else
|
||||||
|
echo "=== Stopping EifelDC Infrastructure (Native) ==="
|
||||||
|
sudo systemctl stop eifeldc-server 2>/dev/null || true
|
||||||
|
sudo systemctl stop eifeldc-synapse 2>/dev/null || true
|
||||||
|
sudo systemctl stop coturn 2>/dev/null || true
|
||||||
|
sudo systemctl stop nginx 2>/dev/null || true
|
||||||
|
echo "All services stopped."
|
||||||
|
fi
|
||||||
88
infra/synapse/homeserver.yaml
Normal file
88
infra/synapse/homeserver.yaml
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
server_name: "eifeldc.local"
|
||||||
|
|
||||||
|
pid_file: "/data/homeserver.pid"
|
||||||
|
|
||||||
|
listeners:
|
||||||
|
- port: 8008
|
||||||
|
type: http
|
||||||
|
tls: false
|
||||||
|
x_forwarded: true
|
||||||
|
bind_addresses: ['0.0.0.0']
|
||||||
|
resources:
|
||||||
|
- names: [client, federation]
|
||||||
|
compress: false
|
||||||
|
|
||||||
|
- port: 8448
|
||||||
|
type: http
|
||||||
|
tls: false
|
||||||
|
x_forwarded: true
|
||||||
|
bind_addresses: ['0.0.0.0']
|
||||||
|
resources:
|
||||||
|
- names: [federation]
|
||||||
|
compress: false
|
||||||
|
|
||||||
|
database:
|
||||||
|
name: psycopg2
|
||||||
|
txn_limit: 10000
|
||||||
|
args:
|
||||||
|
user: synapse
|
||||||
|
password: synapse_password
|
||||||
|
database: synapse
|
||||||
|
host: postgres
|
||||||
|
cp_min: 5
|
||||||
|
cp_max: 10
|
||||||
|
|
||||||
|
log_config: "/data/log.config"
|
||||||
|
|
||||||
|
media_store_path: "/data/media_store"
|
||||||
|
uploads_path: "/data/uploads"
|
||||||
|
|
||||||
|
registration_enabled: true
|
||||||
|
enable_registration: true
|
||||||
|
enable_registration_without_verification: true
|
||||||
|
|
||||||
|
admin_contact: "admin@eifeldc.local"
|
||||||
|
|
||||||
|
macaroon_secret_key: "CHANGE_ME_GENERATE_A_RANDOM_STRING"
|
||||||
|
form_secret: "CHANGE_ME_GENERATE_ANOTHER_RANDOM_STRING"
|
||||||
|
signing_key_path: "/data/signing.key"
|
||||||
|
|
||||||
|
trusted_key_servers:
|
||||||
|
- server_name: "matrix.org"
|
||||||
|
|
||||||
|
turn_uris: ["turn:eifeldc.local?transport=udp", "turn:eifeldc.local?transport=tcp"]
|
||||||
|
turn_shared_secret: "CHANGE_ME_MATCH_COTURN_SECRET"
|
||||||
|
turn_user_lifetime: 86400000
|
||||||
|
|
||||||
|
enable_metrics: true
|
||||||
|
metrics_port: 9100
|
||||||
|
|
||||||
|
rc_message:
|
||||||
|
per_second: 5
|
||||||
|
burst_count: 50
|
||||||
|
|
||||||
|
rc_registration:
|
||||||
|
per_second: 0.5
|
||||||
|
burst_count: 3
|
||||||
|
|
||||||
|
rc_login:
|
||||||
|
account:
|
||||||
|
per_second: 0.5
|
||||||
|
burst_count: 3
|
||||||
|
address:
|
||||||
|
per_second: 0.5
|
||||||
|
burst_count: 3
|
||||||
|
|
||||||
|
rc_joins:
|
||||||
|
local:
|
||||||
|
per_second: 0.1
|
||||||
|
burst_count: 3
|
||||||
|
remote:
|
||||||
|
per_second: 0.01
|
||||||
|
burst_count: 3
|
||||||
|
|
||||||
|
app_service_config_files: []
|
||||||
|
|
||||||
|
instance_map: {}
|
||||||
|
|
||||||
|
stream_writers: {}
|
||||||
39
infra/synapse/log.config
Normal file
39
infra/synapse/log.config
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
version: 1
|
||||||
|
|
||||||
|
formatters:
|
||||||
|
precise:
|
||||||
|
format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s'
|
||||||
|
|
||||||
|
handlers:
|
||||||
|
console:
|
||||||
|
class: logging.StreamHandler
|
||||||
|
formatter: precise
|
||||||
|
stream: 'ext://sys.stdout'
|
||||||
|
|
||||||
|
file:
|
||||||
|
class: logging.handlers.RotatingFileHandler
|
||||||
|
formatter: precise
|
||||||
|
filename: /data/homeserver.log
|
||||||
|
maxBytes: 10485760
|
||||||
|
backupCount: 3
|
||||||
|
encoding: utf8
|
||||||
|
|
||||||
|
loggers:
|
||||||
|
synapse:
|
||||||
|
handlers: [console, file]
|
||||||
|
level: INFO
|
||||||
|
propagate: false
|
||||||
|
|
||||||
|
synapse.storage.SQL:
|
||||||
|
handlers: [console, file]
|
||||||
|
level: WARNING
|
||||||
|
propagate: false
|
||||||
|
|
||||||
|
synapse.federation.transport:
|
||||||
|
handlers: [console, file]
|
||||||
|
level: WARNING
|
||||||
|
propagate: false
|
||||||
|
|
||||||
|
root:
|
||||||
|
handlers: [console, file]
|
||||||
|
level: INFO
|
||||||
22
server/Cargo.toml
Normal file
22
server/Cargo.toml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
[package]
|
||||||
|
name = "eifeldc-server"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
matrix-sdk = { workspace = true }
|
||||||
|
matrix-sdk-base = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
tracing-subscriber = { workspace = true }
|
||||||
|
reqwest = { workspace = true }
|
||||||
|
url = { workspace = true }
|
||||||
|
axum = { version = "0.7", features = ["ws"] }
|
||||||
|
tower = "0.4"
|
||||||
|
tower-http = { version = "0.5", features = ["cors", "fs", "trace"] }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
27
server/Dockerfile
Normal file
27
server/Dockerfile
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
FROM rust:1.82-bookworm AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
COPY server/Cargo.toml server/Cargo.toml
|
||||||
|
RUN mkdir -p server/src && echo "fn main() {}" > server/src/main.rs
|
||||||
|
RUN cargo build --release -p eifeldc-server && rm -rf server/src
|
||||||
|
|
||||||
|
COPY server/src server/src
|
||||||
|
RUN touch server/src/main.rs && cargo build --release -p eifeldc-server
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y ca-certificates libssl3 && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN useradd -m -d /home/eifeldc eifeldc
|
||||||
|
USER eifeldc
|
||||||
|
|
||||||
|
COPY --from=builder /app/target/release/eifeldc-server /usr/local/bin/eifeldc-server
|
||||||
|
|
||||||
|
ENV EIFELDC_STATIC_DIR=/usr/share/eifeldc/client
|
||||||
|
ENV RUST_LOG=eifeldc_server=info,tower_http=info
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["eifeldc-server"]
|
||||||
4
server/src/lib.rs
Normal file
4
server/src/lib.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
pub mod routes;
|
||||||
|
pub mod state;
|
||||||
|
|
||||||
|
pub use state::ServerState;
|
||||||
25
server/src/main.rs
Normal file
25
server/src/main.rs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
use eifeldc_server::routes::api_router;
|
||||||
|
use eifeldc_server::ServerState;
|
||||||
|
use tower_http::services::ServeDir;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
tracing_subscriber::fmt::init();
|
||||||
|
|
||||||
|
let state = ServerState::new();
|
||||||
|
let api = api_router(state);
|
||||||
|
|
||||||
|
let static_dir = std::env::var("EIFELDC_STATIC_DIR")
|
||||||
|
.unwrap_or_else(|_| "client/src-ui/dist".to_string());
|
||||||
|
|
||||||
|
let app = api
|
||||||
|
.fallback_service(ServeDir::new(&static_dir));
|
||||||
|
|
||||||
|
let addr: SocketAddr = ([0, 0, 0, 0], 3000).into();
|
||||||
|
tracing::info!("EifelDC Web Server listening on http://{}", addr);
|
||||||
|
tracing::info!("Serving static files from {}", static_dir);
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
||||||
|
axum::serve(listener, app).await.unwrap();
|
||||||
|
}
|
||||||
160
server/src/routes/auth.rs
Normal file
160
server/src/routes/auth.rs
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::State,
|
||||||
|
http::{HeaderMap, StatusCode},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use matrix_sdk::Client;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use crate::state::VoiceManager;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct LoginRequest {
|
||||||
|
pub homeserver: String,
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct LoginResult {
|
||||||
|
pub success: bool,
|
||||||
|
pub user_id: String,
|
||||||
|
pub token: Option<String>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct RegisterRequest {
|
||||||
|
pub homeserver: String,
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn login(
|
||||||
|
State(state): State<crate::state::ServerState>,
|
||||||
|
Json(req): Json<LoginRequest>,
|
||||||
|
) -> Result<Json<LoginResult>, StatusCode> {
|
||||||
|
let client = Client::builder()
|
||||||
|
.homeserver_url(&req.homeserver)
|
||||||
|
.build()
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||||
|
|
||||||
|
client
|
||||||
|
.matrix_auth()
|
||||||
|
.login_username(&req.username, &req.password)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
|
let user_id = client
|
||||||
|
.user_id()
|
||||||
|
.map(|u| u.to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let token = uuid::Uuid::new_v4().to_string();
|
||||||
|
|
||||||
|
let mut s = state.write().await;
|
||||||
|
s.sessions.insert(token.clone(), crate::state::Session {
|
||||||
|
client,
|
||||||
|
user_id: user_id.clone(),
|
||||||
|
voice_manager: VoiceManager::new(),
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Json(LoginResult {
|
||||||
|
success: true,
|
||||||
|
user_id,
|
||||||
|
token: Some(token),
|
||||||
|
error: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn register(
|
||||||
|
State(state): State<crate::state::ServerState>,
|
||||||
|
Json(req): Json<RegisterRequest>,
|
||||||
|
) -> Result<Json<LoginResult>, StatusCode> {
|
||||||
|
let client = Client::builder()
|
||||||
|
.homeserver_url(&req.homeserver)
|
||||||
|
.build()
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||||
|
|
||||||
|
let mut request = matrix_sdk::ruma::api::client::account::register::v3::Request::new();
|
||||||
|
request.username = Some(req.username);
|
||||||
|
request.password = Some(req.password);
|
||||||
|
|
||||||
|
client
|
||||||
|
.matrix_auth()
|
||||||
|
.register(request)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||||
|
|
||||||
|
let user_id = client
|
||||||
|
.user_id()
|
||||||
|
.map(|u| u.to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let token = uuid::Uuid::new_v4().to_string();
|
||||||
|
|
||||||
|
let mut s = state.write().await;
|
||||||
|
s.sessions.insert(token.clone(), crate::state::Session {
|
||||||
|
client,
|
||||||
|
user_id: user_id.clone(),
|
||||||
|
voice_manager: VoiceManager::new(),
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Json(LoginResult {
|
||||||
|
success: true,
|
||||||
|
user_id,
|
||||||
|
token: Some(token),
|
||||||
|
error: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn logout(
|
||||||
|
State(state): State<crate::state::ServerState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Json<bool>, StatusCode> {
|
||||||
|
let token = extract_token(&headers).ok_or(StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
|
let mut s = state.write().await;
|
||||||
|
if let Some(session) = s.sessions.remove(&token) {
|
||||||
|
let _ = session.client.matrix_auth().logout().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_current_user(
|
||||||
|
State(state): State<crate::state::ServerState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Json<Option<String>>, StatusCode> {
|
||||||
|
let token = extract_token(&headers).ok_or(StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
|
let s = state.read().await;
|
||||||
|
let user_id = s.sessions.get(&token).map(|s| s.user_id.clone());
|
||||||
|
Ok(Json(user_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn extract_token(headers: &HeaderMap) -> Option<String> {
|
||||||
|
headers
|
||||||
|
.get("Authorization")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|v| v.strip_prefix("Bearer "))
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn auth_middleware(
|
||||||
|
headers: HeaderMap,
|
||||||
|
State(state): State<crate::state::ServerState>,
|
||||||
|
request: axum::extract::Request,
|
||||||
|
next: middleware::Next,
|
||||||
|
) -> Result<axum::response::Response, StatusCode> {
|
||||||
|
let token = extract_token(&headers).ok_or(StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
|
let s = state.read().await;
|
||||||
|
if !s.sessions.contains_key(&token) {
|
||||||
|
return Err(StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(next.run(request).await)
|
||||||
|
}
|
||||||
86
server/src/routes/emoji.rs
Normal file
86
server/src/routes/emoji.rs
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
http::HeaderMap,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use crate::ServerState;
|
||||||
|
use super::auth::extract_token;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct CustomEmoji {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub url: String,
|
||||||
|
pub category: String,
|
||||||
|
pub animated: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct StickerPack {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub stickers: Vec<Sticker>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct Sticker {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_custom_emoji(
|
||||||
|
State(state): State<ServerState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path(_room_id): Path<String>,
|
||||||
|
) -> Result<Json<Vec<CustomEmoji>>, axum::http::StatusCode> {
|
||||||
|
let _token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||||
|
Ok(Json(Vec::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UploadEmojiRequest {
|
||||||
|
pub name: String,
|
||||||
|
pub image_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn upload_emoji(
|
||||||
|
State(state): State<ServerState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path(room_id): Path<String>,
|
||||||
|
Json(req): Json<UploadEmojiRequest>,
|
||||||
|
) -> Result<Json<CustomEmoji>, axum::http::StatusCode> {
|
||||||
|
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||||
|
let s = state.read().await;
|
||||||
|
let session = s.sessions.get(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
|
let rid: matrix_sdk::ruma::OwnedRoomId = room_id.as_str().try_into().map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||||
|
let _room = session.client.get_room(&rid).ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
|
let path = std::path::Path::new(&req.image_path);
|
||||||
|
if !path.exists() {
|
||||||
|
return Err(axum::http::StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mime_type = match path.extension().and_then(|e| e.to_str()) {
|
||||||
|
Some("png") => "image/png",
|
||||||
|
Some("jpg") | Some("jpeg") => "image/jpeg",
|
||||||
|
Some("gif") => "image/gif",
|
||||||
|
Some("webp") => "image/webp",
|
||||||
|
_ => "image/png",
|
||||||
|
};
|
||||||
|
|
||||||
|
let data = std::fs::read(path).map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
let content_type = mime_type.parse::<matrix_sdk::ruma::mime::Mime>().map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
let response = session.client.media().upload(&content_type, data).await.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
Ok(Json(CustomEmoji {
|
||||||
|
id: format!("emoji_{}", chrono::Utc::now().timestamp()),
|
||||||
|
name: req.name,
|
||||||
|
url: response.content_uri.to_string(),
|
||||||
|
category: "custom".to_string(),
|
||||||
|
animated: mime_type == "image/gif",
|
||||||
|
}))
|
||||||
|
}
|
||||||
84
server/src/routes/messages.rs
Normal file
84
server/src/routes/messages.rs
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{Path, State, Query},
|
||||||
|
http::HeaderMap,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use serde::{Serialize, Deserialize};
|
||||||
|
use crate::ServerState;
|
||||||
|
use super::auth::extract_token;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct MessageInfo {
|
||||||
|
pub event_id: String,
|
||||||
|
pub sender: String,
|
||||||
|
pub body: String,
|
||||||
|
pub timestamp: u64,
|
||||||
|
pub reply_to: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct MessagesQuery {
|
||||||
|
pub limit: Option<u32>,
|
||||||
|
pub from: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_room_messages(
|
||||||
|
State(state): State<ServerState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path(room_id): Path<String>,
|
||||||
|
Query(query): Query<MessagesQuery>,
|
||||||
|
) -> Result<Json<Vec<MessageInfo>>, axum::http::StatusCode> {
|
||||||
|
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||||
|
let s = state.read().await;
|
||||||
|
let session = s.sessions.get(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
|
let rid: matrix_sdk::ruma::OwnedRoomId = room_id.as_str().try_into().map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||||
|
let room = session.client.get_room(&rid).ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
|
let limit = query.limit.unwrap_or(50);
|
||||||
|
let mut options = matrix_sdk::ruma::api::client::message::get_message_events::v3::Request::new();
|
||||||
|
options.limit = limit.into();
|
||||||
|
options.from = query.from.map(|t| t.into());
|
||||||
|
|
||||||
|
let messages = room.messages(options).await.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
let mut result = Vec::new();
|
||||||
|
for msg in messages.chunk {
|
||||||
|
if let matrix_sdk::ruma::events::AnySyncMessageLikeEvent::RoomMessage(ev) = msg {
|
||||||
|
result.push(MessageInfo {
|
||||||
|
event_id: ev.event_id().to_string(),
|
||||||
|
sender: ev.sender().to_string(),
|
||||||
|
body: ev.content().body().to_string(),
|
||||||
|
timestamp: ev.origin_server_ts().0,
|
||||||
|
reply_to: ev.content().in_reply_to().map(|r| r.event_id.to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SendMessageRequest {
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send_message(
|
||||||
|
State(state): State<ServerState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path(room_id): Path<String>,
|
||||||
|
Json(req): Json<SendMessageRequest>,
|
||||||
|
) -> Result<Json<String>, axum::http::StatusCode> {
|
||||||
|
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||||
|
let s = state.read().await;
|
||||||
|
let session = s.sessions.get(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
|
let rid: matrix_sdk::ruma::OwnedRoomId = room_id.as_str().try_into().map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||||
|
let room = session.client.get_room(&rid).ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
|
let content = matrix_sdk::ruma::events::room::message::RoomMessageEventContent::text_plain(&req.message);
|
||||||
|
let txn_id = matrix_sdk::ruma::TransactionId::new();
|
||||||
|
let response = room.send(content, Some(&txn_id)).await.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
Ok(Json(response.event_id.to_string()))
|
||||||
|
}
|
||||||
63
server/src/routes/mod.rs
Normal file
63
server/src/routes/mod.rs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
pub mod auth;
|
||||||
|
pub mod rooms;
|
||||||
|
pub mod messages;
|
||||||
|
pub mod presence;
|
||||||
|
pub mod voice;
|
||||||
|
pub mod emoji;
|
||||||
|
pub mod roles;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
Router,
|
||||||
|
routing::{get, post},
|
||||||
|
middleware,
|
||||||
|
http::HeaderMap,
|
||||||
|
};
|
||||||
|
use tower_http::cors::{CorsLayer, Any};
|
||||||
|
use crate::ServerState;
|
||||||
|
use crate::routes::auth::auth_middleware;
|
||||||
|
|
||||||
|
pub fn api_router(state: ServerState) -> Router {
|
||||||
|
let cors = CorsLayer::new()
|
||||||
|
.allow_origin(Any)
|
||||||
|
.allow_methods(Any)
|
||||||
|
.allow_headers(Any);
|
||||||
|
|
||||||
|
Router::new()
|
||||||
|
.nest("/api", api_routes(state))
|
||||||
|
.layer(cors)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn api_routes(state: ServerState) -> Router {
|
||||||
|
let public = Router::new()
|
||||||
|
.route("/login", post(auth::login))
|
||||||
|
.route("/register", post(auth::register))
|
||||||
|
.route("/current-user", get(auth::get_current_user));
|
||||||
|
|
||||||
|
let protected = Router::new()
|
||||||
|
.route("/logout", post(auth::logout))
|
||||||
|
.route("/rooms", get(rooms::get_joined_rooms))
|
||||||
|
.route("/rooms/create", post(rooms::create_room))
|
||||||
|
.route("/rooms/join", post(rooms::join_room))
|
||||||
|
.route("/rooms/{room_id}/leave", post(rooms::leave_room))
|
||||||
|
.route("/rooms/{room_id}/members", get(rooms::get_room_members))
|
||||||
|
.route("/rooms/{room_id}/messages", get(messages::get_room_messages))
|
||||||
|
.route("/rooms/{room_id}/send", post(messages::send_message))
|
||||||
|
.route("/presence/set", post(presence::set_presence))
|
||||||
|
.route("/presence/{user_id}", get(presence::get_presence))
|
||||||
|
.route("/voice/join", post(voice::join_voice_channel))
|
||||||
|
.route("/voice/leave", post(voice::leave_voice_channel))
|
||||||
|
.route("/voice/toggle-mute", post(voice::toggle_mute))
|
||||||
|
.route("/voice/toggle-deafen", post(voice::toggle_deafen))
|
||||||
|
.route("/rooms/{room_id}/roles", get(roles::get_roles))
|
||||||
|
.route("/rooms/{room_id}/roles/assign", post(roles::assign_role))
|
||||||
|
.route("/rooms/{room_id}/roles/remove", post(roles::remove_role))
|
||||||
|
.route("/rooms/{room_id}/permissions/{user_id}", get(roles::get_permissions))
|
||||||
|
.route("/rooms/{room_id}/emoji", get(emoji::get_custom_emoji))
|
||||||
|
.route("/rooms/{room_id}/emoji/upload", post(emoji::upload_emoji))
|
||||||
|
.layer(middleware::from_fn_with_state(state.clone(), auth_middleware));
|
||||||
|
|
||||||
|
Router::new()
|
||||||
|
.merge(public)
|
||||||
|
.merge(protected)
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
77
server/src/routes/presence.rs
Normal file
77
server/src/routes/presence.rs
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
http::HeaderMap,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use crate::ServerState;
|
||||||
|
use super::auth::extract_token;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SetPresenceRequest {
|
||||||
|
pub status: String,
|
||||||
|
pub status_msg: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct PresenceInfo {
|
||||||
|
pub user_id: String,
|
||||||
|
pub status: String,
|
||||||
|
pub status_msg: Option<String>,
|
||||||
|
pub last_active: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_presence(
|
||||||
|
State(state): State<ServerState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(req): Json<SetPresenceRequest>,
|
||||||
|
) -> Result<Json<bool>, axum::http::StatusCode> {
|
||||||
|
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||||
|
let s = state.read().await;
|
||||||
|
let session = s.sessions.get(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
|
let presence_state = match req.status.as_str() {
|
||||||
|
"online" => matrix_sdk::ruma::presence::PresenceState::Online,
|
||||||
|
"away" => matrix_sdk::ruma::presence::PresenceState::Away,
|
||||||
|
"unavailable" => matrix_sdk::ruma::presence::PresenceState::Unavailable,
|
||||||
|
_ => matrix_sdk::ruma::presence::PresenceState::Online,
|
||||||
|
};
|
||||||
|
|
||||||
|
let user_id = session.client.user_id().ok_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
let mut request = matrix_sdk::ruma::api::client::presence::set_presence::v3::Request::new(user_id.to_owned());
|
||||||
|
request.presence = presence_state;
|
||||||
|
request.status_msg = req.status_msg;
|
||||||
|
|
||||||
|
session.client.send(request, None).await.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
Ok(Json(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_presence(
|
||||||
|
State(state): State<ServerState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path(user_id): Path<String>,
|
||||||
|
) -> Result<Json<PresenceInfo>, axum::http::StatusCode> {
|
||||||
|
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||||
|
let s = state.read().await;
|
||||||
|
let session = s.sessions.get(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
|
let uid: matrix_sdk::ruma::OwnedUserId = user_id.as_str().try_into().map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||||
|
let request = matrix_sdk::ruma::api::client::presence::get_presence::v3::Request::new(uid.to_owned());
|
||||||
|
|
||||||
|
let response = session.client.send(request, None).await.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
let status_str = match response.presence {
|
||||||
|
matrix_sdk::ruma::presence::PresenceState::Online => "online",
|
||||||
|
matrix_sdk::ruma::presence::PresenceState::Away => "away",
|
||||||
|
matrix_sdk::ruma::presence::PresenceState::Unavailable => "unavailable",
|
||||||
|
_ => "offline",
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(PresenceInfo {
|
||||||
|
user_id,
|
||||||
|
status: status_str.to_string(),
|
||||||
|
status_msg: response.status_msg,
|
||||||
|
last_active: response.last_active_ago.map(|d| d.as_secs()),
|
||||||
|
}))
|
||||||
|
}
|
||||||
159
server/src/routes/roles.rs
Normal file
159
server/src/routes/roles.rs
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
http::HeaderMap,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use crate::ServerState;
|
||||||
|
use super::auth::extract_token;
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
|
pub struct Role {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub color: String,
|
||||||
|
pub permissions: Vec<String>,
|
||||||
|
pub position: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct Permissions {
|
||||||
|
pub can_send_messages: bool,
|
||||||
|
pub can_delete_messages: bool,
|
||||||
|
pub can_manage_channels: bool,
|
||||||
|
pub can_manage_roles: bool,
|
||||||
|
pub can_kick: bool,
|
||||||
|
pub can_ban: bool,
|
||||||
|
pub can_manage_emoji: bool,
|
||||||
|
pub can_manage_threads: bool,
|
||||||
|
pub can_voice_connect: bool,
|
||||||
|
pub can_voice_stream: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn power_level_to_permissions(power_level: i64) -> Permissions {
|
||||||
|
Permissions {
|
||||||
|
can_send_messages: power_level >= 0,
|
||||||
|
can_delete_messages: power_level >= 50,
|
||||||
|
can_manage_channels: power_level >= 50,
|
||||||
|
can_manage_roles: power_level >= 75,
|
||||||
|
can_kick: power_level >= 50,
|
||||||
|
can_ban: power_level >= 75,
|
||||||
|
can_manage_emoji: power_level >= 50,
|
||||||
|
can_manage_threads: power_level >= 0,
|
||||||
|
can_voice_connect: power_level >= 0,
|
||||||
|
can_voice_stream: power_level >= 25,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_roles(
|
||||||
|
State(state): State<ServerState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path(room_id): Path<String>,
|
||||||
|
) -> Result<Json<Vec<Role>>, axum::http::StatusCode> {
|
||||||
|
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||||
|
let s = state.read().await;
|
||||||
|
let session = s.sessions.get(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
|
let rid: matrix_sdk::ruma::OwnedRoomId = room_id.as_str().try_into().map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||||
|
let room = session.client.get_room(&rid).ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
|
let mut roles = Vec::new();
|
||||||
|
if let Ok(power_levels) = room.power_levels().await {
|
||||||
|
for (uid, power_level) in &power_levels.users {
|
||||||
|
roles.push(Role {
|
||||||
|
id: format!("role_{}", uid),
|
||||||
|
name: uid.to_string(),
|
||||||
|
color: if *power_level >= 100 { "#ed4245".to_string() } else if *power_level >= 50 { "#fee75c".to_string() } else { "#5865f2".to_string() },
|
||||||
|
permissions: vec![],
|
||||||
|
position: *power_level as i32,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(roles))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct AssignRoleRequest {
|
||||||
|
pub user_id: String,
|
||||||
|
pub role_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn assign_role(
|
||||||
|
State(state): State<ServerState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path(room_id): Path<String>,
|
||||||
|
Json(req): Json<AssignRoleRequest>,
|
||||||
|
) -> Result<Json<bool>, axum::http::StatusCode> {
|
||||||
|
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||||
|
let s = state.read().await;
|
||||||
|
let session = s.sessions.get(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
|
let rid: matrix_sdk::ruma::OwnedRoomId = room_id.as_str().try_into().map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||||
|
let room = session.client.get_room(&rid).ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||||
|
let uid: matrix_sdk::ruma::OwnedUserId = req.user_id.as_str().try_into().map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||||
|
|
||||||
|
let power_level: i64 = match req.role_id.as_str() {
|
||||||
|
"admin" => 100,
|
||||||
|
"moderator" => 50,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut content = matrix_sdk::ruma::events::room::power_levels::RoomPowerLevelsEventContent::new();
|
||||||
|
content.users.insert(uid.to_owned(), power_level.into());
|
||||||
|
|
||||||
|
room.send_state_event(content).await.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
Ok(Json(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct RemoveRoleRequest {
|
||||||
|
pub user_id: String,
|
||||||
|
pub role_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_role(
|
||||||
|
State(state): State<ServerState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path(room_id): Path<String>,
|
||||||
|
Json(req): Json<RemoveRoleRequest>,
|
||||||
|
) -> Result<Json<bool>, axum::http::StatusCode> {
|
||||||
|
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||||
|
let s = state.read().await;
|
||||||
|
let session = s.sessions.get(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
|
let rid: matrix_sdk::ruma::OwnedRoomId = room_id.as_str().try_into().map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||||
|
let room = session.client.get_room(&rid).ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||||
|
let uid: matrix_sdk::ruma::OwnedUserId = req.user_id.as_str().try_into().map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||||
|
|
||||||
|
if let Ok(mut power_levels) = room.power_levels().await {
|
||||||
|
power_levels.users.remove(&uid);
|
||||||
|
let content = matrix_sdk::ruma::events::room::power_levels::RoomPowerLevelsEventContent::from(power_levels);
|
||||||
|
room.send_state_event(content).await.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_permissions(
|
||||||
|
State(state): State<ServerState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path((room_id, user_id)): Path<(String, String)>,
|
||||||
|
) -> Result<Json<Permissions>, axum::http::StatusCode> {
|
||||||
|
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||||
|
let s = state.read().await;
|
||||||
|
let session = s.sessions.get(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
|
let rid: matrix_sdk::ruma::OwnedRoomId = room_id.as_str().try_into().map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||||
|
let room = session.client.get_room(&rid).ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||||
|
let uid: matrix_sdk::ruma::OwnedUserId = user_id.as_str().try_into().map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||||
|
|
||||||
|
let user_power = if let Ok(power_levels) = room.power_levels().await {
|
||||||
|
power_levels.users.get(&uid).copied().map(|p| p.into()).unwrap_or(power_levels.users_default as i64)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(power_level_to_permissions(user_power)))
|
||||||
|
}
|
||||||
134
server/src/routes/rooms.rs
Normal file
134
server/src/routes/rooms.rs
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
http::HeaderMap,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use serde::Serialize;
|
||||||
|
use crate::ServerState;
|
||||||
|
use super::auth::extract_token;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct RoomInfo {
|
||||||
|
pub room_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
pub is_encrypted: bool,
|
||||||
|
pub member_count: u64,
|
||||||
|
pub topic: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_joined_rooms(
|
||||||
|
State(state): State<ServerState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Json<Vec<RoomInfo>>, axum::http::StatusCode> {
|
||||||
|
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||||
|
let s = state.read().await;
|
||||||
|
let session = s.sessions.get(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
|
let rooms = session.client.joined_rooms();
|
||||||
|
let mut result = Vec::new();
|
||||||
|
for room in rooms {
|
||||||
|
let name = room.display_name().await.map(|n| n.to_string()).unwrap_or_default();
|
||||||
|
let avatar_url = room.avatar_url().map(|u| u.to_string());
|
||||||
|
let member_count = room.joined_members().len() as u64;
|
||||||
|
let topic = room.topic().map(|t| t.to_string());
|
||||||
|
let is_encrypted = room.is_encrypted().await.unwrap_or(false);
|
||||||
|
result.push(RoomInfo {
|
||||||
|
room_id: room.room_id().to_string(),
|
||||||
|
name,
|
||||||
|
avatar_url,
|
||||||
|
is_encrypted,
|
||||||
|
member_count,
|
||||||
|
topic,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct CreateRoomRequest {
|
||||||
|
pub name: String,
|
||||||
|
pub topic: Option<String>,
|
||||||
|
pub visibility: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_room(
|
||||||
|
State(state): State<ServerState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(req): Json<CreateRoomRequest>,
|
||||||
|
) -> Result<Json<String>, axum::http::StatusCode> {
|
||||||
|
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||||
|
let s = state.read().await;
|
||||||
|
let session = s.sessions.get(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
|
let vis = match req.visibility.as_deref() {
|
||||||
|
Some("public") => matrix_sdk::ruma::Space::Public,
|
||||||
|
_ => matrix_sdk::ruma::Space::Private,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut request = matrix_sdk::ruma::api::client::room::create_room::v3::Request::new();
|
||||||
|
request.name = Some(req.name);
|
||||||
|
request.topic = req.topic;
|
||||||
|
request.visibility = vis;
|
||||||
|
|
||||||
|
let response = session.client.create_room(request).await.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
Ok(Json(response.room_id.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct JoinRoomRequest {
|
||||||
|
pub room_id_or_alias: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn join_room(
|
||||||
|
State(state): State<ServerState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(req): Json<JoinRoomRequest>,
|
||||||
|
) -> Result<Json<String>, axum::http::StatusCode> {
|
||||||
|
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||||
|
let s = state.read().await;
|
||||||
|
let session = s.sessions.get(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
|
let room_id = session.client
|
||||||
|
.join_room_by_id_or_alias(
|
||||||
|
&req.room_id_or_alias.try_into().map_err(|_| axum::http::StatusCode::BAD_REQUEST)?,
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
Ok(Json(room_id.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn leave_room(
|
||||||
|
State(state): State<ServerState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path(room_id): Path<String>,
|
||||||
|
) -> Result<Json<bool>, axum::http::StatusCode> {
|
||||||
|
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||||
|
let s = state.read().await;
|
||||||
|
let session = s.sessions.get(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
|
let rid: matrix_sdk::ruma::OwnedRoomId = room_id.as_str().try_into().map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||||
|
let room = session.client.get_room(&rid).ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||||
|
room.leave().await.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
Ok(Json(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_room_members(
|
||||||
|
State(state): State<ServerState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path(room_id): Path<String>,
|
||||||
|
) -> Result<Json<Vec<String>>, axum::http::StatusCode> {
|
||||||
|
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||||
|
let s = state.read().await;
|
||||||
|
let session = s.sessions.get(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
|
let rid: matrix_sdk::ruma::OwnedRoomId = room_id.as_str().try_into().map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||||
|
let room = session.client.get_room(&rid).ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
|
let members = room.joined_members();
|
||||||
|
Ok(Json(members.iter().map(|m| m.user_id().to_string()).collect()))
|
||||||
|
}
|
||||||
80
server/src/routes/voice.rs
Normal file
80
server/src/routes/voice.rs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::State,
|
||||||
|
http::HeaderMap,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use crate::ServerState;
|
||||||
|
use super::auth::extract_token;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct VoiceRequest {
|
||||||
|
pub room_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct VoiceStateInfo {
|
||||||
|
pub room_id: String,
|
||||||
|
pub muted: bool,
|
||||||
|
pub deafened: bool,
|
||||||
|
pub streaming: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn join_voice_channel(
|
||||||
|
State(state): State<ServerState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(req): Json<VoiceRequest>,
|
||||||
|
) -> Result<Json<VoiceStateInfo>, axum::http::StatusCode> {
|
||||||
|
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||||
|
let mut s = state.write().await;
|
||||||
|
let session = s.sessions.get_mut(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
|
session.voice_manager.join_channel(req.room_id.clone(), session.user_id.clone());
|
||||||
|
|
||||||
|
Ok(Json(VoiceStateInfo {
|
||||||
|
room_id: req.room_id,
|
||||||
|
muted: false,
|
||||||
|
deafened: false,
|
||||||
|
streaming: false,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn leave_voice_channel(
|
||||||
|
State(state): State<ServerState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(req): Json<VoiceRequest>,
|
||||||
|
) -> Result<Json<bool>, axum::http::StatusCode> {
|
||||||
|
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||||
|
let mut s = state.write().await;
|
||||||
|
let session = s.sessions.get_mut(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
|
Ok(Json(session.voice_manager.leave_channel(&req.room_id, &session.user_id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn toggle_mute(
|
||||||
|
State(state): State<ServerState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(req): Json<VoiceRequest>,
|
||||||
|
) -> Result<Json<bool>, axum::http::StatusCode> {
|
||||||
|
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||||
|
let mut s = state.write().await;
|
||||||
|
let session = s.sessions.get_mut(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
|
session.voice_manager.toggle_mute(&req.room_id, &session.user_id)
|
||||||
|
.ok_or(axum::http::StatusCode::BAD_REQUEST)
|
||||||
|
.map(Json)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn toggle_deafen(
|
||||||
|
State(state): State<ServerState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(req): Json<VoiceRequest>,
|
||||||
|
) -> Result<Json<bool>, axum::http::StatusCode> {
|
||||||
|
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||||
|
let mut s = state.write().await;
|
||||||
|
let session = s.sessions.get_mut(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
|
session.voice_manager.toggle_deafen(&req.room_id, &session.user_id)
|
||||||
|
.ok_or(axum::http::StatusCode::BAD_REQUEST)
|
||||||
|
.map(Json)
|
||||||
|
}
|
||||||
117
server/src/state.rs
Normal file
117
server/src/state.rs
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
use matrix_sdk::Client;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
pub struct Session {
|
||||||
|
pub client: Client,
|
||||||
|
pub user_id: String,
|
||||||
|
pub voice_manager: VoiceManager,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct VoiceManager {
|
||||||
|
channels: HashMap<String, VoiceChannel>,
|
||||||
|
active_channel: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct VoiceChannel {
|
||||||
|
pub room_id: String,
|
||||||
|
pub participants: Vec<VoiceParticipant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct VoiceParticipant {
|
||||||
|
pub user_id: String,
|
||||||
|
pub muted: bool,
|
||||||
|
pub deafened: bool,
|
||||||
|
pub streaming: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VoiceManager {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
channels: HashMap::new(),
|
||||||
|
active_channel: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn join_channel(&mut self, room_id: String, user_id: String) -> bool {
|
||||||
|
if let Some(ref old_channel) = self.active_channel {
|
||||||
|
self.leave_channel_internal(old_channel, &user_id);
|
||||||
|
}
|
||||||
|
let channel = self.channels.entry(room_id.clone()).or_insert_with(|| VoiceChannel {
|
||||||
|
room_id: room_id.clone(),
|
||||||
|
participants: Vec::new(),
|
||||||
|
});
|
||||||
|
if channel.participants.iter().any(|p| p.user_id == user_id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
channel.participants.push(VoiceParticipant {
|
||||||
|
user_id,
|
||||||
|
muted: false,
|
||||||
|
deafened: false,
|
||||||
|
streaming: false,
|
||||||
|
});
|
||||||
|
self.active_channel = Some(room_id);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn leave_channel_internal(&mut self, room_id: &str, user_id: &str) {
|
||||||
|
if let Some(channel) = self.channels.get_mut(room_id) {
|
||||||
|
channel.participants.retain(|p| p.user_id != user_id);
|
||||||
|
if channel.participants.is_empty() {
|
||||||
|
self.channels.remove(room_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn leave_channel(&mut self, room_id: &str, user_id: &str) -> bool {
|
||||||
|
self.leave_channel_internal(room_id, user_id);
|
||||||
|
if self.active_channel.as_deref() == Some(room_id) {
|
||||||
|
self.active_channel = None;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle_mute(&mut self, room_id: &str, user_id: &str) -> Option<bool> {
|
||||||
|
if let Some(channel) = self.channels.get_mut(room_id) {
|
||||||
|
if let Some(participant) = channel.participants.iter_mut().find(|p| p.user_id == user_id) {
|
||||||
|
participant.muted = !participant.muted;
|
||||||
|
return Some(participant.muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle_deafen(&mut self, room_id: &str, user_id: &str) -> Option<bool> {
|
||||||
|
if let Some(channel) = self.channels.get_mut(room_id) {
|
||||||
|
if let Some(participant) = channel.participants.iter_mut().find(|p| p.user_id == user_id) {
|
||||||
|
participant.deafened = !participant.deafened;
|
||||||
|
if participant.deafened {
|
||||||
|
participant.muted = true;
|
||||||
|
}
|
||||||
|
return Some(participant.deafened);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ServerStateInner {
|
||||||
|
pub sessions: HashMap<String, Session>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServerStateInner {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
sessions: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type ServerState = Arc<RwLock<ServerStateInner>>;
|
||||||
|
|
||||||
|
impl ServerState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Arc::new(RwLock::new(ServerStateInner::new()))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user