feat: comprehensive project improvements
Some checks failed
CI / Rust Format (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Test Server (push) Has been cancelled
CI / Frontend Check (push) Has been cancelled
CI / Tauri Client Check (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Build Tauri (Linux) (push) Has been cancelled

- Fix 14 Clippy warnings across server and bot-sdk
- Add 67 unit tests (32 bot-sdk, 34 server, 1 doctest)
- Add Prometheus metrics endpoint (/api/metrics)
- Add structured JSON logging (EIFELDC_LOG_FORMAT=json)
- Add release workflow (Docker push + GitHub Release + Tauri builds)
- Add rate limiting middleware (EIFELDC_RATE_LIMIT)
- Add CORS restriction (EIFELDC_CORS_ORIGINS)
- Add session token expiry (EIFELDC_SESSION_TTL)
- Add input validation (username/password/homeserver length limits)
- Add upload size limit (EIFELDC_MAX_UPLOAD_MB)
- Upgrade Tauri client from v1 to v2
- Add session store with SQLite persistence
- Add proper error types and cleanup across all crates
- Format all code with cargo fmt
- Update CI pipeline with fmt, clippy, test, frontend, and Tauri checks
- Add README with full API reference and setup guide
This commit is contained in:
root
2026-04-29 13:08:01 +02:00
parent 0978d0c2e9
commit cacd2b04a7
80 changed files with 18307 additions and 1724 deletions

View File

@@ -4,8 +4,6 @@ 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 }
@@ -15,15 +13,9 @@ 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"
tauri = { version = "2", features = [] }
tauri-plugin-shell = "2"
tauri-plugin-dialog = "2"
[build-dependencies]
tauri-build = { version = "1", features = [] }
tauri-build = { version = "2", features = [] }

View File

@@ -1,3 +1,3 @@
fn main() {
tauri_build::build()
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,15 @@
{
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-utils/schema.json",
"identifier": "default",
"description": "Default capability for EifelDC",
"windows": ["main"],
"permissions": [
"core:default",
"shell:allow-open",
"dialog:allow-open",
"dialog:allow-save",
"dialog:allow-message",
"dialog:allow-ask",
"dialog:allow-confirm"
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 505 B

After

Width:  |  Height:  |  Size: 471 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 935 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 B

After

Width:  |  Height:  |  Size: 185 B

View File

@@ -1,69 +1,55 @@
{
"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
}
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/schema.json",
"productName": "EifelDC",
"version": "0.1.0",
"identifier": "de.eifeldc",
"build": {
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build",
"devUrl": "http://localhost:5173",
"frontendDist": "../src-ui/dist"
},
"app": {
"windows": [
{
"title": "EifelDC",
"width": 1280,
"height": 720,
"minWidth": 800,
"minHeight": 600,
"resizable": true,
"fullscreen": false,
"decorations": true,
"transparent": false
}
],
"security": {
"csp": "default-src 'self'; connect-src https: wss: http://localhost:*; img-src https: data:; style-src 'self' 'unsafe-inline'; script-src 'self'"
}
},
"bundle": {
"active": true,
"icon": [
"src-tauri/icons/32x32.png",
"src-tauri/icons/128x128.png",
"src-tauri/icons/128x128@2x.png",
"src-tauri/icons/icon.icns",
"src-tauri/icons/icon.ico"
],
"category": "SocialNetworking",
"shortDescription": "EifelDC - Matrix Chat Client",
"longDescription": "EifelDC is a Discord-like Matrix chat client built with Tauri, Svelte and Rust.",
"macOS": {
"minimumSystemVersion": "10.15",
"entitlements": "macos/EifelDC.entitlements"
},
"windows": {
"digestAlgorithm": "sha256"
}
},
"plugins": {
"shell": {
"open": true
}
}
}

View File

@@ -7,6 +7,9 @@
"": {
"name": "eifeldc-ui",
"version": "0.1.0",
"dependencies": {
"livekit-client": "^2.18.7"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^3",
"@tauri-apps/api": "^1",
@@ -32,6 +35,12 @@
"node": ">=6.0.0"
}
},
"node_modules/@bufbuild/protobuf": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.1.tgz",
"integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==",
"license": "(Apache-2.0 AND BSD-3-Clause)"
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
@@ -462,6 +471,21 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@livekit/mutex": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@livekit/mutex/-/mutex-1.1.1.tgz",
"integrity": "sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==",
"license": "Apache-2.0"
},
"node_modules/@livekit/protocol": {
"version": "1.45.3",
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.45.3.tgz",
"integrity": "sha512-WmMxBTsy4dRBqcrswFwUUlgq3Z0nnhOqKR6tX749Rb/PcB1yBMUtrHxZvcsS6qi3/5+86zHeVG+exmu1sZqfJg==",
"license": "Apache-2.0",
"dependencies": {
"@bufbuild/protobuf": "^1.10.0"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz",
@@ -869,6 +893,13 @@
"url": "https://opencollective.com/tauri"
}
},
"node_modules/@types/dom-mediacapture-record": {
"version": "1.0.22",
"resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.22.tgz",
"integrity": "sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==",
"license": "MIT",
"peer": true
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1138,6 +1169,15 @@
"@types/estree": "^1.0.0"
}
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -1290,6 +1330,15 @@
"@types/estree": "^1.0.6"
}
},
"node_modules/jose": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/kleur": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
@@ -1300,6 +1349,26 @@
"node": ">=6"
}
},
"node_modules/livekit-client": {
"version": "2.18.7",
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.18.7.tgz",
"integrity": "sha512-8mMNfJ9sPTWT9W5CsV23yJhut4vtw0M4c/AR5diRvLn6XsUDij7Odr6GrI9Oj5/T5vDis4y2JTumMiTweGlqMA==",
"license": "Apache-2.0",
"dependencies": {
"@livekit/mutex": "1.1.1",
"@livekit/protocol": "1.45.3",
"events": "^3.3.0",
"jose": "^6.1.0",
"loglevel": "^1.9.2",
"sdp-transform": "^2.15.0",
"tslib": "2.8.1",
"typed-emitter": "^2.1.0",
"webrtc-adapter": "9.0.5"
},
"peerDependencies": {
"@types/dom-mediacapture-record": "^1"
}
},
"node_modules/locate-character": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
@@ -1307,6 +1376,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/loglevel": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz",
"integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
},
"funding": {
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/loglevel"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -1569,6 +1651,16 @@
"fsevents": "~2.3.2"
}
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/sade": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
@@ -1595,6 +1687,21 @@
"rimraf": "^2.5.2"
}
},
"node_modules/sdp": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.2.tgz",
"integrity": "sha512-xZocWwfyp4hkbN4hLWxMjmv2Q8aNa9MhmOZ7L9aCZPT+dZsgRr6wZRrSYE3HTdyk/2pZKPSgqI7ns7Een1xMSA==",
"license": "MIT"
},
"node_modules/sdp-transform": {
"version": "2.15.0",
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz",
"integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==",
"license": "MIT",
"bin": {
"sdp-verify": "checker.js"
}
},
"node_modules/sorcery": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/sorcery/-/sorcery-0.11.1.tgz",
@@ -1774,9 +1881,17 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/typed-emitter": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz",
"integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==",
"license": "MIT",
"optionalDependencies": {
"rxjs": "*"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -1866,6 +1981,19 @@
}
}
},
"node_modules/webrtc-adapter": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.5.tgz",
"integrity": "sha512-U9vjByy/sK2OMXu5mmfuZFKTMIUQe34c0JXRO+oDrxJTsntdYT2iIFwYMOV7HhMTuktcZLGf2W1N/OcSf9ssWg==",
"license": "BSD-3-Clause",
"dependencies": {
"sdp": "^3.2.0"
},
"engines": {
"node": ">=6.0.0",
"npm": ">=3.10.0"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",

View File

@@ -18,5 +18,8 @@
"tslib": "^2",
"typescript": "^5.9.3",
"vite": "^5"
},
"dependencies": {
"livekit-client": "^2.18.7"
}
}

View File

@@ -6,7 +6,9 @@
import ChatArea from './components/ChatArea.svelte';
import MemberList from './components/MemberList.svelte';
import VoicePanel from './components/VoicePanel.svelte';
import { currentUser, refreshChannels } from './lib/store';
import { currentUser, refreshChannels, handleWsEvent, addServer, switchServer } from './lib/store';
import { voiceRoom, disconnectFromVoice } from './lib/voice';
import { connectWebSocket, disconnectWebSocket } from './lib/api';
let loggedIn = false;
let loading = true;
@@ -15,7 +17,21 @@
return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
}
async function setupTauriSync() {
if (!isTauri()) return;
try {
const { listen } = await import('@tauri-apps/api/event');
await listen<Record<string, unknown>>('sync-event', (event) => {
handleWsEvent(event.payload as any);
});
} catch (e) {
console.error('Failed to listen for Tauri sync events', e);
}
}
onMount(async () => {
await setupTauriSync();
if (isTauri()) {
try {
const { invoke } = await import('@tauri-apps/api/tauri');
@@ -51,6 +67,7 @@
status: 'online',
});
await refreshChannels();
connectWebSocket(handleWsEvent);
}
}
} catch (e) {
@@ -60,6 +77,24 @@
}
loading = false;
});
function handleLogin(e: CustomEvent) {
loggedIn = true;
const homeserver = e.detail.homeserver;
if (homeserver) {
const server = addServer(homeserver);
switchServer(server);
}
if (!isTauri()) {
connectWebSocket(handleWsEvent);
}
}
function handleLogout() {
disconnectWebSocket();
disconnectFromVoice();
loggedIn = false;
}
</script>
{#if loading}
@@ -73,7 +108,7 @@
<p>Loading EifelDC...</p>
</div>
{:else if !loggedIn}
<LoginScreen on:login={() => (loggedIn = true)} />
<LoginScreen on:login={handleLogin} />
{:else}
<div class="app-layout">
<ServerSidebar />

View File

@@ -1,7 +1,8 @@
<script lang="ts">
import { currentServer, channels, currentChannel, textChannels, voiceChannels, currentUser, voiceState, refreshChannels } from '../lib/store';
import { currentServer, channels, currentChannel, textChannels, voiceChannels, currentUser, voiceState, refreshChannels, userProfile, refreshProfile, updateDisplayName, unreadCounts } from '../lib/store';
import { connectToVoice, disconnectFromVoice, toggleMute, toggleDeafen, voiceRoom } from '../lib/voice';
import { onMount } from 'svelte';
import { getJoinedRooms, joinVoiceChannel, createRoom, joinRoom, leaveRoom, logout, setPresence, type VoiceStateInfo, type RoomInfo } from '../lib/api';
import { getJoinedRooms, createRoom, joinRoom, leaveRoom, logout, setPresence, type RoomInfo } from '../lib/api';
let showCreateRoom = false;
let showJoinRoom = false;
@@ -12,6 +13,8 @@
let joinError = '';
let showSettings = false;
let presenceStatus: 'online' | 'idle' | 'dnd' | 'offline' = 'online';
let editingName = false;
let editNameValue = '';
onMount(async () => {
try {
@@ -26,6 +29,7 @@
} catch (e) {
console.error('Failed to load rooms', e);
}
refreshProfile();
});
async function handleCreateRoom() {
@@ -55,13 +59,11 @@
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,
});
await connectToVoice(channel.id);
voiceState.update(s => ({
...s,
channelId: channel.id,
}));
} catch (e) {
console.error('Failed to join voice', e);
}
@@ -88,6 +90,21 @@
}
}
async function handleSaveName() {
if (!editNameValue.trim()) return;
try {
await updateDisplayName(editNameValue.trim());
editingName = false;
} catch (e) {
console.error('Failed to update name', e);
}
}
function startEditName() {
editNameValue = $userProfile?.display_name || $currentUser?.username || '';
editingName = true;
}
async function handlePresenceChange(status: string) {
try {
await setPresence(status);
@@ -140,13 +157,16 @@
<button
class="channel-item"
class:active={$currentChannel?.id === channel.id}
on:click={() => currentChannel.set(channel)}
on:click={() => { currentChannel.set(channel); unreadCounts.update(c => { delete c[channel.id]; return c; }); }}
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>
{#if $unreadCounts[channel.id]}
<span class="unread-badge">{$unreadCounts[channel.id]}</span>
{/if}
</button>
{/each}
</div>
@@ -159,8 +179,14 @@
{#each $voiceChannels as channel}
<button
class="channel-item voice"
class:active={$currentChannel?.id === channel.id}
on:click={() => handleJoinVoice(channel)}
class:active={$voiceRoom.roomId === channel.id}
on:click={() => {
if ($voiceRoom.roomId === channel.id) {
disconnectFromVoice();
} else {
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"/>
@@ -220,17 +246,31 @@
<div class="user-panel">
<div class="user-info">
<div class="avatar-small">{$currentUser?.username?.charAt(0).toUpperCase() || '?'}</div>
<div class="avatar-small">{$userProfile?.display_name?.charAt(0).toUpperCase() || $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>
{#if editingName}
<input
type="text"
bind:value={editNameValue}
placeholder="Anzeigename"
class="edit-name-input"
on:keydown={(e) => { if (e.key === 'Enter') handleSaveName(); if (e.key === 'Escape') editingName = false; }}
/>
<div class="edit-name-actions">
<button class="btn-icon-small" on:click={handleSaveName}>✓</button>
<button class="btn-icon-small" on:click={() => editingName = false}>✕</button>
</div>
{:else}
<span class="username" on:click={startEditName} title="Klicken zum Bearbeiten">{$userProfile?.display_name || $currentUser?.username || 'Benutzer'}</span>
<span class="status-text">{presenceStatus === 'online' ? 'Online' : presenceStatus === 'idle' ? 'Abwesend' : presenceStatus === 'dnd' ? 'Nicht stören' : 'Offline'}</span>
{/if}
</div>
</div>
<div class="user-actions">
<button class="btn-icon" title="Mikrofon" on:click={() => { if ($voiceState.channelId) voiceState.update(s => ({ ...s, muted: !s.muted })); }}>
<button class="btn-icon" title="Mikrofon" on:click={() => { if ($voiceRoom.connected) toggleMute(); }}>
<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 })); }}>
<button class="btn-icon" title="Kopfhörer" on:click={() => { if ($voiceRoom.connected) toggleDeafen(); }}>
<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>
@@ -322,9 +362,20 @@
}
.channel-item.active {
background: var(--bg-active);
background-color: var(--bg-active);
color: var(--text-primary);
border-radius: var(--radius-sm);
}
.unread-badge {
background: var(--danger);
color: white;
font-size: 0.7rem;
font-weight: 700;
border-radius: 8px;
padding: 1px 6px;
margin-left: auto;
min-width: 16px;
text-align: center;
}
.channel-icon {
@@ -383,11 +434,45 @@
}
.username {
font-size: 0.85rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 0.95rem;
cursor: pointer;
}
.username:hover {
text-decoration: underline;
}
.edit-name-input {
width: 100%;
padding: 2px 6px;
background: var(--bg-input);
border: 1px solid var(--accent);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 0.9rem;
outline: none;
}
.edit-name-actions {
display: flex;
gap: 4px;
margin-top: 2px;
}
.btn-icon-small {
background: none;
border: 1px solid var(--border);
color: var(--text-secondary);
cursor: pointer;
padding: 1px 4px;
border-radius: var(--radius-sm);
font-size: 0.75rem;
}
.btn-icon-small:hover {
color: var(--text-primary);
border-color: var(--text-secondary);
}
.status-text {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,187 @@
<script lang="ts">
import { onMount } from 'svelte';
import { getCustomEmoji } from '../lib/api';
export let onSelect: (emoji: string) => void;
export let roomId: string | null = null;
export let show = false;
let search = '';
let customEmojis: { id: string; name: string; url: string; animated: boolean }[] = [];
const commonEmojis = [
'😀','😂','🥲','😊','😍','🥰','😘','😜','🤪','😎',
'🤔','🤗','🤩','🥳','😤','😠','🥺','😢','😭','🤯',
'🥶','🥵','😱','😨','😰','🤮','🤧','😴','🤤','😈',
'👍','👎','👏','🙌','🤝','✌️','🤞','🤟','💪','🖕',
'❤️','🧡','💛','💚','💙','💜','🖤','🤍','💔','❣️',
'🔥','✨','⭐','💫','🌟','💯','🎉','🎊','🏆','🎯',
'👀','🫡','🫠','🫣','🫢','🤌','🤌','💅','🤳','🙏',
];
const categories = [
{ name: 'Smileys', emojis: '😀😂🥲😊😍🥰😘😜🤪😎🤔🤗🤩🥳😤😠🥺😢😭🤯🥶🥵😱😨😰🤮🤧😴🤤😈👀🫡🫠🫣🫢🤌💅🤳🙏' },
{ name: 'Gesten', emojis: '👍👎👏🙌🤝✌️🤞🤟💪🖕' },
{ name: 'Herzen', emojis: '❤️🧡💛💚💙💜🖤🤍💔❣️' },
{ name: 'Symbole', emojis: '🔥✨⭐💫🌟💯🎉🎊🏆🎯' },
];
$: filteredCommon = search
? commonEmojis.filter((_, i) => {
const allEmojis = categories.flatMap(c => c.emojis.split(''));
return true;
})
: commonEmojis;
$: filteredCustom = search
? customEmojis.filter(e => e.name.toLowerCase().includes(search.toLowerCase()))
: customEmojis;
onMount(async () => {
if (roomId) {
try {
customEmojis = await getCustomEmoji(roomId);
} catch (e) {
console.error('Failed to load custom emojis', e);
}
}
});
function pick(emoji: string) {
onSelect(emoji);
show = false;
search = '';
}
function pickCustom(emoji: { name: string }) {
onSelect(`:${emoji.name}:`);
show = false;
search = '';
}
</script>
{#if show}
<div class="emoji-picker" on:click|stopPropagation>
<div class="emoji-search">
<input
type="text"
bind:value={search}
placeholder="Emoji suchen..."
on:keydown|stopPropagation={() => {}}
/>
</div>
<div class="emoji-grid">
{#each categories as cat}
<div class="emoji-category">
<div class="emoji-category-name">{cat.name}</div>
<div class="emoji-row">
{#each cat.emojis.split('') as emoji}
<button class="emoji-btn" on:click={() => pick(emoji)}>{emoji}</button>
{/each}
</div>
</div>
{/each}
{#if filteredCustom.length > 0}
<div class="emoji-category">
<div class="emoji-category-name">Custom</div>
<div class="emoji-row">
{#each filteredCustom as emoji}
<button class="emoji-btn custom-emoji" on:click={() => pickCustom(emoji)} title={emoji.name}>
{emoji.name.slice(0, 2)}
</button>
{/each}
</div>
</div>
{/if}
</div>
</div>
{/if}
<style>
.emoji-picker {
position: absolute;
bottom: 70px;
left: 16px;
width: 340px;
max-height: 320px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
z-index: 100;
display: flex;
flex-direction: column;
overflow: hidden;
}
.emoji-search {
padding: 8px;
border-bottom: 1px solid var(--border);
}
.emoji-search input {
width: 100%;
padding: 6px 10px;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 0.9rem;
outline: none;
}
.emoji-search input:focus {
border-color: var(--accent);
}
.emoji-grid {
flex: 1;
overflow-y: auto;
padding: 4px 8px;
}
.emoji-category {
margin-bottom: 8px;
}
.emoji-category-name {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 4px 0;
}
.emoji-row {
display: flex;
flex-wrap: wrap;
gap: 2px;
}
.emoji-btn {
background: none;
border: none;
cursor: pointer;
font-size: 1.3rem;
padding: 4px;
border-radius: var(--radius-sm);
line-height: 1;
}
.emoji-btn:hover {
background: var(--bg-hover);
}
.custom-emoji {
font-size: 0.7rem;
background: var(--bg-tertiary);
color: var(--accent);
font-weight: 700;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@@ -19,7 +19,7 @@
if (mode === 'login') {
const result = await login(homeserver, username, password);
if (result.success) {
dispatch('login', { userId: result.user_id });
dispatch('login', { userId: result.user_id, homeserver });
} else {
error = result.error || 'Login fehlgeschlagen';
}
@@ -30,7 +30,7 @@
}
const result = await register(homeserver, username, password);
if (result.success) {
dispatch('login', { userId: result.user_id });
dispatch('login', { userId: result.user_id, homeserver });
} else {
error = result.error || 'Registrierung fehlgeschlagen';
}

View File

@@ -1,29 +1,47 @@
<script lang="ts">
import { servers, currentServer } from '../lib/store';
import { onMount } from 'svelte';
import { servers, currentServer, addServer, removeServer, switchServer, currentUser } from '../lib/store';
let showAddServer = false;
let newServerUrl = '';
let addError = '';
function selectServer(server: any) {
currentServer.set(server);
function handleAddServer() {
addError = '';
let url = newServerUrl.trim();
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = 'https://' + url;
}
try {
new URL(url);
} catch {
addError = 'Ungültige URL';
return;
}
const server = addServer(url);
if ($currentServer === null || !$servers.some(s => s.id === $currentServer?.id)) {
switchServer(server);
}
showAddServer = false;
newServerUrl = '';
}
function handleRemoveServer(id: string, e: MouseEvent) {
e.stopPropagation();
removeServer(id);
}
function extractUsername(userId: string): string {
return userId.split(':')[0].replace('@', '');
}
</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)}
on:click={() => switchServer(server)}
title={server.name}
>
{#if server.iconUrl}
@@ -32,17 +50,43 @@
<span class="server-initial">{server.name.charAt(0).toUpperCase()}</span>
{/if}
<div class="pill"></div>
<button class="remove-btn" on:click={(e) => handleRemoveServer(server.id, e)} title="Server entfernen">x</button>
</button>
{/each}
<button class="server-icon add" on:click={() => showAddServer = !showAddServer} title="Server beitreten">
<button class="server-icon add" on:click={() => showAddServer = !showAddServer} title="Server hinzufügen">
<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>
{#if $currentUser}
<div class="user-section">
<div class="user-bubble">{extractUsername($currentUser.id).charAt(0).toUpperCase()}</div>
</div>
{/if}
</div>
{#if showAddServer}
<div class="modal-overlay" on:click={() => showAddServer = false}>
<div class="modal" on:click|stopPropagation>
<h3>Server hinzufügen</h3>
{#if addError}
<div class="modal-error">{addError}</div>
{/if}
<div class="modal-field">
<label>Homeserver-URL</label>
<input type="text" bind:value={newServerUrl} placeholder="matrix.example.org" on:keydown={(e) => { if (e.key === 'Enter') handleAddServer(); }} />
</div>
<div class="modal-actions">
<button class="btn-secondary" on:click={() => showAddServer = false}>Abbrechen</button>
<button class="btn-primary" on:click={handleAddServer} disabled={!newServerUrl.trim()}>Hinzufügen</button>
</div>
</div>
</div>
{/if}
<style>
.server-sidebar {
width: 72px;
@@ -60,14 +104,7 @@
flex-direction: column;
align-items: center;
gap: 8px;
}
.separator {
width: 32px;
height: 2px;
background: var(--border);
border-radius: 1px;
margin: 4px 0;
flex: 1;
}
.server-icon {
@@ -83,7 +120,7 @@
cursor: pointer;
transition: border-radius 0.2s, background 0.2s;
position: relative;
overflow: hidden;
overflow: visible;
}
.server-icon:hover {
@@ -107,11 +144,6 @@
font-weight: 700;
}
.home {
background: var(--bg-tertiary);
color: var(--accent);
}
.add {
background: transparent;
color: var(--success);
@@ -124,7 +156,7 @@
.pill {
position: absolute;
left: -4px;
left: -8px;
width: 4px;
height: 0;
background: white;
@@ -136,4 +168,146 @@
.server-icon:hover .pill {
height: 20px;
}
.remove-btn {
position: absolute;
top: -6px;
right: -6px;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--danger);
color: white;
border: none;
font-size: 0.6rem;
cursor: pointer;
display: none;
align-items: center;
justify-content: center;
line-height: 1;
}
.server-icon:hover .remove-btn {
display: flex;
}
.user-section {
margin-top: auto;
padding-top: 12px;
}
.user-bubble {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--accent);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 1rem;
}
.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);
}
</style>

View File

@@ -1,57 +1,57 @@
<script lang="ts">
import { voiceState } from '../lib/store';
import { leaveVoiceChannel, toggleMute, toggleDeafen } from '../lib/api';
import { voiceRoom, disconnectFromVoice, toggleMute, toggleDeafen, voiceParticipants } from '../lib/voice';
function extractUsername(userId: string): string {
return userId.split(':')[0].replace('@', '');
}
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);
}
await disconnectFromVoice();
}
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);
}
await toggleMute();
}
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);
}
await toggleDeafen();
}
</script>
{#if $voiceState.channelId}
{#if $voiceRoom.connected || $voiceRoom.connecting}
<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>
{#if $voiceRoom.connecting}
<div class="voice-spinner"></div>
<span class="voice-label connecting">Verbinden...</span>
{:else}
<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>
<div class="voice-details">
<span class="voice-label">Sprachverbunden</span>
<span class="voice-room-name">{extractUsername($voiceRoom.roomId || '')}</span>
</div>
{/if}
</div>
<div class="voice-participants">
{#each $voiceParticipants as participant}
<div class="participant-dot" class:muted={participant.muted} class:speaking={participant.speaking} title="{extractUsername(participant.userId)} {participant.muted ? '(stumm)' : ''}">
<span class="participant-avatar">{extractUsername(participant.userId).charAt(0).toUpperCase()}</span>
</div>
{/each}
</div>
<div class="voice-actions">
<button class="voice-btn" class:active={$voiceState.muted} on:click={handleMute} title="Mikrofon stumm">
{#if $voiceState.muted}
<button class="voice-btn" class:active={$voiceRoom.localMuted} on:click={handleMute} title="Mikrofon stumm">
{#if $voiceRoom.localMuted}
<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}
<button class="voice-btn" class:active={$voiceRoom.localDeafened} on:click={handleDeafen} title="Kopfhörer stumm">
{#if $voiceRoom.localDeafened}
<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>
@@ -64,6 +64,13 @@
</div>
{/if}
{#if $voiceRoom.error}
<div class="voice-error">
<span class="voice-error-text">{$voiceRoom.error}</span>
<button class="voice-error-dismiss" on:click={() => voiceRoom.update(s => ({ ...s, error: null }))}>x</button>
</div>
{/if}
<style>
.voice-panel {
position: fixed;
@@ -84,17 +91,93 @@
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.voice-details {
display: flex;
flex-direction: column;
min-width: 0;
}
.voice-label {
font-size: 0.85rem;
color: var(--success);
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.voice-label.connecting {
color: var(--text-secondary);
}
.voice-room-name {
font-size: 0.7rem;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.voice-spinner {
width: 14px;
height: 14px;
border: 2px solid var(--border);
border-top: 2px solid var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.voice-participants {
display: flex;
align-items: center;
gap: 2px;
overflow: hidden;
flex: 1;
justify-content: center;
}
.participant-dot {
position: relative;
}
.participant-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--bg-tertiary);
border: 2px solid var(--success);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.6rem;
font-weight: 700;
color: var(--text-primary);
}
.participant-dot.muted .participant-avatar {
border-color: var(--danger);
}
.participant-dot.muted .participant-avatar {
opacity: 0.6;
}
.participant-dot.speaking .participant-avatar {
border-color: var(--success);
box-shadow: 0 0 4px var(--success);
}
.voice-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.voice-btn {
@@ -127,4 +210,29 @@
.voice-btn.disconnect:hover {
background: #c03033;
}
.voice-error {
position: fixed;
bottom: 4px;
left: 50%;
transform: translateX(-50%);
background: var(--danger);
color: white;
padding: 6px 12px;
border-radius: var(--radius-sm);
font-size: 0.8rem;
display: flex;
align-items: center;
gap: 8px;
z-index: 200;
}
.voice-error-dismiss {
background: transparent;
border: none;
color: white;
cursor: pointer;
font-size: 0.9rem;
font-weight: 700;
}
</style>

View File

@@ -12,6 +12,8 @@ export interface RoomInfo {
is_encrypted: boolean;
member_count: number;
topic: string | null;
unread_notifications: number;
unread_messages: number;
}
export interface MessageInfo {
@@ -20,6 +22,15 @@ export interface MessageInfo {
body: string;
timestamp: number;
reply_to: string | null;
edited?: boolean;
reactions?: Record<string, string[]>;
msgtype?: string;
media_url?: string | null;
filename?: string | null;
mimetype?: string | null;
width?: number | null;
height?: number | null;
size?: number | null;
}
export interface RoleInfo {
@@ -50,6 +61,23 @@ export interface VoiceStateInfo {
streaming: boolean;
}
export interface VoiceJoinResult {
room_id: string;
livekit_url: string;
livekit_token: string;
}
export interface VoiceToggleResult {
muted: boolean;
deafened: boolean;
}
export interface VoiceParticipantInfo {
user_id: string;
muted: boolean;
deafened: boolean;
}
export interface PresenceInfo {
user_id: string;
status: string;
@@ -196,7 +224,7 @@ export async function getPresence(userId: string): Promise<PresenceInfo> {
return httpGet(`/api/presence/${encodeURIComponent(userId)}`);
}
export async function joinVoiceChannel(roomId: string): Promise<VoiceStateInfo> {
export async function joinVoiceChannel(roomId: string): Promise<VoiceJoinResult> {
if (isTauri()) {
return tauriInvoke('join_voice_channel', { roomId });
}
@@ -210,20 +238,27 @@ export async function leaveVoiceChannel(roomId: string): Promise<boolean> {
return httpPost('/api/voice/leave', { room_id: roomId });
}
export async function toggleMute(roomId: string): Promise<boolean> {
export async function toggleMute(roomId: string): Promise<VoiceToggleResult> {
if (isTauri()) {
return tauriInvoke('toggle_mute', { roomId });
}
return httpPost('/api/voice/toggle-mute', { room_id: roomId });
}
export async function toggleDeafen(roomId: string): Promise<boolean> {
export async function toggleDeafen(roomId: string): Promise<VoiceToggleResult> {
if (isTauri()) {
return tauriInvoke('toggle_deafen', { roomId });
}
return httpPost('/api/voice/toggle-deafen', { room_id: roomId });
}
export async function getVoiceParticipants(): Promise<VoiceParticipantInfo[]> {
if (isTauri()) {
return tauriInvoke('get_voice_participants', {});
}
return httpGet('/api/voice/participants');
}
export async function getRoles(roomId: string): Promise<RoleInfo[]> {
if (isTauri()) {
return tauriInvoke('get_roles', { roomId });
@@ -250,4 +285,343 @@ export async function getPermissions(roomId: string, userId: string): Promise<Pe
return tauriInvoke('get_permissions', { roomId, userId });
}
return httpGet(`/api/rooms/${encodeURIComponent(roomId)}/permissions/${encodeURIComponent(userId)}`);
}
export async function editMessage(roomId: string, eventId: string, newContent: string): Promise<string> {
if (isTauri()) {
return tauriInvoke('edit_message', { roomId, eventId, newContent });
}
const result = await httpPost(`/api/rooms/${encodeURIComponent(roomId)}/edit`, { event_id: eventId, new_content: newContent });
return result;
}
export async function deleteMessage(roomId: string, eventId: string, reason?: string): Promise<string> {
if (isTauri()) {
return tauriInvoke('delete_message', { roomId, eventId, reason });
}
const result = await httpPost(`/api/rooms/${encodeURIComponent(roomId)}/delete/${encodeURIComponent(eventId)}`, { reason });
return result;
}
export async function reactToMessage(roomId: string, eventId: string, key: string): Promise<string> {
if (isTauri()) {
return tauriInvoke('react_to_message', { roomId, eventId, key });
}
const result = await httpPost(`/api/rooms/${encodeURIComponent(roomId)}/react`, { event_id: eventId, key });
return result;
}
export async function setTyping(roomId: string, typing: boolean): Promise<boolean> {
if (isTauri()) {
return tauriInvoke('set_typing', { roomId, typing });
}
return httpPost(`/api/rooms/${encodeURIComponent(roomId)}/typing`, { typing });
}
export type WsEventType = 'message' | 'message_edited' | 'message_deleted' | 'reaction' | 'room_joined' | 'room_left' | 'presence' | 'typing' | 'connected' | 'voice_state_update' | 'voice_user_joined' | 'voice_user_left' | 'thread_reply';
export interface WsMessage {
type: WsEventType;
room_id?: string;
event_id?: string;
sender?: string;
body?: string;
timestamp?: number;
reply_to?: string | null;
name?: string;
user_id?: string;
status?: string;
status_msg?: string | null;
typing?: boolean;
new_body?: string;
redacts?: string;
key?: string;
muted?: boolean;
deafened?: boolean;
root_event_id?: string;
msgtype?: string | null;
media_url?: string | null;
filename?: string | null;
mimetype?: string | null;
}
export type WsEventHandler = (event: WsMessage) => void;
let ws: WebSocket | null = null;
let wsReconnectTimer: ReturnType<typeof setTimeout> | null = null;
let wsHandler: WsEventHandler | null = null;
export function connectWebSocket(handler: WsEventHandler): void {
wsHandler = handler;
doConnect();
}
function doConnect(): void {
if (isTauri()) return;
const token = getToken();
if (!token) return;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${protocol}//${window.location.host}/api/ws?token=${encodeURIComponent(token)}`;
ws = new WebSocket(url);
ws.onopen = () => {
console.log('[WS] Connected');
};
ws.onmessage = (event) => {
try {
const data: WsMessage = JSON.parse(event.data);
if (wsHandler) {
wsHandler(data);
}
} catch (e) {
console.error('[WS] Failed to parse message', e);
}
};
ws.onclose = () => {
console.log('[WS] Disconnected, reconnecting in 3s...');
scheduleReconnect();
};
ws.onerror = () => {
ws?.close();
};
}
function scheduleReconnect(): void {
if (wsReconnectTimer) clearTimeout(wsReconnectTimer);
wsReconnectTimer = setTimeout(() => {
doConnect();
}, 3000);
}
export function disconnectWebSocket(): void {
if (wsReconnectTimer) {
clearTimeout(wsReconnectTimer);
wsReconnectTimer = null;
}
if (ws) {
ws.onclose = null;
ws.close();
ws = null;
}
wsHandler = null;
}
export interface ThreadInfo {
root_event_id: string;
root_sender: string;
root_body: string;
root_timestamp: number;
reply_count: number;
last_reply_event_id: string | null;
last_reply_sender: string | null;
last_reply_body: string | null;
last_reply_timestamp: number | null;
}
export interface ThreadMessageInfo {
event_id: string;
sender: string;
body: string;
timestamp: number;
}
export async function getThreads(roomId: string): Promise<ThreadInfo[]> {
if (isTauri()) {
return tauriInvoke('get_threads', { roomId });
}
return httpGet(`/api/rooms/${encodeURIComponent(roomId)}/threads`);
}
export async function getThreadMessages(roomId: string, threadId: string, limit: number = 50): Promise<ThreadMessageInfo[]> {
if (isTauri()) {
return tauriInvoke('get_thread_messages', { roomId, threadId, limit });
}
return httpGet(`/api/rooms/${encodeURIComponent(roomId)}/threads/${encodeURIComponent(threadId)}?limit=${limit}`);
}
export async function sendThreadReply(roomId: string, threadId: string, message: string): Promise<string> {
if (isTauri()) {
return tauriInvoke('send_thread_reply', { roomId, threadId, message });
}
const result = await httpPost(`/api/rooms/${encodeURIComponent(roomId)}/threads/${encodeURIComponent(threadId)}/reply`, { message });
return result;
}
export async function sendReply(roomId: string, replyTo: string, message: string): Promise<string> {
if (isTauri()) {
return tauriInvoke('send_reply', { roomId, replyTo, message });
}
const result = await httpPost(`/api/rooms/${encodeURIComponent(roomId)}/reply`, { message, reply_to: replyTo });
return result;
}
export function getMediaUrl(mediaUrl: string | null | undefined): string | null {
if (!mediaUrl) return null;
const mxcMatch = mediaUrl.match(/^mxc:\/\/([^/]+)\/(.+)$/);
if (!mxcMatch) return null;
const serverName = mxcMatch[1];
const mediaId = mxcMatch[2];
const token = getToken();
return `${getApiBase()}/api/media/${encodeURIComponent(serverName + '/' + mediaId)}`;
}
export interface UploadResult {
event_id: string;
media_url: string | null;
filename: string;
mimetype: string | null;
size: number | null;
}
export async function uploadFile(roomId: string, file: File): Promise<UploadResult> {
const formData = new FormData();
formData.append('file', file);
if (isTauri()) {
const { invoke } = await import('@tauri-apps/api/tauri');
const arrayBuffer = await file.arrayBuffer();
const bytes = Array.from(new Uint8Array(arrayBuffer));
return invoke('upload_file', { roomId, fileName: file.name, mimeType: file.type, bytes });
}
const res = await fetch(`${getApiBase()}/api/rooms/${encodeURIComponent(roomId)}/upload`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${getToken()}` },
body: formData,
});
if (res.status === 401) { clearToken(); throw new Error('Unauthorized'); }
return res.json();
}
// Profile API
export interface UserProfile {
user_id: string;
display_name: string | null;
avatar_url: string | null;
}
export async function getOwnProfile(): Promise<UserProfile> {
if (isTauri()) {
return tauriInvoke('get_own_profile', {});
}
return httpGet('/api/profile/me');
}
export async function getUserProfile(userId: string): Promise<UserProfile> {
if (isTauri()) {
return tauriInvoke('get_user_profile', { userId });
}
return httpGet(`/api/profile/${encodeURIComponent(userId)}`);
}
export async function setDisplayName(displayName: string): Promise<boolean> {
if (isTauri()) {
return tauriInvoke('set_display_name', { displayName });
}
const res = await httpPost('/api/profile/displayname', { display_name: displayName });
return res;
}
export async function uploadAvatar(file: File): Promise<UserProfile> {
const formData = new FormData();
formData.append('file', file);
if (isTauri()) {
const { invoke } = await import('@tauri-apps/api/tauri');
const arrayBuffer = await file.arrayBuffer();
const bytes = Array.from(new Uint8Array(arrayBuffer));
return invoke('upload_avatar', { fileName: file.name, mimeType: file.type, bytes });
}
const res = await fetch(`${getApiBase()}/api/profile/avatar`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${getToken()}` },
body: formData,
});
if (res.status === 401) { clearToken(); throw new Error('Unauthorized'); }
return res.json();
}
// Room Settings API
export async function setRoomName(roomId: string, name: string): Promise<boolean> {
if (isTauri()) {
return tauriInvoke('set_room_name', { roomId, name });
}
const res = await httpPost(`/api/rooms/${encodeURIComponent(roomId)}/name`, { name });
return res;
}
export async function setRoomTopic(roomId: string, topic: string): Promise<boolean> {
if (isTauri()) {
return tauriInvoke('set_room_topic', { roomId, topic });
}
const res = await httpPost(`/api/rooms/${encodeURIComponent(roomId)}/topic`, { topic });
return res;
}
export async function setRoomAvatar(roomId: string, file: File): Promise<boolean> {
const formData = new FormData();
formData.append('file', file);
if (isTauri()) {
const { invoke } = await import('@tauri-apps/api/tauri');
const arrayBuffer = await file.arrayBuffer();
const bytes = Array.from(new Uint8Array(arrayBuffer));
return invoke('set_room_avatar', { roomId, fileName: file.name, mimeType: file.type, bytes });
}
const res = await fetch(`${getApiBase()}/api/rooms/${encodeURIComponent(roomId)}/avatar`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${getToken()}` },
body: formData,
});
if (res.status === 401) { clearToken(); throw new Error('Unauthorized'); }
return res.json();
}
// Emoji API
export interface CustomEmoji {
id: string;
name: string;
url: string;
category: string;
animated: boolean;
}
export async function getCustomEmoji(roomId: string): Promise<CustomEmoji[]> {
if (isTauri()) {
return tauriInvoke('get_custom_emoji', { roomId });
}
return httpGet(`/api/rooms/${encodeURIComponent(roomId)}/emoji`);
}
// Unread API
export interface UnreadInfo {
room_id: string;
unread_notifications: number;
unread_messages: number;
}
export async function getUnreadCounts(): Promise<UnreadInfo[]> {
if (isTauri()) {
return tauriInvoke('get_joined_rooms', {});
}
return httpGet('/api/rooms/unread');
}
export async function markRoomRead(roomId: string, eventId: string): Promise<boolean> {
if (isTauri()) {
return tauriInvoke('mark_room_read', { roomId, eventId });
}
const res = await httpPost(`/api/rooms/${encodeURIComponent(roomId)}/read`, { event_id: eventId });
return res;
}

View File

@@ -1,5 +1,6 @@
import { writable, derived } from 'svelte/store';
import { invoke } from '@tauri-apps/api/tauri';
import * as api from './api';
import type { UserProfile } from './api';
interface User {
id: string;
@@ -32,13 +33,23 @@ interface Message {
replyTo: string | null;
isBot: boolean;
reactions: Record<string, string[]>;
edited?: boolean;
threadRoot?: string;
threadReplyCount?: number;
msgtype?: string;
mediaUrl?: string | null;
filename?: string | null;
mimetype?: string | null;
width?: number | null;
height?: number | null;
size?: number | null;
}
interface Server {
id: string;
name: string;
homeserver: string;
iconUrl: string | null;
roles: Role[];
}
interface Role {
@@ -60,20 +71,93 @@ interface Member {
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 servers = writable<Server[]>(loadServers());
export const channels = writable<Channel[]>([]);
export const unreadCounts = writable<Record<string, number>>({});
export const messages = writable<Message[]>([]);
export const members = writable<Member[]>([]);
export const userProfile = writable<UserProfile | null>(null);
function loadServers(): Server[] {
try {
const raw = localStorage.getItem('eifeldc_servers');
if (raw) return JSON.parse(raw);
} catch {}
return [];
}
function saveServers(list: Server[]) {
localStorage.setItem('eifeldc_servers', JSON.stringify(list));
}
export function addServer(homeserver: string, name?: string): Server {
const id = btoa(homeserver).replace(/[^a-zA-Z0-9]/g, '').slice(0, 12);
const server: Server = {
id,
name: name || new URL(homeserver).hostname,
homeserver,
iconUrl: null,
};
servers.update(list => {
if (list.some(s => s.homeserver === homeserver)) return list;
const updated = [...list, server];
saveServers(updated);
return updated;
});
return server;
}
export function removeServer(id: string) {
servers.update(list => {
const updated = list.filter(s => s.id !== id);
saveServers(updated);
return updated;
});
currentServer.update(cs => {
if (cs && cs.id === id) return null;
return cs;
});
}
export function switchServer(server: Server) {
currentServer.set(server);
currentChannel.set(null);
messages.set([]);
members.set([]);
channels.set([]);
refreshChannels();
refreshProfile();
}
export async function refreshProfile() {
try {
const profile = await api.getOwnProfile();
userProfile.set(profile);
} catch (e) {
console.error('Failed to load profile', e);
}
}
export async function updateDisplayName(name: string) {
try {
await api.setDisplayName(name);
await refreshProfile();
} catch (e) {
console.error('Failed to update display name', e);
}
}
export const voiceState = writable<{
channelId: string | null;
muted: boolean;
deafened: boolean;
streaming: boolean;
participants: { userId: string; muted: boolean; deafened: boolean }[];
}>({
channelId: null,
muted: false,
deafened: false,
streaming: false,
participants: [],
});
export const sortedMembers = derived(members, ($members) => {
@@ -91,21 +175,185 @@ 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,
})));
const rooms = await api.getJoinedRooms();
const unreads: Record<string, number> = {};
channels.set(rooms.map(r => {
const count = r.unread_notifications || r.unread_messages || 0;
if (count > 0) unreads[r.room_id] = count;
return {
id: r.room_id,
name: r.name || r.room_id,
type: (r.name || '').startsWith('🔊') || (r.name || '').toLowerCase().startsWith('voice') ? 'voice' as const : 'text' as const,
topic: r.topic || null,
parentId: null,
};
}));
unreadCounts.set(unreads);
} catch (e) {
console.error('Failed to refresh channels', e);
}
}
export function handleWsEvent(event: api.WsMessage): void {
switch (event.type) {
case 'message':
if (!event.room_id || !event.event_id) break;
if (event.room_id !== $currentChannel?.id) {
unreadCounts.update(counts => {
counts[event.room_id!] = (counts[event.room_id!] || 0) + 1;
return counts;
});
}
const newMsg: Message = {
id: event.event_id,
sender: event.sender || '',
content: event.body || '',
timestamp: event.timestamp || Date.now(),
replyTo: event.reply_to || null,
isBot: false,
reactions: {},
msgtype: event.msgtype || 'm.text',
mediaUrl: event.media_url || null,
filename: event.filename || null,
mimetype: event.mimetype || null,
};
messages.update(existing => {
if (existing.some(m => m.id === newMsg.id)) return existing;
return [...existing, newMsg];
});
break;
case 'message_edited':
if (!event.event_id || !event.new_body) break;
messages.update(existing =>
existing.map(m =>
m.id === event.event_id ? { ...m, content: event.new_body!, edited: true } : m
)
);
break;
case 'message_deleted':
if (!event.redacts) break;
messages.update(existing => existing.filter(m => m.id !== event.redacts));
break;
case 'reaction':
if (!event.event_id || !event.key || !event.sender) break;
messages.update(existing =>
existing.map(m => {
if (m.id === event.event_id) {
const reactions = { ...m.reactions };
if (!reactions[event.key!]) {
reactions[event.key!] = [];
}
if (!reactions[event.key!].includes(event.sender!)) {
reactions[event.key!].push(event.sender!);
}
return { ...m, reactions };
}
return m;
})
);
break;
case 'room_joined':
if (!event.room_id) break;
channels.update(existing => {
if (existing.some(c => c.id === event.room_id)) {
return existing.map(c =>
c.id === event.room_id ? { ...c, name: event.name || c.name } : c
);
}
return [...existing, {
id: event.room_id!,
name: event.name || event.room_id!,
type: 'text' as const,
topic: null,
parentId: null,
}];
});
break;
case 'room_left':
if (!event.room_id) break;
channels.update(existing => existing.filter(c => c.id !== event.room_id));
break;
case 'presence':
if (!event.user_id) break;
const statusMap: Record<string, Member['status']> = {
online: 'online',
idle: 'idle',
offline: 'offline',
};
const newStatus = (event.status && statusMap[event.status]) || 'offline';
members.update(existing =>
existing.map(m =>
m.id === event.user_id ? { ...m, status: newStatus } : m
)
);
currentUser.update(u => {
if (u && u.id === event.user_id) {
return { ...u, status: newStatus };
}
return u;
});
break;
case 'typing':
break;
case 'voice_user_joined':
if (!event.room_id || !event.user_id) break;
voiceState.update(s => {
if (s.channelId !== event.room_id) return s;
if (s.participants.some(p => p.userId === event.user_id)) return s;
return {
...s,
participants: [...s.participants, { userId: event.user_id!, muted: false, deafened: false }],
};
});
break;
case 'voice_user_left':
if (!event.room_id || !event.user_id) break;
voiceState.update(s => {
if (s.channelId !== event.room_id) return s;
return {
...s,
participants: s.participants.filter(p => p.userId !== event.user_id),
};
});
break;
case 'voice_state_update':
if (!event.room_id || !event.user_id) break;
voiceState.update(s => {
if (s.channelId !== event.room_id) return s;
return {
...s,
participants: s.participants.map(p =>
p.userId === event.user_id ? { ...p, muted: event.muted ?? p.muted, deafened: event.deafened ?? p.deafened } : p
),
};
});
break;
case 'thread_reply':
if (!event.room_id || !event.root_event_id) break;
messages.update(existing =>
existing.map(m => {
if (m.id === event.root_event_id) {
return { ...m, threadReplyCount: (m.threadReplyCount || 0) + 1 };
}
if (m.threadRoot === event.root_event_id) {
return { ...m, threadReplyCount: (m.threadReplyCount || 0) + 1 };
}
return m;
})
);
break;
}
}

View File

@@ -0,0 +1,213 @@
import { Room, Track, ConnectionState, LocalParticipant, RemoteParticipant, RemoteTrackPublication, TrackEvent, RoomEvent } from 'livekit-client';
import { writable, derived, get } from 'svelte/store';
import * as api from './api';
export interface VoiceParticipantState {
userId: string;
muted: boolean;
deafened: boolean;
speaking: boolean;
}
interface VoiceState {
connected: boolean;
connecting: boolean;
roomId: string | null;
room: Room | null;
localMuted: boolean;
localDeafened: boolean;
participants: VoiceParticipantState[];
error: string | null;
}
export const voiceRoom = writable<VoiceState>({
connected: false,
connecting: false,
roomId: null,
room: null,
localMuted: false,
localDeafened: false,
participants: [],
error: null,
});
export const isConnected = derived(voiceRoom, ($v) => $v.connected);
export const voiceParticipants = derived(voiceRoom, ($v) => $v.participants);
export const voiceError = derived(voiceRoom, ($v) => $v.error);
function updateParticipantsFromRoom(room: Room): VoiceParticipantState[] {
const participants: VoiceParticipantState[] = [];
const local = room.localParticipant;
participants.push({
userId: local.identity || local.name || 'unknown',
muted: local.isMicrophoneEnabled === false,
deafened: false,
speaking: local.isSpeaking,
});
for (const [, remote] of room.remoteParticipants) {
participants.push({
userId: remote.identity || remote.name || 'unknown',
muted: !remote.isMicrophoneEnabled,
deafened: false,
speaking: remote.isSpeaking,
});
}
return participants;
}
export async function connectToVoice(roomId: string): Promise<void> {
const current = get(voiceRoom);
if (current.connecting || current.connected) {
if (current.roomId === roomId) return;
await disconnectFromVoice();
}
voiceRoom.update(s => ({ ...s, connecting: true, error: null }));
try {
const result = await api.joinVoiceChannel(roomId);
const room = new Room();
room.on(RoomEvent.TrackMuted, () => {
voiceRoom.update(s => ({
...s,
participants: updateParticipantsFromRoom(room),
}));
});
room.on(RoomEvent.TrackUnmuted, () => {
voiceRoom.update(s => ({
...s,
participants: updateParticipantsFromRoom(room),
}));
});
room.on(RoomEvent.ActiveSpeakersChanged, () => {
voiceRoom.update(s => ({
...s,
participants: updateParticipantsFromRoom(room),
}));
});
room.on(RoomEvent.ParticipantConnected, () => {
voiceRoom.update(s => ({
...s,
participants: updateParticipantsFromRoom(room),
}));
});
room.on(RoomEvent.ParticipantDisconnected, () => {
voiceRoom.update(s => ({
...s,
participants: updateParticipantsFromRoom(room),
}));
});
room.on(RoomEvent.Disconnected, () => {
voiceRoom.update(s => ({
...s,
connected: false,
roomId: null,
room: null,
participants: [],
}));
});
room.on(RoomEvent.Reconnecting, () => {
voiceRoom.update(s => ({ ...s, connecting: true }));
});
room.on(RoomEvent.Reconnected, () => {
voiceRoom.update(s => ({ ...s, connecting: false, connected: true }));
});
await room.connect(result.livekit_url, result.livekit_token);
const local = room.localParticipant;
await local.setCameraEnabled(false);
await local.setMicrophoneEnabled(true);
voiceRoom.update(s => ({
...s,
connected: true,
connecting: false,
roomId: result.room_id,
room,
participants: updateParticipantsFromRoom(room),
}));
} catch (e: any) {
voiceRoom.update(s => ({
...s,
connecting: false,
error: e.message || 'Verbindung fehlgeschlagen',
}));
}
}
export async function disconnectFromVoice(): Promise<void> {
const current = get(voiceRoom);
if (current.room) {
await current.room.disconnect();
}
if (current.roomId) {
try {
await api.leaveVoiceChannel(current.roomId);
} catch (e) {
console.error('Failed to leave voice channel', e);
}
}
voiceRoom.update(s => ({
...s,
connected: false,
connecting: false,
roomId: null,
room: null,
localMuted: false,
localDeafened: false,
participants: [],
error: null,
}));
}
export async function toggleMute(): Promise<void> {
const current = get(voiceRoom);
if (!current.room || !current.roomId) return;
const result = await api.toggleMute(current.roomId);
if (current.room) {
const local = current.room.localParticipant;
await local.setMicrophoneEnabled(!result.muted);
}
voiceRoom.update(s => ({
...s,
localMuted: result.muted,
localDeafened: result.deafened,
}));
}
export async function toggleDeafen(): Promise<void> {
const current = get(voiceRoom);
if (!current.room || !current.roomId) return;
const result = await api.toggleDeafen(current.roomId);
if (current.room) {
const local = current.room.localParticipant;
await local.setMicrophoneEnabled(!result.muted);
}
voiceRoom.update(s => ({
...s,
localMuted: result.muted,
localDeafened: result.deafened,
}));
}

View File

@@ -1,107 +1,151 @@
use matrix_sdk::Client;
use serde::Serialize;
use tauri::State;
use crate::state::AppState;
use serde::{Deserialize, Serialize};
use tauri::State;
#[derive(Serialize)]
pub struct LoginResult {
pub success: bool,
pub user_id: String,
pub error: Option<String>,
}
#[derive(Deserialize, Serialize)]
struct ServerLoginRequest {
homeserver: String,
username: String,
password: String,
}
#[derive(Deserialize)]
struct ServerLoginResponse {
success: bool,
user_id: String,
token: Option<String>,
error: Option<String>,
}
#[derive(Deserialize, Serialize)]
struct ServerRegisterRequest {
homeserver: String,
username: String,
password: String,
}
#[tauri::command]
pub async fn login(
state: State<'_, crate::state::AppState>,
state: 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 server_url = format!("https://{}", homeserver.trim_end_matches('/'));
let client = reqwest::Client::new();
client
.matrix_auth()
.login_username(&username, &password)
let res = client
.post(format!("{}/api/login", server_url.trim_end_matches('/')))
.json(&ServerLoginRequest {
homeserver,
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 data: ServerLoginResponse = res.json().await.map_err(|e| e.to_string())?;
if !data.success {
return Ok(LoginResult {
success: false,
user_id: String::new(),
error: data.error,
});
}
let mut s = state.write().await;
s.client = Some(client);
s.logged_in = true;
s.user_id = Some(user_id.clone());
s.user_id = Some(data.user_id.clone());
s.auth_token = data.token.clone();
s.server_url = Some(server_url.trim_end_matches('/').to_string());
Ok(LoginResult {
success: true,
user_id,
user_id: data.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;
pub async fn logout(state: State<'_, AppState>) -> Result<bool, String> {
let (server_url, token) = {
let s = state.read().await;
(s.server_url.clone(), s.auth_token.clone())
};
if let (Some(url), Some(tok)) = (server_url, token) {
let client = reqwest::Client::new();
let _ = client
.post(format!("{}/api/logout", url.trim_end_matches('/')))
.header("Authorization", format!("Bearer {}", tok))
.send()
.await;
}
let mut s = state.write().await;
s.logged_in = false;
s.user_id = None;
s.auth_token = None;
s.server_url = None;
Ok(true)
}
#[tauri::command]
pub async fn register(
state: State<'_, crate::state::AppState>,
state: State<'_, AppState>,
homeserver: String,
username: String,
password: String,
) -> Result<LoginResult, String> {
let client = Client::builder()
.homeserver_url(&homeserver)
.build()
let server_url = format!("https://{}", homeserver.trim_end_matches('/'));
let client = reqwest::Client::new();
let res = client
.post(format!("{}/api/register", server_url.trim_end_matches('/')))
.json(&ServerRegisterRequest {
homeserver,
username,
password,
})
.send()
.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);
let data: ServerLoginResponse = res.json().await.map_err(|e| e.to_string())?;
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();
if !data.success {
return Ok(LoginResult {
success: false,
user_id: String::new(),
error: data.error,
});
}
let mut s = state.write().await;
s.client = Some(client);
s.logged_in = true;
s.user_id = Some(user_id.clone());
s.user_id = Some(data.user_id.clone());
s.auth_token = data.token.clone();
s.server_url = Some(server_url.trim_end_matches('/').to_string());
Ok(LoginResult {
success: true,
user_id,
user_id: data.user_id,
error: None,
})
}
#[tauri::command]
pub async fn get_current_user(
state: State<'_, crate::state::AppState>,
) -> Result<Option<String>, String> {
pub async fn get_current_user(state: State<'_, AppState>) -> Result<Option<String>, String> {
let s = state.read().await;
Ok(s.user_id.clone())
}
}

View File

@@ -1,10 +1,8 @@
use matrix_sdk::ruma::room_id;
use serde::Serialize;
use tauri::State;
use crate::state::AppState;
use std::path::Path;
use serde::{Deserialize, Serialize};
use tauri::State;
#[derive(Serialize)]
#[derive(Serialize, Deserialize)]
pub struct CustomEmoji {
id: String,
name: String,
@@ -13,6 +11,76 @@ pub struct CustomEmoji {
animated: bool,
}
#[tauri::command]
pub async fn get_custom_emoji(
state: State<'_, AppState>,
room_id: String,
) -> Result<Vec<CustomEmoji>, String> {
let s = state.read().await;
let server_url = s.server_url.clone().ok_or("Not logged in")?;
let token = s.auth_token.clone().ok_or("Not logged in")?;
drop(s);
let client = reqwest::Client::new();
let res = client
.get(format!(
"{}/api/rooms/{}/emoji",
server_url.trim_end_matches('/'),
room_id
))
.header("Authorization", format!("Bearer {}", token))
.send()
.await
.map_err(|e| e.to_string())?;
res.json().await.map_err(|e| e.to_string())
}
#[derive(Deserialize)]
struct UploadEmojiResponse {
id: String,
name: String,
url: String,
category: String,
animated: bool,
}
#[tauri::command]
pub async fn upload_emoji(
state: State<'_, AppState>,
room_id: String,
name: String,
image_path: String,
) -> Result<CustomEmoji, String> {
let s = state.read().await;
let server_url = s.server_url.clone().ok_or("Not logged in")?;
let token = s.auth_token.clone().ok_or("Not logged in")?;
drop(s);
let client = reqwest::Client::new();
let res = client
.post(format!(
"{}/api/rooms/{}/emoji/upload",
server_url.trim_end_matches('/'),
room_id
))
.header("Authorization", format!("Bearer {}", token))
.json(&serde_json::json!({ "name": name, "image_path": image_path }))
.send()
.await
.map_err(|e| e.to_string())?;
let data: UploadEmojiResponse = res.json().await.map_err(|e| e.to_string())?;
Ok(CustomEmoji {
id: data.id,
name: data.name,
url: data.url,
category: data.category,
animated: data.animated,
})
}
#[derive(Serialize)]
pub struct StickerPack {
id: String,
@@ -28,66 +96,6 @@ pub struct Sticker {
}
#[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();
pub async fn get_sticker_packs(_state: State<'_, AppState>) -> Result<Vec<StickerPack>, String> {
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())
}

View File

@@ -1,7 +1,8 @@
pub mod auth;
pub mod emoji;
pub mod presence;
pub mod profile;
pub mod roles;
pub mod rooms;
pub mod threads;
pub mod presence;
pub mod voice;
pub mod emoji;
pub mod roles;

View File

@@ -1,9 +1,8 @@
use matrix_sdk::ruma::user_id;
use serde::Serialize;
use tauri::State;
use crate::state::AppState;
use serde::{Deserialize, Serialize};
use tauri::State;
#[derive(Serialize)]
#[derive(Serialize, Deserialize)]
pub struct PresenceInfo {
user_id: String,
status: String,
@@ -13,63 +12,51 @@ pub struct PresenceInfo {
#[tauri::command]
pub async fn set_presence(
state: State<'_, crate::state::AppState>,
state: 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 server_url = s.server_url.clone().ok_or("Not logged in")?;
let token = s.auth_token.clone().ok_or("Not logged in")?;
drop(s);
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)
let client = reqwest::Client::new();
let res = client
.post(format!(
"{}/api/presence/set",
server_url.trim_end_matches('/')
))
.header("Authorization", format!("Bearer {}", token))
.json(&serde_json::json!({ "status": status, "status_msg": status_msg }))
.send()
.await
.map_err(|e| e.to_string())?;
Ok(true)
res.json().await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_presence(
state: State<'_, crate::state::AppState>,
user_id_str: String,
state: State<'_, AppState>,
user_id: String,
) -> Result<PresenceInfo, String> {
let s = state.read().await;
let client = s.client.as_ref().ok_or("Not logged in")?;
let server_url = s.server_url.clone().ok_or("Not logged in")?;
let token = s.auth_token.clone().ok_or("Not logged in")?;
drop(s);
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)
let client = reqwest::Client::new();
let res = client
.get(format!(
"{}/api/presence/{}",
server_url.trim_end_matches('/'),
user_id
))
.header("Authorization", format!("Bearer {}", token))
.send()
.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()),
})
}
res.json().await.map_err(|e| e.to_string())
}

View File

@@ -0,0 +1,130 @@
use crate::state::AppState;
use serde::{Deserialize, Serialize};
use tauri::State;
#[derive(Serialize, Deserialize)]
pub struct UserProfile {
user_id: String,
display_name: Option<String>,
avatar_url: Option<String>,
}
#[tauri::command]
pub async fn get_own_profile(state: State<'_, AppState>) -> Result<UserProfile, String> {
let s = state.read().await;
let server_url = s.server_url.clone().ok_or("Not logged in")?;
let token = s.auth_token.clone().ok_or("Not logged in")?;
drop(s);
let client = reqwest::Client::new();
let res = client
.get(format!(
"{}/api/profile/me",
server_url.trim_end_matches('/')
))
.header("Authorization", format!("Bearer {}", token))
.send()
.await
.map_err(|e| e.to_string())?;
res.json().await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_user_profile(
state: State<'_, AppState>,
user_id: String,
) -> Result<UserProfile, String> {
let s = state.read().await;
let server_url = s.server_url.clone().ok_or("Not logged in")?;
let token = s.auth_token.clone().ok_or("Not logged in")?;
drop(s);
let client = reqwest::Client::new();
let res = client
.get(format!(
"{}/api/profile/{}",
server_url.trim_end_matches('/'),
user_id
))
.header("Authorization", format!("Bearer {}", token))
.send()
.await
.map_err(|e| e.to_string())?;
res.json().await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn set_display_name(
state: State<'_, AppState>,
display_name: String,
) -> Result<bool, String> {
let s = state.read().await;
let server_url = s.server_url.clone().ok_or("Not logged in")?;
let token = s.auth_token.clone().ok_or("Not logged in")?;
drop(s);
let client = reqwest::Client::new();
let res = client
.post(format!(
"{}/api/profile/displayname",
server_url.trim_end_matches('/')
))
.header("Authorization", format!("Bearer {}", token))
.json(&serde_json::json!({ "display_name": display_name }))
.send()
.await
.map_err(|e| e.to_string())?;
res.json().await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn upload_avatar(
state: State<'_, AppState>,
file_path: String,
) -> Result<UserProfile, String> {
let s = state.read().await;
let server_url = s.server_url.clone().ok_or("Not logged in")?;
let token = s.auth_token.clone().ok_or("Not logged in")?;
drop(s);
let data = std::fs::read(&file_path).map_err(|e| e.to_string())?;
let file_name = std::path::Path::new(&file_path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("avatar")
.to_string();
let mime_type = match std::path::Path::new(&file_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 client = reqwest::Client::new();
let part = reqwest::multipart::Part::bytes(data)
.file_name(file_name)
.mime_str(mime_type)
.map_err(|e| e.to_string())?;
let form = reqwest::multipart::Form::new().part("file", part);
let res = client
.post(format!(
"{}/api/profile/avatar",
server_url.trim_end_matches('/')
))
.header("Authorization", format!("Bearer {}", token))
.multipart(form)
.send()
.await
.map_err(|e| e.to_string())?;
res.json().await.map_err(|e| e.to_string())
}

View File

@@ -1,10 +1,8 @@
use matrix_sdk::ruma::room_id;
use matrix_sdk::ruma::user_id;
use crate::state::AppState;
use serde::{Deserialize, Serialize};
use tauri::State;
use crate::state::AppState;
#[derive(Serialize, Deserialize, Clone)]
#[derive(Serialize, Deserialize)]
pub struct Role {
id: String,
name: String,
@@ -13,7 +11,7 @@ pub struct Role {
position: i32,
}
#[derive(Serialize)]
#[derive(Serialize, Deserialize)]
pub struct Permissions {
can_send_messages: bool,
can_delete_messages: bool,
@@ -27,155 +25,107 @@ pub struct Permissions {
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> {
pub async fn get_roles(state: 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 server_url = s.server_url.clone().ok_or("Not logged in")?;
let token = s.auth_token.clone().ok_or("Not logged in")?;
drop(s);
let mut roles = Vec::new();
let client = reqwest::Client::new();
let res = client
.get(format!(
"{}/api/rooms/{}/roles",
server_url.trim_end_matches('/'),
room_id
))
.header("Authorization", format!("Bearer {}", token))
.send()
.await
.map_err(|e| e.to_string())?;
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)
res.json().await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn assign_role(
state: State<'_, crate::state::AppState>,
state: 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 server_url = s.server_url.clone().ok_or("Not logged in")?;
let token = s.auth_token.clone().ok_or("Not logged in")?;
drop(s);
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)
let client = reqwest::Client::new();
let res = client
.post(format!(
"{}/api/rooms/{}/roles/assign",
server_url.trim_end_matches('/'),
room_id
))
.header("Authorization", format!("Bearer {}", token))
.json(&serde_json::json!({ "user_id": user_id, "role_id": role_id }))
.send()
.await
.map_err(|e| e.to_string())?;
Ok(true)
res.json().await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn remove_role(
state: State<'_, crate::state::AppState>,
state: State<'_, AppState>,
room_id: String,
user_id: String,
_role_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 server_url = s.server_url.clone().ok_or("Not logged in")?;
let token = s.auth_token.clone().ok_or("Not logged in")?;
drop(s);
if let Ok(mut power_levels) = room.power_levels().await {
power_levels.users.remove(&uid);
let client = reqwest::Client::new();
let res = client
.post(format!(
"{}/api/rooms/{}/roles/remove",
server_url.trim_end_matches('/'),
room_id
))
.header("Authorization", format!("Bearer {}", token))
.json(&serde_json::json!({ "user_id": user_id, "role_id": role_id }))
.send()
.await
.map_err(|e| e.to_string())?;
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)
res.json().await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_permissions(
state: State<'_, crate::state::AppState>,
state: 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 server_url = s.server_url.clone().ok_or("Not logged in")?;
let token = s.auth_token.clone().ok_or("Not logged in")?;
drop(s);
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
};
let client = reqwest::Client::new();
let res = client
.get(format!(
"{}/api/rooms/{}/permissions/{}",
server_url.trim_end_matches('/'),
room_id,
user_id
))
.header("Authorization", format!("Bearer {}", token))
.send()
.await
.map_err(|e| e.to_string())?;
Ok(power_level_to_permissions(user_power))
}
res.json().await.map_err(|e| e.to_string())
}

View File

@@ -1,9 +1,8 @@
use matrix_sdk::ruma::room_id;
use serde::Serialize;
use tauri::State;
use crate::state::AppState;
use serde::{Deserialize, Serialize};
use tauri::State;
#[derive(Serialize)]
#[derive(Serialize, Deserialize)]
pub struct RoomInfo {
room_id: String,
name: String,
@@ -13,165 +12,466 @@ pub struct RoomInfo {
topic: Option<String>,
}
#[derive(Serialize)]
#[derive(Serialize, Deserialize)]
pub struct MessageInfo {
event_id: String,
sender: String,
body: String,
timestamp: u64,
reply_to: Option<String>,
edited: Option<bool>,
reactions: Option<std::collections::HashMap<String, Vec<String>>>,
msgtype: Option<String>,
media_url: Option<String>,
filename: Option<String>,
mimetype: Option<String>,
width: Option<u64>,
height: Option<u64>,
size: Option<u64>,
}
#[tauri::command]
pub async fn get_joined_rooms(
state: State<'_, crate::state::AppState>,
) -> Result<Vec<RoomInfo>, String> {
pub async fn get_joined_rooms(state: State<'_, AppState>) -> Result<Vec<RoomInfo>, String> {
let s = state.read().await;
let client = s.client.as_ref().ok_or("Not logged in")?;
let server_url = s.server_url.clone().ok_or("Not logged in")?;
let token = s.auth_token.clone().ok_or("Not logged in")?;
drop(s);
let rooms = client.joined_rooms();
let mut result = Vec::new();
let client = reqwest::Client::new();
let res = client
.get(format!("{}/api/rooms", server_url.trim_end_matches('/')))
.header("Authorization", format!("Bearer {}", token))
.send()
.await
.map_err(|e| e.to_string())?;
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)
res.json().await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_room_messages(
state: State<'_, crate::state::AppState>,
state: 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 server_url = s.server_url.clone().ok_or("Not logged in")?;
let token = s.auth_token.clone().ok_or("Not logged in")?;
drop(s);
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 client = reqwest::Client::new();
let mut url = format!(
"{}/api/rooms/{}/messages?limit={}",
server_url.trim_end_matches('/'),
room_id,
limit
);
if let Some(from) = from {
url = format!("{}&from={}", url, from);
}
let messages = room
.messages(options)
let res = client
.get(&url)
.header("Authorization", format!("Bearer {}", token))
.send()
.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)
res.json().await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn send_message(
state: State<'_, crate::state::AppState>,
state: 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 server_url = s.server_url.clone().ok_or("Not logged in")?;
let token = s.auth_token.clone().ok_or("Not logged in")?;
drop(s);
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())?;
let client = reqwest::Client::new();
let res = client
.post(format!(
"{}/api/rooms/{}/send",
server_url.trim_end_matches('/'),
room_id
))
.header("Authorization", format!("Bearer {}", token))
.json(&serde_json::json!({ "message": message }))
.send()
.await
.map_err(|e| e.to_string())?;
Ok(response.event_id.to_string())
res.json().await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn create_room(
state: State<'_, crate::state::AppState>,
state: 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 server_url = s.server_url.clone().ok_or("Not logged in")?;
let token = s.auth_token.clone().ok_or("Not logged in")?;
drop(s);
let vis = match visibility.as_str() {
"public" => matrix_sdk::ruma::Space::Public,
_ => matrix_sdk::ruma::Space::Private,
};
let client = reqwest::Client::new();
let res = client
.post(format!(
"{}/api/rooms/create",
server_url.trim_end_matches('/')
))
.header("Authorization", format!("Bearer {}", token))
.json(&serde_json::json!({ "name": name, "topic": topic, "visibility": visibility }))
.send()
.await
.map_err(|e| e.to_string())?;
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())
res.json().await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn join_room(
state: State<'_, crate::state::AppState>,
state: 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 server_url = s.server_url.clone().ok_or("Not logged in")?;
let token = s.auth_token.clone().ok_or("Not logged in")?;
drop(s);
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())?;
let client = reqwest::Client::new();
let res = client
.post(format!(
"{}/api/rooms/join",
server_url.trim_end_matches('/')
))
.header("Authorization", format!("Bearer {}", token))
.json(&serde_json::json!({ "room_id_or_alias": room_id_or_alias }))
.send()
.await
.map_err(|e| e.to_string())?;
Ok(room_id.to_string())
res.json().await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn leave_room(
state: State<'_, crate::state::AppState>,
room_id: String,
) -> Result<bool, String> {
pub async fn leave_room(state: 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")?;
let server_url = s.server_url.clone().ok_or("Not logged in")?;
let token = s.auth_token.clone().ok_or("Not logged in")?;
drop(s);
room.leave().await.map_err(|e| e.to_string())?;
Ok(true)
let client = reqwest::Client::new();
let res = client
.post(format!(
"{}/api/rooms/{}/leave",
server_url.trim_end_matches('/'),
room_id
))
.header("Authorization", format!("Bearer {}", token))
.send()
.await
.map_err(|e| e.to_string())?;
res.json().await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_room_members(
state: State<'_, crate::state::AppState>,
state: 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 server_url = s.server_url.clone().ok_or("Not logged in")?;
let token = s.auth_token.clone().ok_or("Not logged in")?;
drop(s);
let members = room.joined_members();
Ok(members.iter().map(|m| m.user_id().to_string()).collect())
}
let client = reqwest::Client::new();
let res = client
.get(format!(
"{}/api/rooms/{}/members",
server_url.trim_end_matches('/'),
room_id
))
.header("Authorization", format!("Bearer {}", token))
.send()
.await
.map_err(|e| e.to_string())?;
res.json().await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn edit_message(
state: State<'_, AppState>,
room_id: String,
event_id: String,
new_body: String,
) -> Result<bool, String> {
let s = state.read().await;
let server_url = s.server_url.clone().ok_or("Not logged in")?;
let token = s.auth_token.clone().ok_or("Not logged in")?;
drop(s);
let client = reqwest::Client::new();
let res = client
.post(format!(
"{}/api/rooms/{}/edit",
server_url.trim_end_matches('/'),
room_id
))
.header("Authorization", format!("Bearer {}", token))
.json(&serde_json::json!({ "event_id": event_id, "new_body": new_body }))
.send()
.await
.map_err(|e| e.to_string())?;
res.json().await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn delete_message(
state: State<'_, AppState>,
room_id: String,
event_id: String,
) -> Result<bool, String> {
let s = state.read().await;
let server_url = s.server_url.clone().ok_or("Not logged in")?;
let token = s.auth_token.clone().ok_or("Not logged in")?;
drop(s);
let client = reqwest::Client::new();
let res = client
.post(format!(
"{}/api/rooms/{}/delete/{}",
server_url.trim_end_matches('/'),
room_id,
event_id
))
.header("Authorization", format!("Bearer {}", token))
.send()
.await
.map_err(|e| e.to_string())?;
res.json().await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn react_to_message(
state: State<'_, AppState>,
room_id: String,
event_id: String,
key: String,
) -> Result<bool, String> {
let s = state.read().await;
let server_url = s.server_url.clone().ok_or("Not logged in")?;
let token = s.auth_token.clone().ok_or("Not logged in")?;
drop(s);
let client = reqwest::Client::new();
let res = client
.post(format!(
"{}/api/rooms/{}/react",
server_url.trim_end_matches('/'),
room_id
))
.header("Authorization", format!("Bearer {}", token))
.json(&serde_json::json!({ "event_id": event_id, "key": key }))
.send()
.await
.map_err(|e| e.to_string())?;
res.json().await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn set_typing(
state: State<'_, AppState>,
room_id: String,
typing: bool,
) -> Result<bool, String> {
let s = state.read().await;
let server_url = s.server_url.clone().ok_or("Not logged in")?;
let token = s.auth_token.clone().ok_or("Not logged in")?;
drop(s);
let client = reqwest::Client::new();
let res = client
.post(format!(
"{}/api/rooms/{}/typing",
server_url.trim_end_matches('/'),
room_id
))
.header("Authorization", format!("Bearer {}", token))
.json(&serde_json::json!({ "typing": typing }))
.send()
.await
.map_err(|e| e.to_string())?;
res.json().await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn upload_file(
state: State<'_, AppState>,
room_id: String,
file_path: String,
) -> Result<String, String> {
let s = state.read().await;
let server_url = s.server_url.clone().ok_or("Not logged in")?;
let token = s.auth_token.clone().ok_or("Not logged in")?;
drop(s);
let data = std::fs::read(&file_path).map_err(|e| e.to_string())?;
let file_name = std::path::Path::new(&file_path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("upload")
.to_string();
let mime_type = match std::path::Path::new(&file_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",
Some("pdf") => "application/pdf",
Some("mp4") => "video/mp4",
Some("mp3") => "audio/mpeg",
_ => "application/octet-stream",
};
let client = reqwest::Client::new();
let part = reqwest::multipart::Part::bytes(data)
.file_name(file_name)
.mime_str(mime_type)
.map_err(|e| e.to_string())?;
let form = reqwest::multipart::Form::new().part("file", part);
let res = client
.post(format!(
"{}/api/rooms/{}/upload",
server_url.trim_end_matches('/'),
room_id
))
.header("Authorization", format!("Bearer {}", token))
.multipart(form)
.send()
.await
.map_err(|e| e.to_string())?;
res.text().await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn set_room_name(
state: State<'_, AppState>,
room_id: String,
name: String,
) -> Result<bool, String> {
let s = state.read().await;
let server_url = s.server_url.clone().ok_or("Not logged in")?;
let token = s.auth_token.clone().ok_or("Not logged in")?;
drop(s);
let client = reqwest::Client::new();
let res = client
.post(format!(
"{}/api/rooms/{}/name",
server_url.trim_end_matches('/'),
room_id
))
.header("Authorization", format!("Bearer {}", token))
.json(&serde_json::json!({ "name": name }))
.send()
.await
.map_err(|e| e.to_string())?;
res.json().await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn set_room_topic(
state: State<'_, AppState>,
room_id: String,
topic: String,
) -> Result<bool, String> {
let s = state.read().await;
let server_url = s.server_url.clone().ok_or("Not logged in")?;
let token = s.auth_token.clone().ok_or("Not logged in")?;
drop(s);
let client = reqwest::Client::new();
let res = client
.post(format!(
"{}/api/rooms/{}/topic",
server_url.trim_end_matches('/'),
room_id
))
.header("Authorization", format!("Bearer {}", token))
.json(&serde_json::json!({ "topic": topic }))
.send()
.await
.map_err(|e| e.to_string())?;
res.json().await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn set_room_avatar(
state: State<'_, AppState>,
room_id: String,
file_path: String,
) -> Result<bool, String> {
let s = state.read().await;
let server_url = s.server_url.clone().ok_or("Not logged in")?;
let token = s.auth_token.clone().ok_or("Not logged in")?;
drop(s);
let data = std::fs::read(&file_path).map_err(|e| e.to_string())?;
let file_name = std::path::Path::new(&file_path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("avatar")
.to_string();
let mime_type = match std::path::Path::new(&file_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 client = reqwest::Client::new();
let part = reqwest::multipart::Part::bytes(data)
.file_name(file_name)
.mime_str(mime_type)
.map_err(|e| e.to_string())?;
let form = reqwest::multipart::Form::new().part("file", part);
let res = client
.post(format!(
"{}/api/rooms/{}/avatar",
server_url.trim_end_matches('/'),
room_id
))
.header("Authorization", format!("Bearer {}", token))
.multipart(form)
.send()
.await
.map_err(|e| e.to_string())?;
res.json().await.map_err(|e| e.to_string())
}

View File

@@ -1,87 +1,135 @@
use matrix_sdk::ruma::room_id;
use serde::Serialize;
use tauri::State;
use crate::state::AppState;
use serde::{Deserialize, Serialize};
use tauri::State;
#[derive(Serialize)]
#[derive(Serialize, Deserialize)]
pub struct ThreadInfo {
thread_id: String,
root_event_id: String,
sender: String,
body: String,
reply_count: u32,
pub root_event_id: String,
pub root_sender: String,
pub root_body: String,
pub root_timestamp: u64,
pub reply_count: u32,
pub last_reply_event_id: Option<String>,
pub last_reply_sender: Option<String>,
pub last_reply_body: Option<String>,
pub last_reply_timestamp: Option<u64>,
}
#[derive(Serialize, Deserialize)]
pub struct ThreadMessageInfo {
pub event_id: String,
pub sender: String,
pub body: String,
pub timestamp: u64,
}
#[tauri::command]
pub async fn get_threads(
state: State<'_, AppState>,
room_id: String,
) -> Result<Vec<ThreadInfo>, String> {
let s = state.read().await;
let server_url = s.server_url.clone().ok_or("No server URL")?;
let token = s.auth_token.clone().ok_or("No auth token")?;
drop(s);
let client = reqwest::Client::new();
let res = client
.get(format!(
"{}/api/rooms/{}/threads",
server_url.trim_end_matches('/'),
room_id
))
.header("Authorization", format!("Bearer {}", token))
.send()
.await
.map_err(|e| e.to_string())?;
res.json().await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_thread_messages(
state: State<'_, crate::state::AppState>,
state: State<'_, AppState>,
room_id: String,
thread_id: String,
limit: u32,
) -> Result<Vec<ThreadInfo>, String> {
) -> Result<Vec<ThreadMessageInfo>, 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 server_url = s.server_url.clone().ok_or("No server URL")?;
let token = s.auth_token.clone().ok_or("No auth token")?;
drop(s);
let mut options = matrix_sdk::ruma::api::client::message::get_message_events::v3::Request::new();
options.limit = limit.into();
let client = reqwest::Client::new();
let res = client
.get(format!(
"{}/api/rooms/{}/threads/{}?limit={}",
server_url.trim_end_matches('/'),
room_id,
thread_id,
limit
))
.header("Authorization", format!("Bearer {}", token))
.send()
.await
.map_err(|e| e.to_string())?;
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)
res.json().await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn send_thread_reply(
state: State<'_, crate::state::AppState>,
state: 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 server_url = s.server_url.clone().ok_or("No server URL")?;
let token = s.auth_token.clone().ok_or("No auth token")?;
drop(s);
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())?;
let client = reqwest::Client::new();
let res = client
.post(format!(
"{}/api/rooms/{}/threads/{}/reply",
server_url.trim_end_matches('/'),
room_id,
thread_id
))
.header("Authorization", format!("Bearer {}", token))
.json(&serde_json::json!({ "message": message }))
.send()
.await
.map_err(|e| e.to_string())?;
Ok(response.event_id.to_string())
res.json().await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn create_thread(
state: State<'_, crate::state::AppState>,
pub async fn send_reply(
state: State<'_, AppState>,
room_id: String,
root_event_id: String,
reply_to: 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 server_url = s.server_url.clone().ok_or("No server URL")?;
let token = s.auth_token.clone().ok_or("No auth token")?;
drop(s);
Ok(root_event_id)
}
let client = reqwest::Client::new();
let res = client
.post(format!(
"{}/api/rooms/{}/reply",
server_url.trim_end_matches('/'),
room_id
))
.header("Authorization", format!("Bearer {}", token))
.json(&serde_json::json!({ "message": message, "reply_to": reply_to }))
.send()
.await
.map_err(|e| e.to_string())?;
res.json().await.map_err(|e| e.to_string())
}

View File

@@ -1,68 +1,152 @@
use serde::Serialize;
use tauri::State;
use crate::state::AppState;
use serde::{Deserialize, Serialize};
use tauri::State;
#[derive(Serialize)]
pub struct VoiceState {
room_id: String,
muted: bool,
deafened: bool,
streaming: bool,
#[derive(Serialize, Deserialize)]
pub struct VoiceJoinResult {
pub room_id: String,
pub livekit_url: String,
pub livekit_token: String,
}
#[derive(Serialize, Deserialize)]
pub struct VoiceToggleResult {
pub muted: bool,
pub deafened: bool,
}
#[derive(Serialize, Deserialize)]
pub struct VoiceParticipantInfo {
pub user_id: String,
pub muted: bool,
pub deafened: bool,
}
#[tauri::command]
pub async fn join_voice_channel(
state: State<'_, crate::state::AppState>,
state: State<'_, AppState>,
room_id: String,
) -> Result<VoiceState, String> {
) -> Result<VoiceJoinResult, String> {
let s = state.read().await;
let user_id = s.user_id.clone().ok_or("Not logged in")?;
let server_url = s.server_url.clone().ok_or("No server URL")?;
let token = s.auth_token.clone().ok_or("No auth token")?;
let mut s = state.write().await;
s.voice_manager.join_channel(room_id.clone(), user_id);
drop(s);
Ok(VoiceState {
room_id,
muted: false,
deafened: false,
streaming: false,
})
let client = reqwest::Client::new();
let res = client
.post(format!(
"{}/api/voice/join",
server_url.trim_end_matches('/')
))
.header("Authorization", format!("Bearer {}", token))
.json(&serde_json::json!({ "room_id": room_id }))
.send()
.await
.map_err(|e| e.to_string())?;
let result: VoiceJoinResult = res.json().await.map_err(|e| e.to_string())?;
Ok(result)
}
#[tauri::command]
pub async fn leave_voice_channel(
state: State<'_, crate::state::AppState>,
state: 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 server_url = s.server_url.clone().ok_or("No server URL")?;
let token = s.auth_token.clone().ok_or("No auth token")?;
let mut s = state.write().await;
Ok(s.voice_manager.leave_channel(&room_id, &user_id))
drop(s);
let client = reqwest::Client::new();
let res = client
.post(format!(
"{}/api/voice/leave",
server_url.trim_end_matches('/')
))
.header("Authorization", format!("Bearer {}", token))
.json(&serde_json::json!({ "room_id": room_id }))
.send()
.await
.map_err(|e| e.to_string())?;
res.json().await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn toggle_mute(
state: State<'_, crate::state::AppState>,
state: State<'_, AppState>,
room_id: String,
) -> Result<bool, String> {
) -> Result<VoiceToggleResult, String> {
let s = state.read().await;
let user_id = s.user_id.clone().ok_or("Not logged in")?;
let server_url = s.server_url.clone().ok_or("No server URL")?;
let token = s.auth_token.clone().ok_or("No auth token")?;
let mut s = state.write().await;
s.voice_manager.toggle_mute(&room_id, &user_id)
.ok_or("Not in voice channel".to_string())
drop(s);
let client = reqwest::Client::new();
let res = client
.post(format!(
"{}/api/voice/toggle-mute",
server_url.trim_end_matches('/')
))
.header("Authorization", format!("Bearer {}", token))
.json(&serde_json::json!({ "room_id": room_id }))
.send()
.await
.map_err(|e| e.to_string())?;
res.json().await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn toggle_deafen(
state: State<'_, crate::state::AppState>,
state: State<'_, AppState>,
room_id: String,
) -> Result<bool, String> {
) -> Result<VoiceToggleResult, String> {
let s = state.read().await;
let user_id = s.user_id.clone().ok_or("Not logged in")?;
let server_url = s.server_url.clone().ok_or("No server URL")?;
let token = s.auth_token.clone().ok_or("No auth token")?;
let mut s = state.write().await;
s.voice_manager.toggle_deafen(&room_id, &user_id)
.ok_or("Not in voice channel".to_string())
}
drop(s);
let client = reqwest::Client::new();
let res = client
.post(format!(
"{}/api/voice/toggle-deafen",
server_url.trim_end_matches('/')
))
.header("Authorization", format!("Bearer {}", token))
.json(&serde_json::json!({ "room_id": room_id }))
.send()
.await
.map_err(|e| e.to_string())?;
res.json().await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_voice_participants(
state: State<'_, AppState>,
) -> Result<Vec<VoiceParticipantInfo>, String> {
let s = state.read().await;
let server_url = s.server_url.clone().ok_or("No server URL")?;
let token = s.auth_token.clone().ok_or("No auth token")?;
drop(s);
let client = reqwest::Client::new();
let res = client
.get(format!(
"{}/api/voice/participants",
server_url.trim_end_matches('/')
))
.header("Authorization", format!("Bearer {}", token))
.send()
.await
.map_err(|e| e.to_string())?;
res.json().await.map_err(|e| e.to_string())
}

View File

@@ -1,12 +1,13 @@
pub mod matrix;
pub mod commands;
pub mod state;
use state::AppState;
use state::AppStateInner;
fn main() {
tauri::Builder::default()
.manage(AppState::new())
.manage(AppStateInner::default())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
.invoke_handler(tauri::generate_handler![
commands::auth::login,
commands::auth::logout,
@@ -15,19 +16,29 @@ fn main() {
commands::rooms::get_joined_rooms,
commands::rooms::get_room_messages,
commands::rooms::send_message,
commands::rooms::edit_message,
commands::rooms::delete_message,
commands::rooms::react_to_message,
commands::rooms::set_typing,
commands::rooms::upload_file,
commands::rooms::create_room,
commands::rooms::join_room,
commands::rooms::leave_room,
commands::rooms::get_room_members,
commands::rooms::set_room_name,
commands::rooms::set_room_topic,
commands::rooms::set_room_avatar,
commands::threads::get_thread_messages,
commands::threads::send_thread_reply,
commands::threads::create_thread,
commands::threads::get_threads,
commands::threads::send_reply,
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::voice::get_voice_participants,
commands::emoji::get_custom_emoji,
commands::emoji::upload_emoji,
commands::emoji::get_sticker_packs,
@@ -35,7 +46,11 @@ fn main() {
commands::roles::assign_role,
commands::roles::remove_role,
commands::roles::get_permissions,
commands::profile::get_own_profile,
commands::profile::get_user_profile,
commands::profile::set_display_name,
commands::profile::upload_avatar,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
}

View File

@@ -1,75 +0,0 @@
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
}
}

View File

@@ -1,68 +0,0 @@
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
}
}

View File

@@ -1,8 +0,0 @@
pub mod client;
pub mod room;
pub mod event;
pub mod sync;
pub mod voice;
pub mod presence;
pub use client::MatrixClient;

View File

@@ -1,39 +0,0 @@
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;
}
}

View File

@@ -1,67 +0,0 @@
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;
}
}
}

View File

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

View File

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

View File

@@ -1,21 +1,13 @@
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 auth_token: Option<String>,
pub server_url: Option<String>,
}
pub type AppState = Arc<RwLock<AppStateInner>>;
pub type AppState = tauri::async_runtime::RwLock<AppStateInner>;
impl AppState {
pub fn new() -> Self {
Arc::new(RwLock::new(AppStateInner::default()))
}
}
pub fn get_client() -> reqwest::Client {
reqwest::Client::new()
}

55
client/tauri.conf.json Normal file
View File

@@ -0,0 +1,55 @@
{
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/schema.json",
"productName": "EifelDC",
"version": "0.1.0",
"identifier": "de.eifeldc",
"build": {
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build",
"devUrl": "http://localhost:5173",
"frontendDist": "../src-ui/dist"
},
"app": {
"windows": [
{
"title": "EifelDC",
"width": 1280,
"height": 720,
"minWidth": 800,
"minHeight": 600,
"resizable": true,
"fullscreen": false,
"decorations": true,
"transparent": false
}
],
"security": {
"csp": "default-src 'self'; connect-src https: wss: http://localhost:*; img-src https: data:; style-src 'self' 'unsafe-inline'; script-src 'self'"
}
},
"bundle": {
"active": true,
"icon": [
"src-tauri/icons/32x32.png",
"src-tauri/icons/128x128.png",
"src-tauri/icons/128x128@2x.png",
"src-tauri/icons/icon.icns",
"src-tauri/icons/icon.ico"
],
"category": "SocialNetworking",
"shortDescription": "EifelDC - Matrix Chat Client",
"longDescription": "EifelDC is a Discord-like Matrix chat client built with Tauri, Svelte and Rust.",
"macOS": {
"minimumSystemVersion": "10.15",
"entitlements": "macos/EifelDC.entitlements"
},
"windows": {
"digestAlgorithm": "sha256"
}
},
"plugins": {
"shell": {
"open": true
}
}
}