Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a4c9b959a9 | |||
| 37becba7e2 | |||
| eb8f9857ff | |||
| de431386ea | |||
| 1fab2edc57 | |||
| 4db65993d5 | |||
| 0ca29952ba | |||
| ff5ea274b9 | |||
| b0f9f1dada | |||
| 961ac2dde5 | |||
| 7efbcf7e39 | |||
| 49c64cc5d9 | |||
| d20c7e210b | |||
| 58fa9487e3 | |||
| d9b08592da | |||
| 4a80bf1c0e | |||
| 05c962d46f | |||
| cf3fef6936 | |||
| 8c878b3718 | |||
| 389f00e85c | |||
| 75618637e2 | |||
| e57f81c9c3 | |||
| 0a5811cc68 | |||
| 0d94b684bf | |||
| 3c2579b3ed | |||
| c413ee1cf6 |
@@ -21,12 +21,8 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- platform: macos-14 # apple silicon
|
- platform: macos-14 # universal binary covers both Intel + Apple Silicon
|
||||||
args: "--target aarch64-apple-darwin --bundles app,updater"
|
args: "--target universal-apple-darwin --bundles app,updater"
|
||||||
- platform: macos-13 # intel
|
|
||||||
args: "--target x86_64-apple-darwin --bundles app,updater"
|
|
||||||
- platform: ubuntu-22.04
|
|
||||||
args: ""
|
|
||||||
- platform: windows-latest
|
- platform: windows-latest
|
||||||
args: ""
|
args: ""
|
||||||
|
|
||||||
@@ -47,13 +43,7 @@ jobs:
|
|||||||
- name: Setup Rust
|
- name: Setup Rust
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
with:
|
||||||
targets: ${{ matrix.platform == 'macos-14' && 'aarch64-apple-darwin' || matrix.platform == 'macos-13' && 'x86_64-apple-darwin' || '' }}
|
targets: ${{ matrix.platform == 'macos-14' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||||
|
|
||||||
- name: Install Linux build deps
|
|
||||||
if: matrix.platform == 'ubuntu-22.04'
|
|
||||||
run: |
|
|
||||||
sudo apt update
|
|
||||||
sudo apt install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libgtk-3-dev
|
|
||||||
|
|
||||||
- name: Install JS deps
|
- name: Install JS deps
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDg0Q0Y0N0I1Q0U2MjBEMzcKUldRM0RXTE90VWZQaERVWnBGNTVKUVZ2MWZyRktaaDFJaXVWc3NGNUZVb08yVHNxaVp2c2dOL2oK
|
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDQ5N0U0RDcxOTU2OEQ0QUUKUldTdTFHaVZjVTErU1ZuMk1lWXBUbEcyS1RHYzJQN3k4VDdiUGRvRnVJYVJKR3BxWG1xcENpdlYK
|
||||||
@@ -4,9 +4,23 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
<title>ChatApp</title>
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<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>
|
||||||
|
|||||||
@@ -23,13 +23,14 @@
|
|||||||
"@livekit/components-react": "^2.9.0",
|
"@livekit/components-react": "^2.9.0",
|
||||||
"@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-global-shortcut": "^2.3.1",
|
"@tauri-apps/plugin-global-shortcut": "^2.3.1",
|
||||||
"@tauri-apps/plugin-notification": "^2.0.1",
|
"@tauri-apps/plugin-notification": "^2.0.1",
|
||||||
"@tauri-apps/plugin-sql": "^2.0.1",
|
"@tauri-apps/plugin-sql": "^2.0.1",
|
||||||
"@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",
|
||||||
@@ -40,6 +41,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",
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<clipPath id="cp02">
|
||||||
|
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 667 B |
@@ -684,6 +684,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
|
"tauri-plugin-fs",
|
||||||
"tauri-plugin-global-shortcut",
|
"tauri-plugin-global-shortcut",
|
||||||
"tauri-plugin-notification",
|
"tauri-plugin-notification",
|
||||||
"tauri-plugin-sql",
|
"tauri-plugin-sql",
|
||||||
@@ -5483,6 +5484,30 @@ dependencies = [
|
|||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-fs"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "36e1ec28b79f3d0683f4507e1615c36292c0ea6716668770d4396b9b39871ed8"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"dunce",
|
||||||
|
"glob",
|
||||||
|
"log",
|
||||||
|
"objc2-foundation",
|
||||||
|
"percent-encoding",
|
||||||
|
"schemars 0.8.22",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_repr",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"tauri-utils",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"toml 0.9.12+spec-1.1.0",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-global-shortcut"
|
name = "tauri-plugin-global-shortcut"
|
||||||
version = "2.3.1"
|
version = "2.3.1"
|
||||||
|
|||||||
@@ -14,10 +14,11 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
|||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = ["devtools"] }
|
||||||
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"
|
||||||
|
tauri-plugin-fs = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,28 @@
|
|||||||
"updater:allow-check",
|
"updater:allow-check",
|
||||||
"updater:allow-download",
|
"updater:allow-download",
|
||||||
"updater:allow-install",
|
"updater:allow-install",
|
||||||
"updater:allow-download-and-install"
|
"updater:allow-download-and-install",
|
||||||
|
"stronghold:default",
|
||||||
|
"stronghold:allow-initialize",
|
||||||
|
"stronghold:allow-load-client",
|
||||||
|
"stronghold:allow-create-client",
|
||||||
|
"stronghold:allow-save",
|
||||||
|
"stronghold:allow-get-store-record",
|
||||||
|
"stronghold:allow-save-store-record",
|
||||||
|
"stronghold:allow-remove-store-record",
|
||||||
|
"fs:default",
|
||||||
|
"fs:allow-read-file",
|
||||||
|
"fs:allow-write-file",
|
||||||
|
"fs:allow-mkdir",
|
||||||
|
"fs:allow-exists",
|
||||||
|
"fs:allow-rename",
|
||||||
|
"fs:allow-remove",
|
||||||
|
{
|
||||||
|
"identifier": "fs:scope",
|
||||||
|
"allow": [
|
||||||
|
{ "path": "$APPLOCALDATA" },
|
||||||
|
{ "path": "$APPLOCALDATA/**" }
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 948 B |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 928 B |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#fff</color>
|
||||||
|
</resources>
|
||||||
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 611 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 858 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
@@ -0,0 +1,14 @@
|
|||||||
|
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<clipPath id="cp02">
|
||||||
|
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 667 B |
@@ -3,6 +3,7 @@ pub fn run() {
|
|||||||
let mut builder = tauri::Builder::default()
|
let mut builder = tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_notification::init())
|
.plugin(tauri_plugin_notification::init())
|
||||||
.plugin(tauri_plugin_sql::Builder::default().build())
|
.plugin(tauri_plugin_sql::Builder::default().build())
|
||||||
|
.plugin(tauri_plugin_fs::init())
|
||||||
.plugin(
|
.plugin(
|
||||||
tauri_plugin_stronghold::Builder::new(|password| {
|
tauri_plugin_stronghold::Builder::new(|password| {
|
||||||
// TODO: derive stronghold key from password using argon2 / blake2b.
|
// TODO: derive stronghold key from password using argon2 / blake2b.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "ChatApp",
|
"productName": "ChatApp",
|
||||||
"version": "0.1.0",
|
"version": "0.7.1",
|
||||||
"identifier": "com.meinname.chatapp",
|
"identifier": "com.meinname.chatapp",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "pnpm vite:dev",
|
"beforeDevCommand": "pnpm vite:dev",
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
import { BrowserRouter, Navigate, Outlet, Route, Routes } from 'react-router-dom';
|
||||||
|
|
||||||
import { AppShell } from './components/AppShell';
|
import { AppShell } from './components/AppShell';
|
||||||
|
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||||
|
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 { ThemeProvider } from './context/ThemeContext';
|
||||||
import { AdminPage } from './pages/AdminPage';
|
import { AdminPage } from './pages/AdminPage';
|
||||||
import { AuthCallbackPage } from './pages/AuthCallbackPage';
|
import { AuthCallbackPage } from './pages/AuthCallbackPage';
|
||||||
import { AuthPage } from './pages/AuthPage';
|
import { AuthPage } from './pages/AuthPage';
|
||||||
@@ -15,39 +18,85 @@ import { DevicePage } from './pages/DevicePage';
|
|||||||
import { FriendsPage } from './pages/FriendsPage';
|
import { FriendsPage } from './pages/FriendsPage';
|
||||||
import { SettingsPage } from './pages/SettingsPage';
|
import { SettingsPage } from './pages/SettingsPage';
|
||||||
|
|
||||||
|
// 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={<AuthCallbackPage />} />
|
||||||
|
</Route>
|
||||||
<Route element={<RequireAuth />}>
|
<Route element={<RequireAuth />}>
|
||||||
|
<Route element={<RouteBoundary scope="device" />}>
|
||||||
<Route path="/device" element={<DevicePage />} />
|
<Route path="/device" element={<DevicePage />} />
|
||||||
|
</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>
|
||||||
|
<Route element={<RouteBoundary scope="friends" />}>
|
||||||
<Route path="/friends" element={<FriendsPage />} />
|
<Route path="/friends" element={<FriendsPage />} />
|
||||||
|
</Route>
|
||||||
|
<Route element={<RouteBoundary scope="settings" />}>
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
|
</Route>
|
||||||
<Route element={<RequireAdmin />}>
|
<Route element={<RequireAdmin />}>
|
||||||
|
<Route element={<RouteBoundary scope="admin" />}>
|
||||||
<Route path="/admin" element={<AdminPage />} />
|
<Route path="/admin" element={<AdminPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/chats" replace />} />
|
<Route path="*" element={<Navigate to="/chats" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
<UpdateToast />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</CallProvider>
|
</CallProvider>
|
||||||
</ConversationsProvider>
|
</ConversationsProvider>
|
||||||
</FriendshipsProvider>
|
</FriendshipsProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,28 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
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';
|
||||||
import { UpdateToast } from './UpdateToast';
|
|
||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
|
const { session, device } = useAuth();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Prompt once per authenticated shell mount. Module-level guard prevents
|
// Prompt once per authenticated shell mount. Module-level guard prevents
|
||||||
// re-asking if the user already responded this session.
|
// re-asking if the user already responded this session.
|
||||||
void ensureNotificationPermission();
|
void ensureNotificationPermission();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!session?.user.id || !device?.id) return;
|
||||||
|
return startConversationKeySync(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 />
|
||||||
@@ -25,16 +33,16 @@ export function AppShell() {
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<CallUI />
|
<CallUI />
|
||||||
<UpdateToast />
|
<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,45 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
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) {
|
||||||
|
if (url) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
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,251 @@
|
|||||||
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { exportDeviceBackup } 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 [backup, setBackup] = useState<string | null>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const canGenerate = useMemo(() => {
|
||||||
|
return passphrase.length >= 8 && passphrase === confirm && !busy;
|
||||||
|
}, [passphrase, confirm, busy]);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setPassphrase('');
|
||||||
|
setConfirm('');
|
||||||
|
setBackup(null);
|
||||||
|
setError(null);
|
||||||
|
setCopied(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
reset();
|
||||||
|
onClose();
|
||||||
|
}, [reset, onClose]);
|
||||||
|
|
||||||
|
const handleGenerate = useCallback(async () => {
|
||||||
|
if (!canGenerate) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const str = await exportDeviceBackup({ userId, deviceId, privateKey, passphrase });
|
||||||
|
setBackup(str);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}, [canGenerate, userId, deviceId, privateKey, passphrase]);
|
||||||
|
|
||||||
|
const handleCopy = useCallback(async () => {
|
||||||
|
if (!backup) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(backup);
|
||||||
|
setCopied(true);
|
||||||
|
window.setTimeout(() => setCopied(false), 1500);
|
||||||
|
} catch {
|
||||||
|
/* fall back — user can select manually */
|
||||||
|
}
|
||||||
|
}, [backup]);
|
||||||
|
|
||||||
|
const handleDownload = useCallback(() => {
|
||||||
|
if (!backup) return;
|
||||||
|
const blob = new Blob([backup], { 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);
|
||||||
|
}, [backup, 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">
|
||||||
|
{!backup ? (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
<textarea
|
||||||
|
readOnly
|
||||||
|
value={backup}
|
||||||
|
rows={8}
|
||||||
|
onFocus={(e) => e.currentTarget.select()}
|
||||||
|
className="mt-3 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-3 flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleCopy()}
|
||||||
|
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
|
||||||
|
? t('app:backup.copied', { defaultValue: 'Kopiert!' })
|
||||||
|
: t('app:backup.copy', { defaultValue: '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"
|
||||||
|
>
|
||||||
|
{t('app:backup.download', { defaultValue: 'Als Datei speichern' })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="flex items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3">
|
||||||
|
{!backup ? (
|
||||||
|
<>
|
||||||
|
<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,170 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import {
|
||||||
|
MicIcon,
|
||||||
|
MicOffIcon,
|
||||||
|
MonitorShareIcon,
|
||||||
|
MonitorStopIcon,
|
||||||
|
PhoneOffIcon,
|
||||||
|
UsersIcon,
|
||||||
|
VideoIcon,
|
||||||
|
} from './icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
muted: boolean;
|
||||||
|
sharing: boolean;
|
||||||
|
video: boolean;
|
||||||
|
onToggleMute: () => void;
|
||||||
|
onToggleShare: () => void;
|
||||||
|
onToggleVideo?: () => void;
|
||||||
|
onHangup: () => void;
|
||||||
|
onOpenParticipants?: () => void;
|
||||||
|
/** 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,
|
||||||
|
onToggleMute,
|
||||||
|
onToggleShare,
|
||||||
|
onToggleVideo,
|
||||||
|
onHangup,
|
||||||
|
onOpenParticipants,
|
||||||
|
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>
|
||||||
|
{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}
|
||||||
|
disabled={disabledMedia}
|
||||||
|
glass={glass}
|
||||||
|
className={btnSize}
|
||||||
|
>
|
||||||
|
{sharing ? (
|
||||||
|
<MonitorStopIcon className="h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
<MonitorShareIcon className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
</CallButton>
|
||||||
|
{onOpenParticipants && (
|
||||||
|
<CallButton
|
||||||
|
label={t('app:call.participants', { defaultValue: 'Teilnehmer' })}
|
||||||
|
onClick={onOpenParticipants}
|
||||||
|
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;
|
||||||
|
disabled?: boolean;
|
||||||
|
active?: boolean;
|
||||||
|
activeTone?: 'accent' | 'danger';
|
||||||
|
tone?: 'default' | 'danger';
|
||||||
|
glass?: boolean;
|
||||||
|
className?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CallButton({
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
disabled,
|
||||||
|
active,
|
||||||
|
activeTone = 'accent',
|
||||||
|
tone = 'default',
|
||||||
|
glass = false,
|
||||||
|
className = '',
|
||||||
|
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}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={label}
|
||||||
|
aria-pressed={active}
|
||||||
|
title={label}
|
||||||
|
className={`${base} ${toneClass} ${className}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import { CrownIcon, LockIcon, MicOffIcon, MonitorShareIcon } 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;
|
||||||
|
speaking: boolean;
|
||||||
|
sharing: boolean;
|
||||||
|
video: boolean;
|
||||||
|
e2ee: boolean;
|
||||||
|
size?: 'default' | 'small';
|
||||||
|
focused?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
onOpenScreenShare?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CallParticipantTile(props: ParticipantTileProps) {
|
||||||
|
const {
|
||||||
|
displayName,
|
||||||
|
me,
|
||||||
|
muted,
|
||||||
|
speaking,
|
||||||
|
sharing,
|
||||||
|
video,
|
||||||
|
e2ee,
|
||||||
|
size = 'default',
|
||||||
|
focused = false,
|
||||||
|
onClick,
|
||||||
|
onOpenScreenShare,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const small = size === 'small';
|
||||||
|
const borderClass = speaking
|
||||||
|
? '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}
|
||||||
|
className={
|
||||||
|
'relative flex flex-col overflow-hidden rounded-[14px] border bg-surface-3 transition ' +
|
||||||
|
(onClick ? 'cursor-pointer ' : '') +
|
||||||
|
borderClass +
|
||||||
|
(small ? ' min-w-[140px]' : '')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{sharing ? (
|
||||||
|
<ScreenshareStub
|
||||||
|
displayName={displayName}
|
||||||
|
small={small}
|
||||||
|
onOpen={onOpenScreenShare}
|
||||||
|
/>
|
||||||
|
) : video ? (
|
||||||
|
<VideoStub {...props} small={small} />
|
||||||
|
) : (
|
||||||
|
<AudioContent {...props} small={small} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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 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>
|
||||||
|
)}
|
||||||
|
{sharing && (
|
||||||
|
<span className="flex h-5 w-5 items-center justify-center rounded-md bg-emerald-500/80 text-white">
|
||||||
|
<MonitorShareIcon 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,
|
||||||
|
small,
|
||||||
|
}: ParticipantTileProps & { small: boolean }) {
|
||||||
|
// Video capture playback is out of scope for this UI iteration — show the
|
||||||
|
// audio-avatar gradient background as a placeholder so the layout is stable
|
||||||
|
// when a participant enables video.
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScreenshareStubProps {
|
||||||
|
displayName: string;
|
||||||
|
small: boolean;
|
||||||
|
onOpen?: (() => void) | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fake browser window placeholder — click to open the real <video> viewer.
|
||||||
|
// Matches the design spec's "screenshare-stub" look.
|
||||||
|
function ScreenshareStub({ displayName, small, onOpen }: ScreenshareStubProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={
|
||||||
|
onOpen
|
||||||
|
? (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onOpen();
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
aria-label={`${displayName} teilt Bildschirm`}
|
||||||
|
className="relative flex min-h-0 flex-1 cursor-pointer items-center justify-center bg-ink-900 p-3 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||||
|
>
|
||||||
|
<div className="h-[85%] w-[90%] overflow-hidden rounded-lg border border-white/10 bg-ink-700">
|
||||||
|
<div className="flex h-[20px] items-center gap-1 bg-ink-600 px-2">
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-ink-500" />
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-ink-500" />
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-ink-500" />
|
||||||
|
</div>
|
||||||
|
<div className={'flex flex-col gap-2 ' + (small ? 'gap-[3px] p-1.5' : 'p-3.5')}>
|
||||||
|
<div className={'w-[60%] rounded ' + (small ? 'h-[3px]' : 'h-1.5') + ' bg-ink-500'} />
|
||||||
|
<div className={'w-[80%] rounded ' + (small ? 'h-[3px]' : 'h-1.5') + ' bg-ink-500'} />
|
||||||
|
<div className={'w-[40%] rounded ' + (small ? 'h-[3px]' : 'h-1.5') + ' bg-ink-500'} />
|
||||||
|
<div className={'my-1 rounded ' + (small ? 'h-[14px]' : 'h-10') + ' bg-accent/30'} />
|
||||||
|
<div className={'w-[70%] rounded ' + (small ? 'h-[3px]' : 'h-1.5') + ' bg-ink-500'} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!small && (
|
||||||
|
<div className="absolute left-3 top-3 flex items-center gap-1.5 glass-chip rounded-lg px-2.5 py-1 text-[10px] font-medium">
|
||||||
|
<MonitorShareIcon className="h-3 w-3" />
|
||||||
|
<span>{displayName} teilt Bildschirm</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,26 +1,20 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect } 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 { 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();
|
||||||
|
|
||||||
@@ -34,19 +28,37 @@ export function CallUI() {
|
|||||||
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 +66,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 +122,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 +133,77 @@ 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 === 'outgoing';
|
||||||
return () => window.clearInterval(id);
|
if (!active) return null;
|
||||||
}, [state.kind]);
|
|
||||||
|
|
||||||
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'
|
|
||||||
? t('app:call.outgoing_ringing')
|
|
||||||
: state.kind === 'connecting'
|
|
||||||
? t('app:call.connecting')
|
|
||||||
: t('app:call.voice_connected', { defaultValue: 'Sprachchat verbunden' });
|
|
||||||
|
|
||||||
const elapsed =
|
|
||||||
state.kind === 'connected'
|
|
||||||
? formatElapsed(Date.now() - new Date(state.startedAt).getTime())
|
|
||||||
: 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>Live · {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>) {
|
|
||||||
return (
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" {...props}>
|
|
||||||
<path d="M5 12v0" />
|
|
||||||
<path d="M9 9v6" />
|
|
||||||
<path d="M13 6v12" />
|
|
||||||
<path d="M17 9v6" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatElapsed(ms: number): string {
|
|
||||||
const total = Math.max(0, Math.floor(ms / 1000));
|
|
||||||
const mm = Math.floor(total / 60).toString().padStart(2, '0');
|
|
||||||
const ss = (total % 60).toString().padStart(2, '0');
|
|
||||||
return mm + ':' + ss;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,31 +1,39 @@
|
|||||||
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 { 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: PresenceState | 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 +42,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} />
|
||||||
</>
|
</>
|
||||||
@@ -44,43 +53,52 @@ interface HeaderBarProps {
|
|||||||
conversation: ConversationSummary;
|
conversation: ConversationSummary;
|
||||||
peerPresence: PresenceState | null;
|
peerPresence: PresenceState | 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 showPresence = isDm && peerPresence && peerPresence !== 'invisible';
|
||||||
const presenceLabel = peerPresence ? t('app:presence.' + peerPresence) : '';
|
const presenceLabel = peerPresence ? t('app:presence.' + peerPresence) : '';
|
||||||
|
|
||||||
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 ? (
|
||||||
|
<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 && peerPresence && (
|
{showPresence && peerPresence && (
|
||||||
<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[peerPresence]
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</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 +111,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,160 @@
|
|||||||
|
import {
|
||||||
|
type DeviceRecord,
|
||||||
|
restoreDeviceFromServerRecord,
|
||||||
|
} from '@chat-app/shared/auth';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import {
|
||||||
|
decodePrivateKeyFromBackup,
|
||||||
|
importDeviceBackup,
|
||||||
|
} 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 [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 payload = await importDeviceBackup(backup.trim(), passphrase);
|
||||||
|
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, 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">
|
||||||
|
<label className="block text-xs font-medium uppercase tracking-wide text-neutral-400">
|
||||||
|
{t('app:backup.passphrase', { defaultValue: 'Passphrase' })}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
value={passphrase}
|
||||||
|
onChange={(e) => setPassphrase(e.target.value)}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</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,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>
|
||||||
|
|||||||
@@ -1,46 +1,60 @@
|
|||||||
import type { ConversationSummary } from '@chat-app/shared/chat';
|
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||||
import type { RemoteTrack } from 'livekit-client';
|
import { useEffect, useState } from 'react';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { type RemoteScreenShare, useCall } from '../context/CallContext';
|
import { type CallMode, useCall } from '../context/CallContext';
|
||||||
import {
|
import {
|
||||||
getPttSettings,
|
getPttSettings,
|
||||||
type PttSettings,
|
type PttSettings,
|
||||||
subscribePttSettings,
|
subscribePttSettings,
|
||||||
} from '../lib/pttSettings';
|
} from '../lib/pttSettings';
|
||||||
import {
|
import { useActiveSpeakers } from '../lib/useActiveSpeakers';
|
||||||
LockIcon,
|
import { CallControls } from './CallControls';
|
||||||
MicIcon,
|
import { CallParticipantTile } from './CallParticipantTile';
|
||||||
MicOffIcon,
|
import { FocusIcon, GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } from './icons';
|
||||||
MonitorShareIcon,
|
import { ScreenShareViewer } from './ScreenShareViewer';
|
||||||
MonitorStopIcon,
|
|
||||||
PhoneOffIcon,
|
|
||||||
SpinnerIcon,
|
|
||||||
} from './icons';
|
|
||||||
|
|
||||||
|
// Discord-style in-call dock rendered above the message list. Renders three
|
||||||
|
// visual modes driven by CallContext.callMode: grid, focus, fullscreen.
|
||||||
|
// The fullscreen variant absolute-positions itself over the conversation
|
||||||
|
// container so the rail+chat-list remain visible on the left.
|
||||||
interface Props {
|
interface Props {
|
||||||
conversation: ConversationSummary;
|
conversation: ConversationSummary;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Discord-style call widget rendered above the message list when the user is
|
interface Tile {
|
||||||
// in the current conversation's call. Shows participant avatars + controls.
|
userId: string;
|
||||||
|
displayName: string;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
self: boolean;
|
||||||
|
muted: boolean;
|
||||||
|
video: boolean;
|
||||||
|
sharing: boolean;
|
||||||
|
remoteSharing: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export function InCallPanel({ conversation }: Props) {
|
export function InCallPanel({ conversation }: Props) {
|
||||||
const { t } = useTranslation(['app']);
|
const { t } = useTranslation(['app']);
|
||||||
const {
|
const {
|
||||||
state,
|
state,
|
||||||
|
room,
|
||||||
remoteParticipants,
|
remoteParticipants,
|
||||||
isMuted,
|
isMuted,
|
||||||
isE2EEActive,
|
isE2EEActive,
|
||||||
toggleMute,
|
|
||||||
hangup,
|
|
||||||
isScreenSharing,
|
isScreenSharing,
|
||||||
remoteScreenShares,
|
remoteScreenShares,
|
||||||
|
callMode,
|
||||||
|
focusedId,
|
||||||
|
toggleMute,
|
||||||
toggleScreenShare,
|
toggleScreenShare,
|
||||||
|
hangup,
|
||||||
|
setCallMode,
|
||||||
|
setFocusedId,
|
||||||
} = useCall();
|
} = useCall();
|
||||||
const { session } = useAuth();
|
const { session } = useAuth();
|
||||||
const myId = session?.user.id ?? null;
|
const myId = session?.user.id ?? null;
|
||||||
|
const activeSpeakers = useActiveSpeakers(room);
|
||||||
|
|
||||||
const active =
|
const active =
|
||||||
(state.kind === 'connected' ||
|
(state.kind === 'connected' ||
|
||||||
@@ -49,33 +63,19 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
state.conversationId === conversation.id;
|
state.conversationId === conversation.id;
|
||||||
if (!active) return null;
|
if (!active) return null;
|
||||||
|
|
||||||
const remoteIds = new Set<string>(
|
const tiles = buildTiles({
|
||||||
remoteParticipants.map((p) => p.identity).filter((s): s is string => Boolean(s)),
|
conversation,
|
||||||
);
|
myId,
|
||||||
const tiles: ParticipantTileData[] = [];
|
remoteIdentities: remoteParticipants.map((p) => p.identity),
|
||||||
if (myId) {
|
isMuted,
|
||||||
const me = conversation.members.find((m) => m.userId === myId) ?? null;
|
isScreenSharing,
|
||||||
tiles.push({
|
remoteSharerIds: new Set(remoteScreenShares.map((s) => s.participantId)),
|
||||||
userId: myId,
|
|
||||||
displayName: me?.profile?.displayName ?? '?',
|
|
||||||
avatarUrl: me?.profile?.avatarUrl ?? null,
|
|
||||||
self: true,
|
|
||||||
speaking: false,
|
|
||||||
muted: isMuted,
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
for (const m of conversation.members) {
|
const duration =
|
||||||
if (m.userId === myId) continue;
|
state.kind === 'connected'
|
||||||
if (!remoteIds.has(m.userId)) continue;
|
? <LiveDuration startedAt={state.startedAt} />
|
||||||
tiles.push({
|
: null;
|
||||||
userId: m.userId,
|
|
||||||
displayName: m.profile?.displayName ?? '?',
|
|
||||||
avatarUrl: m.profile?.avatarUrl ?? null,
|
|
||||||
self: false,
|
|
||||||
speaking: false,
|
|
||||||
muted: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusLabel =
|
const statusLabel =
|
||||||
state.kind === 'outgoing'
|
state.kind === 'outgoing'
|
||||||
@@ -86,268 +86,491 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
? t('app:call.waiting_for_peers', { defaultValue: 'Warte auf andere…' })
|
? t('app:call.waiting_for_peers', { defaultValue: 'Warte auf andere…' })
|
||||||
: t('app:call.connected');
|
: t('app:call.connected');
|
||||||
|
|
||||||
|
const sharingTile = tiles.find((p) => p.sharing);
|
||||||
|
const effectiveFocusedId = focusedId ?? sharingTile?.userId ?? tiles[0]?.userId ?? null;
|
||||||
|
const speaker = tiles.find((p) => p.userId === effectiveFocusedId) ?? tiles[0];
|
||||||
|
|
||||||
|
const controls = (
|
||||||
|
<CallControls
|
||||||
|
muted={isMuted}
|
||||||
|
sharing={isScreenSharing}
|
||||||
|
video={false}
|
||||||
|
onToggleMute={toggleMute}
|
||||||
|
onToggleShare={() => void toggleScreenShare()}
|
||||||
|
onHangup={() => void hangup()}
|
||||||
|
compact={callMode !== 'fullscreen'}
|
||||||
|
glass={callMode === 'fullscreen'}
|
||||||
|
disabledMedia={state.kind !== 'connected'}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (callMode === 'fullscreen') {
|
||||||
|
return (
|
||||||
|
<FullscreenCall
|
||||||
|
tiles={tiles}
|
||||||
|
speaker={speaker}
|
||||||
|
remoteScreenShares={remoteScreenShares}
|
||||||
|
conversationMembers={conversation.members}
|
||||||
|
activeSpeakers={activeSpeakers}
|
||||||
|
e2ee={isE2EEActive}
|
||||||
|
onExit={() => setCallMode('grid')}
|
||||||
|
onFocusTile={(id) => {
|
||||||
|
setFocusedId(id);
|
||||||
|
}}
|
||||||
|
controls={controls}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const title =
|
||||||
|
conversation.type === 'group'
|
||||||
|
? conversation.name ?? t('app:chats.new_group')
|
||||||
|
: conversation.peer?.displayName ?? '—';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
aria-label={t('app:call.in_call', { defaultValue: 'Im Anruf' })}
|
aria-label={t('app:call.in_call', { defaultValue: 'Im Anruf' })}
|
||||||
className="border-b border-white/5 bg-gradient-to-b from-ink-900/80 to-ink-950/40 px-6 py-5"
|
className="flex min-h-[280px] max-h-[420px] shrink-0 flex-col overflow-hidden border-b border-line bg-surface-2"
|
||||||
|
style={{ height: '50%' }}
|
||||||
>
|
>
|
||||||
<div className="mb-4 flex items-center gap-2 text-xs font-medium text-emerald-300">
|
<div className="flex items-center justify-between border-b border-line bg-surface-3 px-4 py-2">
|
||||||
{state.kind === 'connecting' ? (
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
<SpinnerIcon className="h-3.5 w-3.5" />
|
<div className="flex items-center gap-2 text-sm font-semibold text-fg">
|
||||||
) : (
|
<UsersIcon className="h-4 w-4 shrink-0 text-fg-muted" />
|
||||||
<span className="relative flex h-2 w-2">
|
<span className="truncate">{title}</span>
|
||||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400/60" />
|
<span className="text-fg-muted/50" aria-hidden="true">·</span>
|
||||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-400" />
|
<span className="tabular-nums text-fg-muted">
|
||||||
</span>
|
{duration ?? statusLabel}
|
||||||
|
{state.kind !== 'connected' && (
|
||||||
|
<SpinnerIcon className="ml-1 inline h-3 w-3" />
|
||||||
)}
|
)}
|
||||||
<span className="uppercase tracking-wide">{statusLabel}</span>
|
</span>
|
||||||
|
</div>
|
||||||
{isE2EEActive && (
|
{isE2EEActive && (
|
||||||
<span
|
<div className="flex items-center gap-1 text-[11px] font-medium text-emerald-600 dark:text-emerald-400">
|
||||||
className="ml-2 inline-flex items-center gap-1 rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-emerald-200 ring-1 ring-emerald-400/30"
|
|
||||||
title={t('app:call.e2ee_active_hint', {
|
|
||||||
defaultValue: 'Audio + Video sind Ende-zu-Ende-verschlüsselt',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<LockIcon className="h-3 w-3" />
|
<LockIcon className="h-3 w-3" />
|
||||||
E2EE
|
<span>{t('app:call.e2ee_active', { defaultValue: 'E2E verschlüsselt' })}</span>
|
||||||
</span>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<ModeToggles mode={callMode} onChange={setCallMode} />
|
||||||
<div className="flex flex-wrap justify-center gap-4">
|
|
||||||
{tiles.map((p) => (
|
|
||||||
<ParticipantTile key={p.userId} {...p} />
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{remoteScreenShares.length > 0 && (
|
<CallStage
|
||||||
<div className="mt-4 space-y-3">
|
tiles={tiles}
|
||||||
{remoteScreenShares.map((s) => {
|
speaker={speaker}
|
||||||
const member = conversation.members.find((m) => m.userId === s.participantId);
|
mode={callMode}
|
||||||
return (
|
activeSpeakers={activeSpeakers}
|
||||||
<ScreenShareViewer
|
e2ee={isE2EEActive}
|
||||||
key={s.track.sid ?? s.participantId}
|
remoteScreenShares={remoteScreenShares}
|
||||||
share={s}
|
conversationMembers={conversation.members}
|
||||||
avatarUrl={member?.profile?.avatarUrl ?? null}
|
onFocusTile={(id) => {
|
||||||
displayName={member?.profile?.displayName ?? s.participantName}
|
setFocusedId(id);
|
||||||
|
if (callMode === 'grid') setCallMode('focus');
|
||||||
|
}}
|
||||||
|
compact
|
||||||
/>
|
/>
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-5 flex items-center justify-center gap-2">
|
{controls}
|
||||||
<ControlButton
|
|
||||||
onClick={toggleMute}
|
|
||||||
disabled={state.kind !== 'connected'}
|
|
||||||
active={isMuted}
|
|
||||||
label={isMuted ? t('app:call.unmute') : t('app:call.mute')}
|
|
||||||
tone={isMuted ? 'amber' : 'neutral'}
|
|
||||||
>
|
|
||||||
{isMuted ? <MicOffIcon className="h-5 w-5" /> : <MicIcon className="h-5 w-5" />}
|
|
||||||
</ControlButton>
|
|
||||||
<ControlButton
|
|
||||||
onClick={() => void toggleScreenShare()}
|
|
||||||
disabled={state.kind !== 'connected'}
|
|
||||||
active={isScreenSharing}
|
|
||||||
label={
|
|
||||||
isScreenSharing
|
|
||||||
? t('app:call.stop_share_screen', { defaultValue: 'Screen-Share stoppen' })
|
|
||||||
: t('app:call.share_screen', { defaultValue: 'Bildschirm teilen' })
|
|
||||||
}
|
|
||||||
tone={isScreenSharing ? 'emerald' : 'neutral'}
|
|
||||||
>
|
|
||||||
{isScreenSharing ? (
|
|
||||||
<MonitorStopIcon className="h-5 w-5" />
|
|
||||||
) : (
|
|
||||||
<MonitorShareIcon className="h-5 w-5" />
|
|
||||||
)}
|
|
||||||
</ControlButton>
|
|
||||||
<ControlButton
|
|
||||||
onClick={() => void hangup()}
|
|
||||||
label={t('app:call.hangup')}
|
|
||||||
tone="rose"
|
|
||||||
>
|
|
||||||
<PhoneOffIcon className="h-5 w-5" />
|
|
||||||
</ControlButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<PttHint />
|
<PttHint />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ScreenShareViewerProps {
|
// ---------------------------------------------------------------------------
|
||||||
share: RemoteScreenShare;
|
// Helpers
|
||||||
avatarUrl: string | null;
|
// ---------------------------------------------------------------------------
|
||||||
displayName: string;
|
|
||||||
|
interface BuildArgs {
|
||||||
|
conversation: ConversationSummary;
|
||||||
|
myId: string | null;
|
||||||
|
remoteIdentities: string[];
|
||||||
|
isMuted: boolean;
|
||||||
|
isScreenSharing: boolean;
|
||||||
|
remoteSharerIds: Set<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShareViewerProps) {
|
function buildTiles({
|
||||||
const { t } = useTranslation(['app']);
|
conversation,
|
||||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
myId,
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
remoteIdentities,
|
||||||
const [watching, setWatching] = useState(false);
|
isMuted,
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
isScreenSharing,
|
||||||
const letter = displayName.trim().charAt(0).toUpperCase() || '?';
|
remoteSharerIds,
|
||||||
|
}: BuildArgs): Tile[] {
|
||||||
|
const remoteSet = new Set(remoteIdentities);
|
||||||
|
const out: Tile[] = [];
|
||||||
|
if (myId) {
|
||||||
|
const me = conversation.members.find((m) => m.userId === myId) ?? null;
|
||||||
|
out.push({
|
||||||
|
userId: myId,
|
||||||
|
displayName: me?.profile?.displayName ?? '?',
|
||||||
|
avatarUrl: me?.profile?.avatarUrl ?? null,
|
||||||
|
self: true,
|
||||||
|
muted: isMuted,
|
||||||
|
video: false,
|
||||||
|
sharing: isScreenSharing,
|
||||||
|
remoteSharing: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const m of conversation.members) {
|
||||||
|
if (m.userId === myId) continue;
|
||||||
|
if (!remoteSet.has(m.userId)) continue;
|
||||||
|
const sharing = remoteSharerIds.has(m.userId);
|
||||||
|
out.push({
|
||||||
|
userId: m.userId,
|
||||||
|
displayName: m.profile?.displayName ?? '?',
|
||||||
|
avatarUrl: m.profile?.avatarUrl ?? null,
|
||||||
|
self: false,
|
||||||
|
muted: false,
|
||||||
|
video: false,
|
||||||
|
sharing,
|
||||||
|
remoteSharing: sharing,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LiveDuration({ startedAt }: { startedAt: string }) {
|
||||||
|
const [, tick] = useState(0);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!watching) return;
|
const id = window.setInterval(() => tick((v) => v + 1), 1000);
|
||||||
const el = videoRef.current;
|
return () => window.clearInterval(id);
|
||||||
if (!el) return;
|
}, []);
|
||||||
const track: RemoteTrack = share.track;
|
return <>{formatElapsed(Date.now() - new Date(startedAt).getTime())}</>;
|
||||||
track.attach(el);
|
}
|
||||||
return () => {
|
|
||||||
track.detach(el);
|
|
||||||
};
|
|
||||||
}, [share.track, watching]);
|
|
||||||
|
|
||||||
|
function formatElapsed(ms: number): string {
|
||||||
|
const total = Math.max(0, Math.floor(ms / 1000));
|
||||||
|
const hh = Math.floor(total / 3600);
|
||||||
|
const mm = Math.floor((total % 3600) / 60);
|
||||||
|
const ss = total % 60;
|
||||||
|
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||||
|
return `${pad(hh)}:${pad(mm)}:${pad(ss)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModeToggles({
|
||||||
|
mode,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
mode: CallMode;
|
||||||
|
onChange: (mode: CallMode) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<ModeButton active={mode === 'grid'} onClick={() => onChange('grid')} label="Grid">
|
||||||
|
<GridIcon className="h-4 w-4" />
|
||||||
|
</ModeButton>
|
||||||
|
<ModeButton active={mode === 'focus'} onClick={() => onChange('focus')} label="Fokus">
|
||||||
|
<FocusIcon className="h-4 w-4" />
|
||||||
|
</ModeButton>
|
||||||
|
<ModeButton
|
||||||
|
active={mode === 'fullscreen'}
|
||||||
|
onClick={() => onChange('fullscreen')}
|
||||||
|
label="Vollbild"
|
||||||
|
>
|
||||||
|
<MaximizeIcon className="h-4 w-4" />
|
||||||
|
</ModeButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModeButton({
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
label: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
aria-label={label}
|
||||||
|
aria-pressed={active}
|
||||||
|
title={label}
|
||||||
|
className={
|
||||||
|
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 ' +
|
||||||
|
(active
|
||||||
|
? 'border-accent bg-accent text-accent-fg'
|
||||||
|
: 'border-line bg-surface-2 text-fg-muted hover:bg-surface-3 hover:text-fg')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StageProps {
|
||||||
|
tiles: Tile[];
|
||||||
|
speaker: Tile | undefined;
|
||||||
|
mode: CallMode;
|
||||||
|
activeSpeakers: Set<string>;
|
||||||
|
e2ee: boolean;
|
||||||
|
remoteScreenShares: {
|
||||||
|
track: import('livekit-client').RemoteTrack;
|
||||||
|
participantId: string;
|
||||||
|
participantName: string;
|
||||||
|
}[];
|
||||||
|
conversationMembers: ConversationSummary['members'];
|
||||||
|
onFocusTile: (id: string) => void;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CallStage({
|
||||||
|
tiles,
|
||||||
|
speaker,
|
||||||
|
mode,
|
||||||
|
activeSpeakers,
|
||||||
|
e2ee,
|
||||||
|
remoteScreenShares,
|
||||||
|
conversationMembers,
|
||||||
|
onFocusTile,
|
||||||
|
compact = false,
|
||||||
|
}: StageProps) {
|
||||||
|
if (mode === 'focus' && speaker) {
|
||||||
|
const others = tiles.filter((p) => p.userId !== speaker.userId);
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col gap-2.5 p-3">
|
||||||
|
<div className="min-h-0 flex-1">
|
||||||
|
<FocusedTile
|
||||||
|
tile={speaker}
|
||||||
|
e2ee={e2ee}
|
||||||
|
speaking={activeSpeakers.has(speaker.userId)}
|
||||||
|
remoteScreenShares={remoteScreenShares}
|
||||||
|
conversationMembers={conversationMembers}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{others.length > 0 && (
|
||||||
|
<div className="flex h-[110px] gap-2.5 overflow-x-auto">
|
||||||
|
{others.map((p) => (
|
||||||
|
<div key={p.userId} className="h-full min-w-[160px]">
|
||||||
|
<CallParticipantTile
|
||||||
|
userId={p.userId}
|
||||||
|
displayName={p.displayName}
|
||||||
|
avatarUrl={p.avatarUrl}
|
||||||
|
me={p.self}
|
||||||
|
muted={p.muted}
|
||||||
|
speaking={activeSpeakers.has(p.userId)}
|
||||||
|
sharing={p.sharing}
|
||||||
|
video={p.video}
|
||||||
|
e2ee={e2ee}
|
||||||
|
size="small"
|
||||||
|
onClick={() => onFocusTile(p.userId)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grid
|
||||||
|
const gridClass = gridColsFor(tiles.length);
|
||||||
|
return (
|
||||||
|
<div className={'min-h-0 flex-1 p-3 ' + (compact ? '' : 'p-4')}>
|
||||||
|
<div className={'grid h-full gap-2 ' + gridClass}>
|
||||||
|
{tiles.map((p) => (
|
||||||
|
<CallParticipantTile
|
||||||
|
key={p.userId}
|
||||||
|
userId={p.userId}
|
||||||
|
displayName={p.displayName}
|
||||||
|
avatarUrl={p.avatarUrl}
|
||||||
|
me={p.self}
|
||||||
|
muted={p.muted}
|
||||||
|
speaking={activeSpeakers.has(p.userId)}
|
||||||
|
sharing={p.sharing}
|
||||||
|
video={p.video}
|
||||||
|
e2ee={e2ee}
|
||||||
|
onClick={() => onFocusTile(p.userId)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FocusedTile({
|
||||||
|
tile,
|
||||||
|
e2ee,
|
||||||
|
speaking,
|
||||||
|
remoteScreenShares,
|
||||||
|
conversationMembers,
|
||||||
|
}: {
|
||||||
|
tile: Tile;
|
||||||
|
e2ee: boolean;
|
||||||
|
speaking: boolean;
|
||||||
|
remoteScreenShares: StageProps['remoteScreenShares'];
|
||||||
|
conversationMembers: StageProps['conversationMembers'];
|
||||||
|
}) {
|
||||||
|
// When the focused participant is remotely sharing their screen, embed the
|
||||||
|
// real video stream rather than the fake-window placeholder.
|
||||||
|
if (tile.remoteSharing) {
|
||||||
|
const share = remoteScreenShares.find((s) => s.participantId === tile.userId);
|
||||||
|
const member = conversationMembers.find((m) => m.userId === tile.userId);
|
||||||
|
if (share) {
|
||||||
|
return (
|
||||||
|
<div className="h-full">
|
||||||
|
<ScreenShareViewer
|
||||||
|
share={share}
|
||||||
|
avatarUrl={member?.profile?.avatarUrl ?? tile.avatarUrl}
|
||||||
|
displayName={member?.profile?.displayName ?? tile.displayName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="h-full">
|
||||||
|
<CallParticipantTile
|
||||||
|
userId={tile.userId}
|
||||||
|
displayName={tile.displayName}
|
||||||
|
avatarUrl={tile.avatarUrl}
|
||||||
|
me={tile.self}
|
||||||
|
muted={tile.muted}
|
||||||
|
speaking={speaking}
|
||||||
|
sharing={tile.sharing}
|
||||||
|
video={tile.video}
|
||||||
|
e2ee={e2ee}
|
||||||
|
focused
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function gridColsFor(n: number): string {
|
||||||
|
if (n <= 1) return 'grid-cols-1';
|
||||||
|
if (n === 2) return 'grid-cols-2';
|
||||||
|
if (n === 3) return 'grid-cols-3';
|
||||||
|
if (n === 4) return 'grid-cols-2 grid-rows-2';
|
||||||
|
return 'grid-cols-3 grid-rows-2';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fullscreen cinema mode
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface FullscreenProps {
|
||||||
|
tiles: Tile[];
|
||||||
|
speaker: Tile | undefined;
|
||||||
|
remoteScreenShares: StageProps['remoteScreenShares'];
|
||||||
|
conversationMembers: StageProps['conversationMembers'];
|
||||||
|
activeSpeakers: Set<string>;
|
||||||
|
e2ee: boolean;
|
||||||
|
onExit: () => void;
|
||||||
|
onFocusTile: (id: string) => void;
|
||||||
|
controls: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FullscreenCall({
|
||||||
|
tiles,
|
||||||
|
speaker,
|
||||||
|
remoteScreenShares,
|
||||||
|
conversationMembers,
|
||||||
|
activeSpeakers,
|
||||||
|
e2ee,
|
||||||
|
onExit: _onExit,
|
||||||
|
onFocusTile,
|
||||||
|
controls,
|
||||||
|
}: FullscreenProps) {
|
||||||
|
const others = speaker ? tiles.filter((p) => p.userId !== speaker.userId) : tiles;
|
||||||
|
const [hintGone, setHintGone] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onChange = () => {
|
const id = window.setTimeout(() => setHintGone(true), 3500);
|
||||||
setIsFullscreen(document.fullscreenElement === containerRef.current);
|
return () => window.clearTimeout(id);
|
||||||
};
|
|
||||||
document.addEventListener('fullscreenchange', onChange);
|
|
||||||
return () => document.removeEventListener('fullscreenchange', onChange);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toggleFullscreen = () => {
|
|
||||||
const el = containerRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
if (document.fullscreenElement === el) {
|
|
||||||
void document.exitFullscreen();
|
|
||||||
} else {
|
|
||||||
void el.requestFullscreen();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="fixed inset-0 z-[60] flex flex-col overflow-hidden bg-surface">
|
||||||
ref={containerRef}
|
<div className="relative min-h-0 flex-1">
|
||||||
className={
|
{speaker && (
|
||||||
'overflow-hidden rounded-xl border border-emerald-500/20 bg-black ' +
|
<div className="absolute inset-0">
|
||||||
(isFullscreen ? 'flex h-screen w-screen flex-col rounded-none' : '')
|
{speaker.remoteSharing ? (
|
||||||
}
|
<FullscreenShare speaker={speaker} remoteScreenShares={remoteScreenShares} conversationMembers={conversationMembers} />
|
||||||
>
|
|
||||||
<div className="flex shrink-0 items-center gap-2 border-b border-emerald-500/10 bg-emerald-500/10 px-3 py-1.5 text-xs text-emerald-200">
|
|
||||||
<MonitorShareIcon className="h-3.5 w-3.5 shrink-0" />
|
|
||||||
<span className="truncate flex-1">
|
|
||||||
{t('app:call.is_sharing_screen', {
|
|
||||||
name: displayName,
|
|
||||||
defaultValue: displayName + ' teilt den Bildschirm',
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
{watching && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={toggleFullscreen}
|
|
||||||
aria-label={t('app:call.fullscreen', { defaultValue: 'Vollbild' })}
|
|
||||||
title={t('app:call.fullscreen', { defaultValue: 'Vollbild' })}
|
|
||||||
className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-emerald-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40"
|
|
||||||
>
|
|
||||||
<FullscreenIcon className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
if (document.fullscreenElement === containerRef.current) {
|
|
||||||
void document.exitFullscreen();
|
|
||||||
}
|
|
||||||
setWatching(false);
|
|
||||||
}}
|
|
||||||
aria-label={t('app:call.stop_watching', { defaultValue: 'Nicht mehr anschauen' })}
|
|
||||||
title={t('app:call.stop_watching', { defaultValue: 'Nicht mehr anschauen' })}
|
|
||||||
className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-emerald-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40"
|
|
||||||
>
|
|
||||||
<span aria-hidden="true" className="text-[14px] leading-none">×</span>
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{watching ? (
|
|
||||||
<video
|
|
||||||
ref={videoRef}
|
|
||||||
autoPlay
|
|
||||||
playsInline
|
|
||||||
muted
|
|
||||||
onDoubleClick={toggleFullscreen}
|
|
||||||
className={
|
|
||||||
'block cursor-zoom-in bg-black ' +
|
|
||||||
(isFullscreen ? 'h-full w-full flex-1 object-contain' : 'w-full')
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<button
|
<div className="h-full w-full [&>div]:rounded-none [&>div]:border-0">
|
||||||
type="button"
|
<CallParticipantTile
|
||||||
onClick={() => setWatching(true)}
|
userId={speaker.userId}
|
||||||
aria-label={t('app:call.watch_screen', { defaultValue: 'Bildschirm anschauen' })}
|
displayName={speaker.displayName}
|
||||||
className="group relative block w-full cursor-pointer overflow-hidden bg-ink-900 focus:outline-none"
|
avatarUrl={speaker.avatarUrl}
|
||||||
style={{ aspectRatio: '16 / 9' }}
|
me={speaker.self}
|
||||||
>
|
muted={speaker.muted}
|
||||||
<BlurredTile avatarUrl={avatarUrl} letter={letter} />
|
speaking={activeSpeakers.has(speaker.userId)}
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-black/30 transition group-hover:bg-black/40">
|
sharing={speaker.sharing}
|
||||||
<div className="flex flex-col items-center gap-2">
|
video={speaker.video}
|
||||||
<span className="flex h-14 w-14 items-center justify-center rounded-full bg-emerald-500/90 text-white shadow-lg transition group-hover:scale-105">
|
e2ee={e2ee}
|
||||||
<PlayIcon className="ml-0.5 h-6 w-6" />
|
focused
|
||||||
</span>
|
/>
|
||||||
<span className="text-xs font-medium text-white/90">
|
|
||||||
{t('app:call.watch_screen', { defaultValue: 'Bildschirm anschauen' })}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
)}
|
||||||
}
|
|
||||||
|
|
||||||
function BlurredTile({ avatarUrl, letter }: { avatarUrl: string | null; letter: string }) {
|
{others.length > 0 && (
|
||||||
if (avatarUrl) {
|
<div className="absolute bottom-24 right-4 flex w-[180px] flex-col gap-2">
|
||||||
return (
|
{others.slice(0, 4).map((p) => (
|
||||||
<>
|
<div key={p.userId} className="h-[100px] backdrop-blur-xl">
|
||||||
<img
|
<CallParticipantTile
|
||||||
src={avatarUrl}
|
userId={p.userId}
|
||||||
alt=""
|
displayName={p.displayName}
|
||||||
className="absolute inset-0 h-full w-full scale-110 object-cover blur-2xl"
|
avatarUrl={p.avatarUrl}
|
||||||
|
me={p.self}
|
||||||
|
muted={p.muted}
|
||||||
|
speaking={activeSpeakers.has(p.userId)}
|
||||||
|
sharing={p.sharing}
|
||||||
|
video={p.video}
|
||||||
|
e2ee={e2ee}
|
||||||
|
size="small"
|
||||||
|
onClick={() => onFocusTile(p.userId)}
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-brand-500/20 to-emerald-500/20" />
|
</div>
|
||||||
</>
|
))}
|
||||||
);
|
</div>
|
||||||
}
|
)}
|
||||||
return (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-brand-500/40 via-fuchsia-500/20 to-emerald-500/30">
|
{!hintGone && (
|
||||||
<span
|
<div
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="text-[120px] font-display font-bold text-white/20 blur-[2px]"
|
className="pointer-events-none absolute left-1/2 top-5 z-10 animate-fs-hint rounded-lg border border-white/10 bg-black/60 px-3.5 py-1.5 text-[11px] font-medium tracking-wide text-white/70 backdrop-blur-md"
|
||||||
>
|
>
|
||||||
{letter}
|
Esc zum Verlassen
|
||||||
</span>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="pointer-events-auto absolute bottom-4 left-1/2 -translate-x-1/2">
|
||||||
|
{controls}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PlayIcon(props: React.SVGProps<SVGSVGElement>) {
|
function FullscreenShare({
|
||||||
|
speaker,
|
||||||
|
remoteScreenShares,
|
||||||
|
conversationMembers,
|
||||||
|
}: {
|
||||||
|
speaker: Tile;
|
||||||
|
remoteScreenShares: StageProps['remoteScreenShares'];
|
||||||
|
conversationMembers: StageProps['conversationMembers'];
|
||||||
|
}) {
|
||||||
|
const share = remoteScreenShares.find((s) => s.participantId === speaker.userId);
|
||||||
|
const member = conversationMembers.find((m) => m.userId === speaker.userId);
|
||||||
|
if (!share) return null;
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>
|
<div className="h-full w-full">
|
||||||
<path d="M8 5v14l11-7z" />
|
<ScreenShareViewer
|
||||||
</svg>
|
share={share}
|
||||||
);
|
avatarUrl={member?.profile?.avatarUrl ?? speaker.avatarUrl}
|
||||||
}
|
displayName={member?.profile?.displayName ?? speaker.displayName}
|
||||||
|
/>
|
||||||
function FullscreenIcon(props: React.SVGProps<SVGSVGElement>) {
|
</div>
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={2}
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5" />
|
|
||||||
</svg>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,95 +579,11 @@ function PttHint() {
|
|||||||
useEffect(() => subscribePttSettings(setPtt), []);
|
useEffect(() => subscribePttSettings(setPtt), []);
|
||||||
if (!ptt.enabled) return null;
|
if (!ptt.enabled) return null;
|
||||||
return (
|
return (
|
||||||
<p className="mt-3 text-center text-[11px] text-neutral-500">
|
<p className="border-t border-line bg-surface-3 py-2 text-center text-[11px] text-fg-muted">
|
||||||
Push-to-Talk:
|
Push-to-Talk:
|
||||||
<kbd className="rounded border border-white/10 bg-white/5 px-1.5 py-0.5 font-mono text-[10px] text-neutral-300">
|
<kbd className="rounded border border-line bg-surface-2 px-1.5 py-0.5 font-mono text-[10px] text-fg">
|
||||||
{ptt.keyLabel}
|
{ptt.keyLabel}
|
||||||
</kbd>
|
</kbd>
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ParticipantTileData {
|
|
||||||
userId: string;
|
|
||||||
displayName: string;
|
|
||||||
avatarUrl: string | null;
|
|
||||||
self: boolean;
|
|
||||||
speaking: boolean;
|
|
||||||
muted: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ParticipantTile(p: ParticipantTileData) {
|
|
||||||
const letter = p.displayName.trim().charAt(0).toUpperCase() || '?';
|
|
||||||
return (
|
|
||||||
<div className="flex w-28 flex-col items-center gap-2">
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
'relative flex h-20 w-20 items-center justify-center rounded-2xl bg-gradient-to-br from-brand-500/40 to-brand-700/40 text-2xl font-semibold text-white ring-2 transition ' +
|
|
||||||
(p.speaking
|
|
||||||
? 'ring-emerald-400 shadow-[0_0_24px_rgba(52,211,153,0.35)]'
|
|
||||||
: 'ring-white/10')
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{p.avatarUrl ? (
|
|
||||||
<img
|
|
||||||
src={p.avatarUrl}
|
|
||||||
alt=""
|
|
||||||
className="h-full w-full rounded-2xl object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span aria-hidden="true">{letter}</span>
|
|
||||||
)}
|
|
||||||
{p.muted && (
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
className="absolute -bottom-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full bg-amber-500 ring-2 ring-ink-950"
|
|
||||||
>
|
|
||||||
<MicOffIcon className="h-3 w-3 text-ink-950" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="max-w-full truncate text-xs font-medium text-neutral-200">
|
|
||||||
{p.displayName}
|
|
||||||
{p.self && (
|
|
||||||
<span className="ml-1 text-neutral-500">· Du</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ControlButtonProps {
|
|
||||||
onClick: () => void;
|
|
||||||
label: string;
|
|
||||||
tone: 'neutral' | 'amber' | 'rose' | 'emerald';
|
|
||||||
active?: boolean;
|
|
||||||
disabled?: boolean;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ControlButton({ onClick, label, tone, disabled, children }: ControlButtonProps) {
|
|
||||||
const toneClass =
|
|
||||||
tone === 'rose'
|
|
||||||
? 'bg-rose-500 text-white hover:bg-rose-400 focus-visible:ring-rose-400/50'
|
|
||||||
: tone === 'amber'
|
|
||||||
? 'bg-amber-500/90 text-ink-950 hover:bg-amber-400 focus-visible:ring-amber-400/50'
|
|
||||||
: tone === 'emerald'
|
|
||||||
? 'bg-emerald-500/85 text-white hover:bg-emerald-400 focus-visible:ring-emerald-400/50'
|
|
||||||
: 'bg-white/10 text-neutral-100 hover:bg-white/20 focus-visible:ring-brand-400/40';
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
disabled={disabled}
|
|
||||||
aria-label={label}
|
|
||||||
title={label}
|
|
||||||
className={
|
|
||||||
'inline-flex h-11 w-11 cursor-pointer items-center justify-center rounded-full transition focus:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-50 ' +
|
|
||||||
toneClass
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
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 } 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-[360px] gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={rejectIncoming}
|
||||||
|
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-[14px] border border-rose-500/40 bg-transparent px-5 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()}
|
||||||
|
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-[14px] bg-emerald-600 px-5 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>{t('app:call.accept')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,29 +14,60 @@ 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 { AttachmentImage } from './AttachmentImage';
|
import { AttachmentImage } from './AttachmentImage';
|
||||||
import { PencilIcon, PhoneIcon, PhoneOffIcon, SmileIcon, SpinnerIcon, TrashIcon, XIcon } from './icons';
|
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;
|
||||||
|
/** 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;
|
||||||
|
/** 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,
|
||||||
|
quoted = null,
|
||||||
|
onJumpToMessage,
|
||||||
|
onReply,
|
||||||
|
onForward,
|
||||||
|
highlighted = false,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation(['app']);
|
const { t } = useTranslation(['app']);
|
||||||
const { session, device } = useAuth();
|
const { session, device } = useAuth();
|
||||||
@@ -98,6 +129,8 @@ export function MessageBubble({
|
|||||||
messageId: message.id,
|
messageId: message.id,
|
||||||
conversationId,
|
conversationId,
|
||||||
newPlaintext: trimmed,
|
newPlaintext: trimmed,
|
||||||
|
senderUserId: session.user.id,
|
||||||
|
senderDeviceId: device.id,
|
||||||
senderPrivateKey: priv,
|
senderPrivateKey: priv,
|
||||||
});
|
});
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
@@ -141,8 +174,13 @@ export function MessageBubble({
|
|||||||
|
|
||||||
if (message.deletedAt) {
|
if (message.deletedAt) {
|
||||||
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}
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
@@ -154,14 +192,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)}
|
||||||
@@ -177,10 +220,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>
|
||||||
)}
|
)}
|
||||||
@@ -191,7 +234,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>
|
||||||
@@ -199,7 +242,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>
|
||||||
@@ -208,15 +251,48 @@ 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 px-2 py-1.5 text-left text-xs transition hover:opacity-90 ' +
|
||||||
|
(mine
|
||||||
|
? 'bg-white/15 text-accent-fg/90'
|
||||||
|
: 'bg-surface-2 text-fg-muted')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={
|
||||||
|
'w-0.5 shrink-0 rounded-full ' + (mine ? 'bg-white/50' : 'bg-accent')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className={'block truncate font-semibold ' + (mine ? '' : 'text-fg')}>
|
||||||
|
{quoted.senderName}
|
||||||
|
</span>
|
||||||
|
<span className="block truncate italic opacity-90">
|
||||||
|
{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>{bodyText}</div>}
|
||||||
@@ -228,7 +304,7 @@ export function MessageBubble({
|
|||||||
<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>
|
||||||
@@ -240,7 +316,7 @@ export function MessageBubble({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{showSeen && mine && !editing && !message.deletedAt && (
|
{showSeen && mine && !editing && !message.deletedAt && (
|
||||||
<p className="mt-0.5 text-right text-[10px] text-neutral-500">
|
<p className="mt-0.5 text-right text-[10px] text-fg-muted">
|
||||||
{t('app:chats.seen')}
|
{t('app:chats.seen')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -253,10 +329,10 @@ export function MessageBubble({
|
|||||||
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 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>
|
||||||
@@ -273,12 +349,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"
|
||||||
@@ -304,7 +394,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')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -313,7 +403,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>
|
||||||
@@ -321,7 +411,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>
|
||||||
@@ -334,6 +424,27 @@ export function MessageBubble({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AvatarSlot({
|
||||||
|
show,
|
||||||
|
url,
|
||||||
|
displayName,
|
||||||
|
}: {
|
||||||
|
show: boolean;
|
||||||
|
url: string | null;
|
||||||
|
displayName: string | null;
|
||||||
|
}) {
|
||||||
|
if (!show) {
|
||||||
|
return <div aria-hidden="true" className="h-8 w-8 shrink-0" />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Avatar
|
||||||
|
url={url}
|
||||||
|
displayName={displayName ?? ''}
|
||||||
|
className="h-8 w-8 text-xs"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function CallEventRow({
|
function CallEventRow({
|
||||||
parsed,
|
parsed,
|
||||||
mine,
|
mine,
|
||||||
@@ -349,8 +460,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'
|
||||||
@@ -406,10 +517,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,192 @@
|
|||||||
|
import type { RemoteTrack } from 'livekit-client';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import type { RemoteScreenShare } from '../context/CallContext';
|
||||||
|
import { MonitorShareIcon } from './icons';
|
||||||
|
|
||||||
|
interface ScreenShareViewerProps {
|
||||||
|
share: RemoteScreenShare;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click-to-watch gate, live <video> attach/detach, native fullscreen toggle.
|
||||||
|
// Lifted out of the old InCallPanel so the new CallDock stays lean.
|
||||||
|
export function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShareViewerProps) {
|
||||||
|
const { t } = useTranslation(['app']);
|
||||||
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [watching, setWatching] = useState(false);
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
const letter = displayName.trim().charAt(0).toUpperCase() || '?';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!watching) return;
|
||||||
|
const el = videoRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const track: RemoteTrack = share.track;
|
||||||
|
track.attach(el);
|
||||||
|
return () => {
|
||||||
|
track.detach(el);
|
||||||
|
};
|
||||||
|
}, [share.track, watching]);
|
||||||
|
|
||||||
|
// Esc exits CSS fullscreen.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isFullscreen) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') setIsFullscreen(false);
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [isFullscreen]);
|
||||||
|
|
||||||
|
// CSS-only "app fullscreen" — Discord-style: overlay the whole window
|
||||||
|
// including the left sidebar + chat list. Native Fullscreen API is
|
||||||
|
// unreliable in Tauri's WKWebView and doesn't add useful chrome-hiding
|
||||||
|
// beyond what `fixed inset-0 z-[60]` already gives us.
|
||||||
|
const toggleFullscreen = () => {
|
||||||
|
setIsFullscreen((v) => !v);
|
||||||
|
};
|
||||||
|
|
||||||
|
const viewerNode = (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={
|
||||||
|
isFullscreen
|
||||||
|
? 'fixed inset-0 z-[60] flex h-screen w-screen flex-col overflow-hidden border-0 bg-black'
|
||||||
|
: 'overflow-hidden rounded-xl border border-emerald-500/30 bg-black'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex shrink-0 items-center gap-2 border-b border-emerald-500/20 bg-emerald-500/15 px-3 py-1.5 text-xs text-emerald-700 dark:text-emerald-200">
|
||||||
|
<MonitorShareIcon className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span className="flex-1 truncate">
|
||||||
|
{t('app:call.is_sharing_screen', {
|
||||||
|
name: displayName,
|
||||||
|
defaultValue: displayName + ' teilt den Bildschirm',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
{watching && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleFullscreen}
|
||||||
|
aria-label={t('app:call.fullscreen', { defaultValue: 'Vollbild' })}
|
||||||
|
title={t('app:call.fullscreen', { defaultValue: 'Vollbild' })}
|
||||||
|
className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-emerald-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40"
|
||||||
|
>
|
||||||
|
<FullscreenIcon className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (document.fullscreenElement === containerRef.current) {
|
||||||
|
void document.exitFullscreen();
|
||||||
|
}
|
||||||
|
setWatching(false);
|
||||||
|
}}
|
||||||
|
aria-label={t('app:call.stop_watching', { defaultValue: 'Nicht mehr anschauen' })}
|
||||||
|
title={t('app:call.stop_watching', { defaultValue: 'Nicht mehr anschauen' })}
|
||||||
|
className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-emerald-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true" className="text-[14px] leading-none">×</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{watching ? (
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
autoPlay
|
||||||
|
playsInline
|
||||||
|
muted
|
||||||
|
onDoubleClick={toggleFullscreen}
|
||||||
|
className={
|
||||||
|
'block cursor-zoom-in bg-black ' +
|
||||||
|
(isFullscreen ? 'h-full w-full flex-1 object-contain' : 'w-full')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setWatching(true)}
|
||||||
|
aria-label={t('app:call.watch_screen', { defaultValue: 'Bildschirm anschauen' })}
|
||||||
|
className="group relative block w-full cursor-pointer overflow-hidden bg-ink-900 focus:outline-none"
|
||||||
|
style={{ aspectRatio: '16 / 9' }}
|
||||||
|
>
|
||||||
|
<BlurredTile avatarUrl={avatarUrl} letter={letter} />
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/30 transition group-hover:bg-black/40">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<span className="flex h-14 w-14 items-center justify-center rounded-full bg-emerald-500 text-white shadow-lg transition group-hover:scale-105">
|
||||||
|
<PlayIcon className="ml-0.5 h-6 w-6" />
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-medium text-white/90">
|
||||||
|
{t('app:call.watch_screen', { defaultValue: 'Bildschirm anschauen' })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// When in fullscreen, portal out of the call-panel subtree into <body> so
|
||||||
|
// no ancestor can clip or stack below us. Sidebar/chat-list are siblings of
|
||||||
|
// AppShell's root — portalled node sits above them via z-[60].
|
||||||
|
if (isFullscreen) {
|
||||||
|
return createPortal(viewerNode, document.body);
|
||||||
|
}
|
||||||
|
return viewerNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BlurredTile({ avatarUrl, letter }: { avatarUrl: string | null; letter: string }) {
|
||||||
|
if (avatarUrl) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<img
|
||||||
|
src={avatarUrl}
|
||||||
|
alt=""
|
||||||
|
className="absolute inset-0 h-full w-full scale-110 object-cover blur-2xl"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-accent/20 to-emerald-500/20" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-accent/40 via-fuchsia-500/20 to-emerald-500/30">
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="text-[120px] font-display font-bold text-white/20 blur-[2px]"
|
||||||
|
>
|
||||||
|
{letter}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlayIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>
|
||||||
|
<path d="M8 5v14l11-7z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FullscreenIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,110 +4,176 @@ import { NavLink } from 'react-router-dom';
|
|||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { useConversationsContext } from '../context/ConversationsContext';
|
import { useConversationsContext } from '../context/ConversationsContext';
|
||||||
import { useFriendshipsContext } from '../context/FriendshipsContext';
|
import { useFriendshipsContext } from '../context/FriendshipsContext';
|
||||||
import { CallBar } from './CallUI';
|
import { useTheme } from '../context/ThemeContext';
|
||||||
import {
|
import {
|
||||||
ChatBubbleIcon,
|
ChatBubbleIcon,
|
||||||
GearIcon,
|
GearIcon,
|
||||||
LogoMark,
|
LogoMark,
|
||||||
|
MoonIcon,
|
||||||
ShieldIcon,
|
ShieldIcon,
|
||||||
SignOutIcon,
|
SignOutIcon,
|
||||||
|
SunIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
} from './icons';
|
} from './icons';
|
||||||
import { UserBar } from './UserBar';
|
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
to: string;
|
to: string;
|
||||||
labelKey: string;
|
labelKey: string;
|
||||||
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
|
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
|
||||||
|
badge?: 'friends' | 'chats';
|
||||||
}
|
}
|
||||||
|
|
||||||
const BASE_NAV_ITEMS: NavItem[] = [
|
const PRIMARY_NAV: NavItem[] = [
|
||||||
{ to: '/chats', labelKey: 'app:nav.chats', icon: ChatBubbleIcon },
|
{ to: '/chats', labelKey: 'app:nav.chats', icon: ChatBubbleIcon, badge: 'chats' },
|
||||||
{ to: '/friends', labelKey: 'app:nav.friends', icon: UsersIcon },
|
{ to: '/friends', labelKey: 'app:nav.friends', icon: UsersIcon, badge: 'friends' },
|
||||||
{ to: '/settings', labelKey: 'app:nav.settings', icon: GearIcon },
|
|
||||||
];
|
];
|
||||||
const ADMIN_NAV_ITEM: NavItem = {
|
const ADMIN_NAV: NavItem = {
|
||||||
to: '/admin',
|
to: '/admin',
|
||||||
labelKey: 'app:nav.admin',
|
labelKey: 'app:nav.admin',
|
||||||
icon: ShieldIcon,
|
icon: ShieldIcon,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Clean-Rail 72px icon rail. Brand logo top → primary tabs → spacer →
|
||||||
|
// settings + theme toggle + sign-out at bottom. Active tab shows a pill
|
||||||
|
// indicator anchored to the left edge of the rail.
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const { t } = useTranslation(['app', 'common']);
|
const { t } = useTranslation(['app']);
|
||||||
const { signOut, profile } = useAuth();
|
const { signOut, profile } = useAuth();
|
||||||
const { incomingCount } = useFriendshipsContext();
|
const { incomingCount } = useFriendshipsContext();
|
||||||
const { totalUnread } = useConversationsContext();
|
const { totalUnread } = useConversationsContext();
|
||||||
|
const { theme, toggle } = useTheme();
|
||||||
|
|
||||||
const navItems = profile?.isAdmin ? [...BASE_NAV_ITEMS, ADMIN_NAV_ITEM] : BASE_NAV_ITEMS;
|
const navItems: NavItem[] = profile?.isAdmin ? [...PRIMARY_NAV, ADMIN_NAV] : PRIMARY_NAV;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
aria-label="Primary navigation"
|
aria-label="Primary navigation"
|
||||||
className="flex h-screen w-72 shrink-0 flex-col border-r border-white/5 bg-ink-900/70 backdrop-blur-xl"
|
className="flex h-screen w-[72px] shrink-0 flex-col items-center border-r border-line bg-surface-2/80 py-4 backdrop-blur-xl"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2.5 px-5 pb-3 pt-5">
|
<div className="flex h-10 w-10 items-center justify-center" title="Netralax">
|
||||||
<LogoMark className="h-7 w-7" />
|
<LogoMark className="h-8 w-8" />
|
||||||
<span className="font-display text-base font-semibold tracking-tight text-white">
|
|
||||||
{t('common:app_name')}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="mt-2 flex flex-col gap-0.5 px-3">
|
<div className="my-3 h-px w-8 bg-line" aria-hidden="true" />
|
||||||
|
|
||||||
|
<nav className="flex flex-col items-center gap-2">
|
||||||
{navItems.map((item) => {
|
{navItems.map((item) => {
|
||||||
const badge =
|
const badgeCount =
|
||||||
item.to === '/friends'
|
item.badge === 'friends'
|
||||||
? incomingCount
|
? incomingCount
|
||||||
: item.to === '/chats'
|
: item.badge === 'chats'
|
||||||
? totalUnread
|
? totalUnread
|
||||||
: 0;
|
: 0;
|
||||||
const ariaLabel = badge > 0 ? `${t(item.labelKey)} (${badge})` : undefined;
|
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<RailNavLink
|
||||||
key={item.to}
|
key={item.to}
|
||||||
to={item.to}
|
to={item.to}
|
||||||
aria-label={ariaLabel}
|
label={t(item.labelKey)}
|
||||||
className={({ isActive }) =>
|
icon={item.icon}
|
||||||
[
|
badge={badgeCount}
|
||||||
'group flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition',
|
/>
|
||||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/50',
|
|
||||||
isActive
|
|
||||||
? 'bg-brand-500/15 text-white ring-1 ring-brand-400/30'
|
|
||||||
: 'text-neutral-400 hover:bg-white/5 hover:text-neutral-100',
|
|
||||||
].join(' ')
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<item.icon style={{ width: '18px', height: '18px' }} className="transition" />
|
|
||||||
<span className="flex-1">{t(item.labelKey)}</span>
|
|
||||||
{badge > 0 && <NavBadge count={badge} />}
|
|
||||||
</NavLink>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
<div className="border-t border-white/5 p-3">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<CallBar />
|
<RailNavLink
|
||||||
<UserBar />
|
to="/settings"
|
||||||
<button
|
label={t('app:nav.settings')}
|
||||||
type="button"
|
icon={GearIcon}
|
||||||
|
/>
|
||||||
|
<RailIconButton
|
||||||
|
label={theme === 'dark' ? t('app:theme.light', { defaultValue: 'Light mode' }) : t('app:theme.dark', { defaultValue: 'Dark mode' })}
|
||||||
|
onClick={toggle}
|
||||||
|
icon={theme === 'dark' ? SunIcon : MoonIcon}
|
||||||
|
/>
|
||||||
|
<RailIconButton
|
||||||
|
label={t('app:sidebar.sign_out')}
|
||||||
onClick={() => void signOut()}
|
onClick={() => void signOut()}
|
||||||
className="mt-2 flex w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-xs font-medium text-neutral-400 transition hover:bg-rose-500/10 hover:text-rose-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/40"
|
icon={SignOutIcon}
|
||||||
>
|
tone="danger"
|
||||||
<SignOutIcon className="h-4 w-4" />
|
/>
|
||||||
<span>{t('app:sidebar.sign_out')}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function NavBadge({ count }: { count: number }) {
|
interface RailNavLinkProps {
|
||||||
|
to: string;
|
||||||
|
label: string;
|
||||||
|
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
|
||||||
|
badge?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RailNavLink({ to, label, icon: Icon, badge = 0 }: RailNavLinkProps) {
|
||||||
|
const ariaLabel = badge > 0 ? `${label} (${badge})` : label;
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
to={to}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
title={label}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
[
|
||||||
|
'group relative flex h-11 w-11 cursor-pointer items-center justify-center rounded-xl transition',
|
||||||
|
'focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50',
|
||||||
|
isActive
|
||||||
|
? 'bg-accent/15 text-accent'
|
||||||
|
: 'text-fg-muted hover:bg-surface-3/60 hover:text-fg',
|
||||||
|
].join(' ')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ isActive }) => (
|
||||||
|
<>
|
||||||
|
{isActive && (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute -left-4 h-6 w-1 rounded-r-full bg-accent"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Icon style={{ width: '20px', height: '20px' }} />
|
||||||
|
{badge > 0 && <RailBadge count={badge} />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RailIconButtonProps {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
|
||||||
|
tone?: 'default' | 'danger';
|
||||||
|
}
|
||||||
|
|
||||||
|
function RailIconButton({ label, onClick, icon: Icon, tone = 'default' }: RailIconButtonProps) {
|
||||||
|
const toneClass =
|
||||||
|
tone === 'danger'
|
||||||
|
? 'text-fg-muted hover:bg-rose-500/10 hover:text-rose-400 focus-visible:ring-rose-400/40'
|
||||||
|
: 'text-fg-muted hover:bg-surface-3/60 hover:text-fg focus-visible:ring-accent/50';
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={label}
|
||||||
|
title={label}
|
||||||
|
onClick={onClick}
|
||||||
|
className={
|
||||||
|
'flex h-11 w-11 cursor-pointer items-center justify-center rounded-xl transition focus:outline-none focus-visible:ring-2 ' +
|
||||||
|
toneClass
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon style={{ width: '20px', height: '20px' }} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RailBadge({ count }: { count: number }) {
|
||||||
const display = count > 99 ? '99+' : String(count);
|
const display = count > 99 ? '99+' : String(count);
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="inline-flex min-w-[20px] items-center justify-center rounded-full bg-rose-500 px-1.5 text-[10px] font-bold leading-tight text-white shadow-[0_0_0_2px_rgba(15,15,24,1)]"
|
className="absolute -right-1 -top-1 inline-flex min-w-[18px] items-center justify-center rounded-full bg-rose-500 px-1 text-[10px] font-bold leading-tight text-white ring-2 ring-surface-2"
|
||||||
>
|
>
|
||||||
{display}
|
{display}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function TypingIndicator({ typingUserIds, members }: Props) {
|
|||||||
: t('app:chats.typing_many', { count: typingUserIds.length });
|
: t('app:chats.typing_many', { count: typingUserIds.length });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-6 pb-1 pt-0 text-xs text-neutral-400">
|
<div className="bg-surface-3 px-6 pb-1 pt-0 text-xs text-fg-muted">
|
||||||
<span className="inline-flex items-center gap-2">
|
<span className="inline-flex items-center gap-2">
|
||||||
<TypingDots />
|
<TypingDots />
|
||||||
<span>{text}</span>
|
<span>{text}</span>
|
||||||
@@ -32,9 +32,9 @@ export function TypingIndicator({ typingUserIds, members }: Props) {
|
|||||||
function TypingDots() {
|
function TypingDots() {
|
||||||
return (
|
return (
|
||||||
<span aria-hidden="true" className="inline-flex items-center gap-0.5">
|
<span aria-hidden="true" className="inline-flex items-center gap-0.5">
|
||||||
<span className="h-1 w-1 rounded-full bg-neutral-500 [animation:pulse_1.2s_ease-in-out_infinite]" />
|
<span className="h-1 w-1 rounded-full bg-fg-muted [animation:pulse_1.2s_ease-in-out_infinite]" />
|
||||||
<span className="h-1 w-1 rounded-full bg-neutral-500 [animation:pulse_1.2s_ease-in-out_infinite] [animation-delay:0.15s]" />
|
<span className="h-1 w-1 rounded-full bg-fg-muted [animation:pulse_1.2s_ease-in-out_infinite] [animation-delay:0.15s]" />
|
||||||
<span className="h-1 w-1 rounded-full bg-neutral-500 [animation:pulse_1.2s_ease-in-out_infinite] [animation-delay:0.3s]" />
|
<span className="h-1 w-1 rounded-full bg-fg-muted [animation:pulse_1.2s_ease-in-out_infinite] [animation-delay:0.3s]" />
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,22 +57,22 @@ export function UpdateToast() {
|
|||||||
<div
|
<div
|
||||||
role="status"
|
role="status"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
className="fixed bottom-6 left-6 z-50 w-80 overflow-hidden rounded-2xl border border-white/10 bg-ink-900/95 p-4 shadow-2xl backdrop-blur-xl"
|
className="fixed bottom-6 left-6 z-50 w-80 overflow-hidden rounded-2xl border border-line bg-surface-3 p-4 shadow-2xl"
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-500/25 text-brand-200 ring-1 ring-brand-400/30">
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-accent/20 text-accent">
|
||||||
<SparklesIcon className="h-4 w-4" />
|
<SparklesIcon className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-sm font-semibold text-white">
|
<p className="text-sm font-semibold text-fg">
|
||||||
{t('app:update.available', { defaultValue: 'Update verfügbar' })}
|
{t('app:update.available', { defaultValue: 'Update verfügbar' })}
|
||||||
{state.version ? ' · v' + state.version : ''}
|
{state.version ? ' · v' + state.version : ''}
|
||||||
</p>
|
</p>
|
||||||
{state.notes && (
|
{state.notes && (
|
||||||
<p className="mt-0.5 line-clamp-3 text-xs text-neutral-400">{state.notes}</p>
|
<p className="mt-0.5 line-clamp-3 text-xs text-fg-muted">{state.notes}</p>
|
||||||
)}
|
)}
|
||||||
{state.error && (
|
{state.error && (
|
||||||
<p className="mt-1 text-xs text-rose-300">{state.error}</p>
|
<p className="mt-1 text-xs text-rose-500 dark:text-rose-300">{state.error}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!installing && (
|
{!installing && (
|
||||||
@@ -80,7 +80,7 @@ export function UpdateToast() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setDismissed(true)}
|
onClick={() => setDismissed(true)}
|
||||||
aria-label={t('common:close', { defaultValue: 'Schließen' })}
|
aria-label={t('common:close', { defaultValue: 'Schließen' })}
|
||||||
className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-neutral-400 transition hover:bg-white/10 hover:text-neutral-100"
|
className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-fg-muted transition hover:bg-surface-2 hover:text-fg"
|
||||||
>
|
>
|
||||||
<XIcon className="h-3.5 w-3.5" />
|
<XIcon className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -90,7 +90,7 @@ export function UpdateToast() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => void handleInstall()}
|
onClick={() => void handleInstall()}
|
||||||
disabled={installing}
|
disabled={installing}
|
||||||
className="mt-3 inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-gradient-to-br from-brand-400 to-brand-600 px-3 py-2 text-sm font-semibold text-white transition hover:from-brand-300 hover:to-brand-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/60 disabled:cursor-not-allowed disabled:opacity-60"
|
className="mt-3 inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{installing ? (
|
{installing ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -5,22 +5,19 @@ import { useTranslation } from 'react-i18next';
|
|||||||
|
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
|
import { Avatar } from './Avatar';
|
||||||
import { ChevronDownIcon } from './icons';
|
import { ChevronDownIcon } from './icons';
|
||||||
|
|
||||||
const PRESENCE_OPTIONS: PresenceState[] = ['online', 'idle', 'dnd', 'invisible', 'offline'];
|
const PRESENCE_OPTIONS: PresenceState[] = ['online', 'idle', 'dnd', 'invisible', 'offline'];
|
||||||
|
|
||||||
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',
|
||||||
};
|
};
|
||||||
|
|
||||||
function avatarLetter(input: string | undefined): string {
|
|
||||||
return (input ?? '?').trim().charAt(0).toUpperCase() || '?';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UserBar() {
|
export function UserBar() {
|
||||||
const { t } = useTranslation(['app']);
|
const { t } = useTranslation(['app']);
|
||||||
const { profile, refreshProfile } = useAuth();
|
const { profile, refreshProfile } = useAuth();
|
||||||
@@ -53,34 +50,38 @@ export function UserBar() {
|
|||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
className="flex w-full cursor-pointer items-center gap-3 rounded-lg px-2 py-2 text-left transition hover:bg-white/5 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
|
className="flex w-full cursor-pointer items-center gap-3 rounded-lg px-2 py-2 text-left transition hover:bg-surface-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||||
>
|
>
|
||||||
<div className="relative flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-sm font-semibold text-white ring-1 ring-brand-400/30">
|
<div className="relative">
|
||||||
{avatarLetter(profile?.displayName ?? profile?.username)}
|
<Avatar
|
||||||
|
url={profile?.avatarUrl ?? null}
|
||||||
|
displayName={profile?.displayName ?? profile?.username}
|
||||||
|
className="h-9 w-9 text-sm"
|
||||||
|
/>
|
||||||
<span
|
<span
|
||||||
className={
|
className={
|
||||||
'absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full ring-2 ring-ink-900 ' +
|
'absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full ring-2 ring-surface-2 ' +
|
||||||
PRESENCE_DOT[presence]
|
PRESENCE_DOT[presence]
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-sm font-medium text-white">
|
<p className="truncate text-sm font-medium text-fg">
|
||||||
{profile?.displayName ?? '—'}
|
{profile?.displayName ?? '—'}
|
||||||
</p>
|
</p>
|
||||||
<p className="truncate text-xs text-neutral-400">
|
<p className="truncate text-xs text-fg-muted">
|
||||||
{profile?.username ? '@' + profile.username : '—'}
|
{t('app:presence.' + presence)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ChevronDownIcon
|
<ChevronDownIcon
|
||||||
className={'h-4 w-4 text-neutral-500 transition ' + (open ? 'rotate-180' : '')}
|
className={'h-4 w-4 text-fg-muted transition ' + (open ? 'rotate-180' : '')}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div
|
<div
|
||||||
role="menu"
|
role="menu"
|
||||||
className="absolute bottom-full left-0 right-0 mb-2 overflow-hidden rounded-lg border border-white/10 bg-ink-900/95 shadow-xl backdrop-blur-xl"
|
className="absolute bottom-full left-0 right-0 mb-2 overflow-hidden rounded-lg border border-line bg-surface-3 shadow-xl"
|
||||||
>
|
>
|
||||||
{PRESENCE_OPTIONS.map((opt) => (
|
{PRESENCE_OPTIONS.map((opt) => (
|
||||||
<button
|
<button
|
||||||
@@ -90,12 +91,12 @@ export function UserBar() {
|
|||||||
aria-checked={opt === presence}
|
aria-checked={opt === presence}
|
||||||
onClick={() => void changePresence(opt)}
|
onClick={() => void changePresence(opt)}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
className="flex w-full cursor-pointer items-center gap-3 px-3 py-2 text-left text-sm text-neutral-200 transition hover:bg-white/5 disabled:opacity-50"
|
className="flex w-full cursor-pointer items-center gap-3 px-3 py-2 text-left text-sm text-fg transition hover:bg-surface-2 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<span className={'h-2.5 w-2.5 rounded-full ' + PRESENCE_DOT[opt]} />
|
<span className={'h-2.5 w-2.5 rounded-full ' + PRESENCE_DOT[opt]} />
|
||||||
<span className="flex-1">{t('app:presence.' + opt)}</span>
|
<span className="flex-1">{t('app:presence.' + opt)}</span>
|
||||||
{opt === presence && (
|
{opt === presence && (
|
||||||
<span className="text-xs text-brand-300">●</span>
|
<span className="text-xs text-accent">●</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -336,32 +336,249 @@ export function MonitorStopIcon(props: IconProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SunIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Base {...props}>
|
||||||
|
<circle cx="12" cy="12" r="4" />
|
||||||
|
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
|
||||||
|
</Base>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MoonIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Base {...props}>
|
||||||
|
<path d="M21 12.79A9 9 0 1 1 11.21 3a7 7 0 0 0 9.79 9.79Z" />
|
||||||
|
</Base>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GridIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Base {...props}>
|
||||||
|
<rect x="3" y="3" width="7" height="7" rx="1.5" />
|
||||||
|
<rect x="14" y="3" width="7" height="7" rx="1.5" />
|
||||||
|
<rect x="3" y="14" width="7" height="7" rx="1.5" />
|
||||||
|
<rect x="14" y="14" width="7" height="7" rx="1.5" />
|
||||||
|
</Base>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FocusIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Base {...props}>
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||||
|
<rect x="8" y="8" width="8" height="8" rx="1" />
|
||||||
|
</Base>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MaximizeIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Base {...props}>
|
||||||
|
<path d="M4 9V5a1 1 0 0 1 1-1h4" />
|
||||||
|
<path d="M20 9V5a1 1 0 0 0-1-1h-4" />
|
||||||
|
<path d="M4 15v4a1 1 0 0 0 1 1h4" />
|
||||||
|
<path d="M20 15v4a1 1 0 0 1-1 1h-4" />
|
||||||
|
</Base>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VideoIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Base {...props}>
|
||||||
|
<rect x="2" y="6" width="15" height="12" rx="2" />
|
||||||
|
<path d="m22 8-5 4 5 4V8Z" />
|
||||||
|
</Base>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CrownIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Base {...props}>
|
||||||
|
<path d="m3 7 4 4 5-6 5 6 4-4v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" />
|
||||||
|
</Base>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SendIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Base {...props}>
|
||||||
|
<path d="m3 11 18-8-8 18-2-8-8-2Z" />
|
||||||
|
</Base>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ArchiveIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Base {...props}>
|
||||||
|
<rect x="3" y="4" width="18" height="5" rx="1" />
|
||||||
|
<path d="M5 9v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V9" />
|
||||||
|
<path d="M10 13h4" />
|
||||||
|
</Base>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BellIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Base {...props}>
|
||||||
|
<path d="M6 8a6 6 0 1 1 12 0c0 4 1.5 5 2 6H4c.5-1 2-2 2-6Z" />
|
||||||
|
<path d="M9.5 18a2.5 2.5 0 0 0 5 0" />
|
||||||
|
</Base>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BellOffIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Base {...props}>
|
||||||
|
<path d="M9.5 18a2.5 2.5 0 0 0 5 0" />
|
||||||
|
<path d="M13.73 4A2 2 0 0 0 10 4" />
|
||||||
|
<path d="M18 8c0 1-.1 1.9-.2 2.7" />
|
||||||
|
<path d="M4 4 20 20" />
|
||||||
|
<path d="M6 8a6 6 0 0 1 .2-1.7" />
|
||||||
|
<path d="M4 18h13.5L18 17.4c.5-1 2-2 2-6" />
|
||||||
|
</Base>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MoreVerticalIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Base {...props}>
|
||||||
|
<circle cx="12" cy="5" r="1.5" />
|
||||||
|
<circle cx="12" cy="12" r="1.5" />
|
||||||
|
<circle cx="12" cy="19" r="1.5" />
|
||||||
|
</Base>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReplyIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Base {...props}>
|
||||||
|
<polyline points="9 17 4 12 9 7" />
|
||||||
|
<path d="M20 18v-2a4 4 0 0 0-4-4H4" />
|
||||||
|
</Base>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ForwardIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Base {...props}>
|
||||||
|
<polyline points="15 17 20 12 15 7" />
|
||||||
|
<path d="M4 18v-2a4 4 0 0 1 4-4h12" />
|
||||||
|
</Base>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChevronUpIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Base {...props}>
|
||||||
|
<polyline points="18 15 12 9 6 15" />
|
||||||
|
</Base>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddUserIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Base {...props}>
|
||||||
|
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||||
|
<circle cx="9" cy="7" r="4" />
|
||||||
|
<path d="M19 8v6M22 11h-6" />
|
||||||
|
</Base>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function LogoMark(props: IconProps) {
|
export function LogoMark(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 32 32"
|
viewBox="0 0 64 64"
|
||||||
fill="none"
|
fill="none"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="logo-grad" x1="0" y1="0" x2="32" y2="32" gradientUnits="userSpaceOnUse">
|
<clipPath id="logo-hex-clip">
|
||||||
<stop offset="0" stopColor="#818CF8" />
|
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19" />
|
||||||
<stop offset="1" stopColor="#4F46E5" />
|
</clipPath>
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
</defs>
|
||||||
|
<polygon
|
||||||
|
points="32,5 57,19 57,45 32,59 7,45 7,19"
|
||||||
|
fill="#2e1065"
|
||||||
|
/>
|
||||||
|
<g clipPath="url(#logo-hex-clip)">
|
||||||
<path
|
<path
|
||||||
d="M6 9a5 5 0 0 1 5-5h10a5 5 0 0 1 5 5v8a5 5 0 0 1-5 5h-5.5L9 27v-5H11a5 5 0 0 1-5-5V9Z"
|
d="M-4 32 Q 16 18 32 32 T 68 32 L 68 64 L -4 64 Z"
|
||||||
fill="url(#logo-grad)"
|
fill="#7c4dff"
|
||||||
/>
|
/>
|
||||||
<path
|
<path
|
||||||
d="M12 14h8M12 10h8"
|
d="M-4 32 Q 16 18 32 32 T 68 32"
|
||||||
stroke="#0A0A0F"
|
stroke="#a78bfa"
|
||||||
strokeWidth="1.75"
|
strokeWidth="2"
|
||||||
strokeLinecap="round"
|
fill="none"
|
||||||
strokeOpacity="0.5"
|
/>
|
||||||
|
</g>
|
||||||
|
<polygon
|
||||||
|
points="32,5 57,19 57,45 32,59 7,45 7,19"
|
||||||
|
fill="none"
|
||||||
|
stroke="#a78bfa"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
opacity="0.4"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Full lockup: hex icon + "Netralax" wordmark. `tone` decides text colour:
|
||||||
|
// "dark" = white text (use on dark background), "light" = black text.
|
||||||
|
export function LogoLockup({
|
||||||
|
tone = 'dark',
|
||||||
|
...props
|
||||||
|
}: IconProps & { tone?: 'dark' | 'light' }) {
|
||||||
|
const textFill = tone === 'dark' ? '#ffffff' : '#0F172A';
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 260 64"
|
||||||
|
fill="none"
|
||||||
|
aria-hidden="true"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="logo-lockup-hex-clip">
|
||||||
|
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19" />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19" fill="#2e1065" />
|
||||||
|
<g clipPath="url(#logo-lockup-hex-clip)">
|
||||||
|
<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"
|
||||||
|
strokeWidth="2"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<polygon
|
||||||
|
points="32,5 57,19 57,45 32,59 7,45 7,19"
|
||||||
|
fill="none"
|
||||||
|
stroke="#a78bfa"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
opacity="0.4"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x="78"
|
||||||
|
y="42"
|
||||||
|
fontFamily="'Space Grotesk', system-ui, sans-serif"
|
||||||
|
fontSize="30"
|
||||||
|
fontWeight="600"
|
||||||
|
letterSpacing="-0.6"
|
||||||
|
fill={textFill}
|
||||||
|
>
|
||||||
|
Netralax
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { findExistingDevice } from '../lib/device';
|
import { findExistingDevice } from '../lib/device';
|
||||||
|
import { setSecretStoreUser } from '../lib/secretStore';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
|
|
||||||
interface AuthContextValue {
|
interface AuthContextValue {
|
||||||
@@ -53,34 +54,39 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
(async () => {
|
(async () => {
|
||||||
const { data: sessionRes } = await supabase.auth.getSession();
|
const { data: sessionRes } = await supabase.auth.getSession();
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
// Flip `ready` immediately on cached session read so the UI unblocks even
|
||||||
|
// if the network is slow/down. Validate the token in the background and
|
||||||
|
// only wipe on an unambiguous 401/403 — a stalled getUser (Tauri WebView
|
||||||
|
// with no network, server unreachable) must not keep the app on the
|
||||||
|
// loading spinner forever.
|
||||||
|
setSession(sessionRes.session ?? null);
|
||||||
|
setReady(true);
|
||||||
|
|
||||||
if (sessionRes.session) {
|
if (sessionRes.session) {
|
||||||
const { error } = await supabase.auth.getUser();
|
supabase.auth
|
||||||
if (cancelled) return;
|
.getUser()
|
||||||
if (error) {
|
.then(({ error }) => {
|
||||||
|
if (cancelled || !error) return;
|
||||||
const status = (error as { status?: number }).status;
|
const status = (error as { status?: number }).status;
|
||||||
if (status === 401 || status === 403) {
|
if (status === 401 || status === 403) {
|
||||||
// Token genuinely invalid — wipe.
|
void supabase.auth.signOut({ scope: 'local' }).catch(() => {
|
||||||
await supabase.auth.signOut().catch(() => {
|
|
||||||
/* ignore */
|
/* ignore */
|
||||||
});
|
});
|
||||||
setSession(null);
|
setSession(null);
|
||||||
} else {
|
} else {
|
||||||
// Network / server unreachable — keep cached session, let reads
|
// Network / server unreachable — keep cached session.
|
||||||
// fail gracefully and recover when the stack is back.
|
|
||||||
console.warn('auth.getUser failed, keeping cached session:', error);
|
console.warn('auth.getUser failed, keeping cached session:', error);
|
||||||
setSession(sessionRes.session);
|
|
||||||
}
|
}
|
||||||
} else {
|
})
|
||||||
setSession(sessionRes.session);
|
.catch((err: unknown) => {
|
||||||
|
console.warn('auth.getUser rejected, keeping cached session:', err);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
setSession(null);
|
|
||||||
}
|
|
||||||
setReady(true);
|
|
||||||
})();
|
})();
|
||||||
const { data: sub } = supabase.auth.onAuthStateChange((_event, s) => {
|
const { data: sub } = supabase.auth.onAuthStateChange((_event, s) => {
|
||||||
setSession(s);
|
setSession(s);
|
||||||
setReady(true);
|
setReady(true);
|
||||||
|
void setSecretStoreUser(s?.user.id ?? null);
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { getPttSettings, subscribePttSettings } from '../lib/pttSettings';
|
|||||||
import {
|
import {
|
||||||
getAudioQualityParams,
|
getAudioQualityParams,
|
||||||
getAudioSettings,
|
getAudioSettings,
|
||||||
|
updateAudioSettings,
|
||||||
} from '../lib/audioSettings';
|
} from '../lib/audioSettings';
|
||||||
import {
|
import {
|
||||||
createCallE2EE,
|
createCallE2EE,
|
||||||
@@ -90,6 +91,10 @@ export interface RemoteScreenShare {
|
|||||||
participantName: string;
|
participantName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Visual call modes (Discord-style): grid shows all tiles equally, focus pins
|
||||||
|
// one speaker with others in a strip, fullscreen is cinema mode.
|
||||||
|
export type CallMode = 'grid' | 'focus' | 'fullscreen';
|
||||||
|
|
||||||
interface CallContextValue {
|
interface CallContextValue {
|
||||||
state: CallState;
|
state: CallState;
|
||||||
room: Room | null;
|
room: Room | null;
|
||||||
@@ -102,6 +107,9 @@ interface CallContextValue {
|
|||||||
// Remembers the conversation of the last call we left so a sidebar widget
|
// Remembers the conversation of the last call we left so a sidebar widget
|
||||||
// can show "still live — rejoin" while peers stay in the room.
|
// can show "still live — rejoin" while peers stay in the room.
|
||||||
lastCallConversationId: string | null;
|
lastCallConversationId: string | null;
|
||||||
|
// Clean-Rail UI state — display mode + focused participant id.
|
||||||
|
callMode: CallMode;
|
||||||
|
focusedId: string | null;
|
||||||
// Actions:
|
// Actions:
|
||||||
startCall: (conversationId: string, mediaKind?: CallKind) => Promise<void>;
|
startCall: (conversationId: string, mediaKind?: CallKind) => Promise<void>;
|
||||||
joinActiveCall: (conversationId: string, mediaKind?: CallKind) => Promise<void>;
|
joinActiveCall: (conversationId: string, mediaKind?: CallKind) => Promise<void>;
|
||||||
@@ -111,6 +119,14 @@ interface CallContextValue {
|
|||||||
toggleMute: () => void;
|
toggleMute: () => void;
|
||||||
toggleScreenShare: () => Promise<void>;
|
toggleScreenShare: () => Promise<void>;
|
||||||
dismissLastCall: () => void;
|
dismissLastCall: () => void;
|
||||||
|
setCallMode: (mode: CallMode) => void;
|
||||||
|
setFocusedId: (id: string | null) => void;
|
||||||
|
// Runtime mic switch. Persists choice so subsequent calls reuse it, and
|
||||||
|
// hot-swaps the input on an active call without a reconnect.
|
||||||
|
setAudioInputDevice: (deviceId: string | null) => Promise<void>;
|
||||||
|
// Runtime speaker/headphone switch. Persists + applies setSinkId to all
|
||||||
|
// currently-attached remote-audio elements.
|
||||||
|
setAudioOutputDevice: (deviceId: string | null) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CallContext = createContext<CallContextValue | null>(null);
|
const CallContext = createContext<CallContextValue | null>(null);
|
||||||
@@ -135,6 +151,8 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
const [isScreenSharing, setIsScreenSharing] = useState(false);
|
const [isScreenSharing, setIsScreenSharing] = useState(false);
|
||||||
const [remoteScreenShares, setRemoteScreenShares] = useState<RemoteScreenShare[]>([]);
|
const [remoteScreenShares, setRemoteScreenShares] = useState<RemoteScreenShare[]>([]);
|
||||||
const [lastCallConversationId, setLastCallConversationId] = useState<string | null>(null);
|
const [lastCallConversationId, setLastCallConversationId] = useState<string | null>(null);
|
||||||
|
const [callMode, setCallModeState] = useState<CallMode>('grid');
|
||||||
|
const [focusedId, setFocusedIdState] = useState<string | null>(null);
|
||||||
|
|
||||||
const signalChannelRef = useRef<RealtimeChannel | null>(null);
|
const signalChannelRef = useRef<RealtimeChannel | null>(null);
|
||||||
const presenceChannelRef = useRef<RealtimeChannel | null>(null);
|
const presenceChannelRef = useRef<RealtimeChannel | null>(null);
|
||||||
@@ -471,12 +489,16 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
setIsE2EEActive(false);
|
setIsE2EEActive(false);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
const inputId = getAudioSettings().inputDeviceId;
|
||||||
await r.localParticipant.setMicrophoneEnabled(true, {
|
await r.localParticipant.setMicrophoneEnabled(true, {
|
||||||
echoCancellation: aParams.echoCancellation,
|
echoCancellation: aParams.echoCancellation,
|
||||||
noiseSuppression: aParams.noiseSuppression,
|
noiseSuppression: aParams.noiseSuppression,
|
||||||
autoGainControl: aParams.autoGainControl,
|
autoGainControl: aParams.autoGainControl,
|
||||||
channelCount: aParams.stereo ? 2 : 1,
|
channelCount: aParams.stereo ? 2 : 1,
|
||||||
sampleRate: aParams.sampleRateHz,
|
sampleRate: aParams.sampleRateHz,
|
||||||
|
// Plain string maps to `ideal` — if the device is gone we fall back
|
||||||
|
// to OS default instead of throwing NotFoundError.
|
||||||
|
...(inputId ? { deviceId: inputId } : {}),
|
||||||
});
|
});
|
||||||
} catch (micErr: unknown) {
|
} catch (micErr: unknown) {
|
||||||
console.error('setMicrophoneEnabled failed', micErr);
|
console.error('setMicrophoneEnabled failed', micErr);
|
||||||
@@ -954,6 +976,76 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
}, [myId, clearRingTimer, clearSoloTimer, disconnectRoom, sendSignal, emitCallEvent]);
|
}, [myId, clearRingTimer, clearSoloTimer, disconnectRoom, sendSignal, emitCallEvent]);
|
||||||
|
|
||||||
|
const setCallMode = useCallback((mode: CallMode) => {
|
||||||
|
setCallModeState(mode);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setFocusedId = useCallback((id: string | null) => {
|
||||||
|
setFocusedIdState(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setAudioInputDevice = useCallback(async (deviceId: string | null) => {
|
||||||
|
updateAudioSettings({ inputDeviceId: deviceId });
|
||||||
|
const r = roomRef.current;
|
||||||
|
if (!r) return;
|
||||||
|
try {
|
||||||
|
// LiveKit API: `switchActiveDevice(kind, deviceId)` hot-swaps without a
|
||||||
|
// reconnect. Pass empty string or `default` to revert to OS default.
|
||||||
|
await r.switchActiveDevice('audioinput', deviceId ?? 'default');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error('switchActiveDevice(audioinput) failed', err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setAudioOutputDevice = useCallback(async (deviceId: string | null) => {
|
||||||
|
updateAudioSettings({ outputDeviceId: deviceId });
|
||||||
|
const sinkId = deviceId ?? '';
|
||||||
|
// Apply to every <audio> element we've attached to the body. LiveKit's
|
||||||
|
// switchActiveDevice only tracks elements it attached itself; our custom
|
||||||
|
// appendChild path bypasses that, so we iterate and setSinkId manually.
|
||||||
|
const els = document.querySelectorAll<HTMLAudioElement>(
|
||||||
|
'audio[data-livekit-track]',
|
||||||
|
);
|
||||||
|
for (const el of Array.from(els)) {
|
||||||
|
if (typeof el.setSinkId !== 'function') continue;
|
||||||
|
try {
|
||||||
|
await el.setSinkId(sinkId);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('setSinkId on audio element failed', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const r = roomRef.current;
|
||||||
|
if (r) {
|
||||||
|
try {
|
||||||
|
await r.switchActiveDevice('audiooutput', sinkId || 'default');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('switchActiveDevice(audiooutput) failed', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Reset UI call-mode state when the call leaves any active phase so the next
|
||||||
|
// call starts fresh at grid/unfocused.
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.kind === 'idle' || state.kind === 'error') {
|
||||||
|
setCallModeState('grid');
|
||||||
|
setFocusedIdState(null);
|
||||||
|
}
|
||||||
|
}, [state.kind]);
|
||||||
|
|
||||||
|
// Global Esc: drop out of fullscreen cinema back to grid while in an active
|
||||||
|
// call. Doesn't hangup and doesn't fire when other dialogs would consume it.
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== 'Escape') return;
|
||||||
|
if (callMode !== 'fullscreen') return;
|
||||||
|
if (state.kind !== 'connected' && state.kind !== 'connecting') return;
|
||||||
|
setCallModeState('grid');
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [callMode, state.kind]);
|
||||||
|
|
||||||
const value = useMemo<CallContextValue>(
|
const value = useMemo<CallContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
state,
|
state,
|
||||||
@@ -964,6 +1056,8 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
isScreenSharing,
|
isScreenSharing,
|
||||||
remoteScreenShares,
|
remoteScreenShares,
|
||||||
lastCallConversationId,
|
lastCallConversationId,
|
||||||
|
callMode,
|
||||||
|
focusedId,
|
||||||
startCall,
|
startCall,
|
||||||
joinActiveCall,
|
joinActiveCall,
|
||||||
acceptIncoming,
|
acceptIncoming,
|
||||||
@@ -972,6 +1066,10 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
toggleMute,
|
toggleMute,
|
||||||
toggleScreenShare,
|
toggleScreenShare,
|
||||||
dismissLastCall,
|
dismissLastCall,
|
||||||
|
setCallMode,
|
||||||
|
setFocusedId,
|
||||||
|
setAudioInputDevice,
|
||||||
|
setAudioOutputDevice,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
state,
|
state,
|
||||||
@@ -982,6 +1080,8 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
isScreenSharing,
|
isScreenSharing,
|
||||||
remoteScreenShares,
|
remoteScreenShares,
|
||||||
lastCallConversationId,
|
lastCallConversationId,
|
||||||
|
callMode,
|
||||||
|
focusedId,
|
||||||
startCall,
|
startCall,
|
||||||
joinActiveCall,
|
joinActiveCall,
|
||||||
acceptIncoming,
|
acceptIncoming,
|
||||||
@@ -990,6 +1090,10 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
toggleMute,
|
toggleMute,
|
||||||
toggleScreenShare,
|
toggleScreenShare,
|
||||||
dismissLastCall,
|
dismissLastCall,
|
||||||
|
setCallMode,
|
||||||
|
setFocusedId,
|
||||||
|
setAudioInputDevice,
|
||||||
|
setAudioOutputDevice,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1014,6 +1118,15 @@ function attachTrack(
|
|||||||
audio.setAttribute('playsinline', 'true');
|
audio.setAttribute('playsinline', 'true');
|
||||||
audio.setAttribute('data-livekit-track', track.sid ?? '');
|
audio.setAttribute('data-livekit-track', track.sid ?? '');
|
||||||
document.body.appendChild(audio);
|
document.body.appendChild(audio);
|
||||||
|
// Apply persisted sinkId so the element routes to the user's chosen
|
||||||
|
// speaker/headphone from the start (LiveKit's own `switchActiveDevice`
|
||||||
|
// doesn't track custom-appended elements).
|
||||||
|
const sinkId = getAudioSettings().outputDeviceId;
|
||||||
|
if (sinkId && typeof audio.setSinkId === 'function') {
|
||||||
|
void audio.setSinkId(sinkId).catch((err: unknown) => {
|
||||||
|
console.warn('setSinkId on attach failed', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Video is handled later in M2.6/M3 by a dedicated <video> element.
|
// Video is handled later in M2.6/M3 by a dedicated <video> element.
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { type ConversationSummary, listConversations } from '@chat-app/shared/chat';
|
import {
|
||||||
|
type ConversationSummary,
|
||||||
|
isConversationMuted,
|
||||||
|
listConversations,
|
||||||
|
} from '@chat-app/shared/chat';
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
@@ -151,6 +155,15 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
|
|||||||
.on('postgres_changes', { event: '*', schema: 'public', table: 'conversations' }, () => {
|
.on('postgres_changes', { event: '*', schema: 'public', table: 'conversations' }, () => {
|
||||||
void refresh();
|
void refresh();
|
||||||
})
|
})
|
||||||
|
.on(
|
||||||
|
'postgres_changes',
|
||||||
|
{ event: 'UPDATE', schema: 'public', table: 'profiles' },
|
||||||
|
() => {
|
||||||
|
// Peer updated their profile (e.g. uploaded avatar / changed name).
|
||||||
|
// Re-pull conversations so members[].profile picks up the new data.
|
||||||
|
void refresh();
|
||||||
|
},
|
||||||
|
)
|
||||||
.on(
|
.on(
|
||||||
'postgres_changes',
|
'postgres_changes',
|
||||||
{ event: 'INSERT', schema: 'public', table: 'messages' },
|
{ event: 'INSERT', schema: 'public', table: 'messages' },
|
||||||
@@ -168,10 +181,15 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
|
|||||||
...prev,
|
...prev,
|
||||||
[row.conversation_id]: (prev[row.conversation_id] ?? 0) + 1,
|
[row.conversation_id]: (prev[row.conversation_id] ?? 0) + 1,
|
||||||
}));
|
}));
|
||||||
// Notification sound + OS notification — respect DND. Body stays
|
// Notification sound + OS notification — respect DND and
|
||||||
// empty because message content is E2E-encrypted and only
|
// per-conversation mute. Body stays empty because message
|
||||||
// decryptable in the conversation view (not at this hook level).
|
// content is E2E-encrypted and only decryptable in the
|
||||||
if (presenceRef.current !== 'dnd') {
|
// conversation view (not at this hook level).
|
||||||
|
const convForMute = conversationsRef.current.find(
|
||||||
|
(c) => c.id === row.conversation_id,
|
||||||
|
);
|
||||||
|
const muted = isConversationMuted(convForMute?.mutedUntil ?? null);
|
||||||
|
if (presenceRef.current !== 'dnd' && !muted) {
|
||||||
playNotificationTone();
|
playNotificationTone();
|
||||||
const conv = conversationsRef.current.find(
|
const conv = conversationsRef.current.find(
|
||||||
(c) => c.id === row.conversation_id,
|
(c) => c.id === row.conversation_id,
|
||||||
@@ -195,7 +213,31 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
|
|||||||
)
|
)
|
||||||
.subscribe();
|
.subscribe();
|
||||||
|
|
||||||
|
// Windows WebView2 aggressively throttles background WebSockets and
|
||||||
|
// sometimes drops events entirely while the window is minimised. Force a
|
||||||
|
// refresh + realtime reconnect on visibility/focus regain so we never
|
||||||
|
// leave stale conversation lists on a Windows client after the user
|
||||||
|
// returns to the app.
|
||||||
|
const onAwake = () => {
|
||||||
|
if (document.visibilityState !== 'visible') return;
|
||||||
|
void refresh();
|
||||||
|
try {
|
||||||
|
// If the socket got wedged during background throttle, a no-op
|
||||||
|
// unsubscribe+resubscribe brings it back. `subscribe()` on an already
|
||||||
|
// joined channel is a no-op so this is safe.
|
||||||
|
channel.subscribe();
|
||||||
|
} catch {
|
||||||
|
/* ignore — already live */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('visibilitychange', onAwake);
|
||||||
|
window.addEventListener('focus', onAwake);
|
||||||
|
window.addEventListener('online', onAwake);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
document.removeEventListener('visibilitychange', onAwake);
|
||||||
|
window.removeEventListener('focus', onAwake);
|
||||||
|
window.removeEventListener('online', onAwake);
|
||||||
void supabase.removeChannel(channel);
|
void supabase.removeChannel(channel);
|
||||||
};
|
};
|
||||||
}, [myId, refresh, markRead]);
|
}, [myId, refresh, markRead]);
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
type ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
import { applyTheme, getInitialTheme, setThemePersisted, type Theme } from '../lib/theme';
|
||||||
|
|
||||||
|
interface ThemeContextValue {
|
||||||
|
theme: Theme;
|
||||||
|
toggle: () => void;
|
||||||
|
setTheme: (t: Theme) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [theme, setThemeState] = useState<Theme>(() => getInitialTheme());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
applyTheme(theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
const setTheme = useCallback((next: Theme) => {
|
||||||
|
setThemePersisted(next);
|
||||||
|
setThemeState(next);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
setThemeState((prev) => {
|
||||||
|
const next: Theme = prev === 'dark' ? 'light' : 'dark';
|
||||||
|
setThemePersisted(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo<ThemeContextValue>(
|
||||||
|
() => ({ theme, toggle, setTheme }),
|
||||||
|
[theme, toggle, setTheme],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme(): ThemeContextValue {
|
||||||
|
const ctx = useContext(ThemeContext);
|
||||||
|
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -44,10 +44,22 @@ export async function checkForUpdate(): Promise<UpdateState> {
|
|||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
// Swallow "no release on GitHub yet" / network-unreachable cases silently.
|
||||||
|
// The updater endpoint serves `latest.json` from GitHub releases; a fresh
|
||||||
|
// repo or offline machine produces a generic "Could not fetch a valid
|
||||||
|
// release JSON" error that has no actionable information for the user —
|
||||||
|
// logging it on every launch just pollutes the console.
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
const benign =
|
||||||
|
/could not fetch a valid release json|network|timed? out|failed to fetch|connection/i.test(
|
||||||
|
msg,
|
||||||
|
);
|
||||||
|
if (!benign) {
|
||||||
console.warn('checkForUpdate failed', err);
|
console.warn('checkForUpdate failed', err);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
...IDLE_UPDATE_STATE,
|
...IDLE_UPDATE_STATE,
|
||||||
error: err instanceof Error ? err.message : 'update check failed',
|
error: benign ? null : msg,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,19 @@ export type AudioQuality = 'voice' | 'hifi';
|
|||||||
|
|
||||||
export interface AudioSettings {
|
export interface AudioSettings {
|
||||||
quality: AudioQuality;
|
quality: AudioQuality;
|
||||||
|
// Preferred input deviceId from enumerateDevices. null = use browser default
|
||||||
|
// (whatever the OS points at). Persisted across sessions, so "grandma's
|
||||||
|
// mic is default" stays even after browser picks the wrong device.
|
||||||
|
inputDeviceId: string | null;
|
||||||
|
// Preferred output (speaker/headphone) deviceId. null = system default.
|
||||||
|
// Applied to every <audio> element we attach via HTMLMediaElement.setSinkId.
|
||||||
|
outputDeviceId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULTS: AudioSettings = {
|
const DEFAULTS: AudioSettings = {
|
||||||
quality: 'voice',
|
quality: 'voice',
|
||||||
|
inputDeviceId: null,
|
||||||
|
outputDeviceId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface AudioQualityParams {
|
export interface AudioQualityParams {
|
||||||
@@ -73,6 +82,14 @@ function read(): AudioSettings {
|
|||||||
const parsed = JSON.parse(raw) as Partial<AudioSettings>;
|
const parsed = JSON.parse(raw) as Partial<AudioSettings>;
|
||||||
cached = {
|
cached = {
|
||||||
quality: isQuality(parsed.quality) ? parsed.quality : DEFAULTS.quality,
|
quality: isQuality(parsed.quality) ? parsed.quality : DEFAULTS.quality,
|
||||||
|
inputDeviceId:
|
||||||
|
typeof parsed.inputDeviceId === 'string' && parsed.inputDeviceId.length > 0
|
||||||
|
? parsed.inputDeviceId
|
||||||
|
: DEFAULTS.inputDeviceId,
|
||||||
|
outputDeviceId:
|
||||||
|
typeof parsed.outputDeviceId === 'string' && parsed.outputDeviceId.length > 0
|
||||||
|
? parsed.outputDeviceId
|
||||||
|
: DEFAULTS.outputDeviceId,
|
||||||
};
|
};
|
||||||
return cached;
|
return cached;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { supabase } from './supabase';
|
||||||
|
|
||||||
|
const BUCKET = 'profile-avatars';
|
||||||
|
const MAX_DIM = 512;
|
||||||
|
const QUALITY = 0.85;
|
||||||
|
|
||||||
|
// Resizes the source image to a centred-cropped square ≤ MAX_DIM and
|
||||||
|
// re-encodes as WebP. Falls back to JPEG if WebP isn't supported (rare).
|
||||||
|
async function resizeToSquare(file: File): Promise<Blob> {
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
try {
|
||||||
|
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||||
|
const i = new Image();
|
||||||
|
i.onload = () => resolve(i);
|
||||||
|
i.onerror = () => reject(new Error('image load failed'));
|
||||||
|
i.src = url;
|
||||||
|
});
|
||||||
|
|
||||||
|
const minSide = Math.min(img.naturalWidth, img.naturalHeight);
|
||||||
|
const sx = (img.naturalWidth - minSide) / 2;
|
||||||
|
const sy = (img.naturalHeight - minSide) / 2;
|
||||||
|
const target = Math.min(MAX_DIM, minSide);
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = target;
|
||||||
|
canvas.height = target;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) throw new Error('canvas context unavailable');
|
||||||
|
ctx.drawImage(img, sx, sy, minSide, minSide, 0, 0, target, target);
|
||||||
|
|
||||||
|
const blob = await new Promise<Blob | null>((resolve) =>
|
||||||
|
canvas.toBlob(resolve, 'image/webp', QUALITY),
|
||||||
|
);
|
||||||
|
if (blob) return blob;
|
||||||
|
|
||||||
|
const jpeg = await new Promise<Blob | null>((resolve) =>
|
||||||
|
canvas.toBlob(resolve, 'image/jpeg', QUALITY),
|
||||||
|
);
|
||||||
|
if (!jpeg) throw new Error('canvas toBlob returned null');
|
||||||
|
return jpeg;
|
||||||
|
} finally {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadAvatar(userId: string, file: File): Promise<string> {
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
throw new Error('only image files are accepted');
|
||||||
|
}
|
||||||
|
const blob = await resizeToSquare(file);
|
||||||
|
const ext = blob.type === 'image/webp' ? 'webp' : 'jpg';
|
||||||
|
// Random filename so old uploads don't get overwritten before we update
|
||||||
|
// the profile row — Supabase Storage CDN caches by URL, so a fresh path
|
||||||
|
// also forces clients to fetch the new image.
|
||||||
|
const name =
|
||||||
|
userId + '/' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8) + '.' + ext;
|
||||||
|
const { error: upErr } = await supabase.storage.from(BUCKET).upload(name, blob, {
|
||||||
|
contentType: blob.type,
|
||||||
|
cacheControl: '604800',
|
||||||
|
upsert: false,
|
||||||
|
});
|
||||||
|
if (upErr) throw upErr;
|
||||||
|
const { data: pub } = supabase.storage.from(BUCKET).getPublicUrl(name);
|
||||||
|
return pub.publicUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAvatarObject(publicUrl: string): Promise<void> {
|
||||||
|
// Public URLs look like
|
||||||
|
// https://<host>/storage/v1/object/public/profile-avatars/<path>
|
||||||
|
// Extract <path> and remove.
|
||||||
|
const marker = '/object/public/' + BUCKET + '/';
|
||||||
|
const idx = publicUrl.indexOf(marker);
|
||||||
|
if (idx === -1) return;
|
||||||
|
const path = publicUrl.slice(idx + marker.length);
|
||||||
|
const { error } = await supabase.storage.from(BUCKET).remove([path]);
|
||||||
|
if (error) throw error;
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
|
||||||
|
import { type OwnDeviceCtx, shareConvKeyToDevice } from '@chat-app/shared/chat';
|
||||||
|
import { pgHexToBytes } from '@chat-app/shared/supabase';
|
||||||
|
|
||||||
|
import { devLocalSecretStore } from './secretStore';
|
||||||
|
import { supabase } from './supabase';
|
||||||
|
|
||||||
|
// Watches the `devices` table for new entries AND, on mount, scans every
|
||||||
|
// conversation we participate in for missing key bundles. Fills gaps by
|
||||||
|
// re-wrapping our active conv-key for the missing recipient devices.
|
||||||
|
//
|
||||||
|
// This fixes the "cannot decrypt" cliff for devices that registered while
|
||||||
|
// no other participant device was online to share the key with them.
|
||||||
|
|
||||||
|
interface SyncCtx {
|
||||||
|
myUserId: string;
|
||||||
|
myDeviceId: string;
|
||||||
|
priv: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
// db-types is stale for `conversation_keys`/`active_key_version`; bypass.
|
||||||
|
function rawFrom(table: string) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
return (supabase as unknown as { from: (t: string) => any }).from(table);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Module-level flag — gap-fill runs once per (user, device) combo per
|
||||||
|
// process lifetime. Page reloads / route changes don't re-trigger it.
|
||||||
|
const backfilledKey = new Set<string>();
|
||||||
|
|
||||||
|
export function startConversationKeySync(
|
||||||
|
ownUserId: string,
|
||||||
|
ownDeviceId: string,
|
||||||
|
): () => void {
|
||||||
|
let cancelled = false;
|
||||||
|
let priv: Uint8Array | null = null;
|
||||||
|
const dedupeKey = ownUserId + ':' + ownDeviceId;
|
||||||
|
|
||||||
|
void loadDevicePrivateKey(devLocalSecretStore, ownUserId, ownDeviceId).then(async (pk) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
priv = pk;
|
||||||
|
if (!priv) return;
|
||||||
|
if (backfilledKey.has(dedupeKey)) return;
|
||||||
|
backfilledKey.add(dedupeKey);
|
||||||
|
await syncAllExistingGaps({ myUserId: ownUserId, myDeviceId: ownDeviceId, priv });
|
||||||
|
});
|
||||||
|
|
||||||
|
const channel = supabase
|
||||||
|
.channel('device-key-sync:' + ownDeviceId)
|
||||||
|
.on(
|
||||||
|
'postgres_changes',
|
||||||
|
{ event: 'INSERT', schema: 'public', table: 'devices' },
|
||||||
|
(payload: { new: { id?: string; user_id?: string; public_key?: string } }) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const row = payload.new;
|
||||||
|
if (!row?.id || !row.user_id || !row.public_key) return;
|
||||||
|
if (row.user_id === ownUserId && row.id === ownDeviceId) return;
|
||||||
|
if (!priv) return; // backfill on mount will catch it later
|
||||||
|
void wrapForOneDevice(
|
||||||
|
{ myUserId: ownUserId, myDeviceId: ownDeviceId, priv },
|
||||||
|
row.id,
|
||||||
|
row.user_id,
|
||||||
|
row.public_key,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.subscribe();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
void supabase.removeChannel(channel);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listMyConversationIds(myUserId: string): Promise<string[]> {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('conversation_members')
|
||||||
|
.select('conversation_id')
|
||||||
|
.eq('user_id', myUserId)
|
||||||
|
.eq('accepted', true);
|
||||||
|
if (error) {
|
||||||
|
console.warn('keySync: own-member lookup failed', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return (data ?? []).map((r) => r.conversation_id as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listConversationDevices(
|
||||||
|
conversationId: string,
|
||||||
|
): Promise<{ id: string; user_id: string; public_key: string }[]> {
|
||||||
|
const { data: members, error: mErr } = await supabase
|
||||||
|
.from('conversation_members')
|
||||||
|
.select('user_id')
|
||||||
|
.eq('conversation_id', conversationId)
|
||||||
|
.eq('accepted', true);
|
||||||
|
if (mErr) {
|
||||||
|
console.warn('keySync: members lookup failed', mErr);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const userIds = (members ?? []).map((m) => m.user_id as string);
|
||||||
|
if (userIds.length === 0) return [];
|
||||||
|
|
||||||
|
const { data: devices, error: dErr } = await supabase
|
||||||
|
.from('devices')
|
||||||
|
.select('id, user_id, public_key')
|
||||||
|
.in('user_id', userIds);
|
||||||
|
if (dErr) {
|
||||||
|
console.warn('keySync: devices lookup failed', dErr);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return (devices ?? []) as { id: string; user_id: string; public_key: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listExistingKeyRecipients(
|
||||||
|
conversationId: string,
|
||||||
|
keyVersion: number,
|
||||||
|
): Promise<Set<string>> {
|
||||||
|
const { data, error } = await rawFrom('conversation_keys')
|
||||||
|
.select('recipient_device_id')
|
||||||
|
.eq('conversation_id', conversationId)
|
||||||
|
.eq('key_version', keyVersion);
|
||||||
|
if (error) {
|
||||||
|
console.warn('keySync: existing keys lookup failed', error);
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
return new Set((data ?? []).map((r: { recipient_device_id: string }) => r.recipient_device_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getActiveKeyVersion(conversationId: string): Promise<number> {
|
||||||
|
const { data, error } = await rawFrom('conversations')
|
||||||
|
.select('active_key_version')
|
||||||
|
.eq('id', conversationId)
|
||||||
|
.single();
|
||||||
|
if (error) {
|
||||||
|
console.warn('keySync: active key version lookup failed', error);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return (data as { active_key_version: number }).active_key_version;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncAllExistingGaps(ctx: SyncCtx): Promise<void> {
|
||||||
|
const convs = await listMyConversationIds(ctx.myUserId);
|
||||||
|
for (const convId of convs) {
|
||||||
|
try {
|
||||||
|
await syncOneConversationGaps(ctx, convId);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('keySync: conv gap sync failed', { convId, err });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncOneConversationGaps(ctx: SyncCtx, convId: string): Promise<void> {
|
||||||
|
const version = await getActiveKeyVersion(convId);
|
||||||
|
const devices = await listConversationDevices(convId);
|
||||||
|
if (devices.length === 0) return;
|
||||||
|
|
||||||
|
const recipients = await listExistingKeyRecipients(convId, version);
|
||||||
|
const ownCtx: OwnDeviceCtx = {
|
||||||
|
userId: ctx.myUserId,
|
||||||
|
deviceId: ctx.myDeviceId,
|
||||||
|
privateKey: ctx.priv,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const dev of devices) {
|
||||||
|
if (recipients.has(dev.id)) continue;
|
||||||
|
// Skip our own device — we already have the bundle if we're capable of
|
||||||
|
// sharing (or don't need it if we ourselves haven't been wrapped yet).
|
||||||
|
if (dev.id === ctx.myDeviceId) continue;
|
||||||
|
try {
|
||||||
|
await shareConvKeyToDevice(supabase, convId, dev.id, pgHexToBytes(dev.public_key), ownCtx);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
// Backfill is best-effort. Most common silent failures:
|
||||||
|
// - tryGetConvKey couldn't unwrap (another peer will fill the gap).
|
||||||
|
// - RLS rejects because the recipient's owner is a pending (not-yet
|
||||||
|
// accepted) DM member, or was removed from the conv.
|
||||||
|
// Both are recoverable / expected, swallow without spam.
|
||||||
|
if (isExpectedShareFailure(err)) continue;
|
||||||
|
console.warn('keySync: shareConvKeyToDevice gap-fill failed', {
|
||||||
|
convId,
|
||||||
|
recipient: dev.id,
|
||||||
|
err,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExpectedShareFailure(err: unknown): boolean {
|
||||||
|
if (!err || typeof err !== 'object') return false;
|
||||||
|
const e = err as { message?: string; code?: string; details?: string; status?: number };
|
||||||
|
const code = (e.code ?? '').toString();
|
||||||
|
const status = e.status;
|
||||||
|
const haystack = (e.message ?? '') + ' ' + (e.details ?? '');
|
||||||
|
return (
|
||||||
|
status === 403 ||
|
||||||
|
code === '42501' || // postgres: insufficient_privilege (RLS)
|
||||||
|
code === '23505' || // unique_violation
|
||||||
|
haystack.includes('row-level security') ||
|
||||||
|
haystack.includes('does not have it yet') ||
|
||||||
|
haystack.includes('Forbidden')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wrapForOneDevice(
|
||||||
|
ctx: SyncCtx,
|
||||||
|
newDeviceId: string,
|
||||||
|
newDeviceUserId: string,
|
||||||
|
newDevicePubHex: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const myConvs = new Set(await listMyConversationIds(ctx.myUserId));
|
||||||
|
const { data: peerMember, error: pErr } = await supabase
|
||||||
|
.from('conversation_members')
|
||||||
|
.select('conversation_id')
|
||||||
|
.eq('user_id', newDeviceUserId);
|
||||||
|
if (pErr) {
|
||||||
|
console.warn('keySync: peer-member lookup failed', pErr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sharedConvs = (peerMember ?? [])
|
||||||
|
.map((r) => r.conversation_id as string)
|
||||||
|
.filter((id) => myConvs.has(id));
|
||||||
|
if (sharedConvs.length === 0) return;
|
||||||
|
|
||||||
|
const newPub = pgHexToBytes(newDevicePubHex);
|
||||||
|
const ownCtx: OwnDeviceCtx = {
|
||||||
|
userId: ctx.myUserId,
|
||||||
|
deviceId: ctx.myDeviceId,
|
||||||
|
privateKey: ctx.priv,
|
||||||
|
};
|
||||||
|
for (const convId of sharedConvs) {
|
||||||
|
try {
|
||||||
|
await shareConvKeyToDevice(supabase, convId, newDeviceId, newPub, ownCtx);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('keySync: shareConvKeyToDevice failed', { convId, err });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { CryptoBackend } from '@chat-app/shared/crypto';
|
import type { CryptoBackend } from '@chat-app/shared/crypto';
|
||||||
import _sodium from 'libsodium-wrappers';
|
import _sodium from 'libsodium-wrappers-sumo';
|
||||||
|
|
||||||
// Build the libsodium-backed CryptoBackend. Awaits the WASM ready-gate once,
|
// Build the libsodium-backed CryptoBackend. Awaits the WASM ready-gate once,
|
||||||
// then returns a synchronous implementation of the CryptoBackend contract.
|
// then returns a synchronous implementation of the CryptoBackend contract.
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function readLocalDeviceId(userId: string): string | null {
|
|||||||
return window.localStorage.getItem(deviceIdStorageKey(userId));
|
return window.localStorage.getItem(deviceIdStorageKey(userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeLocalDeviceId(userId: string, deviceId: string): void {
|
export function writeLocalDeviceId(userId: string, deviceId: string): void {
|
||||||
window.localStorage.setItem(deviceIdStorageKey(userId), deviceId);
|
window.localStorage.setItem(deviceIdStorageKey(userId), deviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { getCryptoBackend } from '@chat-app/shared/crypto';
|
||||||
|
import sodium from 'libsodium-wrappers-sumo';
|
||||||
|
|
||||||
|
// Encrypts/decrypts the device private key with a user-provided passphrase
|
||||||
|
// so the backup string can be safely written down or stored in a password
|
||||||
|
// manager. Uses Argon2id (libsodium crypto_pwhash) for the KDF and
|
||||||
|
// XSalsa20-Poly1305 (crypto_secretbox) for the AEAD.
|
||||||
|
//
|
||||||
|
// Backup format (base64url-encoded blob, prefixed with a magic string so we
|
||||||
|
// can version it):
|
||||||
|
//
|
||||||
|
// chatapp-backup-v1.<base64url(salt(16) | nonce(24) | ciphertext)>
|
||||||
|
|
||||||
|
const MAGIC = 'chatapp-backup-v1.';
|
||||||
|
const SALT_LEN = 16; // crypto_pwhash_SALTBYTES
|
||||||
|
const NONCE_LEN = 24; // crypto_secretbox_NONCEBYTES
|
||||||
|
const KEY_LEN = 32; // crypto_secretbox_KEYBYTES
|
||||||
|
|
||||||
|
async function ensureSodium(): Promise<typeof sodium> {
|
||||||
|
await sodium.ready;
|
||||||
|
return sodium;
|
||||||
|
}
|
||||||
|
|
||||||
|
function b64url(bytes: Uint8Array): string {
|
||||||
|
let s = '';
|
||||||
|
for (const b of bytes) s += String.fromCharCode(b);
|
||||||
|
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function unb64url(s: string): Uint8Array {
|
||||||
|
let str = s.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
while (str.length % 4) str += '=';
|
||||||
|
const bin = atob(str);
|
||||||
|
const out = new Uint8Array(bin.length);
|
||||||
|
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deriveKey(passphrase: string, salt: Uint8Array, sodiumLib: typeof sodium): Promise<Uint8Array> {
|
||||||
|
return sodiumLib.crypto_pwhash(
|
||||||
|
KEY_LEN,
|
||||||
|
passphrase,
|
||||||
|
salt,
|
||||||
|
sodiumLib.crypto_pwhash_OPSLIMIT_MODERATE,
|
||||||
|
sodiumLib.crypto_pwhash_MEMLIMIT_MODERATE,
|
||||||
|
sodiumLib.crypto_pwhash_ALG_ARGON2ID13,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportDeviceKey(
|
||||||
|
privateKey: Uint8Array,
|
||||||
|
passphrase: string,
|
||||||
|
): Promise<string> {
|
||||||
|
if (passphrase.length < 8) throw new Error('Passphrase must be at least 8 characters.');
|
||||||
|
const s = await ensureSodium();
|
||||||
|
const salt = s.randombytes_buf(SALT_LEN);
|
||||||
|
const nonce = s.randombytes_buf(NONCE_LEN);
|
||||||
|
const key = await deriveKey(passphrase, salt, s);
|
||||||
|
const backend = getCryptoBackend();
|
||||||
|
const ciphertext = backend.secretbox(privateKey, nonce, key);
|
||||||
|
s.memzero(key);
|
||||||
|
const blob = new Uint8Array(SALT_LEN + NONCE_LEN + ciphertext.length);
|
||||||
|
blob.set(salt, 0);
|
||||||
|
blob.set(nonce, SALT_LEN);
|
||||||
|
blob.set(ciphertext, SALT_LEN + NONCE_LEN);
|
||||||
|
return MAGIC + b64url(blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importDeviceKey(
|
||||||
|
backup: string,
|
||||||
|
passphrase: string,
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
if (!backup.startsWith(MAGIC)) {
|
||||||
|
throw new Error('Invalid backup format');
|
||||||
|
}
|
||||||
|
const blob = unb64url(backup.slice(MAGIC.length));
|
||||||
|
if (blob.length < SALT_LEN + NONCE_LEN + 1) {
|
||||||
|
throw new Error('Backup too short');
|
||||||
|
}
|
||||||
|
const salt = blob.slice(0, SALT_LEN);
|
||||||
|
const nonce = blob.slice(SALT_LEN, SALT_LEN + NONCE_LEN);
|
||||||
|
const ciphertext = blob.slice(SALT_LEN + NONCE_LEN);
|
||||||
|
const s = await ensureSodium();
|
||||||
|
const key = await deriveKey(passphrase, salt, s);
|
||||||
|
const backend = getCryptoBackend();
|
||||||
|
try {
|
||||||
|
return backend.secretboxOpen(ciphertext, nonce, key);
|
||||||
|
} catch {
|
||||||
|
throw new Error('Wrong passphrase or corrupt backup');
|
||||||
|
} finally {
|
||||||
|
s.memzero(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full-device backup. Wraps userId + deviceId + privateKey in a JSON payload
|
||||||
|
// before encrypting, so a restore flow can re-seed localStorage + vault + server
|
||||||
|
// device row without requiring the user to remember IDs.
|
||||||
|
export interface DeviceBackupPayload {
|
||||||
|
v: 2;
|
||||||
|
userId: string;
|
||||||
|
deviceId: string;
|
||||||
|
privateKeyB64: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportDeviceBackup(
|
||||||
|
params: {
|
||||||
|
userId: string;
|
||||||
|
deviceId: string;
|
||||||
|
privateKey: Uint8Array;
|
||||||
|
passphrase: string;
|
||||||
|
},
|
||||||
|
): Promise<string> {
|
||||||
|
const payload: DeviceBackupPayload = {
|
||||||
|
v: 2,
|
||||||
|
userId: params.userId,
|
||||||
|
deviceId: params.deviceId,
|
||||||
|
privateKeyB64: b64url(params.privateKey),
|
||||||
|
};
|
||||||
|
const bytes = new TextEncoder().encode(JSON.stringify(payload));
|
||||||
|
return exportDeviceKey(bytes, params.passphrase);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importDeviceBackup(
|
||||||
|
backup: string,
|
||||||
|
passphrase: string,
|
||||||
|
): Promise<DeviceBackupPayload> {
|
||||||
|
const plain = await importDeviceKey(backup, passphrase);
|
||||||
|
const text = new TextDecoder().decode(plain);
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(text) as DeviceBackupPayload;
|
||||||
|
if (obj && obj.v === 2 && obj.userId && obj.deviceId && obj.privateKeyB64) {
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* fall through */
|
||||||
|
}
|
||||||
|
throw new Error('Backup format not supported — v2 expected');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodePrivateKeyFromBackup(payload: DeviceBackupPayload): Uint8Array {
|
||||||
|
return unb64url(payload.privateKeyB64);
|
||||||
|
}
|
||||||
@@ -4,23 +4,54 @@ import {
|
|||||||
sendNotification,
|
sendNotification,
|
||||||
} from '@tauri-apps/plugin-notification';
|
} from '@tauri-apps/plugin-notification';
|
||||||
|
|
||||||
|
import { isTauriRuntime } from './globalShortcut';
|
||||||
|
|
||||||
// Tracks whether permission has already been requested this session so we
|
// Tracks whether permission has already been requested this session so we
|
||||||
// don't spam the OS prompt. Actual permission state lives in the OS.
|
// don't spam the OS prompt. Actual permission state lives in the OS, but we
|
||||||
|
// also persist a "we've asked" marker in localStorage so reloads don't
|
||||||
|
// re-request (OS would block anyway after denial, but calling it every reload
|
||||||
|
// triggers noisy plugin warnings on some platforms).
|
||||||
let permissionChecked = false;
|
let permissionChecked = false;
|
||||||
let permissionGranted = false;
|
let permissionGranted = false;
|
||||||
|
|
||||||
|
const ASKED_KEY = 'chatapp.notif.asked';
|
||||||
|
|
||||||
|
function readAskedMarker(): boolean {
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(ASKED_KEY) === '1';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeAskedMarker(): void {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(ASKED_KEY, '1');
|
||||||
|
} catch {
|
||||||
|
/* quota / private mode */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function ensureNotificationPermission(): Promise<boolean> {
|
export async function ensureNotificationPermission(): Promise<boolean> {
|
||||||
if (permissionChecked) return permissionGranted;
|
if (permissionChecked) return permissionGranted;
|
||||||
permissionChecked = true;
|
permissionChecked = true;
|
||||||
|
if (!isTauriRuntime()) {
|
||||||
|
// Web preview / Chrome — Tauri notification plugin not available.
|
||||||
|
permissionGranted = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
let granted = await isPermissionGranted();
|
let granted = await isPermissionGranted();
|
||||||
if (!granted) {
|
if (!granted && !readAskedMarker()) {
|
||||||
|
// First-install: prompt the user once. After this we remember via the
|
||||||
|
// marker and never re-prompt — the user can re-enable later via OS
|
||||||
|
// system settings if they change their mind.
|
||||||
const result = await requestPermission();
|
const result = await requestPermission();
|
||||||
granted = result === 'granted';
|
granted = result === 'granted';
|
||||||
|
writeAskedMarker();
|
||||||
}
|
}
|
||||||
permissionGranted = granted;
|
permissionGranted = granted;
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
// Not running under Tauri (e.g. web preview) — fall back silently.
|
|
||||||
permissionGranted = false;
|
permissionGranted = false;
|
||||||
console.warn('notification permission check failed', err);
|
console.warn('notification permission check failed', err);
|
||||||
}
|
}
|
||||||
@@ -40,6 +71,7 @@ interface NotifyOpts {
|
|||||||
|
|
||||||
export async function notify({ title, body, force = false }: NotifyOpts): Promise<void> {
|
export async function notify({ title, body, force = false }: NotifyOpts): Promise<void> {
|
||||||
if (!force && isAppFocused()) return;
|
if (!force && isAppFocused()) return;
|
||||||
|
if (!isTauriRuntime()) return;
|
||||||
const granted = await ensureNotificationPermission();
|
const granted = await ensureNotificationPermission();
|
||||||
if (!granted) return;
|
if (!granted) return;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
import { base64FromBytes, bytesFromBase64, type SecretStore } from '@chat-app/shared/auth';
|
import { base64FromBytes, bytesFromBase64, type SecretStore } from '@chat-app/shared/auth';
|
||||||
|
|
||||||
// M1 dev-only impl: persists secrets as base64 in localStorage.
|
import { isTauriRuntime } from './globalShortcut';
|
||||||
// Swap this out for a tauri-plugin-stronghold implementation before release.
|
import { makeSecureFileStore, migrateLocalStorageToVault } from './secureFileStore';
|
||||||
// The SecretStore interface stays identical so callers won't notice.
|
|
||||||
|
// Two-tier SecretStore:
|
||||||
|
// - Tauri runtime: encrypted single-file vault in `appLocalDataDir`
|
||||||
|
// (`secureFileStore` — XSalsa20-Poly1305 + Argon2id KDF). Survives app
|
||||||
|
// reinstalls when the OS preserves the data dir.
|
||||||
|
// - Web / pre-auth: plain localStorage (legacy fallback).
|
||||||
|
//
|
||||||
|
// Callers import `devLocalSecretStore` and call `setSecretStoreUser(userId)`
|
||||||
|
// once the session is known. The singleton object's identity is stable so
|
||||||
|
// existing imports keep working.
|
||||||
|
|
||||||
const PREFIX = 'chatapp.secret:';
|
const PREFIX = 'chatapp.secret:';
|
||||||
|
|
||||||
export const devLocalSecretStore: SecretStore = {
|
const localStore: SecretStore = {
|
||||||
async getSecret(key: string): Promise<Uint8Array | null> {
|
async getSecret(key: string): Promise<Uint8Array | null> {
|
||||||
const raw = window.localStorage.getItem(PREFIX + key);
|
const raw = window.localStorage.getItem(PREFIX + key);
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
@@ -20,3 +29,48 @@ export const devLocalSecretStore: SecretStore = {
|
|||||||
window.localStorage.removeItem(PREFIX + key);
|
window.localStorage.removeItem(PREFIX + key);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let activeBackend: SecretStore = localStore;
|
||||||
|
let activeUserId: string | null = null;
|
||||||
|
|
||||||
|
export async function setSecretStoreUser(userId: string | null): Promise<void> {
|
||||||
|
if (userId === activeUserId) return;
|
||||||
|
activeUserId = userId;
|
||||||
|
|
||||||
|
if (userId && isTauriRuntime()) {
|
||||||
|
const fileStore = makeSecureFileStore(userId);
|
||||||
|
try {
|
||||||
|
// Probe write/read to confirm the vault is usable on this machine.
|
||||||
|
// If anything throws (perm denied, disk full, KDF error), fall back to
|
||||||
|
// localStorage so the rest of the app keeps working.
|
||||||
|
await fileStore.getSecret('__probe');
|
||||||
|
activeBackend = fileStore;
|
||||||
|
try {
|
||||||
|
await migrateLocalStorageToVault(userId, PREFIX);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('vault migration failed', err);
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('secure file vault init failed — falling back to localStorage', err);
|
||||||
|
activeBackend = localStore;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
activeBackend = localStore;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const devLocalSecretStore: SecretStore = {
|
||||||
|
async getSecret(key) {
|
||||||
|
return activeBackend.getSecret(key);
|
||||||
|
},
|
||||||
|
async setSecret(key, value) {
|
||||||
|
return activeBackend.setSecret(key, value);
|
||||||
|
},
|
||||||
|
async removeSecret(key) {
|
||||||
|
return activeBackend.removeSecret(key);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isEncryptedVaultActive(): boolean {
|
||||||
|
return activeBackend !== localStore;
|
||||||
|
}
|
||||||
|
|||||||