Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 72d02e385f | |||
| c9b885b91c | |||
| adbdfaa2aa | |||
| 6f1e1a5f9a | |||
| f9bbcdee47 | |||
| ccbef6d959 | |||
| 73ddeecfca | |||
| 727aced411 | |||
| 3d605c09bc | |||
| 74074115d2 | |||
| 12bb585081 | |||
| 665f450878 | |||
| e2e8217b86 | |||
| 6c6a23e672 | |||
| 16d179f8e8 | |||
| 12e91c0bbe | |||
| 8b9a40f059 | |||
| eac19823ea | |||
| a5e930ac17 | |||
| c3e0c47d32 | |||
| 8f9b823d69 | |||
| 7ad8ba82b6 | |||
| b44a785d20 | |||
| 331b1298f8 | |||
| 02ca3e3581 | |||
| eb8f702576 | |||
| bc8a7c5a32 | |||
| 1c67a5c97f | |||
| 6301ebb392 | |||
| 31d21dd2c2 | |||
| 9add0a4d61 | |||
| 500f1c4bc2 | |||
| a38e2f96c0 | |||
| 5aa39b40ff | |||
| eb452bf57e | |||
| 902c0285e6 | |||
| 1303c8e26f | |||
| 48ac9d2922 | |||
| 725a7e0364 | |||
| 44088b35d7 | |||
| 228608ef2c | |||
| 24fdfee738 | |||
| 636565d552 | |||
| 672c8738c7 | |||
| b89ec90813 | |||
| a04ecf7a19 | |||
| da85f0ba54 | |||
| a4c9b959a9 | |||
| 37becba7e2 | |||
| eb8f9857ff | |||
| de431386ea | |||
| 1fab2edc57 | |||
| 4db65993d5 |
@@ -0,0 +1,21 @@
|
|||||||
|
# Copy to .env.release (gitignored) and fill in.
|
||||||
|
# Consumed by scripts/release.mjs.
|
||||||
|
|
||||||
|
# Absolute path to the private key file produced by `tauri signer generate`.
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY_PATH=C:/Users/denni/.tauri/chatapp.key
|
||||||
|
|
||||||
|
# Password set when generating the key. Leave empty if none.
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD=
|
||||||
|
|
||||||
|
# Host serving latest.json + installer artifacts over HTTPS.
|
||||||
|
UPDATE_HOST=update.netralax.cloud
|
||||||
|
|
||||||
|
# SSH user on UPDATE_HOST with write access to UPDATE_REMOTE_PATH.
|
||||||
|
UPDATE_SSH_USER=chatapp-deploy
|
||||||
|
|
||||||
|
# Optional: path to the SSH private key. Omit to fall back on ssh-agent or the
|
||||||
|
# default id_rsa.
|
||||||
|
UPDATE_SSH_KEY=
|
||||||
|
|
||||||
|
# Absolute path on the server where windows/ artifacts + latest.json live.
|
||||||
|
UPDATE_REMOTE_PATH=/var/www/updates/windows
|
||||||
@@ -1,35 +1,27 @@
|
|||||||
name: Release desktop app
|
name: Release desktop app (manual backup)
|
||||||
|
|
||||||
# Tag a version to trigger a release:
|
# Self-hosted updates run from scripts/release.mjs on Dennis's Windows box.
|
||||||
# git tag v0.1.0 && git push --tags
|
# This workflow is kept as a manual backup — trigger it from the Actions tab
|
||||||
#
|
# if the local build host is unavailable.
|
||||||
# Produces signed Tauri bundles for macOS (arm + intel), Windows, and Linux,
|
|
||||||
# uploads them to a GitHub Release, and publishes `latest.json` for the
|
|
||||||
# updater plugin to discover.
|
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
workflow_dispatch:
|
||||||
tags:
|
inputs:
|
||||||
- "v*"
|
tag:
|
||||||
|
description: "Tag to build (e.g. v0.10.2) — must already exist"
|
||||||
|
required: true
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
strategy:
|
runs-on: windows-latest
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- platform: macos-14 # universal binary covers both Intel + Apple Silicon
|
|
||||||
args: "--target universal-apple-darwin --bundles app,updater"
|
|
||||||
- platform: windows-latest
|
|
||||||
args: ""
|
|
||||||
|
|
||||||
runs-on: ${{ matrix.platform }}
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.tag }}
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
@@ -42,8 +34,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Setup Rust
|
- name: Setup Rust
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
|
||||||
targets: ${{ matrix.platform == 'macos-14' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
|
||||||
|
|
||||||
- name: Install JS deps
|
- name: Install JS deps
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
@@ -54,18 +44,16 @@ jobs:
|
|||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||||
# Client-side env vars baked into the bundle — paste your prod values
|
|
||||||
# into the repo's Actions → Secrets so releases point at prod.
|
|
||||||
VITE_SUPABASE_URL: ${{ secrets.VITE_SUPABASE_URL }}
|
VITE_SUPABASE_URL: ${{ secrets.VITE_SUPABASE_URL }}
|
||||||
VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
||||||
VITE_AUTH_REDIRECT_URL: ${{ secrets.VITE_AUTH_REDIRECT_URL }}
|
VITE_AUTH_REDIRECT_URL: ${{ secrets.VITE_AUTH_REDIRECT_URL }}
|
||||||
VITE_LIVEKIT_URL: ${{ secrets.VITE_LIVEKIT_URL }}
|
VITE_LIVEKIT_URL: ${{ secrets.VITE_LIVEKIT_URL }}
|
||||||
with:
|
with:
|
||||||
projectPath: apps/desktop
|
projectPath: apps/desktop
|
||||||
tagName: ${{ github.ref_name }}
|
tagName: ${{ inputs.tag }}
|
||||||
releaseName: "ChatApp ${{ github.ref_name }}"
|
releaseName: "ChatApp ${{ inputs.tag }}"
|
||||||
releaseBody: "See the assets below to download this version."
|
releaseBody: "Manual build — copy .nsis.zip/.sig/latest.json to the update host."
|
||||||
releaseDraft: true
|
releaseDraft: true
|
||||||
prerelease: false
|
prerelease: false
|
||||||
tauriScript: pnpm exec tauri
|
tauriScript: pnpm exec tauri
|
||||||
args: ${{ matrix.args }}
|
args: "--bundles nsis"
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ out/
|
|||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
.env.release
|
||||||
!.env.example
|
!.env.example
|
||||||
|
!.env.release.example
|
||||||
|
|
||||||
# Expo
|
# Expo
|
||||||
.expo/
|
.expo/
|
||||||
@@ -56,6 +58,9 @@ Thumbs.db
|
|||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
|
|
||||||
|
# Claude Code per-project local settings
|
||||||
|
.claude/
|
||||||
|
|
||||||
# Coverage
|
# Coverage
|
||||||
coverage/
|
coverage/
|
||||||
*.lcov
|
*.lcov
|
||||||
|
|||||||
@@ -6,8 +6,21 @@
|
|||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<title>Netralax</title>
|
<title>Netralax</title>
|
||||||
|
<script>
|
||||||
|
// Apply persisted / system theme before paint to avoid FOUC.
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var stored = localStorage.getItem('netralax.theme');
|
||||||
|
var dark =
|
||||||
|
stored === 'dark' ||
|
||||||
|
(stored !== 'light' &&
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||||
|
if (dark) document.documentElement.classList.add('dark');
|
||||||
|
} catch (_) {}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-[#0b0b0f] text-white antialiased">
|
<body class="bg-surface text-fg antialiased">
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@chat-app/desktop",
|
"name": "@chat-app/desktop",
|
||||||
"version": "0.1.0",
|
"version": "0.11.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Tauri v2 desktop client (Windows / macOS / Linux)",
|
"description": "Tauri v2 desktop client (Windows / macOS / Linux)",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
"@chat-app/shared": "workspace:*",
|
"@chat-app/shared": "workspace:*",
|
||||||
"@chat-app/ui-web": "workspace:*",
|
"@chat-app/ui-web": "workspace:*",
|
||||||
"@livekit/components-react": "^2.9.0",
|
"@livekit/components-react": "^2.9.0",
|
||||||
|
"@livekit/track-processors": "^0.7.2",
|
||||||
"@supabase/supabase-js": "^2.46.0",
|
"@supabase/supabase-js": "^2.46.0",
|
||||||
"@tauri-apps/api": "^2.1.1",
|
"@tauri-apps/api": "^2.1.1",
|
||||||
"@tauri-apps/plugin-fs": "^2.5.0",
|
"@tauri-apps/plugin-fs": "^2.5.0",
|
||||||
@@ -30,7 +31,7 @@
|
|||||||
"@tauri-apps/plugin-stronghold": "^2.0.1",
|
"@tauri-apps/plugin-stronghold": "^2.0.1",
|
||||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||||
"i18next": "^23.16.4",
|
"i18next": "^23.16.4",
|
||||||
"libsodium-wrappers": "0.7.15",
|
"libsodium-wrappers-sumo": "0.7.15",
|
||||||
"livekit-client": "^2.7.0",
|
"livekit-client": "^2.7.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
@@ -41,6 +42,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.1.0",
|
"@tauri-apps/cli": "^2.1.0",
|
||||||
"@types/libsodium-wrappers": "^0.7.14",
|
"@types/libsodium-wrappers": "^0.7.14",
|
||||||
|
"@types/libsodium-wrappers-sumo": "^0.8.2",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@vitejs/plugin-react": "^4.3.3",
|
"@vitejs/plugin-react": "^4.3.3",
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||||
<defs>
|
<rect x="4" y="4" width="56" height="56" rx="20" fill="#a78bfa"/>
|
||||||
<clipPath id="cp02">
|
<path d="M18 46 V22 Q 18 18 22 18 Q 26 18 27 21 L 39 42 Q 40 45 44 45 V22 Q 44 18 40 18"
|
||||||
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19"/>
|
fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" opacity="0"/>
|
||||||
</clipPath>
|
<path d="M20 46 V22 a3 3 0 0 1 5.5 -1.8 L 40 43 a2 2 0 0 0 4 -1.2 V22 a3 3 0 0 0 -6 0"
|
||||||
</defs>
|
fill="none" stroke="#fff" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19" fill="#2e1065"/>
|
|
||||||
<g clip-path="url(#cp02)">
|
|
||||||
<path d="M-4 32 Q 16 18 32 32 T 68 32 L 68 64 L -4 64 Z" fill="#7c4dff"/>
|
|
||||||
<path d="M-4 32 Q 16 18 32 32 T 68 32" stroke="#a78bfa" stroke-width="2" fill="none"/>
|
|
||||||
</g>
|
|
||||||
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19"
|
|
||||||
fill="none" stroke="#a78bfa" stroke-width="1.5" opacity="0.4"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 667 B After Width: | Height: | Size: 563 B |
@@ -0,0 +1,67 @@
|
|||||||
|
// Web Push service worker.
|
||||||
|
//
|
||||||
|
// Handles browser-delivered push events when the app tab is closed or in the
|
||||||
|
// background. Tauri desktop does not install service workers; native OS
|
||||||
|
// notifications are routed through the Tauri notification plugin instead
|
||||||
|
// (see src/lib/osNotify.ts).
|
||||||
|
//
|
||||||
|
// Payload contract — server sends JSON of shape:
|
||||||
|
// { title: string, body?: string, conversationId?: string, kind?: 'message' | 'call' }
|
||||||
|
// Body is intentionally generic; message ciphertext is never included.
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
// Activate immediately so updates apply on next page load.
|
||||||
|
event.waitUntil(self.skipWaiting());
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(self.clients.claim());
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('push', (event) => {
|
||||||
|
let data = { title: 'Neue Nachricht', body: '' };
|
||||||
|
try {
|
||||||
|
if (event.data) {
|
||||||
|
data = { ...data, ...event.data.json() };
|
||||||
|
}
|
||||||
|
} catch (_err) {
|
||||||
|
/* malformed payload — fall back to defaults */
|
||||||
|
}
|
||||||
|
|
||||||
|
const opts = {
|
||||||
|
body: data.body || '',
|
||||||
|
icon: '/favicon.svg',
|
||||||
|
badge: '/favicon.svg',
|
||||||
|
tag: data.conversationId || 'default',
|
||||||
|
renotify: true,
|
||||||
|
data: {
|
||||||
|
conversationId: data.conversationId,
|
||||||
|
kind: data.kind,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
event.waitUntil(self.registration.showNotification(data.title, opts));
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('notificationclick', (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
const conversationId = event.notification.data && event.notification.data.conversationId;
|
||||||
|
const target = conversationId ? '/chats/' + conversationId : '/';
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
self.clients
|
||||||
|
.matchAll({ type: 'window', includeUncontrolled: true })
|
||||||
|
.then((clientList) => {
|
||||||
|
for (const client of clientList) {
|
||||||
|
if ('focus' in client) {
|
||||||
|
client.postMessage({ type: 'navigate', to: target });
|
||||||
|
return client.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (self.clients.openWindow) {
|
||||||
|
return self.clients.openWindow(target);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "chat-app-desktop"
|
name = "chat-app-desktop"
|
||||||
version = "0.1.0"
|
version = "0.11.3"
|
||||||
description = "ChatApp desktop client"
|
description = "ChatApp desktop client"
|
||||||
authors = ["Dennis"]
|
authors = ["Dennis"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
@@ -14,7 +14,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
|||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = ["devtools"] }
|
tauri = { version = "2", features = ["devtools", "tray-icon"] }
|
||||||
tauri-plugin-notification = "2"
|
tauri-plugin-notification = "2"
|
||||||
tauri-plugin-sql = { version = "2", features = ["sqlite"] }
|
tauri-plugin-sql = { version = "2", features = ["sqlite"] }
|
||||||
tauri-plugin-stronghold = "2"
|
tauri-plugin-stronghold = "2"
|
||||||
@@ -22,11 +22,74 @@ tauri-plugin-fs = "2"
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|
||||||
|
# Pure-rust libsodium-compatible primitives. No C toolchain required so
|
||||||
|
# cross-compile for mobile stays clean. API output is bit-compatible with
|
||||||
|
# libsodium-wrappers-sumo for the ops we use (secretbox, box, pwhash).
|
||||||
|
dryoc = { version = "0.7", default-features = false, features = ["serde"] }
|
||||||
|
base64 = "0.22"
|
||||||
|
|
||||||
|
# PNG encoding for screen-source thumbnails returned by the
|
||||||
|
# `enumerate_screen_sources` command. `default-features = false` skips the
|
||||||
|
# image-format decoders we don't use (jpeg, gif, webp, …) — keeps the
|
||||||
|
# thumbnail command at ~200KB extra binary size.
|
||||||
|
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||||
|
|
||||||
|
# Cross-platform screen + window enumeration and capture. Replaces direct
|
||||||
|
# Win32 GDI / macOS CoreGraphics / X11 calls with a small uniform API so
|
||||||
|
# the enumerate-sources command has one code path. The crate pulls in
|
||||||
|
# platform-specific backends automatically (~1.5MB binary growth on
|
||||||
|
# Windows). Marked optional so non-desktop targets don't compile it.
|
||||||
|
xcap = "0.0.14"
|
||||||
|
|
||||||
[target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies]
|
[target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies]
|
||||||
tauri-plugin-global-shortcut = "2"
|
tauri-plugin-global-shortcut = "2"
|
||||||
tauri-plugin-updater = "2"
|
tauri-plugin-updater = "2"
|
||||||
|
tauri-plugin-window-state = "2"
|
||||||
|
|
||||||
|
# Windows-only screen-source enumeration + thumbnail capture. Pulled in
|
||||||
|
# only on Windows so macOS + Linux builds stay slim. The enumerate command
|
||||||
|
# returns stub-empty on non-Windows until we add native equivalents.
|
||||||
|
[target."cfg(target_os = \"windows\")".dependencies]
|
||||||
|
windows = { version = "0.58", features = [
|
||||||
|
"Win32_Foundation",
|
||||||
|
"Win32_Graphics_Gdi",
|
||||||
|
"Win32_UI_HiDpi",
|
||||||
|
"Win32_UI_WindowsAndMessaging",
|
||||||
|
] }
|
||||||
|
|
||||||
|
# WASAPI loopback capture for system-audio screen-share. Lets the custom
|
||||||
|
# picker hand LiveKit a real audio track without falling back to the OS
|
||||||
|
# screen picker (which is the only way getDisplayMedia can grab system
|
||||||
|
# sound). Windows-only for v1; macOS needs ScreenCaptureKit-audio and
|
||||||
|
# Linux needs a PulseAudio / PipeWire path.
|
||||||
|
wasapi = "0.15"
|
||||||
|
|
||||||
|
# LiveKit client SDK — lives behind the `rust-livekit` feature flag so the
|
||||||
|
# baseline build stays unaffected while the JS-SDK path is still the
|
||||||
|
# default. Pulls libwebrtc-rs which adds ~20MB to the binary and ~5-10min
|
||||||
|
# to the first build. Tokio runtime is required; the rest of the crate
|
||||||
|
# stays idle when the feature is off.
|
||||||
|
livekit = { version = "0.7", default-features = false, features = ["tokio", "rustls-tls-native-roots"], optional = true }
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread", "sync", "macros"], optional = true }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# This feature is used for production builds or when `devPath` points to the filesystem
|
# This feature is used for production builds or when `devPath` points to the filesystem
|
||||||
# and disables specific features relevant to the dev build.
|
# and disables specific features relevant to the dev build.
|
||||||
custom-protocol = ["tauri/custom-protocol"]
|
custom-protocol = ["tauri/custom-protocol"]
|
||||||
|
|
||||||
|
# Enable the Rust LiveKit client. Off by default so CI + users stay on the
|
||||||
|
# JS-SDK path until the rust bridge reaches feature parity. Turn on via:
|
||||||
|
# cargo build --features rust-livekit
|
||||||
|
rust-livekit = ["dep:livekit", "dep:tokio"]
|
||||||
|
|
||||||
|
# Release-profile tuned for ChatApp: whole-program LTO + single codegen unit
|
||||||
|
# cuts binary size by ~20-30% and trims startup overhead. `strip = "symbols"`
|
||||||
|
# removes debug + symbol tables (the updater already signs separately so
|
||||||
|
# symbol-backed crash reports aren't the recovery path). `panic = "abort"`
|
||||||
|
# skips unwinding metadata since the app doesn't use catch_unwind anywhere.
|
||||||
|
[profile.release]
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
|
strip = "symbols"
|
||||||
|
panic = "abort"
|
||||||
|
opt-level = "s"
|
||||||
|
|||||||
@@ -9,6 +9,11 @@
|
|||||||
"notification:allow-notify",
|
"notification:allow-notify",
|
||||||
"notification:allow-is-permission-granted",
|
"notification:allow-is-permission-granted",
|
||||||
"notification:allow-request-permission",
|
"notification:allow-request-permission",
|
||||||
|
"sql:default",
|
||||||
|
"sql:allow-load",
|
||||||
|
"sql:allow-execute",
|
||||||
|
"sql:allow-select",
|
||||||
|
"sql:allow-close",
|
||||||
"global-shortcut:allow-register",
|
"global-shortcut:allow-register",
|
||||||
"global-shortcut:allow-unregister",
|
"global-shortcut:allow-unregister",
|
||||||
"global-shortcut:allow-is-registered",
|
"global-shortcut:allow-is-registered",
|
||||||
@@ -34,6 +39,7 @@
|
|||||||
{
|
{
|
||||||
"identifier": "fs:scope",
|
"identifier": "fs:scope",
|
||||||
"allow": [
|
"allow": [
|
||||||
|
{ "path": "$APPLOCALDATA" },
|
||||||
{ "path": "$APPLOCALDATA/**" }
|
{ "path": "$APPLOCALDATA/**" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 948 B After Width: | Height: | Size: 709 B |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 928 B After Width: | Height: | Size: 680 B |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 934 B |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1021 B |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 611 B After Width: | Height: | Size: 471 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 864 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 864 B |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 858 B After Width: | Height: | Size: 669 B |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 864 B |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 2.9 KiB |
@@ -1,14 +1,7 @@
|
|||||||
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||||
<defs>
|
<rect x="4" y="4" width="56" height="56" rx="20" fill="#a78bfa"/>
|
||||||
<clipPath id="cp02">
|
<path d="M18 46 V22 Q 18 18 22 18 Q 26 18 27 21 L 39 42 Q 40 45 44 45 V22 Q 44 18 40 18"
|
||||||
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19"/>
|
fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" opacity="0"/>
|
||||||
</clipPath>
|
<path d="M20 46 V22 a3 3 0 0 1 5.5 -1.8 L 40 43 a2 2 0 0 0 4 -1.2 V22 a3 3 0 0 0 -6 0"
|
||||||
</defs>
|
fill="none" stroke="#fff" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19" fill="#2e1065"/>
|
|
||||||
<g clip-path="url(#cp02)">
|
|
||||||
<path d="M-4 32 Q 16 18 32 32 T 68 32 L 68 64 L -4 64 Z" fill="#7c4dff"/>
|
|
||||||
<path d="M-4 32 Q 16 18 32 32 T 68 32" stroke="#a78bfa" stroke-width="2" fill="none"/>
|
|
||||||
</g>
|
|
||||||
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19"
|
|
||||||
fill="none" stroke="#a78bfa" stroke-width="1.5" opacity="0.4"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 667 B After Width: | Height: | Size: 563 B |
@@ -0,0 +1,287 @@
|
|||||||
|
// Native crypto primitives exposed as Tauri commands. The JS side calls
|
||||||
|
// these via `invoke('crypto_…', …)` through `lib/nativeCryptoBackend.ts`.
|
||||||
|
//
|
||||||
|
// All byte arrays cross the IPC boundary as base64 strings to sidestep
|
||||||
|
// serde_json's lack of native bytes support; JS encodes/decodes at the
|
||||||
|
// thin wrapper layer. The extra encode step costs a few µs per call —
|
||||||
|
// negligible against Argon2id's ~200ms and acceptable for bulk AEAD ops
|
||||||
|
// which still outperform the WASM backend after the round-trip.
|
||||||
|
//
|
||||||
|
// Semantics: bit-compatible with libsodium-wrappers-sumo for all inputs.
|
||||||
|
// AEAD authentication failures surface as `Err(String)` so the JS layer
|
||||||
|
// can re-throw a deterministic error that existing callers already handle.
|
||||||
|
|
||||||
|
use base64::{engine::general_purpose::STANDARD as B64, Engine};
|
||||||
|
use dryoc::classic::crypto_box;
|
||||||
|
use dryoc::classic::crypto_pwhash::{self, PasswordHashAlgorithm};
|
||||||
|
use dryoc::classic::crypto_secretbox;
|
||||||
|
use dryoc::constants::{
|
||||||
|
CRYPTO_BOX_PUBLICKEYBYTES, CRYPTO_BOX_SECRETKEYBYTES, CRYPTO_PWHASH_MEMLIMIT_MODERATE,
|
||||||
|
CRYPTO_PWHASH_OPSLIMIT_MODERATE, CRYPTO_PWHASH_SALTBYTES,
|
||||||
|
};
|
||||||
|
use dryoc::rng::randombytes_buf;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
fn encode(bytes: &[u8]) -> String {
|
||||||
|
B64.encode(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode(s: &str) -> Result<Vec<u8>, String> {
|
||||||
|
B64.decode(s).map_err(|e| format!("invalid base64: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Random
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn crypto_random_bytes(len: usize) -> Result<String, String> {
|
||||||
|
if len == 0 || len > 1024 * 1024 {
|
||||||
|
return Err("invalid length".into());
|
||||||
|
}
|
||||||
|
let buf = randombytes_buf(len);
|
||||||
|
Ok(encode(&buf))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// crypto_secretbox — XSalsa20-Poly1305
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn crypto_secretbox_encrypt(
|
||||||
|
plaintext_b64: String,
|
||||||
|
nonce_b64: String,
|
||||||
|
key_b64: String,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let plaintext = decode(&plaintext_b64)?;
|
||||||
|
let nonce = decode(&nonce_b64)?;
|
||||||
|
let key = decode(&key_b64)?;
|
||||||
|
if nonce.len() != 24 {
|
||||||
|
return Err("nonce must be 24 bytes".into());
|
||||||
|
}
|
||||||
|
if key.len() != 32 {
|
||||||
|
return Err("key must be 32 bytes".into());
|
||||||
|
}
|
||||||
|
let mut ciphertext = vec![0u8; plaintext.len() + 16];
|
||||||
|
let nonce_arr: [u8; 24] = nonce.as_slice().try_into().unwrap();
|
||||||
|
let key_arr: [u8; 32] = key.as_slice().try_into().unwrap();
|
||||||
|
crypto_secretbox::crypto_secretbox_easy(
|
||||||
|
&mut ciphertext,
|
||||||
|
&plaintext,
|
||||||
|
&nonce_arr,
|
||||||
|
&key_arr,
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("secretbox encrypt failed: {}", e))?;
|
||||||
|
Ok(encode(&ciphertext))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn crypto_secretbox_decrypt(
|
||||||
|
ciphertext_b64: String,
|
||||||
|
nonce_b64: String,
|
||||||
|
key_b64: String,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let ciphertext = decode(&ciphertext_b64)?;
|
||||||
|
let nonce = decode(&nonce_b64)?;
|
||||||
|
let key = decode(&key_b64)?;
|
||||||
|
if nonce.len() != 24 {
|
||||||
|
return Err("nonce must be 24 bytes".into());
|
||||||
|
}
|
||||||
|
if key.len() != 32 {
|
||||||
|
return Err("key must be 32 bytes".into());
|
||||||
|
}
|
||||||
|
if ciphertext.len() < 16 {
|
||||||
|
return Err("ciphertext too short".into());
|
||||||
|
}
|
||||||
|
let mut plaintext = vec![0u8; ciphertext.len() - 16];
|
||||||
|
let nonce_arr: [u8; 24] = nonce.as_slice().try_into().unwrap();
|
||||||
|
let key_arr: [u8; 32] = key.as_slice().try_into().unwrap();
|
||||||
|
crypto_secretbox::crypto_secretbox_open_easy(
|
||||||
|
&mut plaintext,
|
||||||
|
&ciphertext,
|
||||||
|
&nonce_arr,
|
||||||
|
&key_arr,
|
||||||
|
)
|
||||||
|
.map_err(|_| "secretbox auth failed".to_string())?;
|
||||||
|
Ok(encode(&plaintext))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// crypto_box — X25519 + XSalsa20-Poly1305
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct KeyPairB64 {
|
||||||
|
pub public_key: String,
|
||||||
|
pub private_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn crypto_box_keypair() -> Result<KeyPairB64, String> {
|
||||||
|
let (pk, sk) = crypto_box::crypto_box_keypair();
|
||||||
|
Ok(KeyPairB64 {
|
||||||
|
public_key: encode(&pk),
|
||||||
|
private_key: encode(&sk),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn crypto_box_encrypt(
|
||||||
|
plaintext_b64: String,
|
||||||
|
nonce_b64: String,
|
||||||
|
recipient_pk_b64: String,
|
||||||
|
sender_sk_b64: String,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let plaintext = decode(&plaintext_b64)?;
|
||||||
|
let nonce = decode(&nonce_b64)?;
|
||||||
|
let pk = decode(&recipient_pk_b64)?;
|
||||||
|
let sk = decode(&sender_sk_b64)?;
|
||||||
|
if pk.len() != CRYPTO_BOX_PUBLICKEYBYTES {
|
||||||
|
return Err("pk must be 32 bytes".into());
|
||||||
|
}
|
||||||
|
if sk.len() != CRYPTO_BOX_SECRETKEYBYTES {
|
||||||
|
return Err("sk must be 32 bytes".into());
|
||||||
|
}
|
||||||
|
let mut ciphertext = vec![0u8; plaintext.len() + 16];
|
||||||
|
let nonce_arr: [u8; 24] = nonce.as_slice().try_into().map_err(|_| "bad nonce")?;
|
||||||
|
let pk_arr: [u8; 32] = pk.as_slice().try_into().unwrap();
|
||||||
|
let sk_arr: [u8; 32] = sk.as_slice().try_into().unwrap();
|
||||||
|
crypto_box::crypto_box_easy(&mut ciphertext, &plaintext, &nonce_arr, &pk_arr, &sk_arr)
|
||||||
|
.map_err(|e| format!("box encrypt failed: {}", e))?;
|
||||||
|
Ok(encode(&ciphertext))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn crypto_box_decrypt(
|
||||||
|
ciphertext_b64: String,
|
||||||
|
nonce_b64: String,
|
||||||
|
sender_pk_b64: String,
|
||||||
|
recipient_sk_b64: String,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let ciphertext = decode(&ciphertext_b64)?;
|
||||||
|
let nonce = decode(&nonce_b64)?;
|
||||||
|
let pk = decode(&sender_pk_b64)?;
|
||||||
|
let sk = decode(&recipient_sk_b64)?;
|
||||||
|
if pk.len() != CRYPTO_BOX_PUBLICKEYBYTES {
|
||||||
|
return Err("pk must be 32 bytes".into());
|
||||||
|
}
|
||||||
|
if sk.len() != CRYPTO_BOX_SECRETKEYBYTES {
|
||||||
|
return Err("sk must be 32 bytes".into());
|
||||||
|
}
|
||||||
|
if ciphertext.len() < 16 {
|
||||||
|
return Err("ciphertext too short".into());
|
||||||
|
}
|
||||||
|
let mut plaintext = vec![0u8; ciphertext.len() - 16];
|
||||||
|
let nonce_arr: [u8; 24] = nonce.as_slice().try_into().map_err(|_| "bad nonce")?;
|
||||||
|
let pk_arr: [u8; 32] = pk.as_slice().try_into().unwrap();
|
||||||
|
let sk_arr: [u8; 32] = sk.as_slice().try_into().unwrap();
|
||||||
|
crypto_box::crypto_box_open_easy(
|
||||||
|
&mut plaintext,
|
||||||
|
&ciphertext,
|
||||||
|
&nonce_arr,
|
||||||
|
&pk_arr,
|
||||||
|
&sk_arr,
|
||||||
|
)
|
||||||
|
.map_err(|_| "box auth failed".to_string())?;
|
||||||
|
Ok(encode(&plaintext))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sealed-box (anonymous) variant — sender identity not authenticated but
|
||||||
|
// recipient still verified. Used by the conv-key wrapping flow.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn crypto_box_seal(
|
||||||
|
plaintext_b64: String,
|
||||||
|
recipient_pk_b64: String,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let plaintext = decode(&plaintext_b64)?;
|
||||||
|
let pk = decode(&recipient_pk_b64)?;
|
||||||
|
if pk.len() != CRYPTO_BOX_PUBLICKEYBYTES {
|
||||||
|
return Err("pk must be 32 bytes".into());
|
||||||
|
}
|
||||||
|
let pk_arr: [u8; 32] = pk.as_slice().try_into().unwrap();
|
||||||
|
let mut ciphertext = vec![0u8; plaintext.len() + 48];
|
||||||
|
crypto_box::crypto_box_seal(&mut ciphertext, &plaintext, &pk_arr)
|
||||||
|
.map_err(|e| format!("seal failed: {}", e))?;
|
||||||
|
Ok(encode(&ciphertext))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn crypto_box_seal_open(
|
||||||
|
ciphertext_b64: String,
|
||||||
|
recipient_pk_b64: String,
|
||||||
|
recipient_sk_b64: String,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let ciphertext = decode(&ciphertext_b64)?;
|
||||||
|
let pk = decode(&recipient_pk_b64)?;
|
||||||
|
let sk = decode(&recipient_sk_b64)?;
|
||||||
|
if pk.len() != CRYPTO_BOX_PUBLICKEYBYTES {
|
||||||
|
return Err("pk must be 32 bytes".into());
|
||||||
|
}
|
||||||
|
if sk.len() != CRYPTO_BOX_SECRETKEYBYTES {
|
||||||
|
return Err("sk must be 32 bytes".into());
|
||||||
|
}
|
||||||
|
if ciphertext.len() < 48 {
|
||||||
|
return Err("ciphertext too short".into());
|
||||||
|
}
|
||||||
|
let pk_arr: [u8; 32] = pk.as_slice().try_into().unwrap();
|
||||||
|
let sk_arr: [u8; 32] = sk.as_slice().try_into().unwrap();
|
||||||
|
let mut plaintext = vec![0u8; ciphertext.len() - 48];
|
||||||
|
crypto_box::crypto_box_seal_open(&mut plaintext, &ciphertext, &pk_arr, &sk_arr)
|
||||||
|
.map_err(|_| "seal open failed".to_string())?;
|
||||||
|
Ok(encode(&plaintext))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// crypto_pwhash — Argon2id
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct PwhashArgs {
|
||||||
|
pub password: String,
|
||||||
|
pub salt_b64: String,
|
||||||
|
pub out_len: usize,
|
||||||
|
// Opslimit / memlimit presets map to libsodium constants; callers pass
|
||||||
|
// one of "interactive" | "moderate" | "sensitive". We default to
|
||||||
|
// moderate which matches every current call-site.
|
||||||
|
#[serde(default)]
|
||||||
|
pub preset: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pwhash_limits(preset: Option<&str>) -> (u64, usize) {
|
||||||
|
match preset {
|
||||||
|
Some("interactive") => (2, 64 * 1024 * 1024),
|
||||||
|
Some("sensitive") => (4, 1024 * 1024 * 1024),
|
||||||
|
_ => (
|
||||||
|
CRYPTO_PWHASH_OPSLIMIT_MODERATE as u64,
|
||||||
|
CRYPTO_PWHASH_MEMLIMIT_MODERATE,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn crypto_pwhash(args: PwhashArgs) -> Result<String, String> {
|
||||||
|
let salt = decode(&args.salt_b64)?;
|
||||||
|
if salt.len() != CRYPTO_PWHASH_SALTBYTES {
|
||||||
|
return Err(format!(
|
||||||
|
"salt must be {} bytes",
|
||||||
|
CRYPTO_PWHASH_SALTBYTES
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if args.out_len < 16 || args.out_len > 64 {
|
||||||
|
return Err("out_len out of range (16..=64)".into());
|
||||||
|
}
|
||||||
|
let salt_arr: [u8; CRYPTO_PWHASH_SALTBYTES] =
|
||||||
|
salt.as_slice().try_into().unwrap();
|
||||||
|
let (opslimit, memlimit) = pwhash_limits(args.preset.as_deref());
|
||||||
|
let mut out = vec![0u8; args.out_len];
|
||||||
|
crypto_pwhash::crypto_pwhash(
|
||||||
|
&mut out,
|
||||||
|
args.password.as_bytes(),
|
||||||
|
&salt_arr,
|
||||||
|
opslimit,
|
||||||
|
memlimit,
|
||||||
|
PasswordHashAlgorithm::Argon2id13,
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("pwhash failed: {}", e))?;
|
||||||
|
Ok(encode(&out))
|
||||||
|
}
|
||||||
@@ -1,7 +1,140 @@
|
|||||||
|
mod crypto;
|
||||||
|
mod screen_audio;
|
||||||
|
mod screen_capture;
|
||||||
|
mod screen_sources;
|
||||||
|
|
||||||
|
#[cfg(feature = "rust-livekit")]
|
||||||
|
mod livekit_bridge;
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
|
use tauri::{
|
||||||
|
menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem},
|
||||||
|
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||||
|
AppHandle, Listener, Manager,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
// Payload for the `tray-unread-update` event the JS layer emits whenever the
|
||||||
|
// aggregate unread-count changes. 0 hides the badge / resets the tooltip;
|
||||||
|
// non-zero sets a count indicator.
|
||||||
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct TrayUnreadPayload {
|
||||||
|
count: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Red-dot overlay icon for the Windows taskbar. Drawn as raw RGBA instead of
|
||||||
|
// shipping a PNG so we don't add another resource to the bundle. Kept small
|
||||||
|
// (32x32) since Windows scales the overlay down anyway.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn unread_overlay_rgba() -> Vec<u8> {
|
||||||
|
const SIZE: u32 = 32;
|
||||||
|
let r = SIZE as f32 / 2.0;
|
||||||
|
let mut buf = Vec::with_capacity((SIZE * SIZE * 4) as usize);
|
||||||
|
for y in 0..SIZE {
|
||||||
|
for x in 0..SIZE {
|
||||||
|
let dx = x as f32 - r + 0.5;
|
||||||
|
let dy = y as f32 - r + 0.5;
|
||||||
|
let d = (dx * dx + dy * dy).sqrt();
|
||||||
|
let edge = r - 1.0;
|
||||||
|
if d <= edge {
|
||||||
|
buf.extend_from_slice(&[0xDC, 0x26, 0x26, 0xFF]);
|
||||||
|
} else if d <= r {
|
||||||
|
let alpha = (255.0 * (r - d)).clamp(0.0, 255.0) as u8;
|
||||||
|
buf.extend_from_slice(&[0xDC, 0x26, 0x26, alpha]);
|
||||||
|
} else {
|
||||||
|
buf.extend_from_slice(&[0, 0, 0, 0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
|
fn show_main_window(app: &AppHandle) {
|
||||||
|
if let Some(win) = app.get_webview_window("main") {
|
||||||
|
let _ = win.show();
|
||||||
|
let _ = win.unminimize();
|
||||||
|
let _ = win.set_focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
|
fn hide_main_window(app: &AppHandle) {
|
||||||
|
if let Some(win) = app.get_webview_window("main") {
|
||||||
|
let _ = win.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
|
fn handle_menu_event(app: &AppHandle, event: MenuEvent) {
|
||||||
|
match event.id.as_ref() {
|
||||||
|
"tray-show" => show_main_window(app),
|
||||||
|
"tray-hide" => hide_main_window(app),
|
||||||
|
"tray-quit" => {
|
||||||
|
app.exit(0);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
|
#[cfg(not(feature = "rust-livekit"))]
|
||||||
let mut builder = tauri::Builder::default()
|
let mut builder = tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_notification::init())
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
crypto::crypto_random_bytes,
|
||||||
|
crypto::crypto_secretbox_encrypt,
|
||||||
|
crypto::crypto_secretbox_decrypt,
|
||||||
|
crypto::crypto_box_keypair,
|
||||||
|
crypto::crypto_box_encrypt,
|
||||||
|
crypto::crypto_box_decrypt,
|
||||||
|
crypto::crypto_box_seal,
|
||||||
|
crypto::crypto_box_seal_open,
|
||||||
|
crypto::crypto_pwhash,
|
||||||
|
screen_sources::enumerate_screen_sources,
|
||||||
|
screen_sources::list_screen_sources,
|
||||||
|
screen_sources::capture_screen_source_thumbnail,
|
||||||
|
screen_sources::capture_screen_source_thumbnail_bytes,
|
||||||
|
screen_capture::start_screen_capture,
|
||||||
|
screen_capture::stop_screen_capture,
|
||||||
|
screen_audio::start_system_audio_capture,
|
||||||
|
screen_audio::stop_system_audio_capture,
|
||||||
|
])
|
||||||
|
.plugin(tauri_plugin_notification::init());
|
||||||
|
|
||||||
|
#[cfg(feature = "rust-livekit")]
|
||||||
|
let mut builder = tauri::Builder::default()
|
||||||
|
.manage(livekit_bridge::LivekitState::new())
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
crypto::crypto_random_bytes,
|
||||||
|
crypto::crypto_secretbox_encrypt,
|
||||||
|
crypto::crypto_secretbox_decrypt,
|
||||||
|
crypto::crypto_box_keypair,
|
||||||
|
crypto::crypto_box_encrypt,
|
||||||
|
crypto::crypto_box_decrypt,
|
||||||
|
crypto::crypto_box_seal,
|
||||||
|
crypto::crypto_box_seal_open,
|
||||||
|
crypto::crypto_pwhash,
|
||||||
|
screen_sources::enumerate_screen_sources,
|
||||||
|
screen_sources::list_screen_sources,
|
||||||
|
screen_sources::capture_screen_source_thumbnail,
|
||||||
|
screen_sources::capture_screen_source_thumbnail_bytes,
|
||||||
|
screen_capture::start_screen_capture,
|
||||||
|
screen_capture::stop_screen_capture,
|
||||||
|
screen_audio::start_system_audio_capture,
|
||||||
|
screen_audio::stop_system_audio_capture,
|
||||||
|
livekit_bridge::livekit_connect,
|
||||||
|
livekit_bridge::livekit_disconnect,
|
||||||
|
livekit_bridge::livekit_send_data,
|
||||||
|
livekit_bridge::livekit_set_mic,
|
||||||
|
livekit_bridge::livekit_set_camera,
|
||||||
|
])
|
||||||
|
.plugin(tauri_plugin_notification::init());
|
||||||
|
|
||||||
|
builder = builder
|
||||||
.plugin(tauri_plugin_sql::Builder::default().build())
|
.plugin(tauri_plugin_sql::Builder::default().build())
|
||||||
.plugin(tauri_plugin_fs::init())
|
.plugin(tauri_plugin_fs::init())
|
||||||
.plugin(
|
.plugin(
|
||||||
@@ -13,12 +146,105 @@ pub fn run() {
|
|||||||
.build(),
|
.build(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Global shortcut + updater plugins are desktop-only (no mobile support).
|
// Global shortcut + updater + window-state plugins are desktop-only
|
||||||
|
// (no mobile support — mobile windows are OS-managed).
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
{
|
{
|
||||||
builder = builder
|
builder = builder
|
||||||
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||||
.plugin(tauri_plugin_updater::Builder::new().build());
|
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||||
|
.plugin(tauri_plugin_window_state::Builder::new().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
|
{
|
||||||
|
builder = builder.setup(|app| {
|
||||||
|
// Tray icon with a minimal menu. Left-click toggles window
|
||||||
|
// visibility; right-click shows the menu. Badge / tooltip updates
|
||||||
|
// come from the JS side via `tray-unread-update` events.
|
||||||
|
let show = MenuItem::with_id(app, "tray-show", "Öffnen", true, None::<&str>)?;
|
||||||
|
let hide = MenuItem::with_id(app, "tray-hide", "Ausblenden", true, None::<&str>)?;
|
||||||
|
let sep = PredefinedMenuItem::separator(app)?;
|
||||||
|
let quit = MenuItem::with_id(app, "tray-quit", "Beenden", true, None::<&str>)?;
|
||||||
|
let menu = Menu::with_items(app, &[&show, &hide, &sep, &quit])?;
|
||||||
|
|
||||||
|
let mut tray_builder = TrayIconBuilder::with_id("chatapp-tray")
|
||||||
|
.menu(&menu)
|
||||||
|
.show_menu_on_left_click(false)
|
||||||
|
.tooltip("ChatApp")
|
||||||
|
.on_menu_event(|app, event| handle_menu_event(app, event))
|
||||||
|
.on_tray_icon_event(|tray, event| {
|
||||||
|
if let TrayIconEvent::Click {
|
||||||
|
button: MouseButton::Left,
|
||||||
|
button_state: MouseButtonState::Up,
|
||||||
|
..
|
||||||
|
} = event
|
||||||
|
{
|
||||||
|
let app = tray.app_handle();
|
||||||
|
if let Some(win) = app.get_webview_window("main") {
|
||||||
|
if win.is_visible().unwrap_or(false) {
|
||||||
|
let _ = win.hide();
|
||||||
|
} else {
|
||||||
|
let _ = win.show();
|
||||||
|
let _ = win.set_focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// `default_window_icon` returns Option<&Image>; only attach if
|
||||||
|
// we actually have one bundled (should always be true via the
|
||||||
|
// tauri.conf.json icon list, but guard to stay typesafe).
|
||||||
|
if let Some(icon) = app.default_window_icon() {
|
||||||
|
tray_builder = tray_builder.icon(icon.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let tray = tray_builder.build(app)?;
|
||||||
|
|
||||||
|
// Listen for JS-side unread updates and mirror them into the tray
|
||||||
|
// tooltip + macOS dock badge. `tray` is cheap to clone (internal
|
||||||
|
// Arc) so we can move it into the listener closure directly.
|
||||||
|
let tray_handle = tray.clone();
|
||||||
|
let badge_window = app.get_webview_window("main");
|
||||||
|
app.listen("tray-unread-update", move |event| {
|
||||||
|
let Ok(payload) = serde_json::from_str::<TrayUnreadPayload>(event.payload())
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let tooltip = if payload.count == 0 {
|
||||||
|
"ChatApp".to_string()
|
||||||
|
} else {
|
||||||
|
format!("ChatApp · {} neu", payload.count)
|
||||||
|
};
|
||||||
|
let _ = tray_handle.set_tooltip(Some(tooltip));
|
||||||
|
// Dock/taskbar badge. macOS uses a numeric label; Windows uses
|
||||||
|
// an overlay icon (red dot = unread). Linux has no cross-DE
|
||||||
|
// badge API — skip.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
if let Some(win) = badge_window.as_ref() {
|
||||||
|
let badge = if payload.count == 0 {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(payload.count.to_string())
|
||||||
|
};
|
||||||
|
let _ = win.set_badge_label(badge);
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
if let Some(win) = badge_window.as_ref() {
|
||||||
|
if payload.count == 0 {
|
||||||
|
let _ = win.set_overlay_icon(None);
|
||||||
|
} else {
|
||||||
|
let rgba = unread_overlay_rgba();
|
||||||
|
let img = tauri::image::Image::new_owned(rgba, 32, 32);
|
||||||
|
let _ = win.set_overlay_icon(Some(img));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||||
|
let _ = &badge_window;
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
builder
|
builder
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
// Rust LiveKit bridge — command/event glue between the JS CallContext and
|
||||||
|
// the native livekit client. Feature-gated behind `rust-livekit` so the
|
||||||
|
// baseline build doesn't pay the libwebrtc download / link cost while the
|
||||||
|
// bridge is still evolving.
|
||||||
|
//
|
||||||
|
// Design contract (matches `lib/nativeLiveKit.ts` on the JS side):
|
||||||
|
// command: livekit_connect { url, token, e2ee_key_b64? }
|
||||||
|
// command: livekit_disconnect
|
||||||
|
// command: livekit_set_mic { enabled }
|
||||||
|
// command: livekit_set_camera { enabled }
|
||||||
|
// command: livekit_start_share {}
|
||||||
|
// command: livekit_stop_share {}
|
||||||
|
// command: livekit_send_data { payload_b64, reliable }
|
||||||
|
// event: livekit:room_state { state }
|
||||||
|
// event: livekit:participant_joined { identity, name? }
|
||||||
|
// event: livekit:participant_left { identity }
|
||||||
|
// event: livekit:track_published { identity, sid, kind, source }
|
||||||
|
// event: livekit:track_unpublished { identity, sid }
|
||||||
|
// event: livekit:audio_level { identity, level }
|
||||||
|
// event: livekit:data_received { identity, payload_b64 }
|
||||||
|
// event: livekit:error { message }
|
||||||
|
//
|
||||||
|
// Phase B.1 (this file) only wires connect/disconnect + room-state events
|
||||||
|
// so the JS side can prove round-trip; mic/camera/video come later.
|
||||||
|
|
||||||
|
#![cfg(feature = "rust-livekit")]
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use livekit::{
|
||||||
|
id::ParticipantIdentity, DataPacketKind, Room, RoomEvent, RoomOptions,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::{AppHandle, Emitter, State};
|
||||||
|
use tokio::sync::{mpsc, Mutex};
|
||||||
|
|
||||||
|
pub struct LivekitState {
|
||||||
|
room: Mutex<Option<Arc<Room>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LivekitState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
room: Mutex::new(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ConnectArgs {
|
||||||
|
pub url: String,
|
||||||
|
pub token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
struct RoomStatePayload {
|
||||||
|
state: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
struct ParticipantPayload {
|
||||||
|
identity: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
struct DataPayload {
|
||||||
|
identity: String,
|
||||||
|
payload_b64: String,
|
||||||
|
reliable: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn livekit_connect(
|
||||||
|
app: AppHandle,
|
||||||
|
state: State<'_, LivekitState>,
|
||||||
|
args: ConnectArgs,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut guard = state.room.lock().await;
|
||||||
|
if guard.is_some() {
|
||||||
|
return Err("already connected".into());
|
||||||
|
}
|
||||||
|
let options = RoomOptions::default();
|
||||||
|
let (room, mut events) = Room::connect(&args.url, &args.token, options)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("livekit connect failed: {}", e))?;
|
||||||
|
let room = Arc::new(room);
|
||||||
|
*guard = Some(room.clone());
|
||||||
|
drop(guard);
|
||||||
|
|
||||||
|
let _ = app.emit(
|
||||||
|
"livekit:room_state",
|
||||||
|
RoomStatePayload { state: "connected" },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Spawn the event pump. Lives for the duration of the room connection;
|
||||||
|
// stops naturally when the channel closes (disconnect or crash).
|
||||||
|
let app_for_events = app.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Some(event) = events.recv().await {
|
||||||
|
pump_event(&app_for_events, event);
|
||||||
|
}
|
||||||
|
let _ = app_for_events.emit(
|
||||||
|
"livekit:room_state",
|
||||||
|
RoomStatePayload {
|
||||||
|
state: "disconnected",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pump_event(app: &AppHandle, event: RoomEvent) {
|
||||||
|
match event {
|
||||||
|
RoomEvent::ParticipantConnected(p) => {
|
||||||
|
let _ = app.emit(
|
||||||
|
"livekit:participant_joined",
|
||||||
|
ParticipantPayload {
|
||||||
|
identity: identity_string(p.identity()),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
RoomEvent::ParticipantDisconnected(p) => {
|
||||||
|
let _ = app.emit(
|
||||||
|
"livekit:participant_left",
|
||||||
|
ParticipantPayload {
|
||||||
|
identity: identity_string(p.identity()),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
RoomEvent::DataReceived {
|
||||||
|
payload,
|
||||||
|
kind,
|
||||||
|
participant,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
use base64::engine::general_purpose::STANDARD;
|
||||||
|
use base64::Engine;
|
||||||
|
let identity = participant
|
||||||
|
.map(|p| identity_string(p.identity()))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let _ = app.emit(
|
||||||
|
"livekit:data_received",
|
||||||
|
DataPayload {
|
||||||
|
identity,
|
||||||
|
payload_b64: STANDARD.encode(payload.as_ref()),
|
||||||
|
reliable: matches!(kind, DataPacketKind::Reliable),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
RoomEvent::Disconnected { .. } => {
|
||||||
|
let _ = app.emit(
|
||||||
|
"livekit:room_state",
|
||||||
|
RoomStatePayload {
|
||||||
|
state: "disconnected",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Remaining events (TrackPublished, TrackSubscribed, etc.) land
|
||||||
|
// in later phases. Ignoring silently keeps the prototype small.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn identity_string(id: ParticipantIdentity) -> String {
|
||||||
|
// ParticipantIdentity is a newtype around String in the livekit crate.
|
||||||
|
id.0
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn livekit_disconnect(state: State<'_, LivekitState>) -> Result<(), String> {
|
||||||
|
let mut guard = state.room.lock().await;
|
||||||
|
if let Some(room) = guard.take() {
|
||||||
|
let _ = room.close().await;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn livekit_send_data(
|
||||||
|
state: State<'_, LivekitState>,
|
||||||
|
payload_b64: String,
|
||||||
|
reliable: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
use base64::engine::general_purpose::STANDARD;
|
||||||
|
use base64::Engine;
|
||||||
|
let room = state.room.lock().await;
|
||||||
|
let room = room.as_ref().ok_or_else(|| "not connected".to_string())?;
|
||||||
|
let payload = STANDARD
|
||||||
|
.decode(&payload_b64)
|
||||||
|
.map_err(|e| format!("bad base64: {}", e))?;
|
||||||
|
let kind = if reliable {
|
||||||
|
DataPacketKind::Reliable
|
||||||
|
} else {
|
||||||
|
DataPacketKind::Lossy
|
||||||
|
};
|
||||||
|
room.local_participant()
|
||||||
|
.publish_data(livekit::prelude::DataPacket {
|
||||||
|
payload,
|
||||||
|
topic: None,
|
||||||
|
reliable: matches!(kind, DataPacketKind::Reliable),
|
||||||
|
destination_identities: Vec::new(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("publish_data failed: {}", e))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Placeholder — Phase B.2 will fill these in.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn livekit_set_mic(_enabled: bool) -> Result<(), String> {
|
||||||
|
Err("livekit_set_mic not implemented — Phase B.2".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn livekit_set_camera(_enabled: bool) -> Result<(), String> {
|
||||||
|
Err("livekit_set_camera not implemented — Phase B.2".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unused-send bridge so `mpsc` doesn't get unused-import-warned when the
|
||||||
|
// feature gate is off.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn _mpsc_anchor() -> mpsc::Sender<()> {
|
||||||
|
let (tx, _rx) = mpsc::channel::<()>(1);
|
||||||
|
tx
|
||||||
|
}
|
||||||
@@ -0,0 +1,428 @@
|
|||||||
|
// Native system-audio capture for the custom screen-share picker. Without
|
||||||
|
// this path the picker has to fall back to getDisplayMedia whenever the
|
||||||
|
// user ticks "Mit System-Sound", because Chromium only wires audio into
|
||||||
|
// desktop captures that the OS picker produced. Here we grab the default
|
||||||
|
// render endpoint's loopback stream via WASAPI, convert it to 48kHz f32
|
||||||
|
// stereo, and ship the samples to the JS side through a Tauri Channel.
|
||||||
|
// An AudioWorklet on the frontend feeds them into a MediaStreamDestination
|
||||||
|
// so LiveKit publishes a plain ScreenShareAudio track.
|
||||||
|
//
|
||||||
|
// Windows-only for v1. macOS + Linux stubs return a clear error so the
|
||||||
|
// frontend can fall back cleanly on those platforms until their native
|
||||||
|
// paths ship (ScreenCaptureKit-audio / PipeWire).
|
||||||
|
|
||||||
|
#![allow(clippy::needless_return)]
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
use std::thread;
|
||||||
|
use tauri::ipc::Channel;
|
||||||
|
|
||||||
|
// Output format we always deliver to the frontend. Picking a single fixed
|
||||||
|
// format means the AudioWorklet never has to renegotiate — it just assumes
|
||||||
|
// interleaved f32 stereo at 48kHz. WASAPI mix format is usually already
|
||||||
|
// this on Windows 10+, so the resample branch is rarely hit.
|
||||||
|
const OUTPUT_SAMPLE_RATE: u32 = 48_000;
|
||||||
|
const OUTPUT_CHANNELS: u16 = 2;
|
||||||
|
|
||||||
|
static NEXT_ID: AtomicU32 = AtomicU32::new(1);
|
||||||
|
static SESSIONS: OnceLock<Mutex<HashMap<u32, Session>>> = OnceLock::new();
|
||||||
|
|
||||||
|
fn sessions() -> &'static Mutex<HashMap<u32, Session>> {
|
||||||
|
SESSIONS.get_or_init(|| Mutex::new(HashMap::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Session {
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
handle: Option<thread::JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AudioFramePayload {
|
||||||
|
pub capture_id: u32,
|
||||||
|
pub sample_rate: u32,
|
||||||
|
pub channels: u16,
|
||||||
|
/// Interleaved little-endian f32 stereo samples, base64-encoded.
|
||||||
|
/// Frontend decodes via `atob` → `Uint8Array` → `Float32Array` view.
|
||||||
|
/// Base64 is used instead of a raw `Vec<f32>` because Tauri Channel
|
||||||
|
/// serialises via JSON — a JSON array of floats balloons to ~2–3×
|
||||||
|
/// the byte count, and at 48kHz stereo that's enough IPC traffic
|
||||||
|
/// to matter.
|
||||||
|
pub samples_base64: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a loopback capture of the default render endpoint and begin
|
||||||
|
/// streaming audio frames on the provided channel. Returns a numeric
|
||||||
|
/// capture id that must be handed to `stop_system_audio_capture` when
|
||||||
|
/// the share ends.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn start_system_audio_capture(
|
||||||
|
channel: Channel<AudioFramePayload>,
|
||||||
|
) -> Result<u32, String> {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
let capture_id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
|
let stop_clone = Arc::clone(&stop);
|
||||||
|
|
||||||
|
let handle = thread::Builder::new()
|
||||||
|
.name(format!("screen-audio-{capture_id}"))
|
||||||
|
.spawn(move || {
|
||||||
|
if let Err(err) =
|
||||||
|
windows_loopback::capture_loop(capture_id, channel, stop_clone)
|
||||||
|
{
|
||||||
|
eprintln!("screen-audio {capture_id}: {err}");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map_err(|e| format!("failed to spawn audio thread: {e}"))?;
|
||||||
|
|
||||||
|
sessions().lock().unwrap().insert(
|
||||||
|
capture_id,
|
||||||
|
Session {
|
||||||
|
stop,
|
||||||
|
handle: Some(handle),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Ok(capture_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
{
|
||||||
|
// Keep the `channel` binding alive so Tauri doesn't complain about
|
||||||
|
// an unused parameter on the non-Windows build.
|
||||||
|
let _ = channel;
|
||||||
|
Err("system audio capture only supported on Windows".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tear down the capture for the given id. Safe to call on a missing id
|
||||||
|
/// (no-op) so the JS side doesn't have to track whether the stop has
|
||||||
|
/// already been issued by the screen-share teardown path.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn stop_system_audio_capture(capture_id: u32) -> Result<(), String> {
|
||||||
|
let session = sessions().lock().unwrap().remove(&capture_id);
|
||||||
|
let Some(mut session) = session else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
session.stop.store(true, Ordering::Relaxed);
|
||||||
|
if let Some(handle) = session.handle.take() {
|
||||||
|
// Best-effort join — the capture loop polls `stop` every event
|
||||||
|
// cycle (≤100ms) so this usually returns promptly. If the WASAPI
|
||||||
|
// call is wedged we'd rather drop the handle than hang the stop.
|
||||||
|
let _ = handle.join();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Windows loopback implementation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
mod windows_loopback {
|
||||||
|
use super::*;
|
||||||
|
use wasapi::{initialize_mta, Direction, SampleType, ShareMode};
|
||||||
|
|
||||||
|
pub fn capture_loop(
|
||||||
|
capture_id: u32,
|
||||||
|
channel: Channel<AudioFramePayload>,
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
// COM must be initialised on every thread that touches WASAPI.
|
||||||
|
// MTA is the right model for a background capture thread — STA
|
||||||
|
// would require message pumping we don't want to add.
|
||||||
|
initialize_mta()
|
||||||
|
.ok()
|
||||||
|
.map_err(|e| format!("initialize_mta: {e:?}"))?;
|
||||||
|
|
||||||
|
let device = wasapi::get_default_device(&Direction::Render)
|
||||||
|
.map_err(|e| format!("get_default_device: {e:?}"))?;
|
||||||
|
let mut audio_client = device
|
||||||
|
.get_iaudioclient()
|
||||||
|
.map_err(|e| format!("get_iaudioclient: {e:?}"))?;
|
||||||
|
|
||||||
|
// Use the mix format that Windows is already pushing to the
|
||||||
|
// endpoint. Loopback capture won't convert for us — asking for a
|
||||||
|
// fixed format here makes Initialize() fail on non-matching
|
||||||
|
// hardware. We resample + channel-mix ourselves downstream.
|
||||||
|
let mix_format = audio_client
|
||||||
|
.get_mixformat()
|
||||||
|
.map_err(|e| format!("get_mixformat: {e:?}"))?;
|
||||||
|
let input_rate = mix_format.get_samplespersec();
|
||||||
|
let input_channels = mix_format.get_nchannels();
|
||||||
|
let bits_per_sample = mix_format.get_bitspersample();
|
||||||
|
let block_align = mix_format.get_blockalign();
|
||||||
|
let sample_type = mix_format.get_subformat().unwrap_or(SampleType::Int);
|
||||||
|
|
||||||
|
let (def_time, _min_time) = audio_client
|
||||||
|
.get_periods()
|
||||||
|
.map_err(|e| format!("get_periods: {e:?}"))?;
|
||||||
|
|
||||||
|
// Direction::Capture + loopback: WASAPI streams what Windows is
|
||||||
|
// sending to the speakers instead of what an input device is
|
||||||
|
// producing. Shared mode so we coexist with other apps.
|
||||||
|
audio_client
|
||||||
|
.initialize_client(
|
||||||
|
&mix_format,
|
||||||
|
def_time,
|
||||||
|
&Direction::Capture,
|
||||||
|
&ShareMode::Shared,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("initialize_client: {e:?}"))?;
|
||||||
|
|
||||||
|
let h_event = audio_client
|
||||||
|
.set_get_eventhandle()
|
||||||
|
.map_err(|e| format!("set_get_eventhandle: {e:?}"))?;
|
||||||
|
let capture_client = audio_client
|
||||||
|
.get_audiocaptureclient()
|
||||||
|
.map_err(|e| format!("get_audiocaptureclient: {e:?}"))?;
|
||||||
|
|
||||||
|
audio_client
|
||||||
|
.start_stream()
|
||||||
|
.map_err(|e| format!("start_stream: {e:?}"))?;
|
||||||
|
|
||||||
|
// Resampler state — last stereo frame from the previous buffer so
|
||||||
|
// linear interpolation at the buffer boundary doesn't click.
|
||||||
|
// Initialised to silence.
|
||||||
|
let mut last_stereo: [f32; 2] = [0.0, 0.0];
|
||||||
|
|
||||||
|
while !stop.load(Ordering::Relaxed) {
|
||||||
|
// 100ms timeout lets the loop check the stop flag even when
|
||||||
|
// the endpoint is silent (WASAPI doesn't signal the event at
|
||||||
|
// all for pure-silence streams on some driver versions).
|
||||||
|
if h_event.wait_for_event(100).is_err() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain all packets available since the last wake — there
|
||||||
|
// can be several queued if we were preempted.
|
||||||
|
loop {
|
||||||
|
if stop.load(Ordering::Relaxed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let frames_available = match capture_client.get_next_nbr_frames() {
|
||||||
|
Ok(Some(n)) if n > 0 => n,
|
||||||
|
Ok(_) => break,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
"screen-audio {capture_id}: get_next_nbr_frames: {e:?}"
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let bytes_needed =
|
||||||
|
frames_available as usize * block_align as usize;
|
||||||
|
let mut raw = vec![0u8; bytes_needed];
|
||||||
|
if let Err(e) = capture_client.read_from_device(&mut raw) {
|
||||||
|
eprintln!(
|
||||||
|
"screen-audio {capture_id}: read_from_device: {e:?}"
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode PCM into interleaved f32 at the device's native
|
||||||
|
// rate + channel count.
|
||||||
|
let decoded = decode_pcm(
|
||||||
|
&raw,
|
||||||
|
input_channels,
|
||||||
|
bits_per_sample,
|
||||||
|
&sample_type,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Channel-fold → 2ch, then resample → 48kHz.
|
||||||
|
let stereo = to_stereo(&decoded, input_channels);
|
||||||
|
let out_samples = resample_linear_stereo(
|
||||||
|
&stereo,
|
||||||
|
input_rate,
|
||||||
|
OUTPUT_SAMPLE_RATE,
|
||||||
|
&mut last_stereo,
|
||||||
|
);
|
||||||
|
|
||||||
|
if out_samples.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pack f32s as little-endian bytes then base64. IPC-wise
|
||||||
|
// this is ~1.3× the raw byte count versus 5–10× for a
|
||||||
|
// JSON array of floats, which is the difference between
|
||||||
|
// "fine" and "wastes a CPU core" at 48kHz stereo.
|
||||||
|
let mut bytes = Vec::with_capacity(out_samples.len() * 4);
|
||||||
|
for s in &out_samples {
|
||||||
|
bytes.extend_from_slice(&s.to_le_bytes());
|
||||||
|
}
|
||||||
|
let samples_b64 =
|
||||||
|
base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||||
|
|
||||||
|
if channel
|
||||||
|
.send(AudioFramePayload {
|
||||||
|
capture_id,
|
||||||
|
sample_rate: OUTPUT_SAMPLE_RATE,
|
||||||
|
channels: OUTPUT_CHANNELS,
|
||||||
|
samples_base64: samples_b64,
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
// Frontend went away — stop cleanly.
|
||||||
|
stop.store(true, Ordering::Relaxed);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = audio_client.stop_stream();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert a raw WASAPI buffer into interleaved f32 at the device's
|
||||||
|
// native channel count. Handles the three formats that actually show
|
||||||
|
// up on Windows render endpoints: f32 (most modern hardware), i16
|
||||||
|
// (older onboard codecs), and i32 (pro audio interfaces). Anything
|
||||||
|
// else falls through to zeros so a weird format doesn't crash the
|
||||||
|
// share — the user will notice silence and can retry.
|
||||||
|
fn decode_pcm(
|
||||||
|
raw: &[u8],
|
||||||
|
channels: u16,
|
||||||
|
bits_per_sample: u16,
|
||||||
|
sample_type: &SampleType,
|
||||||
|
) -> Vec<f32> {
|
||||||
|
match (sample_type, bits_per_sample) {
|
||||||
|
(SampleType::Float, 32) => {
|
||||||
|
let mut out = Vec::with_capacity(raw.len() / 4);
|
||||||
|
for chunk in raw.chunks_exact(4) {
|
||||||
|
out.push(f32::from_le_bytes([
|
||||||
|
chunk[0], chunk[1], chunk[2], chunk[3],
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
(SampleType::Int, 16) => {
|
||||||
|
let scale = 1.0_f32 / (i16::MAX as f32);
|
||||||
|
let mut out = Vec::with_capacity(raw.len() / 2);
|
||||||
|
for chunk in raw.chunks_exact(2) {
|
||||||
|
let s = i16::from_le_bytes([chunk[0], chunk[1]]);
|
||||||
|
out.push(s as f32 * scale);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
(SampleType::Int, 32) => {
|
||||||
|
let scale = 1.0_f32 / (i32::MAX as f32);
|
||||||
|
let mut out = Vec::with_capacity(raw.len() / 4);
|
||||||
|
for chunk in raw.chunks_exact(4) {
|
||||||
|
let s = i32::from_le_bytes([
|
||||||
|
chunk[0], chunk[1], chunk[2], chunk[3],
|
||||||
|
]);
|
||||||
|
out.push(s as f32 * scale);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Unknown format — emit silence of the right frame count
|
||||||
|
// so downstream math stays correct.
|
||||||
|
let bytes_per_frame =
|
||||||
|
(bits_per_sample as usize / 8) * channels as usize;
|
||||||
|
let frames = if bytes_per_frame == 0 {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
raw.len() / bytes_per_frame
|
||||||
|
};
|
||||||
|
vec![0.0; frames * channels as usize]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Down- or up-mix to stereo. Surround layouts fold L+R only (center
|
||||||
|
// + surrounds get dropped) which is the simplest defensible choice
|
||||||
|
// for screen-share audio — most content is LR-centric and a proper
|
||||||
|
// ITU-R BS.775 downmix would pull in matrix coefficients we'd rather
|
||||||
|
// avoid in v1.
|
||||||
|
fn to_stereo(interleaved: &[f32], channels: u16) -> Vec<f32> {
|
||||||
|
if channels == 0 || interleaved.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
if channels == 2 {
|
||||||
|
return interleaved.to_vec();
|
||||||
|
}
|
||||||
|
let ch = channels as usize;
|
||||||
|
let frames = interleaved.len() / ch;
|
||||||
|
let mut out = Vec::with_capacity(frames * 2);
|
||||||
|
if channels == 1 {
|
||||||
|
for i in 0..frames {
|
||||||
|
let s = interleaved[i];
|
||||||
|
out.push(s);
|
||||||
|
out.push(s);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for i in 0..frames {
|
||||||
|
let base = i * ch;
|
||||||
|
out.push(interleaved[base]);
|
||||||
|
out.push(interleaved[base + 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Linear-interpolation resampler for interleaved stereo f32. Not the
|
||||||
|
// prettiest option theoretically, but at 44.1→48 the audible
|
||||||
|
// artefacts stay below threshold for speech + game/music content. The
|
||||||
|
// `last_stereo` state preserves the final frame across invocations so
|
||||||
|
// the interpolation at the buffer boundary doesn't produce a click.
|
||||||
|
fn resample_linear_stereo(
|
||||||
|
input_stereo: &[f32],
|
||||||
|
input_rate: u32,
|
||||||
|
output_rate: u32,
|
||||||
|
last_stereo: &mut [f32; 2],
|
||||||
|
) -> Vec<f32> {
|
||||||
|
if input_stereo.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
if input_rate == output_rate {
|
||||||
|
last_stereo[0] = input_stereo[input_stereo.len() - 2];
|
||||||
|
last_stereo[1] = input_stereo[input_stereo.len() - 1];
|
||||||
|
return input_stereo.to_vec();
|
||||||
|
}
|
||||||
|
let ratio = output_rate as f64 / input_rate as f64;
|
||||||
|
let in_frames = input_stereo.len() / 2;
|
||||||
|
let out_frames = (in_frames as f64 * ratio).floor() as usize;
|
||||||
|
if out_frames == 0 {
|
||||||
|
last_stereo[0] = input_stereo[input_stereo.len() - 2];
|
||||||
|
last_stereo[1] = input_stereo[input_stereo.len() - 1];
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let mut out = Vec::with_capacity(out_frames * 2);
|
||||||
|
|
||||||
|
let prev_l = last_stereo[0];
|
||||||
|
let prev_r = last_stereo[1];
|
||||||
|
|
||||||
|
for i in 0..out_frames {
|
||||||
|
let src_pos = i as f64 / ratio;
|
||||||
|
let src_frame = src_pos.floor() as i64;
|
||||||
|
let frac = (src_pos - src_frame as f64) as f32;
|
||||||
|
|
||||||
|
// `src_frame == -1` comes up for the very first output frame
|
||||||
|
// when ratio > 1 — interpolate against the previous buffer's
|
||||||
|
// final sample to bridge the two.
|
||||||
|
let (l0, r0) = if src_frame < 0 {
|
||||||
|
(prev_l, prev_r)
|
||||||
|
} else {
|
||||||
|
let idx = (src_frame as usize).min(in_frames - 1) * 2;
|
||||||
|
(input_stereo[idx], input_stereo[idx + 1])
|
||||||
|
};
|
||||||
|
let next = ((src_frame + 1) as usize).min(in_frames - 1);
|
||||||
|
let l1 = input_stereo[next * 2];
|
||||||
|
let r1 = input_stereo[next * 2 + 1];
|
||||||
|
|
||||||
|
out.push(l0 + (l1 - l0) * frac);
|
||||||
|
out.push(r0 + (r1 - r0) * frac);
|
||||||
|
}
|
||||||
|
|
||||||
|
last_stereo[0] = input_stereo[input_stereo.len() - 2];
|
||||||
|
last_stereo[1] = input_stereo[input_stereo.len() - 1];
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
// Native screen / window capture pipeline. Spawns a Rust thread per active
|
||||||
|
// capture that grabs frames via xcap, downscales them to the user's target
|
||||||
|
// resolution, encodes JPEG, and streams each frame to the JS side through
|
||||||
|
// a Tauri `Channel<FramePayload>`. The JS end decodes into an
|
||||||
|
// ImageBitmap, draws to a <canvas>, and exposes the canvas as a
|
||||||
|
// MediaStream via captureStream() — that stream is what LiveKit publishes.
|
||||||
|
// Net result: the user picks a source in our custom picker and the share
|
||||||
|
// starts directly, without the OS/browser picker appearing.
|
||||||
|
//
|
||||||
|
// JPEG on the Rust side + decode on the JS side introduces a second
|
||||||
|
// encoding hop (LiveKit re-encodes VP9 later) but keeps IPC bandwidth
|
||||||
|
// manageable — raw RGBA at 1920×1080×30fps would be ~240MB/s and cannot
|
||||||
|
// go over Tauri's JSON-serialised IPC. JPEG frames at Q72 land around
|
||||||
|
// 50–150KB each, so 30fps = 2–5MB/s of base64 traffic, which is fine.
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
use std::thread;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use tauri::ipc::Channel;
|
||||||
|
|
||||||
|
static NEXT_ID: AtomicU32 = AtomicU32::new(1);
|
||||||
|
static SESSIONS: OnceLock<Mutex<HashMap<u32, Session>>> = OnceLock::new();
|
||||||
|
|
||||||
|
fn sessions() -> &'static Mutex<HashMap<u32, Session>> {
|
||||||
|
SESSIONS.get_or_init(|| Mutex::new(HashMap::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Session {
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
handle: Option<thread::JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct FramePayload {
|
||||||
|
pub capture_id: u32,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
/// JPEG image bytes, base64-encoded (no data-URL prefix). Frontend
|
||||||
|
/// reconstructs via `Uint8Array.from(atob(...))` and decodes with
|
||||||
|
/// `createImageBitmap(blob)`.
|
||||||
|
pub jpeg_base64: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Source {
|
||||||
|
Window(xcap::Window),
|
||||||
|
Monitor(xcap::Monitor),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a continuous capture for the given source id and begin streaming
|
||||||
|
/// JPEG frames via the provided channel. Returns a numeric capture id that
|
||||||
|
/// must be passed to `stop_screen_capture` to tear the pipeline down.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn start_screen_capture(
|
||||||
|
source_id: String,
|
||||||
|
max_width: u32,
|
||||||
|
max_height: u32,
|
||||||
|
fps: u32,
|
||||||
|
channel: Channel<FramePayload>,
|
||||||
|
) -> Result<u32, String> {
|
||||||
|
let clamped_fps = fps.clamp(5, 60);
|
||||||
|
let clamped_w = max_width.max(320).min(3840);
|
||||||
|
let clamped_h = max_height.max(180).min(2160);
|
||||||
|
|
||||||
|
// Probe once on the command thread so we can surface a clear error
|
||||||
|
// before spawning; the actual capture handle is re-resolved inside the
|
||||||
|
// worker thread (xcap::Window holds a Windows HWND which is !Send).
|
||||||
|
if find_source(&source_id).is_none() {
|
||||||
|
return Err(format!("screen source {source_id} not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let capture_id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
|
let stop_clone = Arc::clone(&stop);
|
||||||
|
let source_id_owned = source_id;
|
||||||
|
|
||||||
|
let handle = thread::Builder::new()
|
||||||
|
.name(format!("screen-capture-{capture_id}"))
|
||||||
|
.spawn(move || {
|
||||||
|
capture_loop(
|
||||||
|
source_id_owned,
|
||||||
|
clamped_w,
|
||||||
|
clamped_h,
|
||||||
|
clamped_fps,
|
||||||
|
capture_id,
|
||||||
|
channel,
|
||||||
|
stop_clone,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map_err(|e| format!("failed to spawn capture thread: {e}"))?;
|
||||||
|
|
||||||
|
sessions().lock().unwrap().insert(
|
||||||
|
capture_id,
|
||||||
|
Session {
|
||||||
|
stop,
|
||||||
|
handle: Some(handle),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Ok(capture_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signal the capture thread to stop and join it. Safe to call more than
|
||||||
|
/// once — missing ids are silently no-ops so the JS side doesn't need to
|
||||||
|
/// track whether a stop was already issued by the OS "stop sharing" path.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn stop_screen_capture(capture_id: u32) -> Result<(), String> {
|
||||||
|
let session = sessions().lock().unwrap().remove(&capture_id);
|
||||||
|
let Some(mut session) = session else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
session.stop.store(true, Ordering::Relaxed);
|
||||||
|
if let Some(handle) = session.handle.take() {
|
||||||
|
// Best-effort join. If the thread is stuck in a long OS capture
|
||||||
|
// call we don't want to hang the command — detach after a brief
|
||||||
|
// wait by dropping the handle.
|
||||||
|
let _ = handle.join();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capture_loop(
|
||||||
|
source_id: String,
|
||||||
|
max_w: u32,
|
||||||
|
max_h: u32,
|
||||||
|
fps: u32,
|
||||||
|
capture_id: u32,
|
||||||
|
channel: Channel<FramePayload>,
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
) {
|
||||||
|
let frame_interval = Duration::from_nanos(1_000_000_000 / fps as u64);
|
||||||
|
let jpeg_quality: u8 = 72;
|
||||||
|
|
||||||
|
// Re-resolve the source inside the worker thread — xcap::Window holds
|
||||||
|
// an HWND which is !Send so we can't move it across threads. Caching
|
||||||
|
// the handle for the lifetime of the loop keeps per-frame cost to the
|
||||||
|
// actual pixel capture + encode.
|
||||||
|
let source = match find_source(&source_id) {
|
||||||
|
Some(s) => s,
|
||||||
|
None => {
|
||||||
|
eprintln!("screen-capture {capture_id}: source vanished before capture started");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
while !stop.load(Ordering::Relaxed) {
|
||||||
|
let frame_start = Instant::now();
|
||||||
|
|
||||||
|
let img_result = match &source {
|
||||||
|
Source::Window(w) => w.capture_image(),
|
||||||
|
Source::Monitor(m) => m.capture_image(),
|
||||||
|
};
|
||||||
|
let img = match img_result {
|
||||||
|
Ok(i) => i,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("screen-capture {capture_id}: capture failed: {err}");
|
||||||
|
// Brief backoff before retrying — transient Windows GDI
|
||||||
|
// errors (e.g. during screen lock) tend to recover within
|
||||||
|
// a frame or two.
|
||||||
|
thread::sleep(frame_interval);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (raw_w, raw_h) = img.dimensions();
|
||||||
|
let (tgt_w, tgt_h) = scale_to_fit(raw_w, raw_h, max_w, max_h);
|
||||||
|
let scaled = if (tgt_w, tgt_h) != (raw_w, raw_h) {
|
||||||
|
image::imageops::resize(
|
||||||
|
&img,
|
||||||
|
tgt_w,
|
||||||
|
tgt_h,
|
||||||
|
image::imageops::FilterType::Triangle,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
img
|
||||||
|
};
|
||||||
|
|
||||||
|
// JPEG doesn't support alpha; strip it before encoding.
|
||||||
|
let rgb = rgba_to_rgb(&scaled);
|
||||||
|
|
||||||
|
let mut jpeg_buf: Vec<u8> = Vec::with_capacity((tgt_w * tgt_h) as usize);
|
||||||
|
{
|
||||||
|
use image::codecs::jpeg::JpegEncoder;
|
||||||
|
let mut encoder = JpegEncoder::new_with_quality(&mut jpeg_buf, jpeg_quality);
|
||||||
|
if let Err(err) =
|
||||||
|
encoder.encode(&rgb, tgt_w, tgt_h, image::ExtendedColorType::Rgb8)
|
||||||
|
{
|
||||||
|
eprintln!("screen-capture {capture_id}: encode failed: {err}");
|
||||||
|
thread::sleep(frame_interval);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let jpeg_base64 = base64::engine::general_purpose::STANDARD.encode(&jpeg_buf);
|
||||||
|
|
||||||
|
let send_result = channel.send(FramePayload {
|
||||||
|
capture_id,
|
||||||
|
width: tgt_w,
|
||||||
|
height: tgt_h,
|
||||||
|
jpeg_base64,
|
||||||
|
});
|
||||||
|
if send_result.is_err() {
|
||||||
|
// Frontend went away (window closed, renderer crashed).
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pace to target framerate. If the capture + encode already took
|
||||||
|
// longer than one frame interval, yield a millisecond to avoid
|
||||||
|
// pegging a single core when the target is unreachable.
|
||||||
|
let elapsed = frame_start.elapsed();
|
||||||
|
if elapsed < frame_interval {
|
||||||
|
thread::sleep(frame_interval - elapsed);
|
||||||
|
} else {
|
||||||
|
thread::sleep(Duration::from_millis(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rgba_to_rgb(buf: &image::RgbaImage) -> Vec<u8> {
|
||||||
|
let (w, h) = buf.dimensions();
|
||||||
|
let mut out = Vec::with_capacity((w * h * 3) as usize);
|
||||||
|
for p in buf.pixels() {
|
||||||
|
out.extend_from_slice(&[p.0[0], p.0[1], p.0[2]]);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scale_to_fit(w: u32, h: u32, max_w: u32, max_h: u32) -> (u32, u32) {
|
||||||
|
if w == 0 || h == 0 {
|
||||||
|
return (w, h);
|
||||||
|
}
|
||||||
|
let scale = (max_w as f32 / w as f32)
|
||||||
|
.min(max_h as f32 / h as f32)
|
||||||
|
.min(1.0);
|
||||||
|
if scale >= 1.0 {
|
||||||
|
return (w, h);
|
||||||
|
}
|
||||||
|
let new_w = ((w as f32) * scale).round().max(1.0) as u32;
|
||||||
|
let new_h = ((h as f32) * scale).round().max(1.0) as u32;
|
||||||
|
(new_w, new_h)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_source(source_id: &str) -> Option<Source> {
|
||||||
|
if let Some(rest) = source_id.strip_prefix("window:") {
|
||||||
|
let raw = rest.strip_suffix(":0")?;
|
||||||
|
let raw_id: u32 = raw.parse().ok()?;
|
||||||
|
let windows = xcap::Window::all().ok()?;
|
||||||
|
return windows
|
||||||
|
.into_iter()
|
||||||
|
.find(|w| w.id() == raw_id)
|
||||||
|
.map(Source::Window);
|
||||||
|
}
|
||||||
|
if let Some(rest) = source_id.strip_prefix("screen:") {
|
||||||
|
let raw = rest.strip_suffix(":0")?;
|
||||||
|
let raw_id: u32 = raw.parse().ok()?;
|
||||||
|
let monitors = xcap::Monitor::all().ok()?;
|
||||||
|
return monitors
|
||||||
|
.into_iter()
|
||||||
|
.find(|m| m.id() == raw_id)
|
||||||
|
.map(Source::Monitor);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
// Source enumeration for the Discord-style screen-share picker. Split
|
||||||
|
// into two commands on the slow-vs-fast axis:
|
||||||
|
//
|
||||||
|
// - list_screen_sources — metadata only (no thumbnails). Fast; the
|
||||||
|
// picker shows names + placeholders instantly.
|
||||||
|
// - capture_screen_source_thumbnail — one thumbnail at a time, keyed by
|
||||||
|
// the id returned from the list.
|
||||||
|
//
|
||||||
|
// The frontend fans out the thumbnail calls via Promise.all so Tauri's
|
||||||
|
// command thread pool captures them in parallel — wall-clock time ends up
|
||||||
|
// bounded by the *slowest* source rather than the sum of all captures.
|
||||||
|
// The `id` field is emitted in Chromium's internal desktopCapturer format
|
||||||
|
// ("screen:<id>:0" / "window:<hwnd>:0") so the JS side can try to pass it
|
||||||
|
// straight into getUserMedia's `chromeMediaSourceId` constraint, or use
|
||||||
|
// it as the source key for the native capture pipeline in screen_capture.
|
||||||
|
//
|
||||||
|
// xcap abstracts the platform-specific capture APIs (Windows GDI + DXGI,
|
||||||
|
// macOS CoreGraphics/ScreenCaptureKit, X11) so the code here stays flat.
|
||||||
|
// Thumbnails are captured at native resolution, then letterbox-downscaled
|
||||||
|
// to fit a 320×180 box to keep the base64 payload small.
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
|
use image::{ImageBuffer, Rgba};
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
// Thumbnail dimensions tuned for the picker grid: even smaller than the
|
||||||
|
// first pass because we're now streaming raw JPEG bytes (no base64) —
|
||||||
|
// smaller payload = less IPC postMessage work on the main thread. At
|
||||||
|
// 192×108 / Q60 the typical window thumbnail is 5–10 KB and decodes to
|
||||||
|
// the grid in a frame or two.
|
||||||
|
const THUMB_MAX_W: u32 = 192;
|
||||||
|
const THUMB_MAX_H: u32 = 108;
|
||||||
|
const THUMB_JPEG_QUALITY: u8 = 60;
|
||||||
|
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ScreenSource {
|
||||||
|
/// Chromium-format source id — stable within one enumeration call.
|
||||||
|
pub id: String,
|
||||||
|
/// Human-readable label for the picker (monitor name or window title).
|
||||||
|
pub name: String,
|
||||||
|
/// Discriminator for the grid grouping.
|
||||||
|
pub kind: &'static str,
|
||||||
|
/// Base64-encoded PNG, no data-URL prefix. None when the capture
|
||||||
|
/// fails (minimised window, permission-denied surface, transient
|
||||||
|
/// race). The UI renders a name-only card in that case.
|
||||||
|
pub thumbnail_png: Option<String>,
|
||||||
|
/// Native width of the full-res source — mostly informational, used
|
||||||
|
/// by the UI for aspect-ratio styling of the card.
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fast metadata-only enumeration. No image capture happens here — that's
|
||||||
|
// why it returns in tens of milliseconds instead of the multi-second
|
||||||
|
// wait the single-shot enumerate_screen_sources command had.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn list_screen_sources() -> Result<Vec<ScreenSource>, String> {
|
||||||
|
let mut out: Vec<ScreenSource> = Vec::new();
|
||||||
|
append_monitor_metadata(&mut out);
|
||||||
|
append_window_metadata(&mut out);
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// One-shot thumbnail capture by source id. Called N times in parallel from
|
||||||
|
// the frontend after the list lands so the total wall-clock is bounded by
|
||||||
|
// the slowest capture rather than the sum. Legacy base64 variant — kept
|
||||||
|
// for rollback; the preferred path is `capture_screen_source_thumbnail_bytes`.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn capture_screen_source_thumbnail(source_id: String) -> Result<Option<String>, String> {
|
||||||
|
if let Some(raw) = source_id.strip_prefix("screen:").and_then(strip_zero_suffix) {
|
||||||
|
return Ok(capture_monitor_by_id(raw));
|
||||||
|
}
|
||||||
|
if let Some(raw) = source_id.strip_prefix("window:").and_then(strip_zero_suffix) {
|
||||||
|
return Ok(capture_window_by_id(raw));
|
||||||
|
}
|
||||||
|
Err(format!("unknown source id format: {source_id}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Binary variant: returns raw JPEG bytes wrapped in `tauri::ipc::Response`
|
||||||
|
// so Tauri ships them over IPC without JSON-encoding / base64. On the JS
|
||||||
|
// side, `invoke` resolves to an ArrayBuffer that we wrap in a Blob and
|
||||||
|
// expose via `URL.createObjectURL` — skips the base64-decode step
|
||||||
|
// entirely and keeps the main thread responsive during fan-in.
|
||||||
|
//
|
||||||
|
// The return type MUST be `Response` directly (not `Result<Response, E>`):
|
||||||
|
// a Result wrapper forces Tauri to JSON-serialise the variant so the
|
||||||
|
// frontend gets a JSON object instead of raw bytes. Failures — bad id
|
||||||
|
// format, capture errors, source vanished — all funnel into an empty
|
||||||
|
// byte buffer; the caller treats `byteLength === 0` as the "no thumbnail"
|
||||||
|
// signal.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn capture_screen_source_thumbnail_bytes(source_id: String) -> tauri::ipc::Response {
|
||||||
|
let bytes = if let Some(raw) = source_id.strip_prefix("screen:").and_then(strip_zero_suffix) {
|
||||||
|
capture_monitor_bytes_by_id(raw).unwrap_or_default()
|
||||||
|
} else if let Some(raw) = source_id.strip_prefix("window:").and_then(strip_zero_suffix) {
|
||||||
|
capture_window_bytes_by_id(raw).unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
eprintln!("capture_screen_source_thumbnail_bytes: unknown id format: {source_id}");
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
tauri::ipc::Response::new(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strip_zero_suffix(s: &str) -> Option<&str> {
|
||||||
|
s.strip_suffix(":0")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy one-shot all-in-one enumeration. Kept around so the frontend can
|
||||||
|
// fall back during rollout if the split pair throws; marked dead_code so
|
||||||
|
// the linker doesn't grumble when only the split variant is wired up.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn enumerate_screen_sources() -> Result<Vec<ScreenSource>, String> {
|
||||||
|
let mut out: Vec<ScreenSource> = Vec::new();
|
||||||
|
append_monitors(&mut out);
|
||||||
|
append_windows(&mut out);
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Monitors
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn append_monitor_metadata(out: &mut Vec<ScreenSource>) {
|
||||||
|
let monitors = match xcap::Monitor::all() {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("xcap Monitor::all failed: {err}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (idx, m) in monitors.iter().enumerate() {
|
||||||
|
out.push(ScreenSource {
|
||||||
|
id: format!("screen:{}:0", m.id()),
|
||||||
|
name: monitor_label(m, idx),
|
||||||
|
kind: "screen",
|
||||||
|
thumbnail_png: None,
|
||||||
|
width: m.width(),
|
||||||
|
height: m.height(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn append_monitors(out: &mut Vec<ScreenSource>) {
|
||||||
|
let monitors = match xcap::Monitor::all() {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("xcap Monitor::all failed: {err}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (idx, m) in monitors.iter().enumerate() {
|
||||||
|
out.push(ScreenSource {
|
||||||
|
id: format!("screen:{}:0", m.id()),
|
||||||
|
name: monitor_label(m, idx),
|
||||||
|
kind: "screen",
|
||||||
|
thumbnail_png: capture_monitor_thumbnail(m),
|
||||||
|
width: m.width(),
|
||||||
|
height: m.height(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capture_monitor_by_id(raw: &str) -> Option<String> {
|
||||||
|
let raw_id: u32 = raw.parse().ok()?;
|
||||||
|
let monitors = xcap::Monitor::all().ok()?;
|
||||||
|
let target = monitors.into_iter().find(|m| m.id() == raw_id)?;
|
||||||
|
capture_monitor_thumbnail(&target)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capture_monitor_bytes_by_id(raw: &str) -> Option<Vec<u8>> {
|
||||||
|
let raw_id: u32 = raw.parse().ok()?;
|
||||||
|
let monitors = xcap::Monitor::all().ok()?;
|
||||||
|
let target = monitors.into_iter().find(|m| m.id() == raw_id)?;
|
||||||
|
let image = target.capture_image().ok()?;
|
||||||
|
encode_scaled_jpeg_bytes(image)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn monitor_label(m: &xcap::Monitor, idx: usize) -> String {
|
||||||
|
let name = m.name();
|
||||||
|
if name.is_empty() {
|
||||||
|
format!("Bildschirm {}", idx + 1)
|
||||||
|
} else {
|
||||||
|
name.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capture_monitor_thumbnail(m: &xcap::Monitor) -> Option<String> {
|
||||||
|
let image = m.capture_image().ok()?;
|
||||||
|
encode_scaled_jpeg(image)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Windows
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn append_window_metadata(out: &mut Vec<ScreenSource>) {
|
||||||
|
let windows = match xcap::Window::all() {
|
||||||
|
Ok(w) => w,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("xcap Window::all failed: {err}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for w in windows.iter() {
|
||||||
|
if !is_shareable_window(w) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let title = w.title();
|
||||||
|
if title.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(ScreenSource {
|
||||||
|
id: format!("window:{}:0", w.id()),
|
||||||
|
name: title.to_string(),
|
||||||
|
kind: "window",
|
||||||
|
thumbnail_png: None,
|
||||||
|
width: w.width(),
|
||||||
|
height: w.height(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn append_windows(out: &mut Vec<ScreenSource>) {
|
||||||
|
let windows = match xcap::Window::all() {
|
||||||
|
Ok(w) => w,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("xcap Window::all failed: {err}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for w in windows.iter() {
|
||||||
|
if !is_shareable_window(w) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let title = w.title();
|
||||||
|
if title.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(ScreenSource {
|
||||||
|
id: format!("window:{}:0", w.id()),
|
||||||
|
name: title.to_string(),
|
||||||
|
kind: "window",
|
||||||
|
thumbnail_png: capture_window_thumbnail(w),
|
||||||
|
width: w.width(),
|
||||||
|
height: w.height(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capture_window_by_id(raw: &str) -> Option<String> {
|
||||||
|
let raw_id: u32 = raw.parse().ok()?;
|
||||||
|
let windows = xcap::Window::all().ok()?;
|
||||||
|
let target = windows.into_iter().find(|w| w.id() == raw_id)?;
|
||||||
|
capture_window_thumbnail(&target)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capture_window_bytes_by_id(raw: &str) -> Option<Vec<u8>> {
|
||||||
|
let raw_id: u32 = raw.parse().ok()?;
|
||||||
|
let windows = xcap::Window::all().ok()?;
|
||||||
|
let target = windows.into_iter().find(|w| w.id() == raw_id)?;
|
||||||
|
let image = target.capture_image().ok()?;
|
||||||
|
encode_scaled_jpeg_bytes(image)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_shareable_window(w: &xcap::Window) -> bool {
|
||||||
|
if w.is_minimized() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let width = w.width();
|
||||||
|
let height = w.height();
|
||||||
|
// Tooltips, invisible tray-helpers, etc. sit at or near zero size —
|
||||||
|
// they'd clutter the picker grid and usually can't be captured anyway.
|
||||||
|
if width < 80 || height < 60 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capture_window_thumbnail(w: &xcap::Window) -> Option<String> {
|
||||||
|
let image = w.capture_image().ok()?;
|
||||||
|
encode_scaled_jpeg(image)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Scaling + encoding
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Letterbox-shrink the captured image so the long edge is at most
|
||||||
|
// THUMB_MAX_W / THUMB_MAX_H. Keeps aspect ratio, skips upscaling entirely
|
||||||
|
// (tiny windows stay their captured size). Returns raw JPEG bytes — the
|
||||||
|
// binary-IPC path ships these directly, the legacy base64 wrapper
|
||||||
|
// (`encode_scaled_jpeg`) wraps in base64 for the old command.
|
||||||
|
fn encode_scaled_jpeg_bytes(src: ImageBuffer<Rgba<u8>, Vec<u8>>) -> Option<Vec<u8>> {
|
||||||
|
let (w, h) = src.dimensions();
|
||||||
|
if w == 0 || h == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let scale = (THUMB_MAX_W as f32 / w as f32)
|
||||||
|
.min(THUMB_MAX_H as f32 / h as f32)
|
||||||
|
.min(1.0);
|
||||||
|
let scaled = if scale < 1.0 {
|
||||||
|
let new_w = ((w as f32) * scale).round().max(1.0) as u32;
|
||||||
|
let new_h = ((h as f32) * scale).round().max(1.0) as u32;
|
||||||
|
image::imageops::resize(&src, new_w, new_h, image::imageops::FilterType::Triangle)
|
||||||
|
} else {
|
||||||
|
src
|
||||||
|
};
|
||||||
|
// JPEG encoder doesn't accept RGBA — strip alpha into a packed RGB
|
||||||
|
// buffer first. Alpha carries no info for a visible thumbnail anyway.
|
||||||
|
let (sw, sh) = (scaled.width(), scaled.height());
|
||||||
|
let mut rgb: Vec<u8> = Vec::with_capacity((sw * sh * 3) as usize);
|
||||||
|
for p in scaled.pixels() {
|
||||||
|
rgb.extend_from_slice(&[p.0[0], p.0[1], p.0[2]]);
|
||||||
|
}
|
||||||
|
let mut buf: Vec<u8> = Vec::with_capacity((sw * sh / 8) as usize);
|
||||||
|
{
|
||||||
|
use image::codecs::jpeg::JpegEncoder;
|
||||||
|
let mut encoder = JpegEncoder::new_with_quality(&mut buf, THUMB_JPEG_QUALITY);
|
||||||
|
encoder
|
||||||
|
.encode(&rgb, sw, sh, image::ExtendedColorType::Rgb8)
|
||||||
|
.ok()?;
|
||||||
|
}
|
||||||
|
Some(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy base64 wrapper — used by `capture_screen_source_thumbnail`
|
||||||
|
// (Result<Option<String>, String>) which predates the binary variant.
|
||||||
|
fn encode_scaled_jpeg(src: ImageBuffer<Rgba<u8>, Vec<u8>>) -> Option<String> {
|
||||||
|
let bytes = encode_scaled_jpeg_bytes(src)?;
|
||||||
|
Some(base64::engine::general_purpose::STANDARD.encode(&bytes))
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "ChatApp",
|
"productName": "ChatApp",
|
||||||
"version": "0.5.0",
|
"version": "0.11.3",
|
||||||
"identifier": "com.meinname.chatapp",
|
"identifier": "com.meinname.chatapp",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "pnpm vite:dev",
|
"beforeDevCommand": "pnpm vite:dev",
|
||||||
@@ -42,8 +42,10 @@
|
|||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"updater": {
|
"updater": {
|
||||||
"endpoints": ["https://github.com/byGalax/chat-app/releases/latest/download/latest.json"],
|
"endpoints": [
|
||||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDQ5N0U0RDcxOTU2OEQ0QUUKUldTdTFHaVZjVTErU1ZuMk1lWXBUbEcyS1RHYzJQN3k4VDdiUGRvRnVJYVJKR3BxWG1xcENpdlYK",
|
"https://update.netralax.cloud/windows/latest.json"
|
||||||
|
],
|
||||||
|
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEI1Mzc0QjVBQUZEQTA3RUIKUldUckI5cXZXa3MzdGM3QkE4WWFPd3NnVzRZeXdpcUM0eUtjRDlGN09ySEdzNXhLNlo3azBPajYK",
|
||||||
"windows": {
|
"windows": {
|
||||||
"installMode": "passive"
|
"installMode": "passive"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +1,152 @@
|
|||||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
import { lazy, Suspense } from 'react';
|
||||||
|
import { BrowserRouter, Navigate, Outlet, Route, Routes } from 'react-router-dom';
|
||||||
|
|
||||||
import { AppShell } from './components/AppShell';
|
import { AppShell } from './components/AppShell';
|
||||||
|
import { CrashToast } from './components/CrashToast';
|
||||||
|
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||||
|
import { SpinnerIcon } from './components/icons';
|
||||||
import { UpdateToast } from './components/UpdateToast';
|
import { UpdateToast } from './components/UpdateToast';
|
||||||
import { RequireAdmin, RequireAuth, RequireDevice } from './components/guards';
|
import { RequireAdmin, RequireAuth, RequireDevice } from './components/guards';
|
||||||
import { AuthProvider } from './context/AuthContext';
|
import { AuthProvider } from './context/AuthContext';
|
||||||
import { CallProvider } from './context/CallContext';
|
import { CallProvider } from './context/CallContext';
|
||||||
import { ConversationsProvider } from './context/ConversationsContext';
|
import { ConversationsProvider } from './context/ConversationsContext';
|
||||||
import { FriendshipsProvider } from './context/FriendshipsContext';
|
import { FriendshipsProvider } from './context/FriendshipsContext';
|
||||||
import { AdminPage } from './pages/AdminPage';
|
import { ThemeProvider } from './context/ThemeContext';
|
||||||
import { AuthCallbackPage } from './pages/AuthCallbackPage';
|
|
||||||
import { AuthPage } from './pages/AuthPage';
|
import { AuthPage } from './pages/AuthPage';
|
||||||
import { ChatsEmptyState, ChatsPage } from './pages/ChatsPage';
|
import { ChatsEmptyState, ChatsPage } from './pages/ChatsPage';
|
||||||
import { ConversationPage } from './pages/ConversationPage';
|
import { ConversationPage } from './pages/ConversationPage';
|
||||||
import { DevicePage } from './pages/DevicePage';
|
|
||||||
import { FriendsPage } from './pages/FriendsPage';
|
// Routes rarely visited on first render are pulled out of the initial bundle.
|
||||||
import { SettingsPage } from './pages/SettingsPage';
|
// AuthPage stays eager because it's the first screen unauthenticated users
|
||||||
|
// see; ChatsPage + ConversationPage stay eager because every authenticated
|
||||||
|
// session renders them immediately.
|
||||||
|
const AdminPage = lazy(() => import('./pages/AdminPage').then((m) => ({ default: m.AdminPage })));
|
||||||
|
const AuthCallbackPage = lazy(() =>
|
||||||
|
import('./pages/AuthCallbackPage').then((m) => ({ default: m.AuthCallbackPage })),
|
||||||
|
);
|
||||||
|
const DevicePage = lazy(() => import('./pages/DevicePage').then((m) => ({ default: m.DevicePage })));
|
||||||
|
const FriendsPage = lazy(() =>
|
||||||
|
import('./pages/FriendsPage').then((m) => ({ default: m.FriendsPage })),
|
||||||
|
);
|
||||||
|
const SettingsPage = lazy(() =>
|
||||||
|
import('./pages/SettingsPage').then((m) => ({ default: m.SettingsPage })),
|
||||||
|
);
|
||||||
|
|
||||||
|
function RouteSuspense({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="flex min-h-full w-full items-center justify-center bg-surface-3">
|
||||||
|
<SpinnerIcon className="h-5 w-5 text-accent" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Isolates each top-level route so a crash in one page doesn't take the whole
|
||||||
|
// shell down. The boundary auto-retries via `ErrorBoundary`'s backoff schedule.
|
||||||
|
function RouteBoundary({ scope }: { scope: string }) {
|
||||||
|
return (
|
||||||
|
<ErrorBoundary scope={scope}>
|
||||||
|
<Outlet />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
|
<ErrorBoundary scope="root">
|
||||||
|
<ThemeProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<FriendshipsProvider>
|
<FriendshipsProvider>
|
||||||
<ConversationsProvider>
|
<ConversationsProvider>
|
||||||
<CallProvider>
|
<CallProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter
|
||||||
|
future={{
|
||||||
|
// Opt into v7 behaviour early so the upgrade is a no-op:
|
||||||
|
// - `v7_startTransition` wraps navigations in startTransition
|
||||||
|
// so Suspense / concurrent rendering deal with the new tree
|
||||||
|
// - `v7_relativeSplatPath` matches relative paths inside
|
||||||
|
// splat routes against the parent splat segment (not the
|
||||||
|
// full matched path). Our tree has no splat routes today
|
||||||
|
// but this kills the runtime warning and future-proofs.
|
||||||
|
v7_startTransition: true,
|
||||||
|
v7_relativeSplatPath: true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Routes>
|
<Routes>
|
||||||
|
<Route element={<RouteBoundary scope="auth" />}>
|
||||||
<Route path="/auth" element={<AuthPage />} />
|
<Route path="/auth" element={<AuthPage />} />
|
||||||
<Route path="/auth/callback" element={<AuthCallbackPage />} />
|
<Route
|
||||||
|
path="/auth/callback"
|
||||||
|
element={
|
||||||
|
<RouteSuspense>
|
||||||
|
<AuthCallbackPage />
|
||||||
|
</RouteSuspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
<Route element={<RequireAuth />}>
|
<Route element={<RequireAuth />}>
|
||||||
<Route path="/device" element={<DevicePage />} />
|
<Route element={<RouteBoundary scope="device" />}>
|
||||||
|
<Route
|
||||||
|
path="/device"
|
||||||
|
element={
|
||||||
|
<RouteSuspense>
|
||||||
|
<DevicePage />
|
||||||
|
</RouteSuspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
<Route element={<RequireDevice />}>
|
<Route element={<RequireDevice />}>
|
||||||
<Route element={<AppShell />}>
|
<Route element={<AppShell />}>
|
||||||
<Route index element={<Navigate to="/chats" replace />} />
|
<Route index element={<Navigate to="/chats" replace />} />
|
||||||
|
<Route element={<RouteBoundary scope="chats" />}>
|
||||||
<Route path="/chats" element={<ChatsPage />}>
|
<Route path="/chats" element={<ChatsPage />}>
|
||||||
<Route index element={<ChatsEmptyState />} />
|
<Route index element={<ChatsEmptyState />} />
|
||||||
<Route path=":id" element={<ConversationPage />} />
|
<Route
|
||||||
|
path=":id"
|
||||||
|
element={
|
||||||
|
<ErrorBoundary scope="conversation">
|
||||||
|
<ConversationPage />
|
||||||
|
</ErrorBoundary>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
|
<Route element={<RouteBoundary scope="friends" />}>
|
||||||
|
<Route
|
||||||
|
path="/friends"
|
||||||
|
element={
|
||||||
|
<RouteSuspense>
|
||||||
|
<FriendsPage />
|
||||||
|
</RouteSuspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
<Route element={<RouteBoundary scope="settings" />}>
|
||||||
|
<Route
|
||||||
|
path="/settings"
|
||||||
|
element={
|
||||||
|
<RouteSuspense>
|
||||||
|
<SettingsPage />
|
||||||
|
</RouteSuspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/friends" element={<FriendsPage />} />
|
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
|
||||||
<Route element={<RequireAdmin />}>
|
<Route element={<RequireAdmin />}>
|
||||||
<Route path="/admin" element={<AdminPage />} />
|
<Route element={<RouteBoundary scope="admin" />}>
|
||||||
|
<Route
|
||||||
|
path="/admin"
|
||||||
|
element={
|
||||||
|
<RouteSuspense>
|
||||||
|
<AdminPage />
|
||||||
|
</RouteSuspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
@@ -46,10 +154,13 @@ export function App() {
|
|||||||
<Route path="*" element={<Navigate to="/chats" replace />} />
|
<Route path="*" element={<Navigate to="/chats" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
<UpdateToast />
|
<UpdateToast />
|
||||||
|
<CrashToast />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</CallProvider>
|
</CallProvider>
|
||||||
</ConversationsProvider>
|
</ConversationsProvider>
|
||||||
</FriendshipsProvider>
|
</FriendshipsProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Outlet } from 'react-router-dom';
|
|||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { startConversationKeySync } from '../lib/conversationKeySync';
|
import { startConversationKeySync } from '../lib/conversationKeySync';
|
||||||
import { ensureNotificationPermission } from '../lib/osNotify';
|
import { ensureNotificationPermission } from '../lib/osNotify';
|
||||||
|
import { BackupPromptBanner } from './BackupPromptBanner';
|
||||||
import { CallUI } from './CallUI';
|
import { CallUI } from './CallUI';
|
||||||
import { Sidebar } from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
|
|
||||||
@@ -21,7 +22,7 @@ export function AppShell() {
|
|||||||
}, [session?.user.id, device?.id]);
|
}, [session?.user.id, device?.id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex min-h-screen overflow-hidden bg-ink-950 text-neutral-100">
|
<div className="relative flex min-h-screen overflow-hidden bg-surface text-fg">
|
||||||
<ShellBackground />
|
<ShellBackground />
|
||||||
<div className="relative z-10 flex min-h-screen w-full">
|
<div className="relative z-10 flex min-h-screen w-full">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
@@ -32,15 +33,16 @@ export function AppShell() {
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<CallUI />
|
<CallUI />
|
||||||
|
<BackupPromptBanner />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calmer than the auth-screen background — full-bleed grid + 2 large blobs.
|
// Subtle ambient background — only renders in dark mode where the app's
|
||||||
// No animation here so message lists stay readable.
|
// visual language expects depth. Light mode stays clean and flat.
|
||||||
function ShellBackground() {
|
function ShellBackground() {
|
||||||
return (
|
return (
|
||||||
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
|
<div aria-hidden="true" className="pointer-events-none absolute inset-0 hidden dark:block">
|
||||||
<div className="bg-grid absolute inset-0 opacity-[0.18]" />
|
<div className="bg-grid absolute inset-0 opacity-[0.18]" />
|
||||||
<div className="absolute -left-32 top-1/4 h-[420px] w-[420px] rounded-full bg-brand-500/20 blur-3xl" />
|
<div className="absolute -left-32 top-1/4 h-[420px] w-[420px] rounded-full bg-brand-500/20 blur-3xl" />
|
||||||
<div className="absolute -right-32 bottom-0 h-[420px] w-[420px] rounded-full bg-fuchsia-500/10 blur-3xl" />
|
<div className="absolute -right-32 bottom-0 h-[420px] w-[420px] rounded-full bg-fuchsia-500/10 blur-3xl" />
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
|
||||||
|
import { supabase } from '../lib/supabase';
|
||||||
|
import { AlertIcon, MicIcon, SpinnerIcon } from './icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
handle: AttachmentHandle;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BAR_COUNT = 48;
|
||||||
|
|
||||||
|
// Custom voice-message player with waveform visualisation. Decoded peaks are
|
||||||
|
// computed once per blob via OfflineAudioContext so playback only carries the
|
||||||
|
// rendered DOM. Falls back to a rectangular bar if decoding fails (e.g. the
|
||||||
|
// blob mime is recognised by <audio> but not by AudioContext).
|
||||||
|
export function AttachmentAudio({ handle }: Props) {
|
||||||
|
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
||||||
|
const [arrayBuf, setArrayBuf] = useState<ArrayBuffer | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [peaks, setPeaks] = useState<number[] | null>(null);
|
||||||
|
const [duration, setDuration] = useState<number>(0);
|
||||||
|
const [position, setPosition] = useState<number>(0);
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
let url: string | null = null;
|
||||||
|
setError(null);
|
||||||
|
setBlobUrl(null);
|
||||||
|
setArrayBuf(null);
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
const cached = await getCachedAttachment(handle.id);
|
||||||
|
if (cached) {
|
||||||
|
if (cancelled) return;
|
||||||
|
url = URL.createObjectURL(cached);
|
||||||
|
setBlobUrl(url);
|
||||||
|
const buf = await cached.arrayBuffer();
|
||||||
|
if (!cancelled) setArrayBuf(buf);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const blob = await downloadAndDecryptAttachment({ client: supabase, handle });
|
||||||
|
if (cancelled) return;
|
||||||
|
url = URL.createObjectURL(blob);
|
||||||
|
setBlobUrl(url);
|
||||||
|
const buf = await blob.arrayBuffer();
|
||||||
|
if (!cancelled) setArrayBuf(buf);
|
||||||
|
void putCachedAttachment(handle.id, blob);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(err instanceof Error ? err.message : 'download failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (url) URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
}, [handle.id, handle.storagePath, handle.keyB64, handle.nonceB64]);
|
||||||
|
|
||||||
|
// Compute peaks via OfflineAudioContext. Cheap O(n) scan over PCM samples
|
||||||
|
// bucketed into BAR_COUNT bars. Done once per attachment.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!arrayBuf) return;
|
||||||
|
let cancelled = false;
|
||||||
|
const Ctx =
|
||||||
|
window.AudioContext ||
|
||||||
|
(window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
|
||||||
|
const ctx = new Ctx();
|
||||||
|
ctx
|
||||||
|
.decodeAudioData(arrayBuf.slice(0))
|
||||||
|
.then((decoded) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setDuration(decoded.duration);
|
||||||
|
const channel = decoded.getChannelData(0);
|
||||||
|
const bucket = Math.max(1, Math.floor(channel.length / BAR_COUNT));
|
||||||
|
const out = new Array<number>(BAR_COUNT).fill(0);
|
||||||
|
for (let i = 0; i < BAR_COUNT; i++) {
|
||||||
|
let max = 0;
|
||||||
|
const start = i * bucket;
|
||||||
|
const end = Math.min(channel.length, start + bucket);
|
||||||
|
for (let j = start; j < end; j++) {
|
||||||
|
const v = Math.abs(channel[j]!);
|
||||||
|
if (v > max) max = v;
|
||||||
|
}
|
||||||
|
out[i] = max;
|
||||||
|
}
|
||||||
|
// Normalize so loudest peak is 1; keeps quiet recordings visible.
|
||||||
|
const peak = Math.max(...out, 0.001);
|
||||||
|
setPeaks(out.map((v) => v / peak));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Fall through — UI shows a flat bar but playback still works.
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
void ctx.close().catch(() => {});
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [arrayBuf]);
|
||||||
|
|
||||||
|
const fallbackPeaks = useMemo(
|
||||||
|
() => (peaks ? null : Array.from({ length: BAR_COUNT }, () => 0.5)),
|
||||||
|
[peaks],
|
||||||
|
);
|
||||||
|
const visiblePeaks = peaks ?? fallbackPeaks!;
|
||||||
|
const progress = duration > 0 ? position / duration : 0;
|
||||||
|
|
||||||
|
const onTogglePlay = () => {
|
||||||
|
const el = audioRef.current;
|
||||||
|
if (!el || !blobUrl) return;
|
||||||
|
if (el.paused) void el.play();
|
||||||
|
else el.pause();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSeek = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
const el = audioRef.current;
|
||||||
|
if (!el || duration === 0) return;
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const ratio = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
|
||||||
|
el.currentTime = ratio * duration;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="mt-2 inline-flex items-center gap-2 rounded-lg border border-rose-500/20 bg-rose-500/10 px-3 py-2 text-xs text-rose-200">
|
||||||
|
<AlertIcon className="h-4 w-4" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2 flex w-[280px] min-w-[280px] items-center gap-2 rounded-lg border border-line bg-surface-2 px-3 py-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onTogglePlay}
|
||||||
|
disabled={!blobUrl}
|
||||||
|
aria-label={playing ? 'Pause' : 'Wiedergabe'}
|
||||||
|
title={playing ? 'Pause' : 'Wiedergabe'}
|
||||||
|
className="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full bg-accent text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{!blobUrl ? (
|
||||||
|
<SpinnerIcon className="h-4 w-4" />
|
||||||
|
) : playing ? (
|
||||||
|
<PauseGlyph />
|
||||||
|
) : (
|
||||||
|
<PlayGlyph />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||||
|
<div
|
||||||
|
role="slider"
|
||||||
|
aria-label="Position"
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={Math.max(1, Math.floor(duration))}
|
||||||
|
aria-valuenow={Math.floor(position)}
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={onSeek}
|
||||||
|
className="flex h-7 cursor-pointer items-center gap-[2px]"
|
||||||
|
>
|
||||||
|
{visiblePeaks.map((v, i) => {
|
||||||
|
const playedRatio = (i + 0.5) / BAR_COUNT;
|
||||||
|
const played = playedRatio <= progress;
|
||||||
|
const h = Math.max(2, Math.round(v * 22));
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
style={{ height: h + 'px' }}
|
||||||
|
className={
|
||||||
|
'w-[3px] rounded-full ' +
|
||||||
|
(played ? 'bg-accent' : 'bg-fg-muted/40')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-[10px] tabular-nums text-fg-muted">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<MicIcon className="h-3 w-3" />
|
||||||
|
<span>{formatSec(playing ? position : duration)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{blobUrl && (
|
||||||
|
<audio
|
||||||
|
ref={audioRef}
|
||||||
|
src={blobUrl}
|
||||||
|
preload="metadata"
|
||||||
|
onLoadedMetadata={(e) => {
|
||||||
|
// Some webm/opus blobs report Infinity until first seek (Chrome
|
||||||
|
// bug). Force a seek to flush real duration.
|
||||||
|
const el = e.currentTarget;
|
||||||
|
if (!Number.isFinite(el.duration)) {
|
||||||
|
el.currentTime = 1e9;
|
||||||
|
setTimeout(() => {
|
||||||
|
el.currentTime = 0;
|
||||||
|
}, 0);
|
||||||
|
} else if (duration === 0) {
|
||||||
|
setDuration(el.duration);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDurationChange={(e) => {
|
||||||
|
const d = e.currentTarget.duration;
|
||||||
|
if (Number.isFinite(d) && d > 0) setDuration(d);
|
||||||
|
}}
|
||||||
|
onTimeUpdate={(e) => setPosition(e.currentTarget.currentTime)}
|
||||||
|
onPlay={() => setPlaying(true)}
|
||||||
|
onPause={() => setPlaying(false)}
|
||||||
|
onEnded={() => {
|
||||||
|
setPlaying(false);
|
||||||
|
setPosition(0);
|
||||||
|
}}
|
||||||
|
className="hidden"
|
||||||
|
>
|
||||||
|
<track kind="captions" />
|
||||||
|
</audio>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlayGlyph() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M5 3.5l8 4.5-8 4.5z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PauseGlyph() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
|
||||||
|
<rect x="4" y="3" width="3" height="10" rx="1" />
|
||||||
|
<rect x="9" y="3" width="3" height="10" rx="1" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSec(sec: number): string {
|
||||||
|
if (!Number.isFinite(sec) || sec < 0) sec = 0;
|
||||||
|
const m = Math.floor(sec / 60);
|
||||||
|
const s = Math.floor(sec % 60);
|
||||||
|
return m + ':' + s.toString().padStart(2, '0');
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
|
||||||
|
import { supabase } from '../lib/supabase';
|
||||||
|
import { AlertIcon, SpinnerIcon } from './icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
handle: AttachmentHandle;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Catch-all card for attachments without a richer renderer (zip, docx,
|
||||||
|
// txt, etc). Decrypt is deferred to first download click — these can be
|
||||||
|
// large and there's no inline preview to justify auto-fetching them.
|
||||||
|
export function AttachmentGeneric({ handle }: Props) {
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const download = async () => {
|
||||||
|
if (busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
let blob = await getCachedAttachment(handle.id);
|
||||||
|
if (!blob) {
|
||||||
|
blob = await downloadAndDecryptAttachment({ client: supabase, handle });
|
||||||
|
void putCachedAttachment(handle.id, blob);
|
||||||
|
}
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filenameFor(handle);
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
// Defer revoke so Safari has a chance to start the download.
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : 'download failed');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2 inline-flex w-[280px] items-center gap-2.5 rounded-lg border border-line bg-surface-2 p-2.5">
|
||||||
|
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-accent/10 text-accent">
|
||||||
|
<FileGlyph />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-xs font-semibold text-fg">
|
||||||
|
{prettyMime(handle.mimeType)}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-[10px] text-fg-muted">
|
||||||
|
{formatSize(handle.sizeBytes)}
|
||||||
|
</p>
|
||||||
|
{error && (
|
||||||
|
<p className="mt-0.5 inline-flex items-center gap-1 text-[10px] text-rose-500">
|
||||||
|
<AlertIcon className="h-3 w-3" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void download()}
|
||||||
|
disabled={busy}
|
||||||
|
aria-label="Download"
|
||||||
|
title="Download"
|
||||||
|
className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-md border border-line bg-surface-3 text-fg transition hover:brightness-95 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{busy ? <SpinnerIcon className="h-4 w-4" /> : <DownloadGlyph />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FileGlyph() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M3 2a1 1 0 011-1h6l3 3v10a1 1 0 01-1 1H4a1 1 0 01-1-1V2zm7 0v3h3l-3-3z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DownloadGlyph() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M8 2v8M4 7l4 4 4-4M3 13h10" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function filenameFor(handle: AttachmentHandle): string {
|
||||||
|
const ext = extFor(handle.mimeType);
|
||||||
|
return 'attachment-' + handle.id.slice(0, 8) + (ext ? '.' + ext : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function extFor(mime: string): string | null {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
'application/zip': 'zip',
|
||||||
|
'application/x-zip-compressed': 'zip',
|
||||||
|
'application/x-7z-compressed': '7z',
|
||||||
|
'application/x-tar': 'tar',
|
||||||
|
'application/gzip': 'gz',
|
||||||
|
'application/json': 'json',
|
||||||
|
'application/xml': 'xml',
|
||||||
|
'text/plain': 'txt',
|
||||||
|
'text/markdown': 'md',
|
||||||
|
'text/csv': 'csv',
|
||||||
|
'application/msword': 'doc',
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
|
||||||
|
'application/vnd.ms-excel': 'xls',
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
|
||||||
|
'application/vnd.ms-powerpoint': 'ppt',
|
||||||
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx',
|
||||||
|
};
|
||||||
|
return map[mime] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function prettyMime(mime: string): string {
|
||||||
|
const ext = extFor(mime);
|
||||||
|
if (ext) return ext.toUpperCase() + '-Datei';
|
||||||
|
if (mime.startsWith('text/')) return 'Textdatei';
|
||||||
|
return mime || 'Datei';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return bytes + ' B';
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + ' KB';
|
||||||
|
return (bytes / 1024 / 1024).toFixed(1) + ' MB';
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
|
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
import { AlertIcon, SpinnerIcon, XIcon } from './icons';
|
import { AlertIcon, SpinnerIcon, XIcon } from './icons';
|
||||||
|
|
||||||
@@ -8,35 +9,95 @@ interface Props {
|
|||||||
handle: AttachmentHandle;
|
handle: AttachmentHandle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Max inline-preview dimension. Full-resolution stays available for the
|
||||||
|
// lightbox. Animated formats (gif/webp/apng) are passed through untouched
|
||||||
|
// so animation isn't lost; everything else is downscaled to this box.
|
||||||
|
const THUMB_MAX_DIM = 640;
|
||||||
|
const ANIMATED_MIME = /^image\/(gif|apng|webp)/;
|
||||||
|
|
||||||
|
async function makeThumbnail(blob: Blob): Promise<Blob | null> {
|
||||||
|
if (ANIMATED_MIME.test(blob.type)) return null;
|
||||||
|
if (typeof createImageBitmap !== 'function') return null;
|
||||||
|
if (typeof OffscreenCanvas !== 'function') return null;
|
||||||
|
try {
|
||||||
|
const bitmap = await createImageBitmap(blob);
|
||||||
|
const largest = Math.max(bitmap.width, bitmap.height);
|
||||||
|
if (largest <= THUMB_MAX_DIM) {
|
||||||
|
bitmap.close();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const scale = THUMB_MAX_DIM / largest;
|
||||||
|
const w = Math.max(1, Math.round(bitmap.width * scale));
|
||||||
|
const h = Math.max(1, Math.round(bitmap.height * scale));
|
||||||
|
const canvas = new OffscreenCanvas(w, h);
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) {
|
||||||
|
bitmap.close();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
ctx.drawImage(bitmap, 0, 0, w, h);
|
||||||
|
bitmap.close();
|
||||||
|
return await canvas.convertToBlob({ type: 'image/webp', quality: 0.8 });
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function AttachmentImage({ handle }: Props) {
|
export function AttachmentImage({ handle }: Props) {
|
||||||
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
const [fullUrl, setFullUrl] = useState<string | null>(null);
|
||||||
|
const [thumbUrl, setThumbUrl] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let url: string | null = null;
|
const created: string[] = [];
|
||||||
setError(null);
|
setError(null);
|
||||||
setBlobUrl(null);
|
setFullUrl(null);
|
||||||
|
setThumbUrl(null);
|
||||||
|
|
||||||
downloadAndDecryptAttachment({ client: supabase, handle })
|
const take = (blob: Blob): string => {
|
||||||
.then((blob) => {
|
const u = URL.createObjectURL(blob);
|
||||||
if (cancelled) return;
|
created.push(u);
|
||||||
url = URL.createObjectURL(blob);
|
return u;
|
||||||
setBlobUrl(url);
|
};
|
||||||
})
|
|
||||||
.catch((err: unknown) => {
|
// OPFS cache → decrypt → generate thumbnail for inline display.
|
||||||
|
// Lightbox swaps to the full blob when opened.
|
||||||
|
void (async () => {
|
||||||
|
const cached = await getCachedAttachment(handle.id);
|
||||||
|
let blob: Blob;
|
||||||
|
if (cached) {
|
||||||
|
blob = cached;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
blob = await downloadAndDecryptAttachment({ client: supabase, handle });
|
||||||
|
void putCachedAttachment(handle.id, blob);
|
||||||
|
} catch (err: unknown) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setError(err instanceof Error ? err.message : 'download failed');
|
setError(err instanceof Error ? err.message : 'download failed');
|
||||||
}
|
}
|
||||||
});
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cancelled) return;
|
||||||
|
const full = take(blob);
|
||||||
|
setFullUrl(full);
|
||||||
|
const thumb = await makeThumbnail(blob);
|
||||||
|
if (cancelled) return;
|
||||||
|
if (thumb) {
|
||||||
|
setThumbUrl(take(thumb));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
if (url) URL.revokeObjectURL(url);
|
for (const u of created) URL.revokeObjectURL(u);
|
||||||
};
|
};
|
||||||
}, [handle.id, handle.storagePath, handle.keyB64, handle.nonceB64]);
|
}, [handle.id, handle.storagePath, handle.keyB64, handle.nonceB64]);
|
||||||
|
|
||||||
|
const blobUrl = thumbUrl ?? fullUrl;
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="mt-2 inline-flex items-center gap-2 rounded-lg border border-rose-500/20 bg-rose-500/10 px-3 py-2 text-xs text-rose-200">
|
<div className="mt-2 inline-flex items-center gap-2 rounded-lg border border-rose-500/20 bg-rose-500/10 px-3 py-2 text-xs text-rose-200">
|
||||||
@@ -66,10 +127,11 @@ export function AttachmentImage({ handle }: Props) {
|
|||||||
src={blobUrl}
|
src={blobUrl}
|
||||||
alt="attachment"
|
alt="attachment"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
className="block h-auto max-h-80 w-auto max-w-full object-contain"
|
className="block h-auto max-h-80 w-auto max-w-full object-contain"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
{lightboxOpen && <Lightbox url={blobUrl} onClose={() => setLightboxOpen(false)} />}
|
{lightboxOpen && fullUrl && <Lightbox url={fullUrl} onClose={() => setLightboxOpen(false)} />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
|
||||||
|
import { supabase } from '../lib/supabase';
|
||||||
|
import { AlertIcon, SpinnerIcon } from './icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
handle: AttachmentHandle;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PDF preview rendered via the browser's built-in PDF viewer (Chromium /
|
||||||
|
// Safari both ship one). Embedding via <object> with a fallback link keeps
|
||||||
|
// the implementation tiny — no pdf.js dependency.
|
||||||
|
export function AttachmentPdf({ handle }: Props) {
|
||||||
|
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
let url: string | null = null;
|
||||||
|
setError(null);
|
||||||
|
setBlobUrl(null);
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
const cached = await getCachedAttachment(handle.id);
|
||||||
|
if (cached) {
|
||||||
|
if (cancelled) return;
|
||||||
|
const typed = new Blob([cached], { type: 'application/pdf' });
|
||||||
|
url = URL.createObjectURL(typed);
|
||||||
|
setBlobUrl(url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const blob = await downloadAndDecryptAttachment({ client: supabase, handle });
|
||||||
|
if (cancelled) return;
|
||||||
|
// Force the application/pdf type so the browser plugin engages.
|
||||||
|
const typed = new Blob([blob], { type: 'application/pdf' });
|
||||||
|
url = URL.createObjectURL(typed);
|
||||||
|
setBlobUrl(url);
|
||||||
|
void putCachedAttachment(handle.id, blob);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(err instanceof Error ? err.message : 'download failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (url) URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
}, [handle.id, handle.storagePath, handle.keyB64, handle.nonceB64]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="mt-2 inline-flex items-center gap-2 rounded-lg border border-rose-500/20 bg-rose-500/10 px-3 py-2 text-xs text-rose-200">
|
||||||
|
<AlertIcon className="h-4 w-4" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!blobUrl) {
|
||||||
|
return (
|
||||||
|
<div className="mt-2 flex h-24 w-[320px] items-center justify-center rounded-lg border border-line bg-surface-2 text-fg-muted">
|
||||||
|
<SpinnerIcon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2 w-full max-w-[420px] overflow-hidden rounded-lg border border-line bg-surface-2">
|
||||||
|
<div className="flex items-center justify-between gap-2 border-b border-line bg-surface-3 px-3 py-2 text-xs">
|
||||||
|
<span className="flex items-center gap-2 truncate text-fg">
|
||||||
|
<PdfGlyph />
|
||||||
|
<span className="truncate">PDF · {formatSize(handle.sizeBytes)}</span>
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
className="cursor-pointer rounded-md border border-line bg-surface-2 px-2 py-0.5 text-[11px] text-fg hover:bg-surface-3"
|
||||||
|
>
|
||||||
|
{expanded ? 'Einklappen' : 'Vorschau'}
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href={blobUrl}
|
||||||
|
download={'attachment-' + handle.id.slice(0, 8) + '.pdf'}
|
||||||
|
className="cursor-pointer rounded-md border border-line bg-surface-2 px-2 py-0.5 text-[11px] text-fg no-underline hover:bg-surface-3"
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{expanded && (
|
||||||
|
<object data={blobUrl} type="application/pdf" className="block h-[420px] w-full">
|
||||||
|
<p className="p-4 text-xs text-fg-muted">
|
||||||
|
Vorschau nicht verfügbar — bitte herunterladen.
|
||||||
|
</p>
|
||||||
|
</object>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PdfGlyph() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M3 2a1 1 0 011-1h6l3 3v10a1 1 0 01-1 1H4a1 1 0 01-1-1V2zm7 0v3h3l-3-3zM5 9h6v1H5V9zm0 2h6v1H5v-1zm0-4h2v1H5V7z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return bytes + ' B';
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + ' KB';
|
||||||
|
return (bytes / 1024 / 1024).toFixed(1) + ' MB';
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
|
||||||
|
import { supabase } from '../lib/supabase';
|
||||||
|
import { AlertIcon, SpinnerIcon } from './icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
handle: AttachmentHandle;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt the blob, render a native <video controls>. Loads on demand —
|
||||||
|
// metadata-only preload so we don't burn bandwidth until the user hits play.
|
||||||
|
export function AttachmentVideo({ handle }: Props) {
|
||||||
|
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
let url: string | null = null;
|
||||||
|
setError(null);
|
||||||
|
setBlobUrl(null);
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
const cached = await getCachedAttachment(handle.id);
|
||||||
|
if (cached) {
|
||||||
|
if (cancelled) return;
|
||||||
|
url = URL.createObjectURL(cached);
|
||||||
|
setBlobUrl(url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const blob = await downloadAndDecryptAttachment({ client: supabase, handle });
|
||||||
|
if (cancelled) return;
|
||||||
|
url = URL.createObjectURL(blob);
|
||||||
|
setBlobUrl(url);
|
||||||
|
void putCachedAttachment(handle.id, blob);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(err instanceof Error ? err.message : 'download failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (url) URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
}, [handle.id, handle.storagePath, handle.keyB64, handle.nonceB64]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="mt-2 inline-flex items-center gap-2 rounded-lg border border-rose-500/20 bg-rose-500/10 px-3 py-2 text-xs text-rose-200">
|
||||||
|
<AlertIcon className="h-4 w-4" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!blobUrl) {
|
||||||
|
return (
|
||||||
|
<div className="mt-2 flex h-32 w-[320px] items-center justify-center rounded-lg border border-line bg-surface-2 text-fg-muted">
|
||||||
|
<SpinnerIcon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<video
|
||||||
|
controls
|
||||||
|
preload="metadata"
|
||||||
|
src={blobUrl}
|
||||||
|
className="mt-2 block max-h-80 w-full max-w-[420px] rounded-lg border border-line bg-black"
|
||||||
|
>
|
||||||
|
<track kind="captions" />
|
||||||
|
</video>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// Reusable avatar that prefers an uploaded image and falls back to a coloured
|
||||||
|
// letter circle. Use this everywhere the app needs to render a profile.
|
||||||
|
|
||||||
|
import { useCachedAvatarUrl } from '../lib/avatarCache';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
url?: string | null | undefined;
|
||||||
|
displayName?: string | null | undefined;
|
||||||
|
className?: string;
|
||||||
|
// Background gradient classes used when no image is set. Defaults to a
|
||||||
|
// brand-tinted fallback; callers can override (e.g. to colour-by-id).
|
||||||
|
fallbackClass?: string;
|
||||||
|
alt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Avatar({
|
||||||
|
url,
|
||||||
|
displayName,
|
||||||
|
className = 'h-10 w-10',
|
||||||
|
fallbackClass = 'bg-accent/20 text-accent',
|
||||||
|
alt,
|
||||||
|
}: Props) {
|
||||||
|
const effectiveUrl = useCachedAvatarUrl(url);
|
||||||
|
if (effectiveUrl) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={effectiveUrl}
|
||||||
|
alt={alt ?? displayName ?? ''}
|
||||||
|
className={'shrink-0 rounded-full object-cover ' + className}
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const letter = (displayName ?? '?').trim().charAt(0).toUpperCase() || '?';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden={alt ? undefined : 'true'}
|
||||||
|
className={
|
||||||
|
'flex shrink-0 items-center justify-center rounded-full font-semibold ' +
|
||||||
|
fallbackClass +
|
||||||
|
' ' +
|
||||||
|
className
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{letter}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { type BackupBundle, exportDeviceBackupWithRecovery } from '../lib/deviceBackup';
|
||||||
|
import { AlertIcon, CopyIcon, LockIcon, ShieldIcon, SpinnerIcon, XIcon } from './icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
userId: string;
|
||||||
|
deviceId: string;
|
||||||
|
privateKey: Uint8Array;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exports the device's private key + identity into a passphrase-protected
|
||||||
|
// portable string. The user can store this string anywhere (password manager,
|
||||||
|
// printed paper, encrypted file on a USB stick). Without it, losing local
|
||||||
|
// storage on this install means losing all past conversation keys.
|
||||||
|
export function BackupExportDialog({ open, userId, deviceId, privateKey, onClose }: Props) {
|
||||||
|
const { t } = useTranslation(['app']);
|
||||||
|
const [passphrase, setPassphrase] = useState('');
|
||||||
|
const [confirm, setConfirm] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [bundle, setBundle] = useState<BackupBundle | null>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [copiedRecovery, setCopiedRecovery] = useState(false);
|
||||||
|
|
||||||
|
const canGenerate = useMemo(() => {
|
||||||
|
return passphrase.length >= 8 && passphrase === confirm && !busy;
|
||||||
|
}, [passphrase, confirm, busy]);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setPassphrase('');
|
||||||
|
setConfirm('');
|
||||||
|
setBundle(null);
|
||||||
|
setError(null);
|
||||||
|
setCopied(false);
|
||||||
|
setCopiedRecovery(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
reset();
|
||||||
|
onClose();
|
||||||
|
}, [reset, onClose]);
|
||||||
|
|
||||||
|
const handleGenerate = useCallback(async () => {
|
||||||
|
if (!canGenerate) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const b = await exportDeviceBackupWithRecovery({
|
||||||
|
userId,
|
||||||
|
deviceId,
|
||||||
|
privateKey,
|
||||||
|
passphrase,
|
||||||
|
});
|
||||||
|
setBundle(b);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}, [canGenerate, userId, deviceId, privateKey, passphrase]);
|
||||||
|
|
||||||
|
const copyText = async (text: string, marker: 'main' | 'recovery'): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
if (marker === 'main') {
|
||||||
|
setCopied(true);
|
||||||
|
window.setTimeout(() => setCopied(false), 1500);
|
||||||
|
} else {
|
||||||
|
setCopiedRecovery(true);
|
||||||
|
window.setTimeout(() => setCopiedRecovery(false), 1500);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* fall back — user can select manually */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = useCallback(() => {
|
||||||
|
if (!bundle) return;
|
||||||
|
const text =
|
||||||
|
'=== Passphrase backup ===\n' +
|
||||||
|
bundle.passphraseBackup +
|
||||||
|
'\n\n=== Recovery code ===\n' +
|
||||||
|
bundle.recoveryCode +
|
||||||
|
'\n\n=== Recovery backup (use with the recovery code) ===\n' +
|
||||||
|
bundle.recoveryBackup +
|
||||||
|
'\n';
|
||||||
|
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `chatapp-device-backup-${deviceId.slice(0, 8)}.txt`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}, [bundle, deviceId]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t('app:backup.export_title', { defaultValue: 'Gerätesicherung erstellen' })}
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||||
|
onClick={handleClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="flex max-h-[90vh] w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-line bg-surface-3 shadow-xl"
|
||||||
|
>
|
||||||
|
<header className="flex items-center justify-between border-b border-line px-5 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ShieldIcon className="h-4 w-4 text-accent" />
|
||||||
|
<h3 className="font-display text-sm font-semibold text-fg">
|
||||||
|
{t('app:backup.export_title', { defaultValue: 'Gerätesicherung erstellen' })}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
aria-label="Close"
|
||||||
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-2 hover:text-fg"
|
||||||
|
>
|
||||||
|
<XIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-5">
|
||||||
|
{!bundle ? (
|
||||||
|
<>
|
||||||
|
<p className="text-sm text-fg-muted">
|
||||||
|
{t('app:backup.export_explainer', {
|
||||||
|
defaultValue:
|
||||||
|
'Verschlüssele den Geräteschlüssel mit einer Passphrase. Ohne Passphrase UND Backup-String ist keine Wiederherstellung möglich.',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="block text-xs font-medium uppercase tracking-wide text-fg-muted">
|
||||||
|
{t('app:backup.passphrase', { defaultValue: 'Passphrase' })}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoFocus
|
||||||
|
minLength={8}
|
||||||
|
value={passphrase}
|
||||||
|
onChange={(e) => setPassphrase(e.target.value)}
|
||||||
|
placeholder="min. 8 Zeichen"
|
||||||
|
className="w-full rounded-lg border border-line bg-surface-2 px-3 py-2.5 text-sm text-fg placeholder-fg-muted transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="block text-xs font-medium uppercase tracking-wide text-fg-muted">
|
||||||
|
{t('app:backup.passphrase_confirm', { defaultValue: 'Passphrase wiederholen' })}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirm}
|
||||||
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
|
className="w-full rounded-lg border border-line bg-surface-2 px-3 py-2.5 text-sm text-fg placeholder-fg-muted transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{confirm.length > 0 && confirm !== passphrase && (
|
||||||
|
<p className="text-xs text-rose-500 dark:text-rose-300">
|
||||||
|
{t('app:backup.passphrase_mismatch', { defaultValue: 'Passphrasen stimmen nicht überein.' })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-700 dark:text-amber-200">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<AlertIcon className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-300" />
|
||||||
|
<span>
|
||||||
|
{t('app:backup.export_warning', {
|
||||||
|
defaultValue:
|
||||||
|
'Anthropic: Backup + Passphrase sicher aufbewahren. Passphrase kann nicht wiederhergestellt werden.',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="mt-3 text-sm text-rose-600 dark:text-rose-200">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/10 p-3 text-xs text-emerald-700 dark:text-emerald-200">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<LockIcon className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
<span>
|
||||||
|
{t('app:backup.export_success', {
|
||||||
|
defaultValue:
|
||||||
|
'Backup erstellt. Speichere diesen String + Passphrase in einem Passwortmanager oder drucke ihn aus.',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="mt-3 block text-xs font-medium uppercase tracking-wide text-fg-muted">
|
||||||
|
Backup-String
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
readOnly
|
||||||
|
value={bundle.passphraseBackup}
|
||||||
|
rows={6}
|
||||||
|
onFocus={(e) => e.currentTarget.select()}
|
||||||
|
className="mt-1 w-full resize-none rounded-lg border border-line bg-surface-2 p-3 font-mono text-[11px] leading-relaxed text-fg"
|
||||||
|
/>
|
||||||
|
<div className="mt-2 flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void copyText(bundle.passphraseBackup, 'main')}
|
||||||
|
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-medium text-fg transition hover:bg-surface-3"
|
||||||
|
>
|
||||||
|
<CopyIcon className="h-4 w-4" />
|
||||||
|
<span>{copied ? 'Kopiert!' : 'Kopieren'}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDownload}
|
||||||
|
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-medium text-fg transition hover:bg-surface-3"
|
||||||
|
>
|
||||||
|
Als Datei speichern (alles)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<ShieldIcon className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-300" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-xs font-semibold text-amber-700 dark:text-amber-200">
|
||||||
|
Recovery-Code (Passphrase vergessen?)
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-[11px] text-amber-700/80 dark:text-amber-200/80">
|
||||||
|
Code separat aufbewahren. Mit dem Recovery-Backup unten lässt sich der Schlüssel
|
||||||
|
ohne Passphrase wiederherstellen.
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 select-all rounded bg-surface-3 px-2 py-1.5 font-mono text-sm tracking-widest text-fg">
|
||||||
|
{bundle.recoveryCode}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="mt-3 block text-xs font-medium uppercase tracking-wide text-fg-muted">
|
||||||
|
Recovery-Backup-String
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
readOnly
|
||||||
|
value={bundle.recoveryBackup}
|
||||||
|
rows={6}
|
||||||
|
onFocus={(e) => e.currentTarget.select()}
|
||||||
|
className="mt-1 w-full resize-none rounded-lg border border-line bg-surface-2 p-3 font-mono text-[11px] leading-relaxed text-fg"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void copyText(bundle.recoveryBackup, 'recovery')}
|
||||||
|
className="mt-2 inline-flex w-full cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-medium text-fg transition hover:bg-surface-3"
|
||||||
|
>
|
||||||
|
<CopyIcon className="h-4 w-4" />
|
||||||
|
<span>{copiedRecovery ? 'Kopiert!' : 'Recovery-Backup kopieren'}</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="flex items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3">
|
||||||
|
{!bundle ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg transition hover:bg-surface-2"
|
||||||
|
>
|
||||||
|
{t('app:friends.action_cancel', { defaultValue: 'Abbrechen' })}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleGenerate()}
|
||||||
|
disabled={!canGenerate}
|
||||||
|
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{busy && <SpinnerIcon className="h-4 w-4" />}
|
||||||
|
<span>{t('app:backup.generate', { defaultValue: 'Backup erstellen' })}</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
className="cursor-pointer rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110"
|
||||||
|
>
|
||||||
|
{t('app:backup.done', { defaultValue: 'Fertig' })}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { devLocalSecretStore } from '../lib/secretStore';
|
||||||
|
import { BackupExportDialog } from './BackupExportDialog';
|
||||||
|
import { ShieldIcon, XIcon } from './icons';
|
||||||
|
|
||||||
|
const DISMISS_KEY = 'chatapp.backup.prompt.dismissed';
|
||||||
|
const SESSION_KEY = 'chatapp.backup.prompt';
|
||||||
|
|
||||||
|
// Post-registration nudge: right after a fresh device provision we set
|
||||||
|
// `chatapp.backup.prompt` in sessionStorage. This component reads it and
|
||||||
|
// shows a floating "mach jetzt ein Backup" banner until the user either
|
||||||
|
// creates one or explicitly dismisses (persisted in localStorage so we stop
|
||||||
|
// nagging across reloads).
|
||||||
|
export function BackupPromptBanner() {
|
||||||
|
const { t } = useTranslation(['app']);
|
||||||
|
const { profile, device } = useAuth();
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [privateKey, setPrivateKey] = useState<Uint8Array | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
if (window.localStorage.getItem(DISMISS_KEY) === '1') return;
|
||||||
|
if (window.sessionStorage.getItem(SESSION_KEY) !== '1') return;
|
||||||
|
setVisible(true);
|
||||||
|
} catch {
|
||||||
|
/* storage unavailable */
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dismiss = useCallback((persist: boolean) => {
|
||||||
|
setVisible(false);
|
||||||
|
try {
|
||||||
|
window.sessionStorage.removeItem(SESSION_KEY);
|
||||||
|
if (persist) window.localStorage.setItem(DISMISS_KEY, '1');
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openDialog = useCallback(async () => {
|
||||||
|
if (!profile?.userId || !device?.id) return;
|
||||||
|
const priv = await loadDevicePrivateKey(devLocalSecretStore, profile.userId, device.id);
|
||||||
|
if (!priv) return;
|
||||||
|
setPrivateKey(priv);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}, [profile, device]);
|
||||||
|
|
||||||
|
const closeDialog = useCallback(() => {
|
||||||
|
setDialogOpen(false);
|
||||||
|
if (privateKey) {
|
||||||
|
for (let i = 0; i < privateKey.length; i++) privateKey[i] = 0;
|
||||||
|
}
|
||||||
|
setPrivateKey(null);
|
||||||
|
// After the user interacts with the dialog, drop the banner regardless
|
||||||
|
// of whether they actually completed the backup — they're aware now.
|
||||||
|
dismiss(true);
|
||||||
|
}, [privateKey, dismiss]);
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
className="fixed bottom-6 left-1/2 z-40 flex w-[min(92vw,520px)] -translate-x-1/2 items-start gap-3 rounded-2xl border border-amber-500/40 bg-amber-500/10 p-4 text-sm text-amber-800 shadow-xl backdrop-blur-md dark:text-amber-100"
|
||||||
|
>
|
||||||
|
<ShieldIcon className="mt-0.5 h-5 w-5 shrink-0 text-amber-600 dark:text-amber-300" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-semibold">
|
||||||
|
{t('app:backup.prompt_title', { defaultValue: 'Erstelle jetzt ein Geräte-Backup' })}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-xs text-amber-700/90 dark:text-amber-200/90">
|
||||||
|
{t('app:backup.prompt_body', {
|
||||||
|
defaultValue:
|
||||||
|
'Ohne Backup verlierst du Zugriff auf alte Nachrichten, wenn Browser oder Gerät ihren Speicher verlieren. Dauert 10 Sekunden.',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void openDialog()}
|
||||||
|
className="cursor-pointer rounded-md bg-amber-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-amber-500"
|
||||||
|
>
|
||||||
|
{t('app:backup.prompt_create', { defaultValue: 'Jetzt erstellen' })}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => dismiss(true)}
|
||||||
|
className="cursor-pointer rounded-md border border-amber-500/40 bg-transparent px-3 py-1.5 text-xs font-semibold text-amber-700 transition hover:bg-amber-500/15 dark:text-amber-200"
|
||||||
|
>
|
||||||
|
{t('app:backup.prompt_never', { defaultValue: 'Nicht mehr fragen' })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => dismiss(false)}
|
||||||
|
aria-label={t('app:backup.prompt_dismiss', { defaultValue: 'Später' })}
|
||||||
|
className="cursor-pointer text-amber-600/70 transition hover:text-amber-600 dark:text-amber-200/70 dark:hover:text-amber-200"
|
||||||
|
>
|
||||||
|
<XIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dialogOpen && profile?.userId && device?.id && privateKey && (
|
||||||
|
<BackupExportDialog
|
||||||
|
open={dialogOpen}
|
||||||
|
userId={profile.userId}
|
||||||
|
deviceId={device.id}
|
||||||
|
privateKey={privateKey}
|
||||||
|
onClose={closeDialog}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { DeviceRestore } from './DeviceRestore';
|
||||||
|
import { AlertIcon, ShieldIcon, XIcon } from './icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
userId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal wrapper around DeviceRestore for the already-signed-in case. A
|
||||||
|
// successful restore swaps the local device-identity for the one embedded
|
||||||
|
// in the backup string — the app then hard-reloads so every hook
|
||||||
|
// re-initialises against the restored keys (simpler than invalidating
|
||||||
|
// supabase-realtime subscriptions, stronghold caches, livekit rooms, etc.
|
||||||
|
// individually).
|
||||||
|
export function BackupRestoreDialog({ open, userId, onClose }: Props) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
const prev = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onKey);
|
||||||
|
document.body.style.overflow = prev;
|
||||||
|
};
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Backup wiederherstellen"
|
||||||
|
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/60 p-6 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="flex w-full max-w-md flex-col gap-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between rounded-lg border border-white/10 bg-ink-900/70 p-3 backdrop-blur-xl">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ShieldIcon className="h-4 w-4 text-brand-300" />
|
||||||
|
<h3 className="text-sm font-semibold text-white">
|
||||||
|
Gerät aus Backup wiederherstellen
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Schließen"
|
||||||
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-neutral-400 transition hover:bg-white/10 hover:text-white"
|
||||||
|
>
|
||||||
|
<XIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-100">
|
||||||
|
<AlertIcon className="mt-0.5 h-4 w-4 shrink-0 text-amber-300" />
|
||||||
|
<p className="min-w-0 flex-1">
|
||||||
|
Restore ersetzt das aktuelle Gerät durch das aus dem Backup.
|
||||||
|
Die App lädt danach neu. Nachrichten, die auf diesem Gerät seit
|
||||||
|
dem Backup eingegangen sind, sind erst wieder lesbar, nachdem
|
||||||
|
Peer-Geräte den Conversation-Key erneut für die wiederhergestellte
|
||||||
|
Device-ID wrappen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DeviceRestore
|
||||||
|
userId={userId}
|
||||||
|
onRestored={() => {
|
||||||
|
// Hard reload — cleanest way to reset every hook, supabase
|
||||||
|
// realtime channel, stronghold handle, and cached state.
|
||||||
|
window.location.reload();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import {
|
||||||
|
HeadphonesIcon,
|
||||||
|
HeadphonesOffIcon,
|
||||||
|
MicIcon,
|
||||||
|
MicOffIcon,
|
||||||
|
MonitorShareIcon,
|
||||||
|
MonitorStopIcon,
|
||||||
|
MusicIcon,
|
||||||
|
PhoneOffIcon,
|
||||||
|
UsersIcon,
|
||||||
|
VideoIcon,
|
||||||
|
} from './icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
muted: boolean;
|
||||||
|
sharing: boolean;
|
||||||
|
video: boolean;
|
||||||
|
deafened: boolean;
|
||||||
|
onToggleMute: () => void;
|
||||||
|
onToggleShare: () => void;
|
||||||
|
/** Right-click on the share button opens the quality picker dialog while
|
||||||
|
* left-click just starts with last-used settings. Optional so pages that
|
||||||
|
* don't need the advanced path (mobile, etc.) can skip it. */
|
||||||
|
onShareContextMenu?: (e: React.MouseEvent) => void;
|
||||||
|
onToggleVideo?: () => void;
|
||||||
|
onToggleDeafen: () => void;
|
||||||
|
onHangup: () => void;
|
||||||
|
onOpenParticipants?: () => void;
|
||||||
|
/** Toggle the in-call soundboard popover. Active = panel currently open. */
|
||||||
|
onToggleSoundboard?: () => void;
|
||||||
|
soundboardOpen?: boolean;
|
||||||
|
participantsOpen?: boolean;
|
||||||
|
/** Compact variant used inside the docked call (36px buttons). */
|
||||||
|
compact?: boolean;
|
||||||
|
/** Glass variant used when controls float on fullscreen cinema mode. */
|
||||||
|
glass?: boolean;
|
||||||
|
/** Disable transient interactions while the call isn't fully connected. */
|
||||||
|
disabledMedia?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CallControls({
|
||||||
|
muted,
|
||||||
|
sharing,
|
||||||
|
video,
|
||||||
|
deafened,
|
||||||
|
onToggleMute,
|
||||||
|
onToggleShare,
|
||||||
|
onShareContextMenu,
|
||||||
|
onToggleVideo,
|
||||||
|
onToggleDeafen,
|
||||||
|
onHangup,
|
||||||
|
onOpenParticipants,
|
||||||
|
onToggleSoundboard,
|
||||||
|
soundboardOpen = false,
|
||||||
|
participantsOpen = false,
|
||||||
|
compact = false,
|
||||||
|
glass = false,
|
||||||
|
disabledMedia = false,
|
||||||
|
}: Props) {
|
||||||
|
const { t } = useTranslation(['app']);
|
||||||
|
const wrapBase = glass
|
||||||
|
? 'glass-pill flex items-center justify-center gap-2 rounded-[18px] p-2.5'
|
||||||
|
: 'flex items-center justify-center gap-2 border-t border-line bg-surface-3 p-3';
|
||||||
|
const btnSize = compact ? 'h-9 w-9 rounded-[10px]' : 'h-12 w-12 rounded-[14px]';
|
||||||
|
const hangupSize = compact ? 'h-9 w-[54px] rounded-[10px]' : 'h-12 w-[72px] rounded-[14px]';
|
||||||
|
return (
|
||||||
|
<div className={wrapBase}>
|
||||||
|
<CallButton
|
||||||
|
label={muted ? t('app:call.unmute') : t('app:call.mute')}
|
||||||
|
active={muted}
|
||||||
|
activeTone="danger"
|
||||||
|
onClick={onToggleMute}
|
||||||
|
disabled={disabledMedia}
|
||||||
|
glass={glass}
|
||||||
|
className={btnSize}
|
||||||
|
>
|
||||||
|
{muted ? <MicOffIcon className="h-5 w-5" /> : <MicIcon className="h-5 w-5" />}
|
||||||
|
</CallButton>
|
||||||
|
<CallButton
|
||||||
|
label={
|
||||||
|
deafened
|
||||||
|
? t('app:call.undeafen', { defaultValue: 'Ton wieder aktiv' })
|
||||||
|
: t('app:call.deafen', { defaultValue: 'Alle stumm' })
|
||||||
|
}
|
||||||
|
active={deafened}
|
||||||
|
activeTone="danger"
|
||||||
|
onClick={onToggleDeafen}
|
||||||
|
glass={glass}
|
||||||
|
className={btnSize}
|
||||||
|
>
|
||||||
|
{deafened ? (
|
||||||
|
<HeadphonesOffIcon className="h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
<HeadphonesIcon className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
</CallButton>
|
||||||
|
{onToggleVideo && (
|
||||||
|
<CallButton
|
||||||
|
label={t('app:call.start_video', { defaultValue: 'Video' })}
|
||||||
|
active={video}
|
||||||
|
activeTone="accent"
|
||||||
|
onClick={onToggleVideo}
|
||||||
|
disabled={disabledMedia}
|
||||||
|
glass={glass}
|
||||||
|
className={btnSize}
|
||||||
|
>
|
||||||
|
<VideoIcon className="h-5 w-5" />
|
||||||
|
</CallButton>
|
||||||
|
)}
|
||||||
|
<CallButton
|
||||||
|
label={
|
||||||
|
sharing
|
||||||
|
? t('app:call.stop_share_screen', { defaultValue: 'Screen-Share stoppen' })
|
||||||
|
: t('app:call.share_screen', { defaultValue: 'Bildschirm teilen' })
|
||||||
|
}
|
||||||
|
active={sharing}
|
||||||
|
activeTone="accent"
|
||||||
|
onClick={onToggleShare}
|
||||||
|
{...(onShareContextMenu ? { onContextMenu: onShareContextMenu } : {})}
|
||||||
|
disabled={disabledMedia}
|
||||||
|
glass={glass}
|
||||||
|
className={btnSize}
|
||||||
|
>
|
||||||
|
{sharing ? (
|
||||||
|
<MonitorStopIcon className="h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
<MonitorShareIcon className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
</CallButton>
|
||||||
|
{onToggleSoundboard && (
|
||||||
|
<CallButton
|
||||||
|
label={t('app:soundboard.toggle', { defaultValue: 'Soundboard' })}
|
||||||
|
active={soundboardOpen}
|
||||||
|
activeTone="accent"
|
||||||
|
onClick={onToggleSoundboard}
|
||||||
|
glass={glass}
|
||||||
|
className={btnSize}
|
||||||
|
>
|
||||||
|
<MusicIcon className="h-5 w-5" />
|
||||||
|
</CallButton>
|
||||||
|
)}
|
||||||
|
{onOpenParticipants && (
|
||||||
|
<CallButton
|
||||||
|
label={t('app:call.participants', { defaultValue: 'Teilnehmer' })}
|
||||||
|
onClick={onOpenParticipants}
|
||||||
|
active={participantsOpen}
|
||||||
|
activeTone="accent"
|
||||||
|
dataTrigger="participants"
|
||||||
|
glass={glass}
|
||||||
|
className={btnSize}
|
||||||
|
>
|
||||||
|
<UsersIcon className="h-5 w-5" />
|
||||||
|
</CallButton>
|
||||||
|
)}
|
||||||
|
<CallButton
|
||||||
|
label={t('app:call.hangup')}
|
||||||
|
onClick={onHangup}
|
||||||
|
tone="danger"
|
||||||
|
glass={glass}
|
||||||
|
className={hangupSize}
|
||||||
|
>
|
||||||
|
<PhoneOffIcon className="h-5 w-5" />
|
||||||
|
</CallButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CallButtonProps {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
onContextMenu?: (e: React.MouseEvent) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
active?: boolean;
|
||||||
|
activeTone?: 'accent' | 'danger';
|
||||||
|
tone?: 'default' | 'danger';
|
||||||
|
glass?: boolean;
|
||||||
|
className?: string;
|
||||||
|
/** Stable trigger id so portals (popovers) can skip outside-click dismiss
|
||||||
|
* when the user is toggling their own trigger. */
|
||||||
|
dataTrigger?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CallButton({
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
onContextMenu,
|
||||||
|
disabled,
|
||||||
|
active,
|
||||||
|
activeTone = 'accent',
|
||||||
|
tone = 'default',
|
||||||
|
glass = false,
|
||||||
|
className = '',
|
||||||
|
dataTrigger,
|
||||||
|
children,
|
||||||
|
}: CallButtonProps) {
|
||||||
|
const base =
|
||||||
|
'inline-flex cursor-pointer items-center justify-center border transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:cursor-not-allowed disabled:opacity-50';
|
||||||
|
|
||||||
|
let toneClass: string;
|
||||||
|
if (tone === 'danger') {
|
||||||
|
toneClass = 'bg-rose-600 text-white border-rose-600 hover:bg-rose-500';
|
||||||
|
} else if (active && activeTone === 'danger') {
|
||||||
|
toneClass = 'bg-rose-600 text-white border-rose-600 hover:bg-rose-500';
|
||||||
|
} else if (active) {
|
||||||
|
toneClass = 'bg-accent text-accent-fg border-accent hover:brightness-110';
|
||||||
|
} else if (glass) {
|
||||||
|
toneClass =
|
||||||
|
'border-white/15 bg-white/10 text-white hover:bg-white/15';
|
||||||
|
} else {
|
||||||
|
toneClass = 'border-line bg-surface-2 text-fg hover:bg-surface';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={label}
|
||||||
|
aria-pressed={active}
|
||||||
|
title={label}
|
||||||
|
className={`${base} ${toneClass} ${className}`}
|
||||||
|
{...(dataTrigger ? { [`data-${dataTrigger}-trigger`]: 'true' } : {})}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
import { CrownIcon, HeadphonesOffIcon, LockIcon, MicOffIcon } from './icons';
|
||||||
|
|
||||||
|
export type AvatarColorKey = 'violet' | 'amber' | 'rose' | 'teal';
|
||||||
|
|
||||||
|
const AVATAR_COLORS: Record<AvatarColorKey, { bg: string; fg: string }> = {
|
||||||
|
// Tailwind classes tuned per spec: violet/amber/rose/teal swatches in both
|
||||||
|
// light and dark modes. Dark pairs invert to keep contrast readable.
|
||||||
|
violet: {
|
||||||
|
bg: 'bg-violet-200 dark:bg-violet-900/60',
|
||||||
|
fg: 'text-violet-800 dark:text-violet-200',
|
||||||
|
},
|
||||||
|
amber: {
|
||||||
|
bg: 'bg-amber-200 dark:bg-amber-900/60',
|
||||||
|
fg: 'text-amber-900 dark:text-amber-200',
|
||||||
|
},
|
||||||
|
rose: {
|
||||||
|
bg: 'bg-rose-200 dark:bg-rose-900/60',
|
||||||
|
fg: 'text-rose-900 dark:text-rose-200',
|
||||||
|
},
|
||||||
|
teal: {
|
||||||
|
bg: 'bg-teal-200 dark:bg-teal-900/60',
|
||||||
|
fg: 'text-teal-900 dark:text-teal-200',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const COLOR_KEYS: AvatarColorKey[] = ['violet', 'amber', 'rose', 'teal'];
|
||||||
|
|
||||||
|
// Stable per-user color from identity string — keeps avatars visually
|
||||||
|
// consistent across re-mounts without needing a color field on the profile.
|
||||||
|
export function colorKeyFor(id: string): AvatarColorKey {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < id.length; i++) {
|
||||||
|
hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
|
||||||
|
}
|
||||||
|
return COLOR_KEYS[hash % COLOR_KEYS.length]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParticipantTileProps {
|
||||||
|
userId: string;
|
||||||
|
displayName: string;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
me: boolean;
|
||||||
|
muted: boolean;
|
||||||
|
/** Local-only: true when THIS user has muted everyone else via the deafen
|
||||||
|
* toggle. Remote deafen state is not propagated, so only the own tile
|
||||||
|
* ever carries a truthy value. */
|
||||||
|
deafened: boolean;
|
||||||
|
speaking: boolean;
|
||||||
|
video: boolean;
|
||||||
|
e2ee: boolean;
|
||||||
|
/** MediaStreamTrack for the participant's active camera, when `video` is
|
||||||
|
* true. When null the tile falls back to the avatar placeholder. */
|
||||||
|
videoTrack?: MediaStreamTrack | null;
|
||||||
|
size?: 'default' | 'small';
|
||||||
|
focused?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
onContextMenu?: (e: React.MouseEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CallParticipantTile(props: ParticipantTileProps) {
|
||||||
|
const {
|
||||||
|
displayName,
|
||||||
|
me,
|
||||||
|
muted,
|
||||||
|
deafened,
|
||||||
|
speaking,
|
||||||
|
video,
|
||||||
|
e2ee,
|
||||||
|
size = 'default',
|
||||||
|
focused = false,
|
||||||
|
onClick,
|
||||||
|
onContextMenu,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const small = size === 'small';
|
||||||
|
// Split the speaking indicator per-mode so we don't stack a tile border
|
||||||
|
// + inset glow on top of the avatar pulse (visual double-chrome). Video
|
||||||
|
// tiles get the border (the avatar is hidden behind the stream so the
|
||||||
|
// pulse wouldn't be visible anyway); audio tiles rely on the avatar
|
||||||
|
// pulse rendered inside AudioContent.
|
||||||
|
const videoSpeaking = speaking && video;
|
||||||
|
const borderClass = videoSpeaking
|
||||||
|
? 'border-emerald-500 shadow-[0_0_0_2px_rgba(22,163,74,0.25)] dark:border-emerald-400'
|
||||||
|
: focused
|
||||||
|
? 'border-accent'
|
||||||
|
: 'border-line hover:border-accent';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
className={
|
||||||
|
'relative flex flex-col overflow-hidden rounded-[14px] border bg-surface-3 transition ' +
|
||||||
|
(onClick ? 'cursor-pointer ' : '') +
|
||||||
|
borderClass +
|
||||||
|
(small ? ' min-w-[140px]' : '')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{video ? (
|
||||||
|
<VideoStub {...props} small={small} />
|
||||||
|
) : (
|
||||||
|
<AudioContent {...props} small={small} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Video-only speaking indicator. Audio tiles use the avatar pulse
|
||||||
|
from AudioContent so we don't double-render chrome. z-10 keeps
|
||||||
|
it above the video element. */}
|
||||||
|
{videoSpeaking && (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute inset-0 z-10 rounded-[14px] border-[3px] border-emerald-400 shadow-[inset_0_0_18px_rgba(34,197,94,0.55)]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="pointer-events-none absolute inset-x-2 bottom-2 flex items-center justify-between gap-2 rounded-lg glass-chip px-2.5 py-1.5">
|
||||||
|
<div className="flex min-w-0 items-center gap-1.5 text-xs font-semibold">
|
||||||
|
{me && <CrownIcon className="h-3 w-3 shrink-0 text-amber-300" />}
|
||||||
|
<span className="truncate">
|
||||||
|
{displayName}
|
||||||
|
{me ? ' (du)' : ''}
|
||||||
|
</span>
|
||||||
|
{e2ee && (
|
||||||
|
<span
|
||||||
|
aria-label="E2E verschlüsselt"
|
||||||
|
title="E2E verschlüsselt"
|
||||||
|
className="flex opacity-70"
|
||||||
|
>
|
||||||
|
<LockIcon className="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
{muted && (
|
||||||
|
<span
|
||||||
|
aria-label="Mikro stumm"
|
||||||
|
title="Mikro stumm"
|
||||||
|
className="flex h-5 w-5 items-center justify-center rounded-md bg-rose-500/80 text-white"
|
||||||
|
>
|
||||||
|
<MicOffIcon className="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{deafened && (
|
||||||
|
<span
|
||||||
|
aria-label="Ton aus"
|
||||||
|
title="Ton aus"
|
||||||
|
className="flex h-5 w-5 items-center justify-center rounded-md bg-rose-500/80 text-white"
|
||||||
|
>
|
||||||
|
<HeadphonesOffIcon className="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AudioContent({
|
||||||
|
userId,
|
||||||
|
displayName,
|
||||||
|
avatarUrl,
|
||||||
|
speaking,
|
||||||
|
small,
|
||||||
|
}: ParticipantTileProps & { small: boolean }) {
|
||||||
|
const key = colorKeyFor(userId);
|
||||||
|
const colors = AVATAR_COLORS[key];
|
||||||
|
const letter = displayName.trim().charAt(0).toUpperCase() || '?';
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-0 flex-1 items-center justify-center p-4">
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'relative flex items-center justify-center rounded-full ' +
|
||||||
|
(small ? 'h-11 w-11' : 'h-[72px] w-[72px]')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{speaking && (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute -inset-1.5 animate-audio-pulse rounded-full border-2 border-emerald-500 dark:border-emerald-400"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{avatarUrl ? (
|
||||||
|
<img
|
||||||
|
src={avatarUrl}
|
||||||
|
alt=""
|
||||||
|
className="relative h-full w-full rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
'relative flex h-full w-full items-center justify-center rounded-full font-bold ' +
|
||||||
|
colors.bg + ' ' + colors.fg + ' ' +
|
||||||
|
(small ? 'text-lg' : 'text-2xl')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{letter}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VideoStub({
|
||||||
|
userId,
|
||||||
|
displayName,
|
||||||
|
avatarUrl,
|
||||||
|
videoTrack,
|
||||||
|
me,
|
||||||
|
small,
|
||||||
|
}: ParticipantTileProps & { small: boolean }) {
|
||||||
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const el = videoRef.current;
|
||||||
|
if (!el || !videoTrack) return;
|
||||||
|
el.srcObject = new MediaStream([videoTrack]);
|
||||||
|
return () => {
|
||||||
|
if (el.srcObject) {
|
||||||
|
(el.srcObject as MediaStream).getTracks().forEach((t) => {
|
||||||
|
// Don't stop the live track — other consumers may still need it.
|
||||||
|
// Just detach from this element.
|
||||||
|
void t;
|
||||||
|
});
|
||||||
|
el.srcObject = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [videoTrack]);
|
||||||
|
|
||||||
|
if (videoTrack) {
|
||||||
|
return (
|
||||||
|
<div className="relative flex min-h-0 flex-1 items-center justify-center bg-black">
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
autoPlay
|
||||||
|
playsInline
|
||||||
|
muted
|
||||||
|
className={
|
||||||
|
'h-full w-full object-cover ' +
|
||||||
|
(me ? 'scale-x-[-1]' : '') /* mirror local preview */
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Camera flag true but no track yet (publishing / subscribing race).
|
||||||
|
const key = colorKeyFor(userId);
|
||||||
|
const colors = AVATAR_COLORS[key];
|
||||||
|
const letter = displayName.trim().charAt(0).toUpperCase() || '?';
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-0 flex-1 items-center justify-center bg-gradient-to-br from-accent/20 to-surface-2 p-4">
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'flex items-center justify-center rounded-full font-bold ' +
|
||||||
|
colors.bg + ' ' + colors.fg + ' ' +
|
||||||
|
(small ? 'h-11 w-11 text-lg' : 'h-[72px] w-[72px] text-2xl')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{avatarUrl ? (
|
||||||
|
<img src={avatarUrl} alt="" className="h-full w-full rounded-full object-cover" />
|
||||||
|
) : (
|
||||||
|
letter
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,52 +1,70 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { useCall } from '../context/CallContext';
|
import { useCall } from '../context/CallContext';
|
||||||
import { useConversationsContext } from '../context/ConversationsContext';
|
import { useConversationsContext } from '../context/ConversationsContext';
|
||||||
import { useFriendshipsContext } from '../context/FriendshipsContext';
|
import { useFriendshipsContext } from '../context/FriendshipsContext';
|
||||||
import { ringtone } from '../lib/ringtone';
|
import { ringtone } from '../lib/ringtone';
|
||||||
import { useAnyActiveCall } from '../lib/useAnyActiveCall';
|
import { AvatarColorKey, colorKeyFor } from './CallParticipantTile';
|
||||||
import { useCallPresence } from '../lib/useCallPresence';
|
import { LockIcon, MonitorShareIcon, PhoneIcon, PhoneOffIcon } from './icons';
|
||||||
import {
|
|
||||||
MicIcon,
|
|
||||||
MicOffIcon,
|
|
||||||
PhoneIcon,
|
|
||||||
PhoneOffIcon,
|
|
||||||
SpinnerIcon,
|
|
||||||
XIcon,
|
|
||||||
} from './icons';
|
|
||||||
|
|
||||||
// Shell-level mount. Handles ringtones + the IncomingCallToast.
|
// Shell-level mount. Responsibilities:
|
||||||
// The persistent CallBar (active-call widget) is rendered inside Sidebar so
|
// - Start/stop ringtones by call-state transition.
|
||||||
// users can keep browsing/typing while a call is live.
|
// - Toast-style incoming-call banner for conversations the user isn't in
|
||||||
|
// (clicking the toast routes to the docked IncomingCallPanel).
|
||||||
|
// - PiP widget when an active call is live but the user is looking at a
|
||||||
|
// different route.
|
||||||
export function CallUI() {
|
export function CallUI() {
|
||||||
const { state } = useCall();
|
const { state } = useCall();
|
||||||
|
const { profile } = useAuth();
|
||||||
|
const dnd = profile?.presenceState === 'dnd';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// DND silences only the *incoming* ring — outgoing stays audible because
|
||||||
|
// the user initiated that call themselves. The incoming-call panel still
|
||||||
|
// appears visually; only the audible ring is suppressed.
|
||||||
if (state.kind === 'outgoing') ringtone.start('outgoing');
|
if (state.kind === 'outgoing') ringtone.start('outgoing');
|
||||||
else if (state.kind === 'incoming') ringtone.start('incoming');
|
else if (state.kind === 'incoming' && !dnd) ringtone.start('incoming');
|
||||||
else ringtone.stop();
|
else ringtone.stop();
|
||||||
}, [state.kind]);
|
}, [state.kind, dnd]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => ringtone.stop();
|
return () => ringtone.stop();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return <IncomingCallToast />;
|
return (
|
||||||
|
<>
|
||||||
|
<IncomingCallToast />
|
||||||
|
<PipCall />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const TOAST_AVATAR: Record<AvatarColorKey, string> = {
|
||||||
|
violet: 'bg-violet-500/90 text-white',
|
||||||
|
amber: 'bg-amber-500/90 text-white',
|
||||||
|
rose: 'bg-rose-500/90 text-white',
|
||||||
|
teal: 'bg-teal-500/90 text-white',
|
||||||
|
};
|
||||||
|
|
||||||
function IncomingCallToast() {
|
function IncomingCallToast() {
|
||||||
const { t } = useTranslation(['app']);
|
const { t } = useTranslation(['app']);
|
||||||
const { state, acceptIncoming, rejectIncoming } = useCall();
|
const { state, acceptIncoming, rejectIncoming } = useCall();
|
||||||
const { friendships } = useFriendshipsContext();
|
const { friendships } = useFriendshipsContext();
|
||||||
const { conversations } = useConversationsContext();
|
const { conversations } = useConversationsContext();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
if (state.kind !== 'incoming') return null;
|
if (state.kind !== 'incoming') return null;
|
||||||
|
|
||||||
|
// When the user is already looking at the target conversation, the docked
|
||||||
|
// IncomingCallPanel handles the UI; hide the toast to avoid double chrome.
|
||||||
|
if (location.pathname === '/chats/' + state.conversationId) return null;
|
||||||
|
|
||||||
const conv = conversations.find((c) => c.id === state.conversationId) ?? null;
|
const conv = conversations.find((c) => c.id === state.conversationId) ?? null;
|
||||||
const callerName =
|
const callerName =
|
||||||
conv?.members.find((m) => m.userId === state.fromUserId)?.profile?.displayName ??
|
conv?.members.find((m) => m.userId === state.fromUserId)?.profile?.displayName ??
|
||||||
@@ -54,44 +72,55 @@ function IncomingCallToast() {
|
|||||||
'?';
|
'?';
|
||||||
const isGroup = conv?.type === 'group';
|
const isGroup = conv?.type === 'group';
|
||||||
const groupName = isGroup ? (conv?.name ?? t('app:chats.new_group')) : null;
|
const groupName = isGroup ? (conv?.name ?? t('app:chats.new_group')) : null;
|
||||||
const letter = (isGroup ? groupName ?? callerName : callerName)
|
const title = isGroup ? groupName ?? callerName : callerName;
|
||||||
.trim()
|
const letter = title.trim().charAt(0).toUpperCase() || '?';
|
||||||
.charAt(0)
|
const color = colorKeyFor(state.fromUserId);
|
||||||
.toUpperCase() || '?';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="false"
|
aria-modal="false"
|
||||||
aria-label={t('app:call.incoming_title')}
|
aria-label={t('app:call.incoming_title')}
|
||||||
className="fixed bottom-6 right-6 z-50 w-80 animate-slide-up rounded-2xl border border-white/10 bg-ink-900/95 p-5 shadow-2xl backdrop-blur-xl"
|
className="fixed bottom-6 right-6 z-50 w-80 animate-slide-up overflow-hidden rounded-2xl border border-line bg-surface-3 shadow-call-card-dark"
|
||||||
>
|
>
|
||||||
<p className="text-xs font-medium uppercase tracking-wide text-neutral-500">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/chats/' + state.conversationId)}
|
||||||
|
className="group block w-full cursor-pointer p-5 text-left transition hover:bg-surface-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||||
|
>
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-fg-muted">
|
||||||
{t('app:call.incoming_title')}
|
{t('app:call.incoming_title')}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-3 flex items-center gap-3">
|
<div className="mt-2 flex items-center gap-3">
|
||||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-base font-semibold text-white ring-1 ring-brand-400/30">
|
<div
|
||||||
|
className={
|
||||||
|
'flex h-11 w-11 shrink-0 items-center justify-center rounded-full font-semibold ' +
|
||||||
|
TOAST_AVATAR[color]
|
||||||
|
}
|
||||||
|
>
|
||||||
{letter}
|
{letter}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate font-display text-base font-semibold text-white">
|
<p className="truncate font-display text-base font-semibold text-fg">{title}</p>
|
||||||
{isGroup ? groupName : callerName}
|
<p className="flex items-center gap-1.5 truncate text-xs text-fg-muted">
|
||||||
</p>
|
<LockIcon className="h-3 w-3" />
|
||||||
<p className="truncate text-xs text-neutral-400">
|
<span>
|
||||||
{isGroup
|
{isGroup
|
||||||
? t('app:call.incoming_group_from', {
|
? t('app:call.incoming_group_from', {
|
||||||
name: callerName,
|
name: callerName,
|
||||||
defaultValue: callerName + ' ruft Gruppe',
|
defaultValue: callerName + ' ruft Gruppe',
|
||||||
})
|
})
|
||||||
: t('app:call.incoming_from', { name: callerName })}
|
: t('app:call.incoming_from', { name: callerName })}
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 flex gap-2">
|
</button>
|
||||||
|
<div className="flex gap-2 px-5 pb-5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={rejectIncoming}
|
onClick={rejectIncoming}
|
||||||
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm font-semibold text-rose-200 transition hover:bg-rose-500/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/40"
|
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-lg border border-rose-500/40 bg-transparent px-3 py-2 text-sm font-semibold text-rose-600 transition hover:bg-rose-500/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-500/40 dark:text-rose-400"
|
||||||
>
|
>
|
||||||
<PhoneOffIcon className="h-4 w-4" />
|
<PhoneOffIcon className="h-4 w-4" />
|
||||||
<span>{t('app:call.decline')}</span>
|
<span>{t('app:call.decline')}</span>
|
||||||
@@ -99,7 +128,7 @@ function IncomingCallToast() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void acceptIncoming()}
|
onClick={() => void acceptIncoming()}
|
||||||
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-lg bg-emerald-500/90 px-3 py-2 text-sm font-semibold text-white transition hover:bg-emerald-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40"
|
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-lg bg-emerald-600 px-3 py-2 text-sm font-semibold text-white transition hover:bg-emerald-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50"
|
||||||
>
|
>
|
||||||
<PhoneIcon className="h-4 w-4" />
|
<PhoneIcon className="h-4 w-4" />
|
||||||
<span>{t('app:call.accept')}</span>
|
<span>{t('app:call.accept')}</span>
|
||||||
@@ -110,226 +139,110 @@ function IncomingCallToast() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// CallBar — persistent widget inside Sidebar. Lets the user keep browsing
|
// PiP widget — shown when the user has an active call but is browsing
|
||||||
// the app while a call is active / connecting / ringing.
|
// somewhere else. Clicking expands back to the call's conversation.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export function CallBar() {
|
function PipCall() {
|
||||||
const { state, lastCallConversationId } = useCall();
|
|
||||||
const { conversations } = useConversationsContext();
|
|
||||||
const { session } = useAuth();
|
|
||||||
const myId = session?.user.id ?? null;
|
|
||||||
|
|
||||||
// Aggregate observer across every conversation the user is in so a
|
|
||||||
// "call is live, rejoin" affordance appears in the sidebar whenever ANY
|
|
||||||
// peer is in a call — not just calls I previously joined.
|
|
||||||
const convIds = conversations.map((c) => c.id);
|
|
||||||
const anyActive = useAnyActiveCall(convIds, myId);
|
|
||||||
|
|
||||||
if (state.kind === 'connected' || state.kind === 'connecting' || state.kind === 'outgoing') {
|
|
||||||
return <ActiveCallBar />;
|
|
||||||
}
|
|
||||||
// Prefer the conversation I just left, fall back to any other live call.
|
|
||||||
const rejoinId = lastCallConversationId ?? anyActive?.conversationId ?? null;
|
|
||||||
if (rejoinId) {
|
|
||||||
return <RejoinCallBar conversationId={rejoinId} />;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ActiveCallBar() {
|
|
||||||
const { t } = useTranslation(['app']);
|
const { t } = useTranslation(['app']);
|
||||||
const { state, isMuted, hangup, toggleMute } = useCall();
|
const {
|
||||||
|
state,
|
||||||
|
remoteParticipants,
|
||||||
|
remoteScreenShares,
|
||||||
|
hangup,
|
||||||
|
} = useCall();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
const { conversations } = useConversationsContext();
|
const { conversations } = useConversationsContext();
|
||||||
|
|
||||||
const [, forceTick] = useState(0);
|
const active =
|
||||||
useEffect(() => {
|
state.kind === 'connected' ||
|
||||||
if (state.kind !== 'connected') return;
|
state.kind === 'connecting' ||
|
||||||
const id = window.setInterval(() => forceTick((v) => v + 1), 1000);
|
state.kind === 'reconnecting' ||
|
||||||
return () => window.clearInterval(id);
|
state.kind === 'outgoing';
|
||||||
}, [state.kind]);
|
if (!active) return null;
|
||||||
|
|
||||||
if (state.kind !== 'connected' && state.kind !== 'connecting' && state.kind !== 'outgoing') {
|
// Only render when the user is NOT currently viewing the call's
|
||||||
return null;
|
// conversation. Inside that conversation the full dock is visible already.
|
||||||
}
|
if (location.pathname === '/chats/' + state.conversationId) return null;
|
||||||
|
|
||||||
const conv = conversations.find((c) => c.id === state.conversationId) ?? null;
|
const conv = conversations.find((c) => c.id === state.conversationId) ?? null;
|
||||||
const title =
|
const title =
|
||||||
conv?.type === 'group'
|
conv?.type === 'group'
|
||||||
? conv.name ?? '—'
|
? conv?.name ?? t('app:chats.new_group')
|
||||||
: conv?.peer?.displayName ?? '—';
|
: conv?.peer?.displayName ?? '—';
|
||||||
|
const participantCount = 1 + remoteParticipants.length;
|
||||||
const statusLabel =
|
const someoneSharing = remoteScreenShares.length > 0;
|
||||||
state.kind === 'outgoing'
|
// Duration ticks while connected or reconnecting (LiveKit holds the room
|
||||||
? t('app:call.outgoing_ringing')
|
// across reconnects, so the timer shouldn't reset on a wobble). Absent
|
||||||
: state.kind === 'connecting'
|
// on outgoing/connecting where the call hasn't started yet.
|
||||||
? t('app:call.connecting')
|
const startedAt =
|
||||||
: t('app:call.voice_connected', { defaultValue: 'Sprachchat verbunden' });
|
state.kind === 'connected' || state.kind === 'reconnecting'
|
||||||
|
? state.startedAt
|
||||||
const elapsed =
|
|
||||||
state.kind === 'connected'
|
|
||||||
? formatElapsed(Date.now() - new Date(state.startedAt).getTime())
|
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-2 overflow-hidden rounded-xl border border-emerald-500/20 bg-ink-900/80">
|
<div
|
||||||
<Link
|
role="dialog"
|
||||||
to={'/chats/' + state.conversationId}
|
aria-label={t('app:call.active_in_conv', { defaultValue: 'Aktiver Anruf' })}
|
||||||
className="flex items-center gap-2.5 px-3 pt-2.5 pb-2 transition hover:bg-white/5 focus:outline-none"
|
onClick={() => navigate('/chats/' + state.conversationId)}
|
||||||
aria-label={statusLabel}
|
className="fixed bottom-5 right-5 z-40 flex w-[260px] animate-slide-in-call cursor-pointer items-center gap-2.5 rounded-[14px] border border-accent bg-surface-3 p-2.5 shadow-pip-call transition hover:-translate-y-0.5"
|
||||||
>
|
>
|
||||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-emerald-500/15 text-emerald-300">
|
<div className="flex h-11 w-11 shrink-0 items-center justify-center overflow-hidden rounded-[10px] bg-surface-2">
|
||||||
{state.kind === 'connecting' ? (
|
{someoneSharing ? (
|
||||||
<SpinnerIcon className="h-4 w-4" />
|
<MonitorShareIcon className="h-5 w-5 text-accent" />
|
||||||
) : (
|
) : (
|
||||||
<SignalIcon className="h-4 w-4" />
|
<PhoneIcon className="h-5 w-5 text-accent" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-sm font-semibold text-emerald-300">{statusLabel}</p>
|
<p className="truncate text-[13px] font-semibold text-fg">
|
||||||
<p className="truncate text-xs text-neutral-400">
|
{title} · {participantCount}
|
||||||
{title}
|
</p>
|
||||||
{elapsed && <span className="ml-1.5 font-mono text-neutral-500">{elapsed}</span>}
|
<p className="mt-0.5 flex items-center gap-1.5 text-[11px] text-fg-muted">
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="h-1.5 w-1.5 rounded-full bg-rose-500 animate-live-dot"
|
||||||
|
/>
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{startedAt
|
||||||
|
? <PipDuration startedAt={startedAt} />
|
||||||
|
: t('app:call.tap_to_open', { defaultValue: 'tippe zum Öffnen' })}
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-1 border-t border-white/5 bg-ink-950/40 p-1.5">
|
|
||||||
<IconTile
|
|
||||||
onClick={toggleMute}
|
|
||||||
disabled={state.kind !== 'connected'}
|
|
||||||
label={isMuted ? t('app:call.unmute') : t('app:call.mute')}
|
|
||||||
tone={isMuted ? 'amber' : 'neutral'}
|
|
||||||
>
|
|
||||||
{isMuted ? <MicOffIcon className="h-4 w-4" /> : <MicIcon className="h-4 w-4" />}
|
|
||||||
</IconTile>
|
|
||||||
<IconTile onClick={() => void hangup()} label={t('app:call.hangup')} tone="rose">
|
|
||||||
<PhoneOffIcon className="h-4 w-4" />
|
|
||||||
</IconTile>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RejoinCallBar({ conversationId }: { conversationId: string }) {
|
|
||||||
const { t } = useTranslation(['app', 'common']);
|
|
||||||
const { joinActiveCall, dismissLastCall } = useCall();
|
|
||||||
const { conversations } = useConversationsContext();
|
|
||||||
const { session } = useAuth();
|
|
||||||
const active = useCallPresence(conversationId);
|
|
||||||
|
|
||||||
const myId = session?.user.id;
|
|
||||||
const others = active.filter((u) => u !== myId);
|
|
||||||
|
|
||||||
// Presence polling returns [] on the first tick before it syncs, so we can't
|
|
||||||
// treat an initial empty list as "room is empty". Only dismiss after we've
|
|
||||||
// actually seen peers and then watched them leave — and even then confirm
|
|
||||||
// the empty state for a grace period, since presence_diff events can
|
|
||||||
// arrive slightly out of order.
|
|
||||||
const [visible, setVisible] = useState(false);
|
|
||||||
useEffect(() => {
|
|
||||||
if (others.length > 0) {
|
|
||||||
setVisible(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!visible) return;
|
|
||||||
const id = window.setTimeout(() => {
|
|
||||||
setVisible(false);
|
|
||||||
dismissLastCall();
|
|
||||||
}, 2500);
|
|
||||||
return () => window.clearTimeout(id);
|
|
||||||
}, [others.length, visible, dismissLastCall]);
|
|
||||||
|
|
||||||
if (!visible) return null;
|
|
||||||
|
|
||||||
const conv = conversations.find((c) => c.id === conversationId) ?? null;
|
|
||||||
const title =
|
|
||||||
conv?.type === 'group'
|
|
||||||
? conv.name ?? '—'
|
|
||||||
: conv?.peer?.displayName ?? '—';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mb-2 overflow-hidden rounded-xl border border-emerald-500/20 bg-ink-900/80">
|
|
||||||
<Link
|
|
||||||
to={'/chats/' + conversationId}
|
|
||||||
className="flex items-center gap-2.5 px-3 pt-2.5 pb-2 transition hover:bg-white/5 focus:outline-none"
|
|
||||||
>
|
|
||||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-emerald-500/15 text-emerald-300">
|
|
||||||
<SignalIcon className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="truncate text-sm font-semibold text-emerald-300">
|
|
||||||
{t('app:call.still_live', { defaultValue: 'Anruf läuft noch' })}
|
|
||||||
</p>
|
|
||||||
<p className="truncate text-xs text-neutral-400">
|
|
||||||
{title} · {others.length}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
<div className="grid grid-cols-2 gap-1 border-t border-white/5 bg-ink-950/40 p-1.5">
|
|
||||||
<IconTile
|
|
||||||
onClick={() => void joinActiveCall(conversationId, 'audio')}
|
|
||||||
label={t('app:call.join')}
|
|
||||||
tone="emerald"
|
|
||||||
>
|
|
||||||
<PhoneIcon className="h-4 w-4" />
|
|
||||||
</IconTile>
|
|
||||||
<IconTile onClick={dismissLastCall} label={t('common:close', { defaultValue: 'Schließen' })} tone="neutral">
|
|
||||||
<XIcon className="h-4 w-4" />
|
|
||||||
</IconTile>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface IconTileProps {
|
|
||||||
onClick: () => void;
|
|
||||||
label: string;
|
|
||||||
tone: 'neutral' | 'amber' | 'rose' | 'emerald';
|
|
||||||
disabled?: boolean;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
function IconTile({ onClick, label, tone, disabled, children }: IconTileProps) {
|
|
||||||
const toneClass =
|
|
||||||
tone === 'rose'
|
|
||||||
? 'bg-white/5 text-rose-300 hover:bg-rose-500/20 focus-visible:ring-rose-400/40'
|
|
||||||
: tone === 'amber'
|
|
||||||
? 'bg-amber-500/20 text-amber-200 hover:bg-amber-500/30 focus-visible:ring-amber-400/40'
|
|
||||||
: tone === 'emerald'
|
|
||||||
? 'bg-emerald-500/20 text-emerald-200 hover:bg-emerald-500/30 focus-visible:ring-emerald-400/40'
|
|
||||||
: 'bg-white/5 text-neutral-200 hover:bg-white/10 focus-visible:ring-brand-400/40';
|
|
||||||
return (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={(e) => {
|
||||||
disabled={disabled}
|
e.stopPropagation();
|
||||||
aria-label={label}
|
void hangup();
|
||||||
title={label}
|
}}
|
||||||
className={
|
aria-label={t('app:call.hangup')}
|
||||||
'inline-flex h-9 cursor-pointer items-center justify-center rounded-md transition focus:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-50 ' +
|
className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full bg-rose-600 text-white transition hover:bg-rose-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/60"
|
||||||
toneClass
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{children}
|
<PhoneOffIcon className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SignalIcon(props: React.SVGProps<SVGSVGElement>) {
|
// Live-ticking `mm:ss` / `hh:mm:ss` for the PiP. Duplicated from InCallPanel
|
||||||
return (
|
// deliberately — the two widgets have different typography + tabular
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" {...props}>
|
// contexts, and extracting a shared component would be heavier than the
|
||||||
<path d="M5 12v0" />
|
// 8-line countup it replaces.
|
||||||
<path d="M9 9v6" />
|
function PipDuration({ startedAt }: { startedAt: string }) {
|
||||||
<path d="M13 6v12" />
|
const [, tick] = useState(0);
|
||||||
<path d="M17 9v6" />
|
useEffect(() => {
|
||||||
</svg>
|
const id = window.setInterval(() => tick((v) => v + 1), 1000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
const total = Math.max(
|
||||||
|
0,
|
||||||
|
Math.floor((Date.now() - new Date(startedAt).getTime()) / 1000),
|
||||||
);
|
);
|
||||||
}
|
const hh = Math.floor(total / 3600);
|
||||||
|
const mm = Math.floor((total % 3600) / 60);
|
||||||
function formatElapsed(ms: number): string {
|
const ss = total % 60;
|
||||||
const total = Math.max(0, Math.floor(ms / 1000));
|
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||||
const mm = Math.floor(total / 60).toString().padStart(2, '0');
|
return <>{hh > 0 ? `${hh}:${pad(mm)}:${pad(ss)}` : `${pad(mm)}:${pad(ss)}`}</>;
|
||||||
const ss = (total % 60).toString().padStart(2, '0');
|
|
||||||
return mm + ':' + ss;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,40 @@
|
|||||||
import type { ConversationSummary } from '@chat-app/shared/chat';
|
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||||
import type { PresenceState } from '@chat-app/shared/supabase';
|
import type { PresenceState } from '@chat-app/shared/supabase';
|
||||||
|
import { useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { useCall } from '../context/CallContext';
|
import { useCall } from '../context/CallContext';
|
||||||
import { useCallPresence } from '../lib/useCallPresence';
|
import { useCallPresence } from '../lib/useCallPresence';
|
||||||
import { InfoIcon, PhoneIcon, SpinnerIcon, UsersIcon } from './icons';
|
import type { PeerPresence } from '../lib/usePeerPresence';
|
||||||
|
import { Avatar } from './Avatar';
|
||||||
|
import {
|
||||||
|
InfoIcon,
|
||||||
|
PhoneIcon,
|
||||||
|
SearchIcon,
|
||||||
|
SpinnerIcon,
|
||||||
|
UsersIcon,
|
||||||
|
VideoIcon,
|
||||||
|
} from './icons';
|
||||||
|
|
||||||
const PRESENCE_DOT: Record<PresenceState, string> = {
|
const PRESENCE_DOT: Record<PresenceState, string> = {
|
||||||
online: 'bg-emerald-400',
|
online: 'bg-emerald-500',
|
||||||
idle: 'bg-amber-400',
|
idle: 'bg-amber-400',
|
||||||
dnd: 'bg-rose-500',
|
dnd: 'bg-rose-500',
|
||||||
invisible: 'bg-neutral-500',
|
invisible: 'bg-neutral-500',
|
||||||
offline: 'bg-neutral-600',
|
offline: 'bg-neutral-400 dark:bg-neutral-600',
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
conversation: ConversationSummary | null;
|
conversation: ConversationSummary | null;
|
||||||
peerPresence: PresenceState | null;
|
peerPresence: PeerPresence | null;
|
||||||
onInfoClick?: () => void;
|
onInfoClick?: () => void;
|
||||||
|
onSearchClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConversationHeader({ conversation, peerPresence, onInfoClick }: Props) {
|
export function ConversationHeader({ conversation, peerPresence, onInfoClick, onSearchClick }: Props) {
|
||||||
if (!conversation) {
|
if (!conversation) {
|
||||||
return (
|
return <header className="h-[57px] border-b border-line px-6 py-3" aria-busy="true" />;
|
||||||
<header className="h-[57px] border-b border-white/5 px-6 py-3" aria-busy="true" />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -34,6 +43,7 @@ export function ConversationHeader({ conversation, peerPresence, onInfoClick }:
|
|||||||
conversation={conversation}
|
conversation={conversation}
|
||||||
peerPresence={peerPresence}
|
peerPresence={peerPresence}
|
||||||
{...(onInfoClick ? { onInfoClick } : {})}
|
{...(onInfoClick ? { onInfoClick } : {})}
|
||||||
|
{...(onSearchClick ? { onSearchClick } : {})}
|
||||||
/>
|
/>
|
||||||
<ActiveCallBanner conversationId={conversation.id} />
|
<ActiveCallBanner conversationId={conversation.id} />
|
||||||
</>
|
</>
|
||||||
@@ -42,45 +52,64 @@ export function ConversationHeader({ conversation, peerPresence, onInfoClick }:
|
|||||||
|
|
||||||
interface HeaderBarProps {
|
interface HeaderBarProps {
|
||||||
conversation: ConversationSummary;
|
conversation: ConversationSummary;
|
||||||
peerPresence: PresenceState | null;
|
peerPresence: PeerPresence | null;
|
||||||
onInfoClick?: () => void;
|
onInfoClick?: () => void;
|
||||||
|
onSearchClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function HeaderBar({ conversation, peerPresence, onInfoClick }: HeaderBarProps) {
|
function HeaderBar({ conversation, peerPresence, onInfoClick, onSearchClick }: HeaderBarProps) {
|
||||||
const { t } = useTranslation(['app']);
|
const { t } = useTranslation(['app']);
|
||||||
|
|
||||||
const isDm = conversation.type === 'dm';
|
const isDm = conversation.type === 'dm';
|
||||||
const title = isDm ? (conversation.peer?.displayName ?? '?') : (conversation.name ?? '?');
|
const title = isDm ? (conversation.peer?.displayName ?? '?') : (conversation.name ?? '?');
|
||||||
const handle = isDm ? '@' + (conversation.peer?.username ?? '?') : '';
|
const handle = isDm ? '@' + (conversation.peer?.username ?? '?') : '';
|
||||||
const letter = title.trim().charAt(0).toUpperCase() || '?';
|
const peerAvatar = isDm ? (conversation.peer?.avatarUrl ?? null) : (conversation.avatarUrl ?? null);
|
||||||
|
|
||||||
// Hide presence when peer chose invisible — reciprocal privacy.
|
// Hide presence when peer chose invisible — reciprocal privacy.
|
||||||
const showPresence = isDm && peerPresence && peerPresence !== 'invisible';
|
const peerState = peerPresence?.state ?? null;
|
||||||
const presenceLabel = peerPresence ? t('app:presence.' + peerPresence) : '';
|
const showPresence = isDm && peerState && peerState !== 'invisible';
|
||||||
|
// Subtitle priority: custom status_message when online (or idle/dnd), else
|
||||||
|
// the localized presence label. Offline always wins → just "Offline".
|
||||||
|
const presenceLabel = (() => {
|
||||||
|
if (!peerState) return '';
|
||||||
|
if (peerState === 'offline') return t('app:presence.offline');
|
||||||
|
if (peerPresence?.statusMessage && peerPresence.statusMessage.trim().length > 0) {
|
||||||
|
return peerPresence.statusMessage.trim();
|
||||||
|
}
|
||||||
|
return t('app:presence.' + peerState);
|
||||||
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="flex items-center gap-3 border-b border-white/5 px-6 py-3">
|
<header className="flex items-center gap-3 border-b border-line bg-surface-3 px-6 py-3">
|
||||||
<div className="relative flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-base font-semibold text-white ring-1 ring-brand-400/30">
|
<div className="relative">
|
||||||
{isDm ? letter : <UsersIcon className="h-5 w-5" />}
|
{isDm ? (
|
||||||
{showPresence && peerPresence && (
|
<Avatar url={peerAvatar} displayName={title} className="h-10 w-10 text-base" />
|
||||||
|
) : peerAvatar ? (
|
||||||
|
<Avatar url={peerAvatar} displayName={title} className="h-10 w-10 text-base" />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-accent/20 text-accent">
|
||||||
|
<UsersIcon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showPresence && peerState && (
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={
|
className={
|
||||||
'absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full ring-2 ring-ink-950 ' +
|
'absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full ring-2 ring-surface-3 ' +
|
||||||
PRESENCE_DOT[peerPresence]
|
PRESENCE_DOT[peerState]
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate font-display text-base font-semibold text-white">{title}</p>
|
<p className="truncate font-display text-base font-semibold text-fg">{title}</p>
|
||||||
<p className="truncate text-xs text-neutral-500">
|
<p className="truncate text-xs text-fg-muted">
|
||||||
{isDm ? (
|
{isDm ? (
|
||||||
<>
|
<>
|
||||||
<span>{handle}</span>
|
<span>{handle}</span>
|
||||||
{showPresence && (
|
{showPresence && (
|
||||||
<>
|
<>
|
||||||
<span className="mx-1.5 text-neutral-700">·</span>
|
<span className="mx-1.5 text-fg-muted/50">·</span>
|
||||||
<span>{presenceLabel}</span>
|
<span>{presenceLabel}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -93,75 +122,133 @@ function HeaderBar({ conversation, peerPresence, onInfoClick }: HeaderBarProps)
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CallHeaderButton conversationId={conversation.id} />
|
<HeaderActionButton
|
||||||
|
label={t('app:chats.search', { defaultValue: 'Suche' })}
|
||||||
|
icon={SearchIcon}
|
||||||
|
{...(onSearchClick ? { onClick: onSearchClick } : {})}
|
||||||
|
/>
|
||||||
|
<CallHeaderButton conversationId={conversation.id} kind="audio" />
|
||||||
|
<CallHeaderButton conversationId={conversation.id} kind="video" />
|
||||||
{!isDm && onInfoClick && (
|
{!isDm && onInfoClick && (
|
||||||
<button
|
<HeaderActionButton
|
||||||
type="button"
|
label={t('app:group.info_title')}
|
||||||
|
icon={InfoIcon}
|
||||||
onClick={onInfoClick}
|
onClick={onInfoClick}
|
||||||
aria-label={t('app:group.info_title')}
|
/>
|
||||||
title={t('app:group.info_title')}
|
|
||||||
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg text-neutral-400 transition hover:bg-white/10 hover:text-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
|
|
||||||
>
|
|
||||||
<InfoIcon className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CallHeaderButton({ conversationId }: { conversationId: string }) {
|
interface HeaderActionButtonProps {
|
||||||
const { t } = useTranslation(['app']);
|
label: string;
|
||||||
const { state, startCall } = useCall();
|
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
|
||||||
const busy = state.kind !== 'idle';
|
onClick?: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
tone?: 'default' | 'accent';
|
||||||
|
}
|
||||||
|
|
||||||
|
function HeaderActionButton({
|
||||||
|
label,
|
||||||
|
icon: Icon,
|
||||||
|
onClick,
|
||||||
|
disabled,
|
||||||
|
tone = 'default',
|
||||||
|
}: HeaderActionButtonProps) {
|
||||||
|
const toneClass =
|
||||||
|
tone === 'accent'
|
||||||
|
? 'text-accent hover:bg-accent/10'
|
||||||
|
: 'text-fg-muted hover:bg-surface-2 hover:text-fg';
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void startCall(conversationId, 'audio')}
|
onClick={onClick}
|
||||||
disabled={busy}
|
disabled={disabled}
|
||||||
aria-label={t('app:call.start_audio')}
|
aria-label={label}
|
||||||
title={busy ? t('app:call.busy') : t('app:call.start_audio')}
|
title={label}
|
||||||
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg text-neutral-400 transition hover:bg-white/10 hover:text-brand-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 disabled:cursor-not-allowed disabled:opacity-50"
|
className={
|
||||||
|
'flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50 ' +
|
||||||
|
toneClass
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<PhoneIcon className="h-4 w-4" />
|
<Icon className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CallHeaderButton({
|
||||||
|
conversationId,
|
||||||
|
kind,
|
||||||
|
}: {
|
||||||
|
conversationId: string;
|
||||||
|
kind: 'audio' | 'video';
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation(['app']);
|
||||||
|
const { state, startCall } = useCall();
|
||||||
|
const busy = state.kind !== 'idle';
|
||||||
|
const label =
|
||||||
|
kind === 'video'
|
||||||
|
? t('app:call.start_video', { defaultValue: 'Video-Anruf' })
|
||||||
|
: t('app:call.start_audio');
|
||||||
|
const Icon = kind === 'video' ? VideoIcon : PhoneIcon;
|
||||||
|
return (
|
||||||
|
<HeaderActionButton
|
||||||
|
label={busy ? t('app:call.busy') : label}
|
||||||
|
icon={Icon}
|
||||||
|
onClick={() => void startCall(conversationId, kind)}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ActiveCallBanner({ conversationId }: { conversationId: string }) {
|
function ActiveCallBanner({ conversationId }: { conversationId: string }) {
|
||||||
const { t } = useTranslation(['app']);
|
const { t } = useTranslation(['app']);
|
||||||
const { session } = useAuth();
|
const { session } = useAuth();
|
||||||
const { state, joinActiveCall } = useCall();
|
const { state, joinActiveCall, lastCallConversationId, dismissLastCall } = useCall();
|
||||||
const active = useCallPresence(conversationId);
|
const active = useCallPresence(conversationId);
|
||||||
|
|
||||||
const myId = session?.user.id;
|
const myId = session?.user.id;
|
||||||
// Source of truth for "am I in this call" is the CallContext state, not
|
|
||||||
// presence — my own presence entry can lag behind the room join, which
|
|
||||||
// would otherwise make the banner flash while I'm already connected.
|
|
||||||
const iAmIn =
|
const iAmIn =
|
||||||
(state.kind === 'connected' ||
|
(state.kind === 'connected' ||
|
||||||
state.kind === 'connecting' ||
|
state.kind === 'connecting' ||
|
||||||
state.kind === 'outgoing') &&
|
state.kind === 'outgoing') &&
|
||||||
state.conversationId === conversationId;
|
state.conversationId === conversationId;
|
||||||
|
|
||||||
// Only show banner when other people are in it and I'm not.
|
|
||||||
const othersIn = active.filter((u) => u !== myId);
|
const othersIn = active.filter((u) => u !== myId);
|
||||||
if (iAmIn || othersIn.length === 0) return null;
|
// Fallback signal: I just left this conv with peers still inside. Covers the
|
||||||
|
// brief window after hangup where presence may not have re-synced yet (the
|
||||||
|
// realtime channel can churn while ConversationHeader remounts).
|
||||||
|
const justLeft = state.kind === 'idle' && lastCallConversationId === conversationId;
|
||||||
|
|
||||||
|
// Once presence confirms the room is empty, drop the "just left" hint so the
|
||||||
|
// banner hides cleanly instead of sticking forever. Grace window handles the
|
||||||
|
// brief gap between hangup and presence re-sync so we don't flicker.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!justLeft) return;
|
||||||
|
if (othersIn.length > 0) return; // still live — keep banner
|
||||||
|
const id = window.setTimeout(() => dismissLastCall(), 3000);
|
||||||
|
return () => window.clearTimeout(id);
|
||||||
|
}, [justLeft, othersIn.length, dismissLastCall]);
|
||||||
|
|
||||||
|
if (iAmIn) return null;
|
||||||
|
if (othersIn.length === 0 && !justLeft) return null;
|
||||||
|
|
||||||
|
const count = Math.max(othersIn.length, justLeft ? 1 : 0);
|
||||||
const busy = state.kind !== 'idle';
|
const busy = state.kind !== 'idle';
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 border-b border-emerald-500/20 bg-emerald-500/10 px-6 py-2.5 text-sm text-emerald-100">
|
<div className="flex items-center gap-3 border-b border-emerald-500/30 bg-emerald-500/10 px-6 py-2.5 text-sm text-emerald-700 dark:text-emerald-100">
|
||||||
<PhoneIcon className="h-4 w-4 text-emerald-300" />
|
<PhoneIcon className="h-4 w-4 text-emerald-600 dark:text-emerald-300" />
|
||||||
<span className="flex-1">
|
<span className="flex-1">
|
||||||
{t('app:call.active_in_conv', {
|
{t('app:call.active_in_conv', {
|
||||||
defaultValue: 'Active call · {{count}} in room',
|
defaultValue: 'Active call · {{count}} in room',
|
||||||
count: othersIn.length,
|
count,
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void joinActiveCall(conversationId, 'audio')}
|
onClick={() => void joinActiveCall(conversationId, 'audio')}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-emerald-500/90 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-emerald-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40 disabled:cursor-not-allowed disabled:opacity-60"
|
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-emerald-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{busy ? (
|
{busy ? (
|
||||||
<SpinnerIcon className="h-3.5 w-3.5" />
|
<SpinnerIcon className="h-3.5 w-3.5" />
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import {
|
||||||
|
muteDurationToIso,
|
||||||
|
setConversationArchived,
|
||||||
|
setConversationMutedUntil,
|
||||||
|
} from '@chat-app/shared/chat';
|
||||||
|
import { forwardRef, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { supabase } from '../lib/supabase';
|
||||||
|
import { ArchiveIcon, BellIcon, BellOffIcon, MoreVerticalIcon } from './icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
conversationId: string;
|
||||||
|
archived: boolean;
|
||||||
|
mutedUntil: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MuteOption {
|
||||||
|
key: string;
|
||||||
|
labelKey: string;
|
||||||
|
labelDefault: string;
|
||||||
|
minutes: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Muted-forever sentinel ≈ 100 years. UI treats any future timestamp as muted
|
||||||
|
// until that moment; 100y is indistinguishable from "forever" at the UX level
|
||||||
|
// without requiring a dedicated `bool muted_forever` column.
|
||||||
|
const FOREVER_MINUTES = 100 * 365 * 24 * 60;
|
||||||
|
|
||||||
|
const MUTE_OPTIONS: MuteOption[] = [
|
||||||
|
{ key: '1h', labelKey: 'app:chats.mute_1h', labelDefault: '1 Stunde', minutes: 60 },
|
||||||
|
{ key: '8h', labelKey: 'app:chats.mute_8h', labelDefault: '8 Stunden', minutes: 8 * 60 },
|
||||||
|
{ key: '24h', labelKey: 'app:chats.mute_24h', labelDefault: '24 Stunden', minutes: 24 * 60 },
|
||||||
|
{ key: '1w', labelKey: 'app:chats.mute_1w', labelDefault: '1 Woche', minutes: 7 * 24 * 60 },
|
||||||
|
{
|
||||||
|
key: 'forever',
|
||||||
|
labelKey: 'app:chats.mute_forever',
|
||||||
|
labelDefault: 'Bis auf Weiteres',
|
||||||
|
minutes: FOREVER_MINUTES,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface MenuPos {
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-conversation row context-menu. Renders via portal so the submenu can
|
||||||
|
// escape the sidebar's `overflow-y-auto` clipping context. Position is
|
||||||
|
// computed from the trigger's bounding rect — menu anchors right-aligned
|
||||||
|
// under the trigger so it doesn't push off-screen on narrow windows.
|
||||||
|
export function ConversationRowMenu({ conversationId, archived, mutedUntil }: Props) {
|
||||||
|
const { t } = useTranslation(['app']);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [submenuOpen, setSubmenuOpen] = useState<'mute' | null>(null);
|
||||||
|
const [menuPos, setMenuPos] = useState<MenuPos | null>(null);
|
||||||
|
const [submenuPos, setSubmenuPos] = useState<MenuPos | null>(null);
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const muteItemRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
function onDocClick(e: MouseEvent) {
|
||||||
|
const target = e.target as Node;
|
||||||
|
if (triggerRef.current?.contains(target)) return;
|
||||||
|
if (menuRef.current?.contains(target)) return;
|
||||||
|
setOpen(false);
|
||||||
|
setSubmenuOpen(null);
|
||||||
|
}
|
||||||
|
function onEsc(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setOpen(false);
|
||||||
|
setSubmenuOpen(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', onDocClick);
|
||||||
|
document.addEventListener('keydown', onEsc);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onDocClick);
|
||||||
|
document.removeEventListener('keydown', onEsc);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setMenuPos(null);
|
||||||
|
setSubmenuPos(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rect = triggerRef.current?.getBoundingClientRect();
|
||||||
|
if (!rect) return;
|
||||||
|
// Anchor: right edge aligns with trigger's right edge, menu hangs below.
|
||||||
|
const menuWidth = 208;
|
||||||
|
setMenuPos({
|
||||||
|
top: rect.bottom + 4,
|
||||||
|
left: Math.max(8, rect.right - menuWidth),
|
||||||
|
});
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (submenuOpen !== 'mute') {
|
||||||
|
setSubmenuPos(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rect = muteItemRef.current?.getBoundingClientRect();
|
||||||
|
if (!rect) return;
|
||||||
|
const submenuWidth = 192;
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
// Prefer right of the item. Flip to left when it would overflow viewport.
|
||||||
|
const wantLeft = rect.right + 4;
|
||||||
|
const flip = wantLeft + submenuWidth > viewportWidth - 8;
|
||||||
|
setSubmenuPos({
|
||||||
|
top: rect.top,
|
||||||
|
left: flip ? rect.left - submenuWidth - 4 : wantLeft,
|
||||||
|
});
|
||||||
|
}, [submenuOpen]);
|
||||||
|
|
||||||
|
const isMuted =
|
||||||
|
mutedUntil !== null && new Date(mutedUntil).getTime() > Date.now();
|
||||||
|
|
||||||
|
const handleArchive = useCallback(
|
||||||
|
async (next: boolean) => {
|
||||||
|
setOpen(false);
|
||||||
|
try {
|
||||||
|
await setConversationArchived(supabase, conversationId, next);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error('archive toggle failed', err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[conversationId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMute = useCallback(
|
||||||
|
async (minutes: number | null) => {
|
||||||
|
setOpen(false);
|
||||||
|
setSubmenuOpen(null);
|
||||||
|
try {
|
||||||
|
await setConversationMutedUntil(
|
||||||
|
supabase,
|
||||||
|
conversationId,
|
||||||
|
muteDurationToIso(minutes),
|
||||||
|
);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error('mute toggle failed', err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[conversationId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
|
type="button"
|
||||||
|
aria-label={t('app:chats.row_menu', { defaultValue: 'Aktionen' })}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setOpen((v) => !v);
|
||||||
|
}}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted opacity-0 transition group-hover:opacity-100 hover:bg-surface-3 hover:text-fg focus:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||||
|
>
|
||||||
|
<MoreVerticalIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open &&
|
||||||
|
menuPos &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
role="menu"
|
||||||
|
style={{ top: menuPos.top, left: menuPos.left }}
|
||||||
|
className="fixed z-50 w-52 rounded-lg border border-line bg-surface-3 p-1 shadow-xl"
|
||||||
|
>
|
||||||
|
<MenuItem
|
||||||
|
icon={<ArchiveIcon className="h-4 w-4" />}
|
||||||
|
label={
|
||||||
|
archived
|
||||||
|
? t('app:chats.unarchive', { defaultValue: 'Entarchivieren' })
|
||||||
|
: t('app:chats.archive', { defaultValue: 'Archivieren' })
|
||||||
|
}
|
||||||
|
onClick={() => void handleArchive(!archived)}
|
||||||
|
/>
|
||||||
|
<MenuItem
|
||||||
|
ref={muteItemRef}
|
||||||
|
icon={
|
||||||
|
isMuted ? (
|
||||||
|
<BellOffIcon className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<BellIcon className="h-4 w-4" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
label={
|
||||||
|
isMuted
|
||||||
|
? t('app:chats.unmute', { defaultValue: 'Stummschaltung aufheben' })
|
||||||
|
: t('app:chats.mute', { defaultValue: 'Stummschalten' })
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
if (isMuted) void handleMute(null);
|
||||||
|
else setSubmenuOpen((v) => (v === 'mute' ? null : 'mute'));
|
||||||
|
}}
|
||||||
|
hasSubmenu={!isMuted}
|
||||||
|
/>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
|
||||||
|
{open &&
|
||||||
|
submenuOpen === 'mute' &&
|
||||||
|
submenuPos &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
role="menu"
|
||||||
|
style={{ top: submenuPos.top, left: submenuPos.left }}
|
||||||
|
className="fixed z-50 w-48 rounded-lg border border-line bg-surface-3 p-1 shadow-xl"
|
||||||
|
>
|
||||||
|
{MUTE_OPTIONS.map((opt) => (
|
||||||
|
<MenuItem
|
||||||
|
key={opt.key}
|
||||||
|
label={t(opt.labelKey, { defaultValue: opt.labelDefault })}
|
||||||
|
onClick={() => void handleMute(opt.minutes)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MenuItemProps {
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
hasSubmenu?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// React 18 requires forwardRef for function components to receive refs —
|
||||||
|
// without it the `ref` prop is stripped before reaching the component and
|
||||||
|
// measurement-dependent submenus never position.
|
||||||
|
const MenuItem = forwardRef<HTMLButtonElement, MenuItemProps>(
|
||||||
|
({ icon, label, onClick, hasSubmenu }, ref) => (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onClick();
|
||||||
|
}}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
className="flex w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg transition hover:bg-surface-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||||
|
>
|
||||||
|
{icon && <span className="shrink-0 text-fg-muted">{icon}</span>}
|
||||||
|
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||||
|
{hasSubmenu && <span className="shrink-0 text-xs text-fg-muted">›</span>}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
MenuItem.displayName = 'MenuItem';
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
|
import { type CrashEntry, subscribeCrashes } from '../lib/crashRecovery';
|
||||||
|
import { AlertIcon, XIcon } from './icons';
|
||||||
|
|
||||||
|
const VISIBLE_MS = 7_000;
|
||||||
|
const MAX_STACK = 3;
|
||||||
|
|
||||||
|
// Bottom-right stack of toasts for uncaught errors. Auto-dismisses each
|
||||||
|
// entry after VISIBLE_MS. The user can X-out earlier.
|
||||||
|
export function CrashToast() {
|
||||||
|
const [entries, setEntries] = useState<CrashEntry[]>([]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() =>
|
||||||
|
subscribeCrashes((entry) => {
|
||||||
|
setEntries((prev) => [...prev.slice(-(MAX_STACK - 1)), entry]);
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (entries.length === 0) return;
|
||||||
|
const latest = entries[entries.length - 1]!;
|
||||||
|
const id = window.setTimeout(() => {
|
||||||
|
setEntries((prev) => prev.filter((e) => e.id !== latest.id));
|
||||||
|
}, VISIBLE_MS);
|
||||||
|
return () => window.clearTimeout(id);
|
||||||
|
}, [entries]);
|
||||||
|
|
||||||
|
if (entries.length === 0) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
role="region"
|
||||||
|
aria-label="Fehler"
|
||||||
|
className="pointer-events-none fixed bottom-4 right-4 z-[100] flex flex-col gap-2"
|
||||||
|
>
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<div
|
||||||
|
key={entry.id}
|
||||||
|
role="alert"
|
||||||
|
className="pointer-events-auto flex max-w-sm items-start gap-2 rounded-lg border border-rose-500/40 bg-rose-500/10 p-3 text-sm text-rose-700 shadow-xl backdrop-blur-sm dark:text-rose-100"
|
||||||
|
>
|
||||||
|
<AlertIcon className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider">
|
||||||
|
{entry.source === 'promise' ? 'Promise-Fehler' : 'Unerwarteter Fehler'}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 break-words text-xs">{entry.message}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Schließen"
|
||||||
|
onClick={() =>
|
||||||
|
setEntries((prev) => prev.filter((e) => e.id !== entry.id))
|
||||||
|
}
|
||||||
|
className="shrink-0 cursor-pointer rounded-md p-1 text-rose-700 transition hover:bg-rose-500/20 dark:text-rose-100"
|
||||||
|
>
|
||||||
|
<XIcon className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import {
|
||||||
|
type DeviceRecord,
|
||||||
|
restoreDeviceFromServerRecord,
|
||||||
|
} from '@chat-app/shared/auth';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import {
|
||||||
|
decodePrivateKeyFromBackup,
|
||||||
|
importDeviceBackup,
|
||||||
|
normalizeRecoveryCode,
|
||||||
|
} from '../lib/deviceBackup';
|
||||||
|
import { devLocalSecretStore } from '../lib/secretStore';
|
||||||
|
import { supabase } from '../lib/supabase';
|
||||||
|
import { writeLocalDeviceId } from '../lib/device';
|
||||||
|
import { AlertIcon, ArrowRightIcon, LockIcon, ShieldIcon, SpinnerIcon } from './icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
userId: string;
|
||||||
|
onRestored: (device: DeviceRecord) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restores a device from a user-provided backup string. The backup embeds
|
||||||
|
// userId + deviceId + X25519 private key; we verify userId matches the current
|
||||||
|
// session, confirm the device row still exists server-side, and then re-seed
|
||||||
|
// the local vault + cached deviceId so the app treats this install as the
|
||||||
|
// original device (conv-key bundles stay valid, no "awaiting" state).
|
||||||
|
export function DeviceRestore({ userId, onRestored }: Props) {
|
||||||
|
const { t } = useTranslation(['app', 'errors']);
|
||||||
|
const [backup, setBackup] = useState('');
|
||||||
|
const [passphrase, setPassphrase] = useState('');
|
||||||
|
const [mode, setMode] = useState<'passphrase' | 'recovery'>('passphrase');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!backup.trim() || passphrase.length < 1 || busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
let privateKey: Uint8Array | null = null;
|
||||||
|
try {
|
||||||
|
const secret =
|
||||||
|
mode === 'recovery' ? normalizeRecoveryCode(passphrase) : passphrase;
|
||||||
|
const payload = await importDeviceBackup(backup.trim(), secret);
|
||||||
|
privateKey = decodePrivateKeyFromBackup(payload);
|
||||||
|
|
||||||
|
const device = await restoreDeviceFromServerRecord({
|
||||||
|
client: supabase,
|
||||||
|
secretStore: devLocalSecretStore,
|
||||||
|
userId: payload.userId,
|
||||||
|
deviceId: payload.deviceId,
|
||||||
|
privateKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cache deviceId locally so findExistingDevice picks it up on next load.
|
||||||
|
writeLocalDeviceId(userId, device.id);
|
||||||
|
|
||||||
|
onRestored(device);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
if (privateKey) {
|
||||||
|
for (let i = 0; i < privateKey.length; i++) privateKey[i] = 0;
|
||||||
|
}
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[backup, passphrase, mode, userId, onRestored, busy],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="w-full max-w-md animate-slide-up rounded-2xl border border-white/10 bg-ink-900/70 p-7 shadow-glow backdrop-blur-xl sm:p-8"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-500/20 ring-1 ring-emerald-400/30">
|
||||||
|
<ShieldIcon className="h-5 w-5 text-emerald-300" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-display text-lg font-semibold text-white">
|
||||||
|
{t('app:backup.restore_title', { defaultValue: 'Backup wiederherstellen' })}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-neutral-400">
|
||||||
|
{t('app:backup.restore_subtitle', {
|
||||||
|
defaultValue: 'Bringe einen zuvor erstellten Backup-String + Passphrase mit.',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 space-y-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="block text-xs font-medium uppercase tracking-wide text-neutral-400">
|
||||||
|
{t('app:backup.backup_string', { defaultValue: 'Backup-String' })}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
required
|
||||||
|
rows={5}
|
||||||
|
value={backup}
|
||||||
|
onChange={(e) => setBackup(e.target.value)}
|
||||||
|
placeholder="chatapp-backup-v1…"
|
||||||
|
className="w-full resize-none rounded-lg border border-white/10 bg-ink-800 px-3 py-2.5 font-mono text-[11px] leading-relaxed text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="block text-xs font-medium uppercase tracking-wide text-neutral-400">
|
||||||
|
{mode === 'passphrase' ? 'Passphrase' : 'Recovery-Code'}
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setMode((m) => (m === 'passphrase' ? 'recovery' : 'passphrase'));
|
||||||
|
setPassphrase('');
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
className="cursor-pointer text-[11px] font-semibold text-brand-300 hover:underline"
|
||||||
|
>
|
||||||
|
{mode === 'passphrase'
|
||||||
|
? 'Passphrase vergessen? Recovery-Code nutzen'
|
||||||
|
: 'Stattdessen Passphrase eingeben'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type={mode === 'passphrase' ? 'password' : 'text'}
|
||||||
|
required
|
||||||
|
value={passphrase}
|
||||||
|
onChange={(e) => setPassphrase(e.target.value)}
|
||||||
|
placeholder={mode === 'recovery' ? 'XXXXXX-XXXXXX-XXXXXX-XXXXXX' : ''}
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete={mode === 'recovery' ? 'off' : 'current-password'}
|
||||||
|
className={
|
||||||
|
'w-full rounded-lg border border-white/10 bg-ink-800 px-3 py-2.5 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40 ' +
|
||||||
|
(mode === 'recovery' ? 'font-mono tracking-widest' : '')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{mode === 'recovery' && (
|
||||||
|
<p className="text-[11px] text-neutral-500">
|
||||||
|
Stattdessen den Recovery-Backup-String oben einfügen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 rounded-lg border border-brand-500/20 bg-brand-500/10 p-3 text-xs text-brand-100">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<LockIcon className="mt-0.5 h-4 w-4 shrink-0 text-brand-300" />
|
||||||
|
<span className="min-w-0 flex-1 break-words">
|
||||||
|
{t('app:backup.restore_hint', {
|
||||||
|
defaultValue:
|
||||||
|
'Nach Wiederherstellung übernimmt dieses Gerät die alte Identität — existierende Nachrichten sind wieder entschlüsselbar.',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={busy || backup.trim().length === 0 || passphrase.length === 0}
|
||||||
|
aria-busy={busy}
|
||||||
|
className="group mt-6 inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-gradient-to-br from-emerald-400 to-emerald-600 px-4 py-3 text-sm font-semibold text-white shadow-glow transition hover:from-emerald-300 hover:to-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-400/60 focus:ring-offset-2 focus:ring-offset-ink-900 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<>
|
||||||
|
<SpinnerIcon className="h-4 w-4" />
|
||||||
|
<span>{t('app:backup.restoring', { defaultValue: 'Wiederherstellen…' })}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span>{t('app:backup.restore_cta', { defaultValue: 'Gerät wiederherstellen' })}</span>
|
||||||
|
<ArrowRightIcon className="h-4 w-4 transition group-hover:translate-x-0.5" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="mt-4 flex items-start gap-3 rounded-lg border border-rose-500/20 bg-rose-500/10 p-3 text-sm text-rose-100"
|
||||||
|
>
|
||||||
|
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" />
|
||||||
|
<p className="min-w-0 flex-1 break-words">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
interface EmojiEntry {
|
||||||
|
e: string;
|
||||||
|
k: string[]; // search keywords (incl. name)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Category {
|
||||||
|
label: string;
|
||||||
|
entries: EmojiEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Curated emoji set — small enough to stay fast without a dependency, wide
|
||||||
|
// enough to cover everyday messaging. Keywords are the primary search
|
||||||
|
// surface; the emoji character itself is also matched so a user typing ❤️
|
||||||
|
// literally finds it.
|
||||||
|
const CATEGORIES: Category[] = [
|
||||||
|
{
|
||||||
|
label: 'Smileys',
|
||||||
|
entries: [
|
||||||
|
{ e: '😀', k: ['grin', 'smile', 'happy'] },
|
||||||
|
{ e: '😃', k: ['smile', 'happy'] },
|
||||||
|
{ e: '😄', k: ['smile', 'laugh'] },
|
||||||
|
{ e: '😁', k: ['grin', 'smile'] },
|
||||||
|
{ e: '😆', k: ['laugh', 'lol'] },
|
||||||
|
{ e: '😅', k: ['sweat', 'nervous', 'laugh'] },
|
||||||
|
{ e: '🤣', k: ['lol', 'rofl', 'laugh'] },
|
||||||
|
{ e: '😂', k: ['joy', 'laugh', 'tears'] },
|
||||||
|
{ e: '🙂', k: ['smile', 'slight'] },
|
||||||
|
{ e: '🙃', k: ['upside', 'irony'] },
|
||||||
|
{ e: '😉', k: ['wink'] },
|
||||||
|
{ e: '😊', k: ['blush', 'smile'] },
|
||||||
|
{ e: '😇', k: ['angel', 'innocent'] },
|
||||||
|
{ e: '🥰', k: ['love', 'hearts'] },
|
||||||
|
{ e: '😍', k: ['love', 'heart eyes'] },
|
||||||
|
{ e: '🤩', k: ['star', 'excited'] },
|
||||||
|
{ e: '😘', k: ['kiss'] },
|
||||||
|
{ e: '😗', k: ['kiss'] },
|
||||||
|
{ e: '😚', k: ['kiss'] },
|
||||||
|
{ e: '😙', k: ['kiss'] },
|
||||||
|
{ e: '🥲', k: ['tear', 'smile'] },
|
||||||
|
{ e: '😋', k: ['yum', 'tasty'] },
|
||||||
|
{ e: '😛', k: ['tongue'] },
|
||||||
|
{ e: '😜', k: ['tongue', 'wink'] },
|
||||||
|
{ e: '🤪', k: ['zany', 'silly'] },
|
||||||
|
{ e: '😝', k: ['tongue'] },
|
||||||
|
{ e: '🤑', k: ['money'] },
|
||||||
|
{ e: '🤗', k: ['hug'] },
|
||||||
|
{ e: '🤭', k: ['giggle', 'shy'] },
|
||||||
|
{ e: '🤫', k: ['shush', 'quiet'] },
|
||||||
|
{ e: '🤔', k: ['think'] },
|
||||||
|
{ e: '🤐', k: ['zip', 'quiet'] },
|
||||||
|
{ e: '🤨', k: ['raise brow'] },
|
||||||
|
{ e: '😐', k: ['neutral'] },
|
||||||
|
{ e: '😑', k: ['expressionless'] },
|
||||||
|
{ e: '😶', k: ['speechless'] },
|
||||||
|
{ e: '😏', k: ['smirk'] },
|
||||||
|
{ e: '😒', k: ['unamused'] },
|
||||||
|
{ e: '🙄', k: ['eye roll'] },
|
||||||
|
{ e: '😬', k: ['grimace', 'awkward'] },
|
||||||
|
{ e: '🤥', k: ['lying'] },
|
||||||
|
{ e: '😔', k: ['sad', 'pensive'] },
|
||||||
|
{ e: '😪', k: ['sleepy'] },
|
||||||
|
{ e: '😴', k: ['sleep'] },
|
||||||
|
{ e: '😷', k: ['mask', 'sick'] },
|
||||||
|
{ e: '🤒', k: ['sick', 'fever'] },
|
||||||
|
{ e: '🤕', k: ['injured'] },
|
||||||
|
{ e: '🤢', k: ['nauseated'] },
|
||||||
|
{ e: '🤮', k: ['vomit'] },
|
||||||
|
{ e: '🤧', k: ['sneeze'] },
|
||||||
|
{ e: '🥵', k: ['hot'] },
|
||||||
|
{ e: '🥶', k: ['cold'] },
|
||||||
|
{ e: '🥴', k: ['dizzy', 'woozy'] },
|
||||||
|
{ e: '😵', k: ['dizzy'] },
|
||||||
|
{ e: '🤯', k: ['mind blown'] },
|
||||||
|
{ e: '🤠', k: ['cowboy'] },
|
||||||
|
{ e: '🥳', k: ['party'] },
|
||||||
|
{ e: '😎', k: ['cool', 'sunglasses'] },
|
||||||
|
{ e: '🤓', k: ['nerd'] },
|
||||||
|
{ e: '🧐', k: ['monocle'] },
|
||||||
|
{ e: '😕', k: ['confused'] },
|
||||||
|
{ e: '😟', k: ['worried'] },
|
||||||
|
{ e: '🙁', k: ['frown'] },
|
||||||
|
{ e: '☹️', k: ['frown'] },
|
||||||
|
{ e: '😮', k: ['open mouth'] },
|
||||||
|
{ e: '😯', k: ['hushed'] },
|
||||||
|
{ e: '😲', k: ['astonished'] },
|
||||||
|
{ e: '😳', k: ['flushed'] },
|
||||||
|
{ e: '🥺', k: ['pleading'] },
|
||||||
|
{ e: '😦', k: ['frowning'] },
|
||||||
|
{ e: '😧', k: ['anguished'] },
|
||||||
|
{ e: '😨', k: ['fear'] },
|
||||||
|
{ e: '😰', k: ['anxious', 'sweat'] },
|
||||||
|
{ e: '😥', k: ['sad', 'relieved'] },
|
||||||
|
{ e: '😢', k: ['cry'] },
|
||||||
|
{ e: '😭', k: ['cry', 'loud'] },
|
||||||
|
{ e: '😱', k: ['scream', 'scared'] },
|
||||||
|
{ e: '😖', k: ['confounded'] },
|
||||||
|
{ e: '😣', k: ['persevere'] },
|
||||||
|
{ e: '😞', k: ['disappointed'] },
|
||||||
|
{ e: '😓', k: ['sweat'] },
|
||||||
|
{ e: '😩', k: ['weary'] },
|
||||||
|
{ e: '😫', k: ['tired'] },
|
||||||
|
{ e: '🥱', k: ['yawn'] },
|
||||||
|
{ e: '😤', k: ['triumph'] },
|
||||||
|
{ e: '😡', k: ['angry', 'rage'] },
|
||||||
|
{ e: '😠', k: ['angry'] },
|
||||||
|
{ e: '🤬', k: ['swear', 'curse'] },
|
||||||
|
{ e: '😈', k: ['devil'] },
|
||||||
|
{ e: '👿', k: ['imp'] },
|
||||||
|
{ e: '💀', k: ['skull', 'dead'] },
|
||||||
|
{ e: '🤡', k: ['clown'] },
|
||||||
|
{ e: '👻', k: ['ghost'] },
|
||||||
|
{ e: '👽', k: ['alien'] },
|
||||||
|
{ e: '🤖', k: ['robot'] },
|
||||||
|
{ e: '💩', k: ['poop', 'shit'] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Gestures',
|
||||||
|
entries: [
|
||||||
|
{ e: '👋', k: ['wave', 'hi'] },
|
||||||
|
{ e: '🤚', k: ['hand'] },
|
||||||
|
{ e: '🖐️', k: ['hand'] },
|
||||||
|
{ e: '✋', k: ['stop', 'high five'] },
|
||||||
|
{ e: '🖖', k: ['spock'] },
|
||||||
|
{ e: '👌', k: ['ok'] },
|
||||||
|
{ e: '🤌', k: ['pinch'] },
|
||||||
|
{ e: '🤏', k: ['small'] },
|
||||||
|
{ e: '✌️', k: ['peace', 'victory'] },
|
||||||
|
{ e: '🤞', k: ['crossed fingers'] },
|
||||||
|
{ e: '🤟', k: ['love you'] },
|
||||||
|
{ e: '🤘', k: ['rock'] },
|
||||||
|
{ e: '🤙', k: ['call me'] },
|
||||||
|
{ e: '👈', k: ['point left'] },
|
||||||
|
{ e: '👉', k: ['point right'] },
|
||||||
|
{ e: '👆', k: ['point up'] },
|
||||||
|
{ e: '🖕', k: ['middle finger', 'fuck'] },
|
||||||
|
{ e: '👇', k: ['point down'] },
|
||||||
|
{ e: '☝️', k: ['point up'] },
|
||||||
|
{ e: '👍', k: ['thumbs up', 'like'] },
|
||||||
|
{ e: '👎', k: ['thumbs down', 'dislike'] },
|
||||||
|
{ e: '✊', k: ['fist'] },
|
||||||
|
{ e: '👊', k: ['punch'] },
|
||||||
|
{ e: '🤛', k: ['fist left'] },
|
||||||
|
{ e: '🤜', k: ['fist right'] },
|
||||||
|
{ e: '👏', k: ['clap'] },
|
||||||
|
{ e: '🙌', k: ['raised hands'] },
|
||||||
|
{ e: '👐', k: ['open hands'] },
|
||||||
|
{ e: '🤲', k: ['palms'] },
|
||||||
|
{ e: '🙏', k: ['pray', 'thanks'] },
|
||||||
|
{ e: '✍️', k: ['write'] },
|
||||||
|
{ e: '💪', k: ['flex', 'strong'] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Hearts',
|
||||||
|
entries: [
|
||||||
|
{ e: '❤️', k: ['heart', 'love'] },
|
||||||
|
{ e: '🧡', k: ['orange heart'] },
|
||||||
|
{ e: '💛', k: ['yellow heart'] },
|
||||||
|
{ e: '💚', k: ['green heart'] },
|
||||||
|
{ e: '💙', k: ['blue heart'] },
|
||||||
|
{ e: '💜', k: ['purple heart'] },
|
||||||
|
{ e: '🖤', k: ['black heart'] },
|
||||||
|
{ e: '🤍', k: ['white heart'] },
|
||||||
|
{ e: '🤎', k: ['brown heart'] },
|
||||||
|
{ e: '💔', k: ['broken heart'] },
|
||||||
|
{ e: '❣️', k: ['heart exclamation'] },
|
||||||
|
{ e: '💕', k: ['hearts'] },
|
||||||
|
{ e: '💞', k: ['revolving hearts'] },
|
||||||
|
{ e: '💓', k: ['beating heart'] },
|
||||||
|
{ e: '💗', k: ['growing heart'] },
|
||||||
|
{ e: '💖', k: ['sparkle heart'] },
|
||||||
|
{ e: '💘', k: ['cupid'] },
|
||||||
|
{ e: '💝', k: ['heart gift'] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Animals & Food',
|
||||||
|
entries: [
|
||||||
|
{ e: '🐶', k: ['dog'] },
|
||||||
|
{ e: '🐱', k: ['cat'] },
|
||||||
|
{ e: '🐭', k: ['mouse'] },
|
||||||
|
{ e: '🐹', k: ['hamster'] },
|
||||||
|
{ e: '🐰', k: ['rabbit'] },
|
||||||
|
{ e: '🦊', k: ['fox'] },
|
||||||
|
{ e: '🐻', k: ['bear'] },
|
||||||
|
{ e: '🐼', k: ['panda'] },
|
||||||
|
{ e: '🐨', k: ['koala'] },
|
||||||
|
{ e: '🐯', k: ['tiger'] },
|
||||||
|
{ e: '🦁', k: ['lion'] },
|
||||||
|
{ e: '🐸', k: ['frog'] },
|
||||||
|
{ e: '🐵', k: ['monkey'] },
|
||||||
|
{ e: '🐔', k: ['chicken'] },
|
||||||
|
{ e: '🐧', k: ['penguin'] },
|
||||||
|
{ e: '🐦', k: ['bird'] },
|
||||||
|
{ e: '🦆', k: ['duck'] },
|
||||||
|
{ e: '🍎', k: ['apple'] },
|
||||||
|
{ e: '🍌', k: ['banana'] },
|
||||||
|
{ e: '🍕', k: ['pizza'] },
|
||||||
|
{ e: '🍔', k: ['burger'] },
|
||||||
|
{ e: '🍟', k: ['fries'] },
|
||||||
|
{ e: '🌭', k: ['hotdog'] },
|
||||||
|
{ e: '🍿', k: ['popcorn'] },
|
||||||
|
{ e: '🍣', k: ['sushi'] },
|
||||||
|
{ e: '🍩', k: ['donut'] },
|
||||||
|
{ e: '🍪', k: ['cookie'] },
|
||||||
|
{ e: '🎂', k: ['cake', 'birthday'] },
|
||||||
|
{ e: '🍰', k: ['cake'] },
|
||||||
|
{ e: '🍫', k: ['chocolate'] },
|
||||||
|
{ e: '🍺', k: ['beer'] },
|
||||||
|
{ e: '🍷', k: ['wine'] },
|
||||||
|
{ e: '🥂', k: ['cheers'] },
|
||||||
|
{ e: '☕', k: ['coffee'] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Objects & Symbols',
|
||||||
|
entries: [
|
||||||
|
{ e: '🔥', k: ['fire', 'lit'] },
|
||||||
|
{ e: '✨', k: ['sparkle'] },
|
||||||
|
{ e: '⭐', k: ['star'] },
|
||||||
|
{ e: '🌟', k: ['star glowing'] },
|
||||||
|
{ e: '💫', k: ['dizzy'] },
|
||||||
|
{ e: '💥', k: ['boom', 'explosion'] },
|
||||||
|
{ e: '⚡', k: ['lightning'] },
|
||||||
|
{ e: '☀️', k: ['sun'] },
|
||||||
|
{ e: '🌈', k: ['rainbow'] },
|
||||||
|
{ e: '☁️', k: ['cloud'] },
|
||||||
|
{ e: '🌧️', k: ['rain'] },
|
||||||
|
{ e: '❄️', k: ['snow'] },
|
||||||
|
{ e: '🎉', k: ['party', 'tada'] },
|
||||||
|
{ e: '🎊', k: ['confetti'] },
|
||||||
|
{ e: '🎁', k: ['gift'] },
|
||||||
|
{ e: '🎈', k: ['balloon'] },
|
||||||
|
{ e: '💯', k: ['100', 'perfect'] },
|
||||||
|
{ e: '✅', k: ['check'] },
|
||||||
|
{ e: '❌', k: ['x', 'no'] },
|
||||||
|
{ e: '⚠️', k: ['warning'] },
|
||||||
|
{ e: '❓', k: ['question'] },
|
||||||
|
{ e: '❗', k: ['exclamation'] },
|
||||||
|
{ e: '💬', k: ['speech'] },
|
||||||
|
{ e: '💭', k: ['thought'] },
|
||||||
|
{ e: '👀', k: ['eyes'] },
|
||||||
|
{ e: '🚀', k: ['rocket'] },
|
||||||
|
{ e: '🎵', k: ['music'] },
|
||||||
|
{ e: '🎶', k: ['music'] },
|
||||||
|
{ e: '🔔', k: ['bell'] },
|
||||||
|
{ e: '💡', k: ['idea', 'bulb'] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const RECENT_KEY = 'chat.emoji.recents.v1';
|
||||||
|
const RECENT_MAX = 24;
|
||||||
|
|
||||||
|
function loadRecents(): string[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(RECENT_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) return [];
|
||||||
|
return parsed.filter((x): x is string => typeof x === 'string').slice(0, RECENT_MAX);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveRecents(list: string[]): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(RECENT_KEY, JSON.stringify(list.slice(0, RECENT_MAX)));
|
||||||
|
} catch {
|
||||||
|
/* ignore quota */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onPick: (emoji: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmojiPicker({ open, onPick, onClose }: Props) {
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [recents, setRecents] = useState<string[]>(() => loadRecents());
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onDown = (e: MouseEvent) => {
|
||||||
|
const t = e.target as HTMLElement | null;
|
||||||
|
if (rootRef.current && t && !rootRef.current.contains(t) && !t.closest('[data-emoji-trigger]')) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
window.addEventListener('mousedown', onDown);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onKey);
|
||||||
|
window.removeEventListener('mousedown', onDown);
|
||||||
|
};
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) setQuery('');
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return CATEGORIES;
|
||||||
|
return CATEGORIES.map((cat) => ({
|
||||||
|
label: cat.label,
|
||||||
|
entries: cat.entries.filter(
|
||||||
|
(entry) =>
|
||||||
|
entry.e.includes(q) ||
|
||||||
|
entry.k.some((k) => k.includes(q)) ||
|
||||||
|
cat.label.toLowerCase().includes(q),
|
||||||
|
),
|
||||||
|
})).filter((cat) => cat.entries.length > 0);
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const handlePick = (emoji: string) => {
|
||||||
|
onPick(emoji);
|
||||||
|
const next = [emoji, ...recents.filter((e) => e !== emoji)].slice(0, RECENT_MAX);
|
||||||
|
setRecents(next);
|
||||||
|
saveRecents(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={rootRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Emoji auswählen"
|
||||||
|
className="absolute bottom-full right-0 z-30 mb-2 flex w-[320px] flex-col rounded-xl border border-line bg-surface-2 shadow-xl"
|
||||||
|
>
|
||||||
|
<div className="border-b border-line p-2">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
autoFocus
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Suchen…"
|
||||||
|
className="w-full rounded-md border border-line bg-surface-3 px-2.5 py-1.5 text-sm text-fg placeholder-fg-muted outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[320px] overflow-y-auto p-2">
|
||||||
|
{recents.length > 0 && !query && (
|
||||||
|
<CategoryBlock
|
||||||
|
label="Zuletzt"
|
||||||
|
entries={recents.map((e) => ({ e, k: [] }))}
|
||||||
|
onPick={handlePick}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{filtered.map((cat) => (
|
||||||
|
<CategoryBlock
|
||||||
|
key={cat.label}
|
||||||
|
label={cat.label}
|
||||||
|
entries={cat.entries}
|
||||||
|
onPick={handlePick}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<p className="py-4 text-center text-xs text-fg-muted">Keine Treffer</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategoryBlock({
|
||||||
|
label,
|
||||||
|
entries,
|
||||||
|
onPick,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
entries: EmojiEntry[];
|
||||||
|
onPick: (emoji: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="mb-2">
|
||||||
|
<p className="mb-1 px-1 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-8 gap-0.5">
|
||||||
|
{entries.map((entry, idx) => (
|
||||||
|
<button
|
||||||
|
key={entry.e + ':' + idx}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPick(entry.e)}
|
||||||
|
aria-label={entry.k[0] ?? entry.e}
|
||||||
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-lg transition hover:bg-surface-3"
|
||||||
|
>
|
||||||
|
{entry.e}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { Component, type ErrorInfo, Fragment, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { SpinnerIcon } from './icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
/**
|
||||||
|
* Optional scope label shown in logs / devtools. Defaults to `root` — set
|
||||||
|
* per boundary (e.g. `route`, `conversation`) so multiple boundaries can be
|
||||||
|
* distinguished at a glance.
|
||||||
|
*/
|
||||||
|
scope?: string;
|
||||||
|
/**
|
||||||
|
* If the retry count exceeds this, the boundary stops auto-retrying and
|
||||||
|
* shows a more helpful message (still without a button — Discord-style,
|
||||||
|
* the app keeps trying but hints the user to hold on or check network).
|
||||||
|
*/
|
||||||
|
maxAutoRetries?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
error: Error | null;
|
||||||
|
retryKey: number;
|
||||||
|
attempt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RETRY_DELAYS_MS = [2000, 4000, 8000, 15000, 30000];
|
||||||
|
|
||||||
|
// Discord-style error boundary.
|
||||||
|
// - Catches render-time errors in its subtree.
|
||||||
|
// - Shows a centred spinner + status text. Never renders a manual "Reload"
|
||||||
|
// button; the boundary remounts its children on an exponential-backoff
|
||||||
|
// schedule so the UI self-heals once the underlying issue clears (typical
|
||||||
|
// causes: a realtime reconnect, a transient network blip, or a race that
|
||||||
|
// only fires once).
|
||||||
|
// - Escalates the label after each failed retry so the user sees that the
|
||||||
|
// app is trying, rather than silent infinite spinning.
|
||||||
|
export class ErrorBoundary extends Component<Props, State> {
|
||||||
|
state: State = { error: null, retryKey: 0, attempt: 0 };
|
||||||
|
|
||||||
|
private retryTimer: number | null = null;
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): Partial<State> {
|
||||||
|
return { error };
|
||||||
|
}
|
||||||
|
|
||||||
|
override componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||||
|
const scope = this.props.scope ?? 'root';
|
||||||
|
// We explicitly log here — the boundary itself swallows the error from
|
||||||
|
// React, so without this the failure would be invisible in production.
|
||||||
|
console.error('[ErrorBoundary:' + scope + '] caught render error', error, info);
|
||||||
|
}
|
||||||
|
|
||||||
|
override componentDidUpdate(_prev: Props, prevState: State): void {
|
||||||
|
if (this.state.error && !prevState.error) {
|
||||||
|
this.scheduleRetry();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override componentWillUnmount(): void {
|
||||||
|
if (this.retryTimer !== null) {
|
||||||
|
window.clearTimeout(this.retryTimer);
|
||||||
|
this.retryTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleRetry(): void {
|
||||||
|
if (this.retryTimer !== null) return;
|
||||||
|
const attempt = this.state.attempt;
|
||||||
|
const delay =
|
||||||
|
RETRY_DELAYS_MS[Math.min(attempt, RETRY_DELAYS_MS.length - 1)] ??
|
||||||
|
RETRY_DELAYS_MS[RETRY_DELAYS_MS.length - 1] ??
|
||||||
|
30000;
|
||||||
|
this.retryTimer = window.setTimeout(() => {
|
||||||
|
this.retryTimer = null;
|
||||||
|
this.setState((prev) => ({
|
||||||
|
error: null,
|
||||||
|
retryKey: prev.retryKey + 1,
|
||||||
|
attempt: prev.attempt + 1,
|
||||||
|
}));
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
override render(): ReactNode {
|
||||||
|
if (this.state.error) {
|
||||||
|
const max = this.props.maxAutoRetries ?? RETRY_DELAYS_MS.length;
|
||||||
|
const escalated = this.state.attempt >= max;
|
||||||
|
return <RetryingScreen escalated={escalated} attempt={this.state.attempt} />;
|
||||||
|
}
|
||||||
|
// `retryKey` forces a remount of the subtree so hooks re-run cleanly after
|
||||||
|
// an error (otherwise stale state from the crashed tree can immediately
|
||||||
|
// re-throw). Use a keyed Fragment so the boundary doesn't inject an extra
|
||||||
|
// wrapper div — that would break `flex h-full` chains (e.g. AppShell →
|
||||||
|
// Outlet → page column).
|
||||||
|
return <Fragment key={this.state.retryKey}>{this.props.children}</Fragment>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function RetryingScreen({ escalated, attempt }: { escalated: boolean; attempt: number }) {
|
||||||
|
const primary = escalated
|
||||||
|
? 'Verbindungsprobleme…'
|
||||||
|
: attempt === 0
|
||||||
|
? 'Einen Moment bitte'
|
||||||
|
: 'Versuche erneut zu laden…';
|
||||||
|
const secondary = escalated
|
||||||
|
? 'Prüfe deine Internetverbindung. Wir versuchen es weiter.'
|
||||||
|
: 'Die App lädt sich gleich selbst neu.';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
className="flex min-h-screen w-full items-center justify-center bg-surface-3 px-6"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center gap-4 text-center">
|
||||||
|
<div className="relative flex h-16 w-16 items-center justify-center">
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-0 rounded-full border-2 border-accent/20"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-0 rounded-full border-2 border-accent border-r-transparent border-b-transparent animate-spin"
|
||||||
|
/>
|
||||||
|
<SpinnerIcon className="hidden" />
|
||||||
|
</div>
|
||||||
|
<div className="max-w-sm space-y-1.5">
|
||||||
|
<p className="font-display text-lg font-semibold text-fg">{primary}</p>
|
||||||
|
<p className="text-sm text-fg-muted">{secondary}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
|
||||||
|
import {
|
||||||
|
type AttachmentHandle,
|
||||||
|
type DecryptedMessage,
|
||||||
|
downloadAndDecryptAttachment,
|
||||||
|
encryptAndUploadAttachment,
|
||||||
|
insertAttachmentRow,
|
||||||
|
parseMessagePayload,
|
||||||
|
sendEncryptedMessage,
|
||||||
|
} from '@chat-app/shared/chat';
|
||||||
|
import { extractErrorCode } from '@chat-app/shared/i18n';
|
||||||
|
import { bytesToPgHex } from '@chat-app/shared/supabase';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { useConversationsContext } from '../context/ConversationsContext';
|
||||||
|
import { devLocalSecretStore } from '../lib/secretStore';
|
||||||
|
import { supabase } from '../lib/supabase';
|
||||||
|
import { Avatar } from './Avatar';
|
||||||
|
import { ForwardIcon, SpinnerIcon, UsersIcon, XIcon } from './icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
message: DecryptedMessage | null;
|
||||||
|
currentConversationId: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forwards a message's plaintext to one or more conversations. Attachments are
|
||||||
|
// NOT carried over yet (would require re-uploading + re-encrypting under the
|
||||||
|
// new conversation key); only the text payload is forwarded for now and the
|
||||||
|
// preview hints at the dropped attachment.
|
||||||
|
export function ForwardDialog({ open, message, currentConversationId, onClose }: Props) {
|
||||||
|
const { t } = useTranslation(['app', 'errors']);
|
||||||
|
const { session, device } = useAuth();
|
||||||
|
const { conversations } = useConversationsContext();
|
||||||
|
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setSelected(new Set());
|
||||||
|
setError(null);
|
||||||
|
setDone(false);
|
||||||
|
}, [open, message?.id]);
|
||||||
|
|
||||||
|
const targets = useMemo(() => {
|
||||||
|
return conversations
|
||||||
|
.filter((c) => c.id !== currentConversationId && c.acceptedByMe)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const ta = a.lastMessageAt ?? a.createdAt;
|
||||||
|
const tb = b.lastMessageAt ?? b.createdAt;
|
||||||
|
return tb.localeCompare(ta);
|
||||||
|
});
|
||||||
|
}, [conversations, currentConversationId]);
|
||||||
|
|
||||||
|
const preview = useMemo(() => {
|
||||||
|
if (!message?.plaintext) return '';
|
||||||
|
const p = parseMessagePayload(message.plaintext);
|
||||||
|
if (p.kind !== 'text') return '';
|
||||||
|
return p.text.length > 140 ? p.text.slice(0, 140) + '…' : p.text;
|
||||||
|
}, [message]);
|
||||||
|
|
||||||
|
const sourceAttachments = useMemo<AttachmentHandle[]>(() => {
|
||||||
|
if (!message?.plaintext) return [];
|
||||||
|
const p = parseMessagePayload(message.plaintext);
|
||||||
|
return p.kind === 'text' ? p.attachments : [];
|
||||||
|
}, [message]);
|
||||||
|
|
||||||
|
if (!open || !message) return null;
|
||||||
|
|
||||||
|
function toggle(id: string) {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSend() {
|
||||||
|
if (!session?.user.id || !device?.id || !message) return;
|
||||||
|
if (selected.size === 0) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const priv = await loadDevicePrivateKey(devLocalSecretStore, session.user.id, device.id);
|
||||||
|
if (!priv) throw new Error('private key not loaded');
|
||||||
|
|
||||||
|
const hasAttachments = sourceAttachments.length > 0;
|
||||||
|
const text = preview || (hasAttachments ? '' : '');
|
||||||
|
if (!text && !hasAttachments) throw new Error('nothing to forward');
|
||||||
|
|
||||||
|
// Download+decrypt source attachments ONCE (same plaintext goes to every
|
||||||
|
// target). For each target conv we re-encrypt under fresh per-attachment
|
||||||
|
// keys and re-upload under the target conv's storage folder — source and
|
||||||
|
// target conv-keys differ, so the bytes must actually move.
|
||||||
|
const decryptedBlobs: { mime: string; size: number; width?: number; height?: number; blob: Blob }[] =
|
||||||
|
[];
|
||||||
|
for (const h of sourceAttachments) {
|
||||||
|
const blob = await downloadAndDecryptAttachment({ client: supabase, handle: h });
|
||||||
|
const entry: {
|
||||||
|
mime: string;
|
||||||
|
size: number;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
blob: Blob;
|
||||||
|
} = { mime: h.mimeType, size: h.sizeBytes, blob };
|
||||||
|
if (h.width !== undefined) entry.width = h.width;
|
||||||
|
if (h.height !== undefined) entry.height = h.height;
|
||||||
|
decryptedBlobs.push(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const convId of selected) {
|
||||||
|
const newHandles: AttachmentHandle[] = [];
|
||||||
|
const blobNonceHex = new Map<string, string>();
|
||||||
|
for (const src of decryptedBlobs) {
|
||||||
|
const res = await encryptAndUploadAttachment({
|
||||||
|
client: supabase,
|
||||||
|
conversationId: convId,
|
||||||
|
file: src.blob,
|
||||||
|
mimeType: src.mime,
|
||||||
|
sizeBytes: src.size,
|
||||||
|
...(src.width !== undefined ? { width: src.width } : {}),
|
||||||
|
...(src.height !== undefined ? { height: src.height } : {}),
|
||||||
|
});
|
||||||
|
newHandles.push(res.handle);
|
||||||
|
blobNonceHex.set(res.handle.id, bytesToPgHex(res.nonce));
|
||||||
|
}
|
||||||
|
|
||||||
|
const msg = await sendEncryptedMessage({
|
||||||
|
client: supabase,
|
||||||
|
conversationId: convId,
|
||||||
|
plaintext: text,
|
||||||
|
senderUserId: session.user.id,
|
||||||
|
senderDeviceId: device.id,
|
||||||
|
senderPrivateKey: priv,
|
||||||
|
...(newHandles.length > 0 ? { attachmentHandles: newHandles } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const h of newHandles) {
|
||||||
|
const bn = blobNonceHex.get(h.id) ?? '\\x';
|
||||||
|
await insertAttachmentRow(supabase, msg.id, h, bn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setDone(true);
|
||||||
|
window.setTimeout(onClose, 700);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const code = extractErrorCode(err);
|
||||||
|
setError(
|
||||||
|
code
|
||||||
|
? t('errors:' + code, { defaultValue: t('errors:generic') })
|
||||||
|
: err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: t('errors:generic'),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t('app:chats.forward', { defaultValue: 'Weiterleiten' })}
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="flex max-h-[80vh] w-full max-w-md flex-col overflow-hidden rounded-2xl border border-line bg-surface-3 shadow-xl"
|
||||||
|
>
|
||||||
|
<header className="flex items-center justify-between border-b border-line px-5 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ForwardIcon className="h-4 w-4 text-accent" />
|
||||||
|
<h3 className="font-display text-sm font-semibold text-fg">
|
||||||
|
{t('app:chats.forward', { defaultValue: 'Weiterleiten' })}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close"
|
||||||
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-2 hover:text-fg"
|
||||||
|
>
|
||||||
|
<XIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="border-b border-line bg-surface-2 px-5 py-3">
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||||
|
{t('app:chats.forward_preview', { defaultValue: 'Vorschau' })}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 line-clamp-3 break-words text-sm text-fg">
|
||||||
|
{preview || (sourceAttachments.length > 0 ? '📎' : '…')}
|
||||||
|
</p>
|
||||||
|
{sourceAttachments.length > 0 && (
|
||||||
|
<p className="mt-1 text-[11px] text-fg-muted">
|
||||||
|
📎{' '}
|
||||||
|
{t('app:chats.forward_attachments_count', {
|
||||||
|
count: sourceAttachments.length,
|
||||||
|
defaultValue: '{{count}} Anhang wird mit weitergeleitet',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-2">
|
||||||
|
{targets.length === 0 ? (
|
||||||
|
<p className="px-4 py-6 text-center text-sm text-fg-muted">
|
||||||
|
{t('app:chats.forward_no_targets', {
|
||||||
|
defaultValue: 'Keine anderen Unterhaltungen verfügbar.',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{targets.map((c) => {
|
||||||
|
const isGroup = c.type === 'group';
|
||||||
|
const title = isGroup ? c.name ?? '?' : c.peer?.displayName ?? '?';
|
||||||
|
const avatarUrl = isGroup ? c.avatarUrl ?? null : c.peer?.avatarUrl ?? null;
|
||||||
|
const checked = selected.has(c.id);
|
||||||
|
return (
|
||||||
|
<li key={c.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggle(c.id)}
|
||||||
|
className={
|
||||||
|
'flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||||
|
(checked ? 'bg-accent/15' : 'hover:bg-surface-2')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{avatarUrl ? (
|
||||||
|
<Avatar
|
||||||
|
url={avatarUrl}
|
||||||
|
displayName={title}
|
||||||
|
className="h-9 w-9 text-sm"
|
||||||
|
/>
|
||||||
|
) : isGroup ? (
|
||||||
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-accent/20 text-accent">
|
||||||
|
<UsersIcon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Avatar
|
||||||
|
url={null}
|
||||||
|
displayName={title}
|
||||||
|
className="h-9 w-9 text-sm"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="min-w-0 flex-1 truncate text-sm font-medium text-fg">
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={
|
||||||
|
'flex h-5 w-5 shrink-0 items-center justify-center rounded border ' +
|
||||||
|
(checked ? 'border-accent bg-accent text-accent-fg' : 'border-line bg-surface-2')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{checked && (
|
||||||
|
<svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
className="border-t border-rose-500/30 bg-rose-500/10 px-5 py-2 text-xs text-rose-700 dark:text-rose-200"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<footer className="flex items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg transition hover:bg-surface-2"
|
||||||
|
>
|
||||||
|
{t('app:friends.action_cancel', { defaultValue: 'Abbrechen' })}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy || selected.size === 0 || done}
|
||||||
|
onClick={() => void handleSend()}
|
||||||
|
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{busy && <SpinnerIcon className="h-4 w-4" />}
|
||||||
|
<span>
|
||||||
|
{done
|
||||||
|
? t('app:chats.forward_done', { defaultValue: 'Gesendet' })
|
||||||
|
: t('app:chats.forward_send', {
|
||||||
|
count: selected.size,
|
||||||
|
defaultValue: 'An {{count}} senden',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -123,15 +123,25 @@ export function GroupInfoPanel({ open, onClose, conversation }: Props) {
|
|||||||
{conversation.members.map((m) => {
|
{conversation.members.map((m) => {
|
||||||
const name = m.profile?.displayName ?? '?';
|
const name = m.profile?.displayName ?? '?';
|
||||||
const handle = m.profile?.username ? '@' + m.profile.username : '';
|
const handle = m.profile?.username ? '@' + m.profile.username : '';
|
||||||
|
const avatarUrl = m.profile?.avatarUrl ?? null;
|
||||||
const letter = name.trim().charAt(0).toUpperCase() || '?';
|
const letter = name.trim().charAt(0).toUpperCase() || '?';
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={m.userId}
|
key={m.userId}
|
||||||
className="flex items-center gap-3 rounded-lg px-2 py-1.5"
|
className="flex items-center gap-3 rounded-lg px-2 py-1.5"
|
||||||
>
|
>
|
||||||
|
{avatarUrl ? (
|
||||||
|
<img
|
||||||
|
src={avatarUrl}
|
||||||
|
alt=""
|
||||||
|
className="h-8 w-8 shrink-0 rounded-full object-cover"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-xs font-semibold text-white ring-1 ring-brand-400/30">
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-xs font-semibold text-white ring-1 ring-brand-400/30">
|
||||||
{letter}
|
{letter}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-sm text-white">
|
<p className="truncate text-sm text-white">
|
||||||
{name}
|
{name}
|
||||||
@@ -187,9 +197,18 @@ export function GroupInfoPanel({ open, onClose, conversation }: Props) {
|
|||||||
onClick={() => void handleAdd(f.userId)}
|
onClick={() => void handleAdd(f.userId)}
|
||||||
className="flex w-full cursor-pointer items-center gap-3 rounded-lg px-2 py-1.5 text-left transition hover:bg-white/5 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 disabled:opacity-60"
|
className="flex w-full cursor-pointer items-center gap-3 rounded-lg px-2 py-1.5 text-left transition hover:bg-white/5 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 disabled:opacity-60"
|
||||||
>
|
>
|
||||||
|
{f.avatarUrl ? (
|
||||||
|
<img
|
||||||
|
src={f.avatarUrl}
|
||||||
|
alt=""
|
||||||
|
className="h-8 w-8 shrink-0 rounded-full object-cover"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-xs font-semibold text-white ring-1 ring-brand-400/30">
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-xs font-semibold text-white ring-1 ring-brand-400/30">
|
||||||
{letter}
|
{letter}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-sm text-white">{f.displayName}</p>
|
<p className="truncate text-sm text-white">{f.displayName}</p>
|
||||||
<p className="truncate text-xs text-neutral-500">@{f.username}</p>
|
<p className="truncate text-xs text-neutral-500">@{f.username}</p>
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useCall } from '../context/CallContext';
|
||||||
|
import { useFriendshipsContext } from '../context/FriendshipsContext';
|
||||||
|
import { AvatarColorKey, colorKeyFor } from './CallParticipantTile';
|
||||||
|
import { LockIcon, PhoneIcon, PhoneOffIcon, VideoIcon } from './icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
conversation: ConversationSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PULSE_BG: Record<AvatarColorKey, string> = {
|
||||||
|
violet: 'bg-violet-200 text-violet-800 dark:bg-violet-900/60 dark:text-violet-200',
|
||||||
|
amber: 'bg-amber-200 text-amber-900 dark:bg-amber-900/60 dark:text-amber-200',
|
||||||
|
rose: 'bg-rose-200 text-rose-900 dark:bg-rose-900/60 dark:text-rose-200',
|
||||||
|
teal: 'bg-teal-200 text-teal-900 dark:bg-teal-900/60 dark:text-teal-200',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Docked incoming-call panel: replaces the chat header, chat + composer stay
|
||||||
|
// visible and usable below (per user decision). Full-bleed overlay lives in
|
||||||
|
// the shell-level toast for cross-conversation ring notifications.
|
||||||
|
export function IncomingCallPanel({ conversation }: Props) {
|
||||||
|
const { t } = useTranslation(['app']);
|
||||||
|
const { state, acceptIncoming, rejectIncoming } = useCall();
|
||||||
|
const { friendships } = useFriendshipsContext();
|
||||||
|
|
||||||
|
if (state.kind !== 'incoming' || state.conversationId !== conversation.id) return null;
|
||||||
|
|
||||||
|
const isGroup = conversation.type === 'group';
|
||||||
|
const callerProfile = conversation.members.find((m) => m.userId === state.fromUserId)?.profile ?? null;
|
||||||
|
const callerName =
|
||||||
|
callerProfile?.displayName ??
|
||||||
|
friendships.find((f) => f.peer.userId === state.fromUserId)?.peer.displayName ??
|
||||||
|
'?';
|
||||||
|
const groupName = isGroup ? (conversation.name ?? t('app:chats.new_group')) : null;
|
||||||
|
const title = isGroup ? groupName ?? callerName : callerName;
|
||||||
|
const letter = title.trim().charAt(0).toUpperCase() || '?';
|
||||||
|
const avatarUrl = isGroup
|
||||||
|
? (conversation.avatarUrl ?? callerProfile?.avatarUrl ?? null)
|
||||||
|
: callerProfile?.avatarUrl ?? null;
|
||||||
|
const colorKey = colorKeyFor(state.fromUserId);
|
||||||
|
const avatarTone = PULSE_BG[colorKey];
|
||||||
|
const mediaLabel =
|
||||||
|
state.mediaKind === 'video' ? 'Video Call' : 'Voice Call';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
role="region"
|
||||||
|
aria-label={t('app:call.incoming_title')}
|
||||||
|
className="relative overflow-hidden border-b border-line bg-surface-3"
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_30%_20%,rgba(79,70,229,0.10),transparent_60%),radial-gradient(circle_at_70%_80%,rgba(109,115,255,0.08),transparent_55%)]" />
|
||||||
|
<div className="relative mx-auto flex max-w-[480px] flex-col items-center px-6 py-8 text-center">
|
||||||
|
<p className="text-[12px] font-semibold uppercase tracking-[0.12em] text-fg-muted">
|
||||||
|
{t('app:call.incoming_title')}
|
||||||
|
</p>
|
||||||
|
<div className="relative my-4 flex h-[112px] w-[112px] items-center justify-center">
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute inset-0 rounded-full border-2 border-accent animate-pulse-ring"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute inset-0 rounded-full border-2 border-accent animate-pulse-ring"
|
||||||
|
style={{ animationDelay: '1s' }}
|
||||||
|
/>
|
||||||
|
{avatarUrl ? (
|
||||||
|
<img
|
||||||
|
src={avatarUrl}
|
||||||
|
alt={title}
|
||||||
|
className="h-24 w-24 rounded-full border-[3px] border-surface-3 object-cover"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'flex h-24 w-24 items-center justify-center rounded-full border-[3px] border-surface-3 font-display text-4xl font-bold ' +
|
||||||
|
avatarTone
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{letter}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="font-display text-2xl font-bold tracking-tight text-fg">{title}</p>
|
||||||
|
<p className="mt-1.5 flex items-center gap-1.5 text-xs text-fg-muted">
|
||||||
|
<LockIcon className="h-3.5 w-3.5" />
|
||||||
|
<span>E2E verschlüsselt · {mediaLabel}</span>
|
||||||
|
{isGroup && (
|
||||||
|
<>
|
||||||
|
<span className="text-fg-muted/50" aria-hidden="true">·</span>
|
||||||
|
<span>
|
||||||
|
{t('app:call.incoming_group_from', {
|
||||||
|
name: callerName,
|
||||||
|
defaultValue: callerName + ' ruft an',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div className="mt-5 flex w-full max-w-[440px] flex-wrap gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={rejectIncoming}
|
||||||
|
className="inline-flex flex-1 basis-[30%] cursor-pointer items-center justify-center gap-2 rounded-[14px] border border-rose-500/40 bg-transparent px-4 py-3.5 text-sm font-semibold text-rose-600 transition hover:bg-rose-500/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-500/40 dark:text-rose-400"
|
||||||
|
>
|
||||||
|
<PhoneOffIcon className="h-4 w-4" />
|
||||||
|
<span>{t('app:call.decline')}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void acceptIncoming('audio')}
|
||||||
|
className="inline-flex flex-1 basis-[30%] cursor-pointer items-center justify-center gap-2 rounded-[14px] bg-emerald-600 px-4 py-3.5 text-sm font-semibold text-white shadow-accept-btn transition hover:bg-emerald-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50"
|
||||||
|
>
|
||||||
|
<PhoneIcon className="h-4 w-4" />
|
||||||
|
<span>
|
||||||
|
{state.mediaKind === 'video'
|
||||||
|
? t('app:call.accept_audio', { defaultValue: 'Nur Audio' })
|
||||||
|
: t('app:call.accept')}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{state.mediaKind === 'video' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void acceptIncoming('video')}
|
||||||
|
className="inline-flex flex-1 basis-[30%] cursor-pointer items-center justify-center gap-2 rounded-[14px] bg-accent px-4 py-3.5 text-sm font-semibold text-accent-fg shadow-accept-btn transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
|
||||||
|
>
|
||||||
|
<VideoIcon className="h-4 w-4" />
|
||||||
|
<span>{t('app:call.accept_video', { defaultValue: 'Mit Video' })}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,8 +15,8 @@ export function LanguageSwitcher({ compact = false }: { compact?: boolean }) {
|
|||||||
role="group"
|
role="group"
|
||||||
aria-label="Language"
|
aria-label="Language"
|
||||||
className={
|
className={
|
||||||
'inline-flex items-center rounded-full border border-white/10 bg-white/5 p-0.5 text-[11px] font-medium ' +
|
'inline-flex items-center rounded-full border border-line bg-surface-2 p-0.5 text-[11px] font-medium ' +
|
||||||
(compact ? '' : 'backdrop-blur')
|
(compact ? '' : '')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{SUPPORTED_LOCALES.map((locale) => {
|
{SUPPORTED_LOCALES.map((locale) => {
|
||||||
@@ -30,10 +30,10 @@ export function LanguageSwitcher({ compact = false }: { compact?: boolean }) {
|
|||||||
if (!active) void changeLocale(locale);
|
if (!active) void changeLocale(locale);
|
||||||
}}
|
}}
|
||||||
className={
|
className={
|
||||||
'cursor-pointer rounded-full px-2.5 py-1 transition focus:outline-none focus:ring-2 focus:ring-brand-400/40 ' +
|
'cursor-pointer rounded-full px-2.5 py-1 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||||
(active
|
(active
|
||||||
? 'bg-brand-500/25 text-white ring-1 ring-brand-400/40'
|
? 'bg-accent/15 text-accent ring-1 ring-accent/30'
|
||||||
: 'text-neutral-400 hover:text-neutral-200')
|
: 'text-fg-muted hover:text-fg')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{LABELS[locale]}
|
{LABELS[locale]}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { useLinkPreview } from '../lib/useLinkPreview';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renders a compact OpenGraph preview card under a message bubble. Fetches
|
||||||
|
// lazily through the edge function; silent when the URL returned no meta.
|
||||||
|
export function LinkPreviewCard({ url }: Props) {
|
||||||
|
const preview = useLinkPreview(url);
|
||||||
|
if (!preview || !preview.ok) return null;
|
||||||
|
if (!preview.title && !preview.description && !preview.imageUrl) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
className="mt-2 flex max-w-[320px] overflow-hidden rounded-lg border border-line bg-surface-2 text-sm no-underline transition hover:bg-surface-3"
|
||||||
|
>
|
||||||
|
{preview.imageUrl && (
|
||||||
|
<img
|
||||||
|
src={preview.imageUrl}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
className="h-20 w-20 shrink-0 object-cover"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col justify-center gap-0.5 p-2.5">
|
||||||
|
{preview.siteName && (
|
||||||
|
<p className="truncate text-[10px] uppercase tracking-wider text-fg-muted">
|
||||||
|
{preview.siteName}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{preview.title && (
|
||||||
|
<p className="line-clamp-2 text-sm font-semibold text-fg">
|
||||||
|
{preview.title}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{preview.description && (
|
||||||
|
<p className="line-clamp-2 text-xs text-fg-muted">{preview.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { Avatar } from './Avatar';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
members: ConversationSummary['members'];
|
||||||
|
query: string;
|
||||||
|
excludeUserId: string | undefined;
|
||||||
|
onSelect: (username: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dropdown shown above the composer when the user has typed `@` followed
|
||||||
|
// by the start of a member name. Keyboard-first — arrow keys move through,
|
||||||
|
// enter/tab commits, escape cancels.
|
||||||
|
export function MentionAutocomplete({
|
||||||
|
members,
|
||||||
|
query,
|
||||||
|
excludeUserId,
|
||||||
|
onSelect,
|
||||||
|
onClose,
|
||||||
|
}: Props) {
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
const matches = members
|
||||||
|
.filter((m) => m.userId !== excludeUserId)
|
||||||
|
.filter((m) => {
|
||||||
|
if (!q) return true;
|
||||||
|
const name = (m.profile?.displayName ?? '').toLowerCase();
|
||||||
|
const handle = (m.profile?.username ?? '').toLowerCase();
|
||||||
|
return name.includes(q) || handle.includes(q);
|
||||||
|
})
|
||||||
|
.slice(0, 8);
|
||||||
|
|
||||||
|
const [active, setActive] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
setActive(0);
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (matches.length === 0) return;
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
setActive((i) => (i + 1) % matches.length);
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setActive((i) => (i - 1 + matches.length) % matches.length);
|
||||||
|
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
||||||
|
e.preventDefault();
|
||||||
|
const pick = matches[active];
|
||||||
|
if (pick?.profile?.username) onSelect(pick.profile.username);
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey, true);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onKey, true);
|
||||||
|
};
|
||||||
|
}, [matches, active, onSelect, onClose]);
|
||||||
|
|
||||||
|
if (matches.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="listbox"
|
||||||
|
aria-label="Mitglieder"
|
||||||
|
className="absolute bottom-full left-0 right-0 z-20 mb-2 max-h-64 overflow-y-auto rounded-xl border border-line bg-surface-2 p-1 shadow-xl"
|
||||||
|
>
|
||||||
|
{matches.map((m, idx) => {
|
||||||
|
const name = m.profile?.displayName ?? m.profile?.username ?? '?';
|
||||||
|
const handle = m.profile?.username ?? '';
|
||||||
|
const isActive = idx === active;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={m.userId}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={isActive}
|
||||||
|
onMouseEnter={() => setActive(idx)}
|
||||||
|
onClick={() => {
|
||||||
|
if (m.profile?.username) onSelect(m.profile.username);
|
||||||
|
}}
|
||||||
|
className={
|
||||||
|
'flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ' +
|
||||||
|
(isActive ? 'bg-accent/20 text-fg' : 'text-fg-muted hover:bg-surface-3')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
displayName={name}
|
||||||
|
url={m.profile?.avatarUrl ?? null}
|
||||||
|
className="h-6 w-6 text-[10px]"
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-fg">{name}</span>
|
||||||
|
<span className="shrink-0 text-[10px] text-fg-muted">@{handle}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,30 +13,73 @@ import { useAuth } from '../context/AuthContext';
|
|||||||
import { devLocalSecretStore } from '../lib/secretStore';
|
import { devLocalSecretStore } from '../lib/secretStore';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
import type { AggregatedReaction } from '../lib/useMessageReactions';
|
import type { AggregatedReaction } from '../lib/useMessageReactions';
|
||||||
|
import { extractFirstUrl } from '../lib/useLinkPreview';
|
||||||
|
import { AttachmentAudio } from './AttachmentAudio';
|
||||||
|
import { AttachmentGeneric } from './AttachmentGeneric';
|
||||||
import { AttachmentImage } from './AttachmentImage';
|
import { AttachmentImage } from './AttachmentImage';
|
||||||
import { PencilIcon, PhoneIcon, PhoneOffIcon, SmileIcon, SpinnerIcon, TrashIcon, XIcon } from './icons';
|
import { AttachmentPdf } from './AttachmentPdf';
|
||||||
|
import { AttachmentVideo } from './AttachmentVideo';
|
||||||
|
import { LinkPreviewCard } from './LinkPreviewCard';
|
||||||
|
import { Avatar } from './Avatar';
|
||||||
|
import { ForwardIcon, PencilIcon, PhoneIcon, PhoneOffIcon, ReplyIcon, SmileIcon, SpinnerIcon, TrashIcon, XIcon } from './icons';
|
||||||
|
|
||||||
const EMOJI_CHOICES = ['👍', '❤️', '😂', '🎉', '🔥', '😮', '😢', '🙏'];
|
const EMOJI_CHOICES = ['👍', '❤️', '😂', '🎉', '🔥', '😮', '😢', '🙏'];
|
||||||
const EDIT_WINDOW_MS = 24 * 60 * 60 * 1000;
|
const EDIT_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export interface QuotedRef {
|
||||||
|
id: string;
|
||||||
|
senderName: string;
|
||||||
|
snippet: string;
|
||||||
|
isAttachment: boolean;
|
||||||
|
deleted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
message: DecryptedMessage;
|
message: DecryptedMessage;
|
||||||
mine: boolean;
|
mine: boolean;
|
||||||
groupedWithPrev: boolean;
|
groupedWithPrev: boolean;
|
||||||
|
/** Last message of a run from this sender — anchor avatar on this row. */
|
||||||
|
isLastOfRun?: boolean;
|
||||||
|
senderDisplayName?: string | null | undefined;
|
||||||
|
senderAvatarUrl?: string | null | undefined;
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
reactions: AggregatedReaction[];
|
reactions: AggregatedReaction[];
|
||||||
onToggleReaction: (emoji: string) => Promise<void>;
|
onToggleReaction: (emoji: string) => Promise<void>;
|
||||||
showSeen?: boolean;
|
showSeen?: boolean;
|
||||||
|
/** Delivery state for own messages: 'sent' / 'delivered' / 'read'. */
|
||||||
|
deliveryState?: 'sent' | 'delivered' | 'read';
|
||||||
|
/** Resolved quoted message info (parent does the lookup). */
|
||||||
|
quoted?: QuotedRef | null;
|
||||||
|
/** Tap-to-jump on quote bubble. Receives the quoted message's id. */
|
||||||
|
onJumpToMessage?: (id: string) => void;
|
||||||
|
/** Hover action: parent receives current message to start a reply. */
|
||||||
|
onReply?: (m: DecryptedMessage) => void;
|
||||||
|
/** Hover action: parent opens forward dialog for current message. */
|
||||||
|
onForward?: (m: DecryptedMessage) => void;
|
||||||
|
/** Click on the message's avatar surfaces the author's profile card. */
|
||||||
|
onAvatarClick?: (userId: string, ev: React.MouseEvent) => void;
|
||||||
|
/** Highlighted state — set briefly after a jump. */
|
||||||
|
highlighted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageBubble({
|
export function MessageBubble({
|
||||||
message,
|
message,
|
||||||
mine,
|
mine,
|
||||||
groupedWithPrev,
|
groupedWithPrev,
|
||||||
|
isLastOfRun = false,
|
||||||
|
senderDisplayName,
|
||||||
|
senderAvatarUrl,
|
||||||
conversationId,
|
conversationId,
|
||||||
reactions,
|
reactions,
|
||||||
onToggleReaction,
|
onToggleReaction,
|
||||||
showSeen = false,
|
showSeen = false,
|
||||||
|
deliveryState,
|
||||||
|
quoted = null,
|
||||||
|
onJumpToMessage,
|
||||||
|
onReply,
|
||||||
|
onForward,
|
||||||
|
onAvatarClick,
|
||||||
|
highlighted = false,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation(['app']);
|
const { t } = useTranslation(['app']);
|
||||||
const { session, device } = useAuth();
|
const { session, device } = useAuth();
|
||||||
@@ -57,6 +100,47 @@ export function MessageBubble({
|
|||||||
const withinEditWindow = age < EDIT_WINDOW_MS;
|
const withinEditWindow = age < EDIT_WINDOW_MS;
|
||||||
const bodyText = initialText;
|
const bodyText = initialText;
|
||||||
const attachments = initialAttachments;
|
const attachments = initialAttachments;
|
||||||
|
|
||||||
|
// /tempmsg ephemeral window. expireMs embedded in plaintext JSON; sender
|
||||||
|
// fires the soft-delete when the clock runs out. Receivers just watch
|
||||||
|
// the deletedAt flip via realtime.
|
||||||
|
const expireMs = parsed.kind === 'text' ? parsed.expireMs : undefined;
|
||||||
|
const [tickNow, setTickNow] = useState<number>(() => Date.now());
|
||||||
|
useEffect(() => {
|
||||||
|
if (expireMs === undefined) return;
|
||||||
|
const id = window.setInterval(() => setTickNow(Date.now()), 1000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [expireMs]);
|
||||||
|
const msLeft =
|
||||||
|
expireMs !== undefined
|
||||||
|
? Math.max(0, expireMs - (tickNow - createdAt.getTime()))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mine) return;
|
||||||
|
if (expireMs === undefined) return;
|
||||||
|
if (message.deletedAt) return;
|
||||||
|
const remaining = Math.max(0, expireMs - age);
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
void softDeleteMessage(supabase, message.id).catch((err: unknown) => {
|
||||||
|
console.warn('ephemeral auto-delete failed', err);
|
||||||
|
});
|
||||||
|
}, remaining);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [mine, expireMs, age, message.id, message.deletedAt]);
|
||||||
|
|
||||||
|
// Receiver-side auto-hide when the expiry window elapses even if the
|
||||||
|
// sender's delete hasn't propagated yet (network hiccup, offline-sender).
|
||||||
|
const [localExpired, setLocalExpired] = useState<boolean>(
|
||||||
|
msLeft !== null && msLeft <= 0,
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (expireMs === undefined) return;
|
||||||
|
if (localExpired) return;
|
||||||
|
const remaining = Math.max(0, expireMs - age);
|
||||||
|
const t = window.setTimeout(() => setLocalExpired(true), remaining);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [expireMs, age, localExpired]);
|
||||||
const canEdit =
|
const canEdit =
|
||||||
parsed.kind === 'text' &&
|
parsed.kind === 'text' &&
|
||||||
mine &&
|
mine &&
|
||||||
@@ -141,10 +225,18 @@ export function MessageBubble({
|
|||||||
[onToggleReaction],
|
[onToggleReaction],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (message.deletedAt) {
|
if (message.deletedAt || localExpired) {
|
||||||
return (
|
return (
|
||||||
<div className={'flex ' + (mine ? 'justify-end' : 'justify-start')}>
|
<div className={'flex items-end gap-2 ' + (mine ? 'flex-row-reverse' : '')}>
|
||||||
<div className="max-w-[70%] rounded-2xl border border-white/5 bg-white/5 px-3.5 py-1.5 text-xs italic text-neutral-500">
|
<AvatarSlot
|
||||||
|
show={isLastOfRun}
|
||||||
|
url={senderAvatarUrl ?? null}
|
||||||
|
displayName={senderDisplayName ?? null}
|
||||||
|
{...(onAvatarClick
|
||||||
|
? { onClick: (ev: React.MouseEvent) => onAvatarClick(message.senderId, ev) }
|
||||||
|
: {})}
|
||||||
|
/>
|
||||||
|
<div className="max-w-[calc(70%-2.5rem)] rounded-2xl border border-line bg-surface-2 px-3.5 py-1.5 text-xs italic text-fg-muted">
|
||||||
{t('app:chats.deleted')}
|
{t('app:chats.deleted')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -156,14 +248,19 @@ export function MessageBubble({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'flex ' + (mine ? 'justify-end' : 'justify-start')}>
|
<div className={'flex items-end gap-2 ' + (mine ? 'flex-row-reverse' : '')}>
|
||||||
|
<AvatarSlot
|
||||||
|
show={isLastOfRun}
|
||||||
|
url={senderAvatarUrl ?? null}
|
||||||
|
displayName={senderDisplayName ?? null}
|
||||||
|
/>
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
'group relative max-w-[70%] ' + (groupedWithPrev ? 'mt-0.5' : 'mt-2')
|
'group relative max-w-[calc(70%-2.5rem)] ' + (groupedWithPrev ? 'mt-0.5' : 'mt-2')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<div className="rounded-2xl border border-brand-400/40 bg-ink-900/80 p-2 backdrop-blur-xl">
|
<div className="rounded-2xl border border-accent/40 bg-surface-2 p-2 shadow-sm">
|
||||||
<textarea
|
<textarea
|
||||||
value={editText}
|
value={editText}
|
||||||
onChange={(e) => setEditText(e.target.value)}
|
onChange={(e) => setEditText(e.target.value)}
|
||||||
@@ -179,10 +276,10 @@ export function MessageBubble({
|
|||||||
}}
|
}}
|
||||||
rows={2}
|
rows={2}
|
||||||
autoFocus
|
autoFocus
|
||||||
className="w-full resize-none rounded-md bg-ink-800 px-3 py-2 text-sm text-white outline-none focus:ring-2 focus:ring-brand-400/60"
|
className="w-full resize-none rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg outline-none focus:ring-2 focus:ring-accent/50"
|
||||||
/>
|
/>
|
||||||
{editError && (
|
{editError && (
|
||||||
<p className="mt-1.5 break-words rounded-md border border-rose-500/20 bg-rose-500/10 px-2 py-1 text-[11px] text-rose-200">
|
<p className="mt-1.5 break-words rounded-md border border-rose-500/30 bg-rose-500/10 px-2 py-1 text-[11px] text-rose-600 dark:text-rose-200">
|
||||||
{editError}
|
{editError}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -193,7 +290,7 @@ export function MessageBubble({
|
|||||||
setEditing(false);
|
setEditing(false);
|
||||||
setEditError(null);
|
setEditError(null);
|
||||||
}}
|
}}
|
||||||
className="cursor-pointer rounded-md border border-white/10 bg-white/5 px-3 py-1 text-xs text-neutral-200 hover:bg-white/10"
|
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-1 text-xs text-fg hover:bg-surface-2"
|
||||||
>
|
>
|
||||||
{t('app:friends.action_cancel')}
|
{t('app:friends.action_cancel')}
|
||||||
</button>
|
</button>
|
||||||
@@ -201,7 +298,7 @@ export function MessageBubble({
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() => void handleEditSave()}
|
onClick={() => void handleEditSave()}
|
||||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-brand-500/90 px-3 py-1 text-xs font-semibold text-white hover:bg-brand-400 disabled:opacity-60"
|
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-1 text-xs font-semibold text-accent-fg hover:brightness-110 disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{busy && <SpinnerIcon className="h-3.5 w-3.5" />}
|
{busy && <SpinnerIcon className="h-3.5 w-3.5" />}
|
||||||
<span>{t('common:save', { defaultValue: 'Save' })}</span>
|
<span>{t('common:save', { defaultValue: 'Save' })}</span>
|
||||||
@@ -210,55 +307,126 @@ export function MessageBubble({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
|
data-message-id={message.id}
|
||||||
className={
|
className={
|
||||||
'break-words rounded-2xl px-3.5 py-2 text-sm ' +
|
'break-words px-3.5 py-2 text-sm transition ' +
|
||||||
|
(highlighted ? 'ring-2 ring-amber-400 ring-offset-2 ring-offset-surface-3 ' : '') +
|
||||||
(mine
|
(mine
|
||||||
? 'bg-brand-500/85 text-white'
|
? 'rounded-[18px_18px_4px_18px] bg-accent text-accent-fg'
|
||||||
: 'border border-white/5 bg-ink-900/70 text-neutral-100')
|
: 'rounded-[18px_18px_18px_4px] border border-line text-fg')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{quoted && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onJumpToMessage?.(quoted.id)}
|
||||||
|
className={
|
||||||
|
'mb-1.5 flex w-full cursor-pointer items-stretch gap-2 rounded-md pl-2 pr-2.5 py-1.5 text-left text-xs transition hover:brightness-110 hover:shadow-sm ' +
|
||||||
|
(mine
|
||||||
|
? 'bg-white/10 text-accent-fg/90'
|
||||||
|
: 'bg-surface-2/80 text-fg-muted ring-1 ring-inset ring-line')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={
|
||||||
|
'-ml-1 w-1 shrink-0 rounded-full ' +
|
||||||
|
(mine ? 'bg-white/70' : 'bg-accent')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1 pl-1">
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
'flex items-center gap-1 truncate text-[11px] font-semibold ' +
|
||||||
|
(mine ? 'text-accent-fg' : 'text-accent')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ReplyIcon className="h-3 w-3 shrink-0" />
|
||||||
|
<span className="truncate">{quoted.senderName}</span>
|
||||||
|
</span>
|
||||||
|
<span className="mt-0.5 block truncate opacity-80">
|
||||||
|
{quoted.deleted
|
||||||
|
? t('app:chats.deleted')
|
||||||
|
: quoted.isAttachment && !quoted.snippet
|
||||||
|
? '📎 ' + t('app:chats.attachment', { defaultValue: 'Anhang' })
|
||||||
|
: quoted.snippet}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{message.plaintext === null ? (
|
{message.plaintext === null ? (
|
||||||
<span className="italic text-neutral-400">…cannot decrypt</span>
|
<span className="italic opacity-70">…cannot decrypt</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{bodyText.length > 0 && <div>{bodyText}</div>}
|
{bodyText.length > 0 && <div>{renderBodyWithMentions(bodyText)}</div>}
|
||||||
{attachments.map((a) => (
|
{bodyText.length > 0 &&
|
||||||
<AttachmentImage key={a.id} handle={a} />
|
(() => {
|
||||||
))}
|
const url = extractFirstUrl(bodyText);
|
||||||
|
return url ? <LinkPreviewCard url={url} /> : null;
|
||||||
|
})()}
|
||||||
|
{attachments.map((a) => {
|
||||||
|
if (a.mimeType.startsWith('audio/')) {
|
||||||
|
return <AttachmentAudio key={a.id} handle={a} />;
|
||||||
|
}
|
||||||
|
if (a.mimeType.startsWith('image/')) {
|
||||||
|
return <AttachmentImage key={a.id} handle={a} />;
|
||||||
|
}
|
||||||
|
if (a.mimeType.startsWith('video/')) {
|
||||||
|
return <AttachmentVideo key={a.id} handle={a} />;
|
||||||
|
}
|
||||||
|
if (a.mimeType === 'application/pdf') {
|
||||||
|
return <AttachmentPdf key={a.id} handle={a} />;
|
||||||
|
}
|
||||||
|
return <AttachmentGeneric key={a.id} handle={a} />;
|
||||||
|
})}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
'mt-1 flex items-center gap-1.5 text-[10px] ' +
|
'mt-1 flex items-center gap-1.5 text-[10px] ' +
|
||||||
(mine ? 'text-brand-100/70' : 'text-neutral-500')
|
(mine ? 'text-accent-fg/75' : 'text-fg-muted')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>{time}</span>
|
<span>{time}</span>
|
||||||
{message.editedAt && !message.deletedAt && (
|
{message.editedAt && !message.deletedAt && (
|
||||||
<span className="italic">· {t('app:chats.edited')}</span>
|
<span className="italic">· {t('app:chats.edited')}</span>
|
||||||
)}
|
)}
|
||||||
|
{msLeft !== null && msLeft > 0 && (
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
'inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider ' +
|
||||||
|
(mine ? 'bg-white/20' : 'bg-rose-500/15 text-rose-600 dark:text-rose-300')
|
||||||
|
}
|
||||||
|
title="Selbstzerstörung"
|
||||||
|
>
|
||||||
|
⏱ {Math.ceil(msLeft / 1000)}s
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showSeen && mine && !editing && !message.deletedAt && (
|
{mine && !editing && !message.deletedAt && deliveryState && (
|
||||||
<p className="mt-0.5 text-right text-[10px] text-neutral-500">
|
<div className="mt-0.5 flex items-center justify-end gap-1 text-[10px] text-fg-muted">
|
||||||
{t('app:chats.seen')}
|
<DeliveryTicks state={deliveryState} />
|
||||||
</p>
|
{showSeen && <span>{t('app:chats.seen')}</span>}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{reactions.length > 0 && !editing && (
|
{reactions.length > 0 && !editing && (
|
||||||
<div className={'mt-1 flex flex-wrap gap-1 ' + (mine ? 'justify-end' : 'justify-start')}>
|
<div className={'mt-1 flex flex-wrap gap-1 ' + (mine ? 'justify-end' : 'justify-start')}>
|
||||||
{reactions.map((r) => (
|
{reactions.map((r) => (
|
||||||
<button
|
<button
|
||||||
key={r.emoji}
|
// Keying by emoji+count makes React remount the chip when the
|
||||||
|
// count flips, replaying the pop animation. Cheap visual cue.
|
||||||
|
key={r.emoji + ':' + r.count}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void onToggleReaction(r.emoji)}
|
onClick={() => void onToggleReaction(r.emoji)}
|
||||||
className={
|
className={
|
||||||
'inline-flex cursor-pointer items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' +
|
'inline-flex origin-bottom animate-reaction-pop cursor-pointer items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||||
(r.mine
|
(r.mine
|
||||||
? 'border-brand-400/40 bg-brand-500/20 text-brand-100'
|
? 'border-accent/40 bg-accent/20 text-accent'
|
||||||
: 'border-white/10 bg-white/5 text-neutral-200 hover:bg-white/10')
|
: 'border-line bg-surface-2 text-fg hover:bg-surface-3')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>{r.emoji}</span>
|
<span>{r.emoji}</span>
|
||||||
@@ -275,12 +443,26 @@ export function MessageBubble({
|
|||||||
(mine ? 'right-full pr-2' : 'left-full pl-2')
|
(mine ? 'right-full pr-2' : 'left-full pl-2')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-0.5 rounded-lg border border-white/10 bg-ink-900/90 p-1 shadow-lg backdrop-blur-xl">
|
<div className="flex items-center gap-0.5 rounded-lg border border-line bg-surface-3 p-1 shadow-lg">
|
||||||
<ActionButton
|
<ActionButton
|
||||||
label={t('app:friends.action_accept', { defaultValue: 'React' })}
|
label={t('app:friends.action_accept', { defaultValue: 'React' })}
|
||||||
onClick={() => setPickerOpen((v) => !v)}
|
onClick={() => setPickerOpen((v) => !v)}
|
||||||
icon={<SmileIcon className="h-4 w-4" />}
|
icon={<SmileIcon className="h-4 w-4" />}
|
||||||
/>
|
/>
|
||||||
|
{onReply && !message.deletedAt && (
|
||||||
|
<ActionButton
|
||||||
|
label={t('app:chats.reply', { defaultValue: 'Antworten' })}
|
||||||
|
onClick={() => onReply(message)}
|
||||||
|
icon={<ReplyIcon className="h-4 w-4" />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{onForward && !message.deletedAt && (
|
||||||
|
<ActionButton
|
||||||
|
label={t('app:chats.forward', { defaultValue: 'Weiterleiten' })}
|
||||||
|
onClick={() => onForward(message)}
|
||||||
|
icon={<ForwardIcon className="h-4 w-4" />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<ActionButton
|
<ActionButton
|
||||||
label="Edit"
|
label="Edit"
|
||||||
@@ -306,7 +488,7 @@ export function MessageBubble({
|
|||||||
ref={pickerRef}
|
ref={pickerRef}
|
||||||
role="menu"
|
role="menu"
|
||||||
className={
|
className={
|
||||||
'absolute z-30 mt-1 flex gap-1 rounded-lg border border-white/10 bg-ink-900/95 p-1.5 shadow-xl backdrop-blur-xl ' +
|
'absolute z-30 mt-1 flex gap-1 rounded-lg border border-line bg-surface-3 p-1.5 shadow-xl ' +
|
||||||
(mine ? 'right-0' : 'left-0')
|
(mine ? 'right-0' : 'left-0')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -315,7 +497,7 @@ export function MessageBubble({
|
|||||||
key={e}
|
key={e}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void handlePickEmoji(e)}
|
onClick={() => void handlePickEmoji(e)}
|
||||||
className="cursor-pointer rounded-md px-2 py-1 text-lg transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
|
className="cursor-pointer rounded-md px-2 py-1 text-lg transition hover:bg-surface-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||||
>
|
>
|
||||||
{e}
|
{e}
|
||||||
</button>
|
</button>
|
||||||
@@ -323,7 +505,7 @@ export function MessageBubble({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPickerOpen(false)}
|
onClick={() => setPickerOpen(false)}
|
||||||
className="cursor-pointer rounded-md px-1.5 py-1 text-neutral-500 transition hover:bg-white/10"
|
className="cursor-pointer rounded-md px-1.5 py-1 text-fg-muted transition hover:bg-surface-2"
|
||||||
>
|
>
|
||||||
<XIcon className="h-4 w-4" />
|
<XIcon className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -336,6 +518,42 @@ export function MessageBubble({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AvatarSlot({
|
||||||
|
show,
|
||||||
|
url,
|
||||||
|
displayName,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
show: boolean;
|
||||||
|
url: string | null;
|
||||||
|
displayName: string | null;
|
||||||
|
onClick?: (ev: React.MouseEvent) => void;
|
||||||
|
}) {
|
||||||
|
if (!show) {
|
||||||
|
return <div aria-hidden="true" className="h-8 w-8 shrink-0" />;
|
||||||
|
}
|
||||||
|
if (onClick) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-user-popover-trigger
|
||||||
|
onClick={onClick}
|
||||||
|
className="shrink-0 cursor-pointer rounded-full transition hover:ring-2 hover:ring-accent/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
|
||||||
|
aria-label={displayName ?? 'Profil'}
|
||||||
|
>
|
||||||
|
<Avatar url={url} displayName={displayName ?? ''} className="h-8 w-8 text-xs" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Avatar
|
||||||
|
url={url}
|
||||||
|
displayName={displayName ?? ''}
|
||||||
|
className="h-8 w-8 text-xs"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function CallEventRow({
|
function CallEventRow({
|
||||||
parsed,
|
parsed,
|
||||||
mine,
|
mine,
|
||||||
@@ -351,8 +569,8 @@ function CallEventRow({
|
|||||||
|
|
||||||
const Icon = isMissed ? PhoneOffIcon : PhoneIcon;
|
const Icon = isMissed ? PhoneOffIcon : PhoneIcon;
|
||||||
const tone = isMissed
|
const tone = isMissed
|
||||||
? 'border-rose-500/20 bg-rose-500/10 text-rose-200'
|
? 'border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-200'
|
||||||
: 'border-emerald-500/20 bg-emerald-500/10 text-emerald-200';
|
: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-200';
|
||||||
|
|
||||||
const label =
|
const label =
|
||||||
status === 'ended'
|
status === 'ended'
|
||||||
@@ -390,6 +608,72 @@ function formatDuration(totalSec: number): string {
|
|||||||
return m + ':' + s.toString().padStart(2, '0');
|
return m + ':' + s.toString().padStart(2, '0');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Splits body text on `@username` tokens, rendering matches as highlighted
|
||||||
|
// pills. Username alphabet matches Supabase citext usernames: alphanumerics
|
||||||
|
// + underscores, length 1..32 (we don't bound here — regex is permissive
|
||||||
|
// and keys off a leading `@` with an alnum/underscore follow).
|
||||||
|
const MENTION_RE = /@([A-Za-z0-9_]{1,32})/g;
|
||||||
|
|
||||||
|
function renderBodyWithMentions(text: string): React.ReactNode[] {
|
||||||
|
const out: React.ReactNode[] = [];
|
||||||
|
let lastIdx = 0;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
MENTION_RE.lastIndex = 0;
|
||||||
|
while ((m = MENTION_RE.exec(text)) !== null) {
|
||||||
|
if (m.index > lastIdx) out.push(text.slice(lastIdx, m.index));
|
||||||
|
out.push(
|
||||||
|
<span
|
||||||
|
key={m.index + ':' + m[1]}
|
||||||
|
className="rounded bg-accent/20 px-1 text-accent"
|
||||||
|
>
|
||||||
|
{m[0]}
|
||||||
|
</span>,
|
||||||
|
);
|
||||||
|
lastIdx = m.index + m[0].length;
|
||||||
|
}
|
||||||
|
if (lastIdx < text.length) out.push(text.slice(lastIdx));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeliveryTicks({ state }: { state: 'sent' | 'delivered' | 'read' }) {
|
||||||
|
// One checkmark for sent, two for delivered/read. Read shifts color to the
|
||||||
|
// accent to match WhatsApp/Telegram blue-tick convention.
|
||||||
|
const color =
|
||||||
|
state === 'read'
|
||||||
|
? 'text-sky-500 dark:text-sky-400'
|
||||||
|
: 'text-fg-muted';
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-label={
|
||||||
|
state === 'read'
|
||||||
|
? 'Gelesen'
|
||||||
|
: state === 'delivered'
|
||||||
|
? 'Zugestellt'
|
||||||
|
: 'Gesendet'
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
state === 'read'
|
||||||
|
? 'Gelesen'
|
||||||
|
: state === 'delivered'
|
||||||
|
? 'Zugestellt'
|
||||||
|
: 'Gesendet'
|
||||||
|
}
|
||||||
|
className={'flex items-center ' + color}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 16 12" width="14" height="10" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
{state === 'sent' ? (
|
||||||
|
<polyline points="2 7 6 11 14 1" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<polyline points="1 7 5 11 11 2" />
|
||||||
|
<polyline points="6 11 10 11 14 1" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ActionButton({
|
function ActionButton({
|
||||||
label,
|
label,
|
||||||
onClick,
|
onClick,
|
||||||
@@ -408,10 +692,10 @@ function ActionButton({
|
|||||||
title={label}
|
title={label}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={
|
className={
|
||||||
'flex h-7 w-7 cursor-pointer items-center justify-center rounded-md transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' +
|
'flex h-7 w-7 cursor-pointer items-center justify-center rounded-md transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||||
(tone === 'danger'
|
(tone === 'danger'
|
||||||
? 'text-neutral-400 hover:bg-rose-500/20 hover:text-rose-200'
|
? 'text-fg-muted hover:bg-rose-500/20 hover:text-rose-500 dark:hover:text-rose-200'
|
||||||
: 'text-neutral-400 hover:bg-white/10 hover:text-neutral-100')
|
: 'text-fg-muted hover:bg-surface-2 hover:text-fg')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
|
|||||||
@@ -35,22 +35,22 @@ export function Modal({ open, title, onClose, children, size = 'md' }: Props) {
|
|||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={title}
|
aria-label={title}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-ink-950/80 p-6 backdrop-blur-sm animate-fade-in"
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-6 backdrop-blur-sm animate-fade-in"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className={
|
className={
|
||||||
'relative w-full animate-slide-up rounded-2xl border border-white/10 bg-ink-900/95 shadow-xl backdrop-blur-xl ' +
|
'relative w-full animate-slide-up rounded-2xl border border-line bg-surface-3 shadow-xl ' +
|
||||||
width
|
width
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<header className="flex items-center justify-between border-b border-white/5 px-6 py-4">
|
<header className="flex items-center justify-between border-b border-line px-6 py-4">
|
||||||
<h2 className="font-display text-lg font-semibold text-white">{title}</h2>
|
<h2 className="font-display text-lg font-semibold text-fg">{title}</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-neutral-400 transition hover:bg-white/10 hover:text-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-2 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||||
>
|
>
|
||||||
<XIcon className="h-4 w-4" />
|
<XIcon className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getParticipantVolume,
|
||||||
|
setParticipantVolume,
|
||||||
|
subscribeParticipantVolumes,
|
||||||
|
} from '../lib/participantVolumes';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
userId: string;
|
||||||
|
displayName: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MENU_W = 240;
|
||||||
|
const MENU_H = 96;
|
||||||
|
|
||||||
|
export function ParticipantVolumeMenu({
|
||||||
|
userId,
|
||||||
|
displayName,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
onClose,
|
||||||
|
}: Props) {
|
||||||
|
const [volume, setVolume] = useState<number>(() => getParticipantVolume(userId));
|
||||||
|
|
||||||
|
// Re-sync from store in case another menu instance changed the same user.
|
||||||
|
useEffect(() => subscribeParticipantVolumes(() => {
|
||||||
|
setVolume(getParticipantVolume(userId));
|
||||||
|
}), [userId]);
|
||||||
|
|
||||||
|
// Outside click + Esc to close.
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
const onDown = (e: MouseEvent) => {
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
if (target?.closest('[data-volume-menu]')) return;
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
window.addEventListener('mousedown', onDown);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onKey);
|
||||||
|
window.removeEventListener('mousedown', onDown);
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const left = Math.min(Math.max(8, x), window.innerWidth - MENU_W - 8);
|
||||||
|
const top = Math.min(Math.max(8, y), window.innerHeight - MENU_H - 8);
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
data-volume-menu
|
||||||
|
role="dialog"
|
||||||
|
aria-label={'Lautstärke ' + displayName}
|
||||||
|
style={{ left, top, width: MENU_W }}
|
||||||
|
className="fixed z-[80] rounded-xl border border-line bg-surface-2/95 p-3 shadow-xl backdrop-blur-md"
|
||||||
|
>
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-2 text-xs">
|
||||||
|
<span className="truncate font-semibold text-fg">{displayName}</span>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
'tabular-nums ' + (volume > 1 ? 'text-amber-500' : 'text-fg-muted')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{Math.round(volume * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Up to 200% via WebAudio gain. Values above 100% can clip on loud
|
||||||
|
mics — the amber count-up hints at that without a verbose warning. */}
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={2}
|
||||||
|
step={0.01}
|
||||||
|
value={volume}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = Number(e.target.value);
|
||||||
|
setVolume(v);
|
||||||
|
setParticipantVolume(userId, v);
|
||||||
|
}}
|
||||||
|
aria-label={'Lautstärke ' + displayName}
|
||||||
|
className="w-full accent-accent"
|
||||||
|
/>
|
||||||
|
<div className="mt-1 flex justify-between text-[10px] text-fg-muted">
|
||||||
|
<span>0%</span>
|
||||||
|
<span className="tabular-nums">100%</span>
|
||||||
|
<span>200%</span>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getParticipantVolume,
|
||||||
|
setParticipantVolume,
|
||||||
|
subscribeParticipantVolumes,
|
||||||
|
} from '../lib/participantVolumes';
|
||||||
|
import {
|
||||||
|
AvatarColorKey,
|
||||||
|
colorKeyFor,
|
||||||
|
} from './CallParticipantTile';
|
||||||
|
import { HeadphonesOffIcon, MicOffIcon, UsersIcon, XIcon } from './icons';
|
||||||
|
|
||||||
|
// Rows the popover knows how to render. Subset of InCallPanel's Tile so this
|
||||||
|
// component can be reused without the screen-share / video fields.
|
||||||
|
export interface ParticipantRow {
|
||||||
|
userId: string;
|
||||||
|
displayName: string;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
self: boolean;
|
||||||
|
muted: boolean;
|
||||||
|
deafened: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
rows: ParticipantRow[];
|
||||||
|
/** Set of userIds currently above the speaking-threshold. */
|
||||||
|
activeSpeakers: Set<string>;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AVATAR_TONES: Record<AvatarColorKey, string> = {
|
||||||
|
violet: 'bg-violet-200 text-violet-800 dark:bg-violet-900/60 dark:text-violet-200',
|
||||||
|
amber: 'bg-amber-200 text-amber-900 dark:bg-amber-900/60 dark:text-amber-200',
|
||||||
|
rose: 'bg-rose-200 text-rose-900 dark:bg-rose-900/60 dark:text-rose-200',
|
||||||
|
teal: 'bg-teal-200 text-teal-900 dark:bg-teal-900/60 dark:text-teal-200',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Call-scoped participant list. Portal-mounted + fixed-positioned so it
|
||||||
|
// floats above whichever call layout the user is in (docked, focus, or
|
||||||
|
// fullscreen cinema). Mirrors the ParticipantVolumeMenu pattern for
|
||||||
|
// close-on-outside / close-on-Esc behaviour so both feel consistent.
|
||||||
|
export function ParticipantsPopover({ open, rows, activeSpeakers, onClose }: Props) {
|
||||||
|
const { t } = useTranslation(['app']);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
const onDown = (e: MouseEvent) => {
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
if (target?.closest('[data-participants-popover]')) return;
|
||||||
|
// Clicks on the triggering button also bubble here; the button itself
|
||||||
|
// handles toggle, so we only close on genuine outside clicks. The
|
||||||
|
// trigger uses `data-participants-trigger` — ignore those.
|
||||||
|
if (target?.closest('[data-participants-trigger]')) return;
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
window.addEventListener('mousedown', onDown);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onKey);
|
||||||
|
window.removeEventListener('mousedown', onDown);
|
||||||
|
};
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
data-participants-popover
|
||||||
|
role="dialog"
|
||||||
|
aria-label={t('app:call.participants', { defaultValue: 'Teilnehmer' })}
|
||||||
|
className="fixed bottom-20 right-5 z-[70] flex max-h-[60vh] w-[300px] flex-col overflow-hidden rounded-xl border border-line bg-surface-2/95 shadow-xl backdrop-blur-md"
|
||||||
|
>
|
||||||
|
<header className="flex items-center justify-between gap-2 border-b border-line px-3.5 py-2.5">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold text-fg">
|
||||||
|
<UsersIcon className="h-4 w-4 text-fg-muted" />
|
||||||
|
<span>
|
||||||
|
{t('app:call.participants', { defaultValue: 'Teilnehmer' })} · {rows.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label={t('app:common.close', { defaultValue: 'Schließen' })}
|
||||||
|
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
|
||||||
|
>
|
||||||
|
<XIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div className="flex-1 overflow-y-auto px-2 py-2">
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<p className="px-2 py-6 text-center text-xs text-fg-muted">
|
||||||
|
{t('app:call.no_participants', { defaultValue: 'Keine Teilnehmer.' })}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-1">
|
||||||
|
{rows.map((row) => (
|
||||||
|
<li key={row.userId}>
|
||||||
|
<Row row={row} speaking={activeSpeakers.has(row.userId)} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ row, speaking }: { row: ParticipantRow; speaking: boolean }) {
|
||||||
|
const key = colorKeyFor(row.userId);
|
||||||
|
const tone = AVATAR_TONES[key];
|
||||||
|
const letter = row.displayName.trim().charAt(0).toUpperCase() || '?';
|
||||||
|
const [volume, setVolume] = useState<number>(() =>
|
||||||
|
row.self ? 1 : getParticipantVolume(row.userId),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (row.self) return;
|
||||||
|
return subscribeParticipantVolumes(() => {
|
||||||
|
setVolume(getParticipantVolume(row.userId));
|
||||||
|
});
|
||||||
|
}, [row.self, row.userId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5 rounded-lg px-2 py-1.5 hover:bg-surface-3/60">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="relative shrink-0">
|
||||||
|
{row.avatarUrl ? (
|
||||||
|
<img
|
||||||
|
src={row.avatarUrl}
|
||||||
|
alt=""
|
||||||
|
className="h-8 w-8 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
'flex h-8 w-8 items-center justify-center rounded-full text-sm font-bold ' +
|
||||||
|
tone
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{letter}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{speaking && (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute -inset-0.5 rounded-full border-2 border-emerald-500 dark:border-emerald-400"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1 truncate text-sm font-medium text-fg">
|
||||||
|
{row.displayName}
|
||||||
|
{row.self && <span className="ml-1 text-fg-muted">(du)</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
{row.muted && (
|
||||||
|
<span
|
||||||
|
aria-label="Mikro stumm"
|
||||||
|
title="Mikro stumm"
|
||||||
|
className="flex h-5 w-5 items-center justify-center rounded-md bg-rose-500/80 text-white"
|
||||||
|
>
|
||||||
|
<MicOffIcon className="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{row.deafened && (
|
||||||
|
<span
|
||||||
|
aria-label="Ton aus"
|
||||||
|
title="Ton aus"
|
||||||
|
className="flex h-5 w-5 items-center justify-center rounded-md bg-rose-500/80 text-white"
|
||||||
|
>
|
||||||
|
<HeadphonesOffIcon className="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!row.self && (
|
||||||
|
<div className="flex items-center gap-2 pl-10 text-[11px] text-fg-muted">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={2}
|
||||||
|
step={0.01}
|
||||||
|
value={volume}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = Number(e.target.value);
|
||||||
|
setVolume(v);
|
||||||
|
setParticipantVolume(row.userId, v);
|
||||||
|
}}
|
||||||
|
aria-label={'Lautstärke ' + row.displayName}
|
||||||
|
className="flex-1 accent-accent"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
'w-10 text-right tabular-nums ' +
|
||||||
|
(volume > 1 ? 'text-amber-500' : '')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{Math.round(volume * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getAudioSettings,
|
||||||
|
subscribeAudioSettings,
|
||||||
|
updateAudioSettings,
|
||||||
|
} from '../lib/audioSettings';
|
||||||
|
import {
|
||||||
|
clearIncomingRingtone,
|
||||||
|
getIncomingRingtone,
|
||||||
|
MAX_RINGTONE_BYTES,
|
||||||
|
saveIncomingRingtone,
|
||||||
|
type StoredRingtone,
|
||||||
|
} from '../lib/ringtoneStorage';
|
||||||
|
import { PhoneIcon, SpinnerIcon, TrashIcon } from './icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Disable interactions while a parent action is in flight. */
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BYTES_PER_MB = 1024 * 1024;
|
||||||
|
|
||||||
|
// UI for the custom incoming-call ringtone. Single file slot. Upload
|
||||||
|
// validates size + mime and surfaces errors inline. Preview button plays
|
||||||
|
// the stored blob through a local <audio> element without touching the
|
||||||
|
// shared ringtone singleton so we don't interfere with a live call.
|
||||||
|
export function RingtoneSettings({ disabled = false }: Props) {
|
||||||
|
const { t } = useTranslation(['app']);
|
||||||
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const previewRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const previewUrlRef = useRef<string | null>(null);
|
||||||
|
const [current, setCurrent] = useState<StoredRingtone | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [volume, setVolume] = useState<number>(() => getAudioSettings().ringtoneVolume);
|
||||||
|
|
||||||
|
// Subscribe so cross-tab / in-call slider moves stay in sync here too.
|
||||||
|
useEffect(() => subscribeAudioSettings((s) => setVolume(s.ringtoneVolume)), []);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const cur = await getIncomingRingtone();
|
||||||
|
setCurrent(cur);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error('getIncomingRingtone failed', err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Revoke any preview blob URL when the component unmounts so long-lived
|
||||||
|
// pages don't leak memory.
|
||||||
|
return () => {
|
||||||
|
stopPreview();
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function stopPreview(): void {
|
||||||
|
const el = previewRef.current;
|
||||||
|
if (el) {
|
||||||
|
try {
|
||||||
|
el.pause();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
el.src = '';
|
||||||
|
}
|
||||||
|
previewRef.current = null;
|
||||||
|
if (previewUrlRef.current) {
|
||||||
|
URL.revokeObjectURL(previewUrlRef.current);
|
||||||
|
previewUrlRef.current = null;
|
||||||
|
}
|
||||||
|
setPlaying(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFile(file: File): Promise<void> {
|
||||||
|
setError(null);
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await saveIncomingRingtone(file);
|
||||||
|
await refresh();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const code = err instanceof Error ? err.message : 'upload_failed';
|
||||||
|
if (code === 'ringtone_too_large') {
|
||||||
|
setError(
|
||||||
|
t('app:settings.ringtone_error_too_large', {
|
||||||
|
defaultValue: 'Datei zu groß (max {{max}} MB).',
|
||||||
|
max: MAX_RINGTONE_BYTES / BYTES_PER_MB,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else if (code === 'ringtone_not_audio') {
|
||||||
|
setError(
|
||||||
|
t('app:settings.ringtone_error_not_audio', {
|
||||||
|
defaultValue: 'Nur Audio-Dateien werden unterstützt.',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setError(
|
||||||
|
t('app:settings.ringtone_error_generic', {
|
||||||
|
defaultValue: 'Ringtone konnte nicht gespeichert werden.',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
if (inputRef.current) inputRef.current.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReset(): Promise<void> {
|
||||||
|
setError(null);
|
||||||
|
setBusy(true);
|
||||||
|
stopPreview();
|
||||||
|
try {
|
||||||
|
await clearIncomingRingtone();
|
||||||
|
await refresh();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error('clearIncomingRingtone failed', err);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePreview(): void {
|
||||||
|
if (!current) return;
|
||||||
|
if (playing) {
|
||||||
|
stopPreview();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = URL.createObjectURL(current.blob);
|
||||||
|
const el = new Audio(url);
|
||||||
|
el.loop = false;
|
||||||
|
el.volume = volume;
|
||||||
|
el.onended = () => stopPreview();
|
||||||
|
el.onerror = () => {
|
||||||
|
setError(
|
||||||
|
t('app:settings.ringtone_error_play', {
|
||||||
|
defaultValue: 'Ringtone konnte nicht abgespielt werden.',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
stopPreview();
|
||||||
|
};
|
||||||
|
el.play().catch(() => {
|
||||||
|
setError(
|
||||||
|
t('app:settings.ringtone_error_play', {
|
||||||
|
defaultValue: 'Ringtone konnte nicht abgespielt werden.',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
stopPreview();
|
||||||
|
});
|
||||||
|
previewRef.current = el;
|
||||||
|
previewUrlRef.current = url;
|
||||||
|
setPlaying(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasCustom = current !== null;
|
||||||
|
const sizeMb = current ? (current.blob.size / BYTES_PER_MB).toFixed(2) : null;
|
||||||
|
const interactionsDisabled = disabled || busy;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium text-fg">
|
||||||
|
<PhoneIcon className="h-4 w-4 text-fg-muted" />
|
||||||
|
{t('app:settings.ringtone_incoming', { defaultValue: 'Eingehender Anruf' })}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-fg-muted">
|
||||||
|
{hasCustom && current
|
||||||
|
? t('app:settings.ringtone_custom_active', {
|
||||||
|
defaultValue: '{{name}} · {{size}} MB',
|
||||||
|
name: current.filename,
|
||||||
|
size: sizeMb,
|
||||||
|
})
|
||||||
|
: t('app:settings.ringtone_default_active', {
|
||||||
|
defaultValue: 'Standard-Klingelton (Doppelton)',
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
{hasCustom && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handlePreview}
|
||||||
|
disabled={interactionsDisabled}
|
||||||
|
className="cursor-pointer rounded-lg border border-line bg-surface-3 px-3 py-1.5 text-xs font-semibold text-fg transition hover:brightness-95 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{playing
|
||||||
|
? t('app:settings.ringtone_stop', { defaultValue: 'Stop' })
|
||||||
|
: t('app:settings.ringtone_preview', { defaultValue: 'Vorhören' })}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept="audio/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (f) void handleFile(f);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
disabled={interactionsDisabled}
|
||||||
|
className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busy && <SpinnerIcon className="h-3.5 w-3.5" />}
|
||||||
|
<span>
|
||||||
|
{hasCustom
|
||||||
|
? t('app:settings.ringtone_replace', { defaultValue: 'Ersetzen' })
|
||||||
|
: t('app:settings.ringtone_upload', { defaultValue: 'Hochladen' })}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{hasCustom && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleReset()}
|
||||||
|
disabled={interactionsDisabled}
|
||||||
|
aria-label={t('app:settings.ringtone_reset', { defaultValue: 'Zurücksetzen' })}
|
||||||
|
title={t('app:settings.ringtone_reset', { defaultValue: 'Zurücksetzen' })}
|
||||||
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-rose-500/40 bg-rose-500/10 text-rose-600 transition hover:bg-rose-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:text-rose-300"
|
||||||
|
>
|
||||||
|
<TrashIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="rounded-md border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-xs text-rose-600 dark:text-rose-300">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label
|
||||||
|
htmlFor="ringtone-volume"
|
||||||
|
className="shrink-0 text-xs font-medium text-fg-muted"
|
||||||
|
>
|
||||||
|
{t('app:settings.ringtone_volume', { defaultValue: 'Lautstärke' })}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ringtone-volume"
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
value={volume}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = Number(e.target.value);
|
||||||
|
setVolume(v);
|
||||||
|
updateAudioSettings({ ringtoneVolume: v });
|
||||||
|
// Apply to the currently-playing preview so the user hears the
|
||||||
|
// slider effect immediately while dragging.
|
||||||
|
if (previewRef.current) previewRef.current.volume = v;
|
||||||
|
}}
|
||||||
|
aria-label={t('app:settings.ringtone_volume', { defaultValue: 'Lautstärke' })}
|
||||||
|
className="flex-1 accent-accent"
|
||||||
|
/>
|
||||||
|
<span className="w-10 shrink-0 text-right text-xs tabular-nums text-fg-muted">
|
||||||
|
{Math.round(volume * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-[11px] text-fg-muted">
|
||||||
|
{t('app:settings.ringtone_hint', {
|
||||||
|
defaultValue:
|
||||||
|
'MP3, WAV, OGG oder M4A bis 8 MB. Nur für eingehende Anrufe — ausgehend bleibt der Standard.',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||