Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b89ec90813 | |||
| a04ecf7a19 | |||
| da85f0ba54 | |||
| a4c9b959a9 | |||
| 37becba7e2 | |||
| eb8f9857ff | |||
| de431386ea | |||
| 1fab2edc57 | |||
| 4db65993d5 | |||
| 0ca29952ba | |||
| ff5ea274b9 | |||
| b0f9f1dada | |||
| 961ac2dde5 |
@@ -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>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@chat-app/desktop",
|
"name": "@chat-app/desktop",
|
||||||
"version": "0.1.0",
|
"version": "0.8.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Tauri v2 desktop client (Windows / macOS / Linux)",
|
"description": "Tauri v2 desktop client (Windows / macOS / Linux)",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
"@tauri-apps/plugin-stronghold": "^2.0.1",
|
"@tauri-apps/plugin-stronghold": "^2.0.1",
|
||||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||||
"i18next": "^23.16.4",
|
"i18next": "^23.16.4",
|
||||||
"libsodium-wrappers": "0.7.15",
|
"libsodium-wrappers-sumo": "0.7.15",
|
||||||
"livekit-client": "^2.7.0",
|
"livekit-client": "^2.7.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
@@ -41,6 +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 |
@@ -0,0 +1,67 @@
|
|||||||
|
// Web Push service worker.
|
||||||
|
//
|
||||||
|
// Handles browser-delivered push events when the app tab is closed or in the
|
||||||
|
// background. Tauri desktop does not install service workers; native OS
|
||||||
|
// notifications are routed through the Tauri notification plugin instead
|
||||||
|
// (see src/lib/osNotify.ts).
|
||||||
|
//
|
||||||
|
// Payload contract — server sends JSON of shape:
|
||||||
|
// { title: string, body?: string, conversationId?: string, kind?: 'message' | 'call' }
|
||||||
|
// Body is intentionally generic; message ciphertext is never included.
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
// Activate immediately so updates apply on next page load.
|
||||||
|
event.waitUntil(self.skipWaiting());
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(self.clients.claim());
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('push', (event) => {
|
||||||
|
let data = { title: 'Neue Nachricht', body: '' };
|
||||||
|
try {
|
||||||
|
if (event.data) {
|
||||||
|
data = { ...data, ...event.data.json() };
|
||||||
|
}
|
||||||
|
} catch (_err) {
|
||||||
|
/* malformed payload — fall back to defaults */
|
||||||
|
}
|
||||||
|
|
||||||
|
const opts = {
|
||||||
|
body: data.body || '',
|
||||||
|
icon: '/favicon.svg',
|
||||||
|
badge: '/favicon.svg',
|
||||||
|
tag: data.conversationId || 'default',
|
||||||
|
renotify: true,
|
||||||
|
data: {
|
||||||
|
conversationId: data.conversationId,
|
||||||
|
kind: data.kind,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
event.waitUntil(self.registration.showNotification(data.title, opts));
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('notificationclick', (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
const conversationId = event.notification.data && event.notification.data.conversationId;
|
||||||
|
const target = conversationId ? '/chats/' + conversationId : '/';
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
self.clients
|
||||||
|
.matchAll({ type: 'window', includeUncontrolled: true })
|
||||||
|
.then((clientList) => {
|
||||||
|
for (const client of clientList) {
|
||||||
|
if ('focus' in client) {
|
||||||
|
client.postMessage({ type: 'navigate', to: target });
|
||||||
|
return client.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (self.clients.openWindow) {
|
||||||
|
return self.clients.openWindow(target);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -678,7 +678,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chat-app-desktop"
|
name = "chat-app-desktop"
|
||||||
version = "0.1.0"
|
version = "0.8.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "chat-app-desktop"
|
name = "chat-app-desktop"
|
||||||
version = "0.1.0"
|
version = "0.8.0"
|
||||||
description = "ChatApp desktop client"
|
description = "ChatApp desktop client"
|
||||||
authors = ["Dennis"]
|
authors = ["Dennis"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
{
|
{
|
||||||
"identifier": "fs:scope",
|
"identifier": "fs:scope",
|
||||||
"allow": [
|
"allow": [
|
||||||
|
{ "path": "$APPLOCALDATA" },
|
||||||
{ "path": "$APPLOCALDATA/**" }
|
{ "path": "$APPLOCALDATA/**" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "ChatApp",
|
"productName": "ChatApp",
|
||||||
"version": "0.4.1",
|
"version": "0.8.0",
|
||||||
"identifier": "com.meinname.chatapp",
|
"identifier": "com.meinname.chatapp",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "pnpm vite:dev",
|
"beforeDevCommand": "pnpm vite:dev",
|
||||||
|
|||||||
@@ -1,12 +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 { 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';
|
||||||
@@ -16,33 +18,76 @@ 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 />
|
<UpdateToast />
|
||||||
@@ -51,5 +96,7 @@ export function App() {
|
|||||||
</ConversationsProvider>
|
</ConversationsProvider>
|
||||||
</FriendshipsProvider>
|
</FriendshipsProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Outlet } from 'react-router-dom';
|
|||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { startConversationKeySync } from '../lib/conversationKeySync';
|
import { startConversationKeySync } from '../lib/conversationKeySync';
|
||||||
import { ensureNotificationPermission } from '../lib/osNotify';
|
import { ensureNotificationPermission } from '../lib/osNotify';
|
||||||
|
import { BackupPromptBanner } from './BackupPromptBanner';
|
||||||
import { CallUI } from './CallUI';
|
import { CallUI } from './CallUI';
|
||||||
import { Sidebar } from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
|
|
||||||
@@ -21,7 +22,7 @@ export function AppShell() {
|
|||||||
}, [session?.user.id, device?.id]);
|
}, [session?.user.id, device?.id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex min-h-screen overflow-hidden bg-ink-950 text-neutral-100">
|
<div className="relative flex min-h-screen overflow-hidden bg-surface text-fg">
|
||||||
<ShellBackground />
|
<ShellBackground />
|
||||||
<div className="relative z-10 flex min-h-screen w-full">
|
<div className="relative z-10 flex min-h-screen w-full">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
@@ -32,15 +33,16 @@ export function AppShell() {
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<CallUI />
|
<CallUI />
|
||||||
|
<BackupPromptBanner />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calmer than the auth-screen background — full-bleed grid + 2 large blobs.
|
// Subtle ambient background — only renders in dark mode where the app's
|
||||||
// No animation here so message lists stay readable.
|
// visual language expects depth. Light mode stays clean and flat.
|
||||||
function ShellBackground() {
|
function ShellBackground() {
|
||||||
return (
|
return (
|
||||||
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
|
<div aria-hidden="true" className="pointer-events-none absolute inset-0 hidden dark:block">
|
||||||
<div className="bg-grid absolute inset-0 opacity-[0.18]" />
|
<div className="bg-grid absolute inset-0 opacity-[0.18]" />
|
||||||
<div className="absolute -left-32 top-1/4 h-[420px] w-[420px] rounded-full bg-brand-500/20 blur-3xl" />
|
<div className="absolute -left-32 top-1/4 h-[420px] w-[420px] rounded-full bg-brand-500/20 blur-3xl" />
|
||||||
<div className="absolute -right-32 bottom-0 h-[420px] w-[420px] rounded-full bg-fuchsia-500/10 blur-3xl" />
|
<div className="absolute -right-32 bottom-0 h-[420px] w-[420px] rounded-full bg-fuchsia-500/10 blur-3xl" />
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { supabase } from '../lib/supabase';
|
||||||
|
import { AlertIcon, MicIcon, SpinnerIcon } from './icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
handle: AttachmentHandle;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BAR_COUNT = 48;
|
||||||
|
|
||||||
|
// Custom voice-message player with waveform visualisation. Decoded peaks are
|
||||||
|
// computed once per blob via OfflineAudioContext so playback only carries the
|
||||||
|
// rendered DOM. Falls back to a rectangular bar if decoding fails (e.g. the
|
||||||
|
// blob mime is recognised by <audio> but not by AudioContext).
|
||||||
|
export function AttachmentAudio({ handle }: Props) {
|
||||||
|
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
||||||
|
const [arrayBuf, setArrayBuf] = useState<ArrayBuffer | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [peaks, setPeaks] = useState<number[] | null>(null);
|
||||||
|
const [duration, setDuration] = useState<number>(0);
|
||||||
|
const [position, setPosition] = useState<number>(0);
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
let url: string | null = null;
|
||||||
|
setError(null);
|
||||||
|
setBlobUrl(null);
|
||||||
|
setArrayBuf(null);
|
||||||
|
|
||||||
|
downloadAndDecryptAttachment({ client: supabase, handle })
|
||||||
|
.then(async (blob) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
url = URL.createObjectURL(blob);
|
||||||
|
setBlobUrl(url);
|
||||||
|
const buf = await blob.arrayBuffer();
|
||||||
|
if (!cancelled) setArrayBuf(buf);
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(err instanceof Error ? err.message : 'download failed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (url) URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
}, [handle.id, handle.storagePath, handle.keyB64, handle.nonceB64]);
|
||||||
|
|
||||||
|
// Compute peaks via OfflineAudioContext. Cheap O(n) scan over PCM samples
|
||||||
|
// bucketed into BAR_COUNT bars. Done once per attachment.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!arrayBuf) return;
|
||||||
|
let cancelled = false;
|
||||||
|
const Ctx =
|
||||||
|
window.AudioContext ||
|
||||||
|
(window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
|
||||||
|
const ctx = new Ctx();
|
||||||
|
ctx
|
||||||
|
.decodeAudioData(arrayBuf.slice(0))
|
||||||
|
.then((decoded) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setDuration(decoded.duration);
|
||||||
|
const channel = decoded.getChannelData(0);
|
||||||
|
const bucket = Math.max(1, Math.floor(channel.length / BAR_COUNT));
|
||||||
|
const out = new Array<number>(BAR_COUNT).fill(0);
|
||||||
|
for (let i = 0; i < BAR_COUNT; i++) {
|
||||||
|
let max = 0;
|
||||||
|
const start = i * bucket;
|
||||||
|
const end = Math.min(channel.length, start + bucket);
|
||||||
|
for (let j = start; j < end; j++) {
|
||||||
|
const v = Math.abs(channel[j]!);
|
||||||
|
if (v > max) max = v;
|
||||||
|
}
|
||||||
|
out[i] = max;
|
||||||
|
}
|
||||||
|
// Normalize so loudest peak is 1; keeps quiet recordings visible.
|
||||||
|
const peak = Math.max(...out, 0.001);
|
||||||
|
setPeaks(out.map((v) => v / peak));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Fall through — UI shows a flat bar but playback still works.
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
void ctx.close().catch(() => {});
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [arrayBuf]);
|
||||||
|
|
||||||
|
const fallbackPeaks = useMemo(
|
||||||
|
() => (peaks ? null : Array.from({ length: BAR_COUNT }, () => 0.5)),
|
||||||
|
[peaks],
|
||||||
|
);
|
||||||
|
const visiblePeaks = peaks ?? fallbackPeaks!;
|
||||||
|
const progress = duration > 0 ? position / duration : 0;
|
||||||
|
|
||||||
|
const onTogglePlay = () => {
|
||||||
|
const el = audioRef.current;
|
||||||
|
if (!el || !blobUrl) return;
|
||||||
|
if (el.paused) void el.play();
|
||||||
|
else el.pause();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSeek = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
const el = audioRef.current;
|
||||||
|
if (!el || duration === 0) return;
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const ratio = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
|
||||||
|
el.currentTime = ratio * duration;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="mt-2 inline-flex items-center gap-2 rounded-lg border border-rose-500/20 bg-rose-500/10 px-3 py-2 text-xs text-rose-200">
|
||||||
|
<AlertIcon className="h-4 w-4" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2 flex w-[280px] min-w-[280px] items-center gap-2 rounded-lg border border-line bg-surface-2 px-3 py-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onTogglePlay}
|
||||||
|
disabled={!blobUrl}
|
||||||
|
aria-label={playing ? 'Pause' : 'Wiedergabe'}
|
||||||
|
title={playing ? 'Pause' : 'Wiedergabe'}
|
||||||
|
className="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full bg-accent text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{!blobUrl ? (
|
||||||
|
<SpinnerIcon className="h-4 w-4" />
|
||||||
|
) : playing ? (
|
||||||
|
<PauseGlyph />
|
||||||
|
) : (
|
||||||
|
<PlayGlyph />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||||
|
<div
|
||||||
|
role="slider"
|
||||||
|
aria-label="Position"
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={Math.max(1, Math.floor(duration))}
|
||||||
|
aria-valuenow={Math.floor(position)}
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={onSeek}
|
||||||
|
className="flex h-7 cursor-pointer items-center gap-[2px]"
|
||||||
|
>
|
||||||
|
{visiblePeaks.map((v, i) => {
|
||||||
|
const playedRatio = (i + 0.5) / BAR_COUNT;
|
||||||
|
const played = playedRatio <= progress;
|
||||||
|
const h = Math.max(2, Math.round(v * 22));
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
style={{ height: h + 'px' }}
|
||||||
|
className={
|
||||||
|
'w-[3px] rounded-full ' +
|
||||||
|
(played ? 'bg-accent' : 'bg-fg-muted/40')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-[10px] tabular-nums text-fg-muted">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<MicIcon className="h-3 w-3" />
|
||||||
|
<span>{formatSec(playing ? position : duration)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{blobUrl && (
|
||||||
|
<audio
|
||||||
|
ref={audioRef}
|
||||||
|
src={blobUrl}
|
||||||
|
preload="metadata"
|
||||||
|
onLoadedMetadata={(e) => {
|
||||||
|
// Some webm/opus blobs report Infinity until first seek (Chrome
|
||||||
|
// bug). Force a seek to flush real duration.
|
||||||
|
const el = e.currentTarget;
|
||||||
|
if (!Number.isFinite(el.duration)) {
|
||||||
|
el.currentTime = 1e9;
|
||||||
|
setTimeout(() => {
|
||||||
|
el.currentTime = 0;
|
||||||
|
}, 0);
|
||||||
|
} else if (duration === 0) {
|
||||||
|
setDuration(el.duration);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDurationChange={(e) => {
|
||||||
|
const d = e.currentTarget.duration;
|
||||||
|
if (Number.isFinite(d) && d > 0) setDuration(d);
|
||||||
|
}}
|
||||||
|
onTimeUpdate={(e) => setPosition(e.currentTarget.currentTime)}
|
||||||
|
onPlay={() => setPlaying(true)}
|
||||||
|
onPause={() => setPlaying(false)}
|
||||||
|
onEnded={() => {
|
||||||
|
setPlaying(false);
|
||||||
|
setPosition(0);
|
||||||
|
}}
|
||||||
|
className="hidden"
|
||||||
|
>
|
||||||
|
<track kind="captions" />
|
||||||
|
</audio>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlayGlyph() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M5 3.5l8 4.5-8 4.5z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PauseGlyph() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
|
||||||
|
<rect x="4" y="3" width="3" height="10" rx="1" />
|
||||||
|
<rect x="9" y="3" width="3" height="10" rx="1" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSec(sec: number): string {
|
||||||
|
if (!Number.isFinite(sec) || sec < 0) sec = 0;
|
||||||
|
const m = Math.floor(sec / 60);
|
||||||
|
const s = Math.floor(sec % 60);
|
||||||
|
return m + ':' + s.toString().padStart(2, '0');
|
||||||
|
}
|
||||||
@@ -0,0 +1,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,306 @@
|
|||||||
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { type BackupBundle, exportDeviceBackupWithRecovery } from '../lib/deviceBackup';
|
||||||
|
import { AlertIcon, CopyIcon, LockIcon, ShieldIcon, SpinnerIcon, XIcon } from './icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
userId: string;
|
||||||
|
deviceId: string;
|
||||||
|
privateKey: Uint8Array;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exports the device's private key + identity into a passphrase-protected
|
||||||
|
// portable string. The user can store this string anywhere (password manager,
|
||||||
|
// printed paper, encrypted file on a USB stick). Without it, losing local
|
||||||
|
// storage on this install means losing all past conversation keys.
|
||||||
|
export function BackupExportDialog({ open, userId, deviceId, privateKey, onClose }: Props) {
|
||||||
|
const { t } = useTranslation(['app']);
|
||||||
|
const [passphrase, setPassphrase] = useState('');
|
||||||
|
const [confirm, setConfirm] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [bundle, setBundle] = useState<BackupBundle | null>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [copiedRecovery, setCopiedRecovery] = useState(false);
|
||||||
|
|
||||||
|
const canGenerate = useMemo(() => {
|
||||||
|
return passphrase.length >= 8 && passphrase === confirm && !busy;
|
||||||
|
}, [passphrase, confirm, busy]);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setPassphrase('');
|
||||||
|
setConfirm('');
|
||||||
|
setBundle(null);
|
||||||
|
setError(null);
|
||||||
|
setCopied(false);
|
||||||
|
setCopiedRecovery(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
reset();
|
||||||
|
onClose();
|
||||||
|
}, [reset, onClose]);
|
||||||
|
|
||||||
|
const handleGenerate = useCallback(async () => {
|
||||||
|
if (!canGenerate) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const b = await exportDeviceBackupWithRecovery({
|
||||||
|
userId,
|
||||||
|
deviceId,
|
||||||
|
privateKey,
|
||||||
|
passphrase,
|
||||||
|
});
|
||||||
|
setBundle(b);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}, [canGenerate, userId, deviceId, privateKey, passphrase]);
|
||||||
|
|
||||||
|
const copyText = async (text: string, marker: 'main' | 'recovery'): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
if (marker === 'main') {
|
||||||
|
setCopied(true);
|
||||||
|
window.setTimeout(() => setCopied(false), 1500);
|
||||||
|
} else {
|
||||||
|
setCopiedRecovery(true);
|
||||||
|
window.setTimeout(() => setCopiedRecovery(false), 1500);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* fall back — user can select manually */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = useCallback(() => {
|
||||||
|
if (!bundle) return;
|
||||||
|
const text =
|
||||||
|
'=== Passphrase backup ===\n' +
|
||||||
|
bundle.passphraseBackup +
|
||||||
|
'\n\n=== Recovery code ===\n' +
|
||||||
|
bundle.recoveryCode +
|
||||||
|
'\n\n=== Recovery backup (use with the recovery code) ===\n' +
|
||||||
|
bundle.recoveryBackup +
|
||||||
|
'\n';
|
||||||
|
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `chatapp-device-backup-${deviceId.slice(0, 8)}.txt`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}, [bundle, deviceId]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t('app:backup.export_title', { defaultValue: 'Gerätesicherung erstellen' })}
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||||
|
onClick={handleClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="flex max-h-[90vh] w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-line bg-surface-3 shadow-xl"
|
||||||
|
>
|
||||||
|
<header className="flex items-center justify-between border-b border-line px-5 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ShieldIcon className="h-4 w-4 text-accent" />
|
||||||
|
<h3 className="font-display text-sm font-semibold text-fg">
|
||||||
|
{t('app:backup.export_title', { defaultValue: 'Gerätesicherung erstellen' })}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
aria-label="Close"
|
||||||
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-2 hover:text-fg"
|
||||||
|
>
|
||||||
|
<XIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-5">
|
||||||
|
{!bundle ? (
|
||||||
|
<>
|
||||||
|
<p className="text-sm text-fg-muted">
|
||||||
|
{t('app:backup.export_explainer', {
|
||||||
|
defaultValue:
|
||||||
|
'Verschlüssele den Geräteschlüssel mit einer Passphrase. Ohne Passphrase UND Backup-String ist keine Wiederherstellung möglich.',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="block text-xs font-medium uppercase tracking-wide text-fg-muted">
|
||||||
|
{t('app:backup.passphrase', { defaultValue: 'Passphrase' })}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoFocus
|
||||||
|
minLength={8}
|
||||||
|
value={passphrase}
|
||||||
|
onChange={(e) => setPassphrase(e.target.value)}
|
||||||
|
placeholder="min. 8 Zeichen"
|
||||||
|
className="w-full rounded-lg border border-line bg-surface-2 px-3 py-2.5 text-sm text-fg placeholder-fg-muted transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="block text-xs font-medium uppercase tracking-wide text-fg-muted">
|
||||||
|
{t('app:backup.passphrase_confirm', { defaultValue: 'Passphrase wiederholen' })}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirm}
|
||||||
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
|
className="w-full rounded-lg border border-line bg-surface-2 px-3 py-2.5 text-sm text-fg placeholder-fg-muted transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{confirm.length > 0 && confirm !== passphrase && (
|
||||||
|
<p className="text-xs text-rose-500 dark:text-rose-300">
|
||||||
|
{t('app:backup.passphrase_mismatch', { defaultValue: 'Passphrasen stimmen nicht überein.' })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-700 dark:text-amber-200">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<AlertIcon className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-300" />
|
||||||
|
<span>
|
||||||
|
{t('app:backup.export_warning', {
|
||||||
|
defaultValue:
|
||||||
|
'Anthropic: Backup + Passphrase sicher aufbewahren. Passphrase kann nicht wiederhergestellt werden.',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="mt-3 text-sm text-rose-600 dark:text-rose-200">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/10 p-3 text-xs text-emerald-700 dark:text-emerald-200">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<LockIcon className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
<span>
|
||||||
|
{t('app:backup.export_success', {
|
||||||
|
defaultValue:
|
||||||
|
'Backup erstellt. Speichere diesen String + Passphrase in einem Passwortmanager oder drucke ihn aus.',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="mt-3 block text-xs font-medium uppercase tracking-wide text-fg-muted">
|
||||||
|
Backup-String
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
readOnly
|
||||||
|
value={bundle.passphraseBackup}
|
||||||
|
rows={6}
|
||||||
|
onFocus={(e) => e.currentTarget.select()}
|
||||||
|
className="mt-1 w-full resize-none rounded-lg border border-line bg-surface-2 p-3 font-mono text-[11px] leading-relaxed text-fg"
|
||||||
|
/>
|
||||||
|
<div className="mt-2 flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void copyText(bundle.passphraseBackup, 'main')}
|
||||||
|
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-medium text-fg transition hover:bg-surface-3"
|
||||||
|
>
|
||||||
|
<CopyIcon className="h-4 w-4" />
|
||||||
|
<span>{copied ? 'Kopiert!' : 'Kopieren'}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDownload}
|
||||||
|
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-medium text-fg transition hover:bg-surface-3"
|
||||||
|
>
|
||||||
|
Als Datei speichern (alles)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<ShieldIcon className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-300" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-xs font-semibold text-amber-700 dark:text-amber-200">
|
||||||
|
Recovery-Code (Passphrase vergessen?)
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-[11px] text-amber-700/80 dark:text-amber-200/80">
|
||||||
|
Code separat aufbewahren. Mit dem Recovery-Backup unten lässt sich der Schlüssel
|
||||||
|
ohne Passphrase wiederherstellen.
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 select-all rounded bg-surface-3 px-2 py-1.5 font-mono text-sm tracking-widest text-fg">
|
||||||
|
{bundle.recoveryCode}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="mt-3 block text-xs font-medium uppercase tracking-wide text-fg-muted">
|
||||||
|
Recovery-Backup-String
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
readOnly
|
||||||
|
value={bundle.recoveryBackup}
|
||||||
|
rows={6}
|
||||||
|
onFocus={(e) => e.currentTarget.select()}
|
||||||
|
className="mt-1 w-full resize-none rounded-lg border border-line bg-surface-2 p-3 font-mono text-[11px] leading-relaxed text-fg"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void copyText(bundle.recoveryBackup, 'recovery')}
|
||||||
|
className="mt-2 inline-flex w-full cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-medium text-fg transition hover:bg-surface-3"
|
||||||
|
>
|
||||||
|
<CopyIcon className="h-4 w-4" />
|
||||||
|
<span>{copiedRecovery ? 'Kopiert!' : 'Recovery-Backup kopieren'}</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="flex items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3">
|
||||||
|
{!bundle ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg transition hover:bg-surface-2"
|
||||||
|
>
|
||||||
|
{t('app:friends.action_cancel', { defaultValue: 'Abbrechen' })}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleGenerate()}
|
||||||
|
disabled={!canGenerate}
|
||||||
|
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{busy && <SpinnerIcon className="h-4 w-4" />}
|
||||||
|
<span>{t('app:backup.generate', { defaultValue: 'Backup erstellen' })}</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
className="cursor-pointer rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110"
|
||||||
|
>
|
||||||
|
{t('app:backup.done', { defaultValue: 'Fertig' })}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { devLocalSecretStore } from '../lib/secretStore';
|
||||||
|
import { BackupExportDialog } from './BackupExportDialog';
|
||||||
|
import { ShieldIcon, XIcon } from './icons';
|
||||||
|
|
||||||
|
const DISMISS_KEY = 'chatapp.backup.prompt.dismissed';
|
||||||
|
const SESSION_KEY = 'chatapp.backup.prompt';
|
||||||
|
|
||||||
|
// Post-registration nudge: right after a fresh device provision we set
|
||||||
|
// `chatapp.backup.prompt` in sessionStorage. This component reads it and
|
||||||
|
// shows a floating "mach jetzt ein Backup" banner until the user either
|
||||||
|
// creates one or explicitly dismisses (persisted in localStorage so we stop
|
||||||
|
// nagging across reloads).
|
||||||
|
export function BackupPromptBanner() {
|
||||||
|
const { t } = useTranslation(['app']);
|
||||||
|
const { profile, device } = useAuth();
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [privateKey, setPrivateKey] = useState<Uint8Array | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
if (window.localStorage.getItem(DISMISS_KEY) === '1') return;
|
||||||
|
if (window.sessionStorage.getItem(SESSION_KEY) !== '1') return;
|
||||||
|
setVisible(true);
|
||||||
|
} catch {
|
||||||
|
/* storage unavailable */
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dismiss = useCallback((persist: boolean) => {
|
||||||
|
setVisible(false);
|
||||||
|
try {
|
||||||
|
window.sessionStorage.removeItem(SESSION_KEY);
|
||||||
|
if (persist) window.localStorage.setItem(DISMISS_KEY, '1');
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openDialog = useCallback(async () => {
|
||||||
|
if (!profile?.userId || !device?.id) return;
|
||||||
|
const priv = await loadDevicePrivateKey(devLocalSecretStore, profile.userId, device.id);
|
||||||
|
if (!priv) return;
|
||||||
|
setPrivateKey(priv);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}, [profile, device]);
|
||||||
|
|
||||||
|
const closeDialog = useCallback(() => {
|
||||||
|
setDialogOpen(false);
|
||||||
|
if (privateKey) {
|
||||||
|
for (let i = 0; i < privateKey.length; i++) privateKey[i] = 0;
|
||||||
|
}
|
||||||
|
setPrivateKey(null);
|
||||||
|
// After the user interacts with the dialog, drop the banner regardless
|
||||||
|
// of whether they actually completed the backup — they're aware now.
|
||||||
|
dismiss(true);
|
||||||
|
}, [privateKey, dismiss]);
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
className="fixed bottom-6 left-1/2 z-40 flex w-[min(92vw,520px)] -translate-x-1/2 items-start gap-3 rounded-2xl border border-amber-500/40 bg-amber-500/10 p-4 text-sm text-amber-800 shadow-xl backdrop-blur-md dark:text-amber-100"
|
||||||
|
>
|
||||||
|
<ShieldIcon className="mt-0.5 h-5 w-5 shrink-0 text-amber-600 dark:text-amber-300" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-semibold">
|
||||||
|
{t('app:backup.prompt_title', { defaultValue: 'Erstelle jetzt ein Geräte-Backup' })}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-xs text-amber-700/90 dark:text-amber-200/90">
|
||||||
|
{t('app:backup.prompt_body', {
|
||||||
|
defaultValue:
|
||||||
|
'Ohne Backup verlierst du Zugriff auf alte Nachrichten, wenn Browser oder Gerät ihren Speicher verlieren. Dauert 10 Sekunden.',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void openDialog()}
|
||||||
|
className="cursor-pointer rounded-md bg-amber-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-amber-500"
|
||||||
|
>
|
||||||
|
{t('app:backup.prompt_create', { defaultValue: 'Jetzt erstellen' })}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => dismiss(true)}
|
||||||
|
className="cursor-pointer rounded-md border border-amber-500/40 bg-transparent px-3 py-1.5 text-xs font-semibold text-amber-700 transition hover:bg-amber-500/15 dark:text-amber-200"
|
||||||
|
>
|
||||||
|
{t('app:backup.prompt_never', { defaultValue: 'Nicht mehr fragen' })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => dismiss(false)}
|
||||||
|
aria-label={t('app:backup.prompt_dismiss', { defaultValue: 'Später' })}
|
||||||
|
className="cursor-pointer text-amber-600/70 transition hover:text-amber-600 dark:text-amber-200/70 dark:hover:text-amber-200"
|
||||||
|
>
|
||||||
|
<XIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dialogOpen && profile?.userId && device?.id && privateKey && (
|
||||||
|
<BackupExportDialog
|
||||||
|
open={dialogOpen}
|
||||||
|
userId={profile.userId}
|
||||||
|
deviceId={device.id}
|
||||||
|
privateKey={privateKey}
|
||||||
|
onClose={closeDialog}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import {
|
||||||
|
HeadphonesIcon,
|
||||||
|
HeadphonesOffIcon,
|
||||||
|
MicIcon,
|
||||||
|
MicOffIcon,
|
||||||
|
MonitorShareIcon,
|
||||||
|
MonitorStopIcon,
|
||||||
|
PhoneOffIcon,
|
||||||
|
UsersIcon,
|
||||||
|
VideoIcon,
|
||||||
|
} from './icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
muted: boolean;
|
||||||
|
sharing: boolean;
|
||||||
|
video: boolean;
|
||||||
|
deafened: boolean;
|
||||||
|
onToggleMute: () => void;
|
||||||
|
onToggleShare: () => void;
|
||||||
|
onToggleVideo?: () => void;
|
||||||
|
onToggleDeafen: () => 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,
|
||||||
|
deafened,
|
||||||
|
onToggleMute,
|
||||||
|
onToggleShare,
|
||||||
|
onToggleVideo,
|
||||||
|
onToggleDeafen,
|
||||||
|
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>
|
||||||
|
<CallButton
|
||||||
|
label={
|
||||||
|
deafened
|
||||||
|
? t('app:call.undeafen', { defaultValue: 'Ton wieder aktiv' })
|
||||||
|
: t('app:call.deafen', { defaultValue: 'Alle stumm' })
|
||||||
|
}
|
||||||
|
active={deafened}
|
||||||
|
activeTone="danger"
|
||||||
|
onClick={onToggleDeafen}
|
||||||
|
glass={glass}
|
||||||
|
className={btnSize}
|
||||||
|
>
|
||||||
|
{deafened ? (
|
||||||
|
<HeadphonesOffIcon className="h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
<HeadphonesIcon className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
</CallButton>
|
||||||
|
{onToggleVideo && (
|
||||||
|
<CallButton
|
||||||
|
label={t('app:call.start_video', { defaultValue: 'Video' })}
|
||||||
|
active={video}
|
||||||
|
activeTone="accent"
|
||||||
|
onClick={onToggleVideo}
|
||||||
|
disabled={disabledMedia}
|
||||||
|
glass={glass}
|
||||||
|
className={btnSize}
|
||||||
|
>
|
||||||
|
<VideoIcon className="h-5 w-5" />
|
||||||
|
</CallButton>
|
||||||
|
)}
|
||||||
|
<CallButton
|
||||||
|
label={
|
||||||
|
sharing
|
||||||
|
? t('app:call.stop_share_screen', { defaultValue: 'Screen-Share stoppen' })
|
||||||
|
: t('app:call.share_screen', { defaultValue: 'Bildschirm teilen' })
|
||||||
|
}
|
||||||
|
active={sharing}
|
||||||
|
activeTone="accent"
|
||||||
|
onClick={onToggleShare}
|
||||||
|
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,262 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
import { CrownIcon, HeadphonesOffIcon, LockIcon, MicOffIcon } from './icons';
|
||||||
|
|
||||||
|
export type AvatarColorKey = 'violet' | 'amber' | 'rose' | 'teal';
|
||||||
|
|
||||||
|
const AVATAR_COLORS: Record<AvatarColorKey, { bg: string; fg: string }> = {
|
||||||
|
// Tailwind classes tuned per spec: violet/amber/rose/teal swatches in both
|
||||||
|
// light and dark modes. Dark pairs invert to keep contrast readable.
|
||||||
|
violet: {
|
||||||
|
bg: 'bg-violet-200 dark:bg-violet-900/60',
|
||||||
|
fg: 'text-violet-800 dark:text-violet-200',
|
||||||
|
},
|
||||||
|
amber: {
|
||||||
|
bg: 'bg-amber-200 dark:bg-amber-900/60',
|
||||||
|
fg: 'text-amber-900 dark:text-amber-200',
|
||||||
|
},
|
||||||
|
rose: {
|
||||||
|
bg: 'bg-rose-200 dark:bg-rose-900/60',
|
||||||
|
fg: 'text-rose-900 dark:text-rose-200',
|
||||||
|
},
|
||||||
|
teal: {
|
||||||
|
bg: 'bg-teal-200 dark:bg-teal-900/60',
|
||||||
|
fg: 'text-teal-900 dark:text-teal-200',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const COLOR_KEYS: AvatarColorKey[] = ['violet', 'amber', 'rose', 'teal'];
|
||||||
|
|
||||||
|
// Stable per-user color from identity string — keeps avatars visually
|
||||||
|
// consistent across re-mounts without needing a color field on the profile.
|
||||||
|
export function colorKeyFor(id: string): AvatarColorKey {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < id.length; i++) {
|
||||||
|
hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
|
||||||
|
}
|
||||||
|
return COLOR_KEYS[hash % COLOR_KEYS.length]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParticipantTileProps {
|
||||||
|
userId: string;
|
||||||
|
displayName: string;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
me: boolean;
|
||||||
|
muted: boolean;
|
||||||
|
/** Local-only: true when THIS user has muted everyone else via the deafen
|
||||||
|
* toggle. Remote deafen state is not propagated, so only the own tile
|
||||||
|
* ever carries a truthy value. */
|
||||||
|
deafened: boolean;
|
||||||
|
speaking: boolean;
|
||||||
|
video: boolean;
|
||||||
|
e2ee: boolean;
|
||||||
|
/** MediaStreamTrack for the participant's active camera, when `video` is
|
||||||
|
* true. When null the tile falls back to the avatar placeholder. */
|
||||||
|
videoTrack?: MediaStreamTrack | null;
|
||||||
|
size?: 'default' | 'small';
|
||||||
|
focused?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
onContextMenu?: (e: React.MouseEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CallParticipantTile(props: ParticipantTileProps) {
|
||||||
|
const {
|
||||||
|
displayName,
|
||||||
|
me,
|
||||||
|
muted,
|
||||||
|
deafened,
|
||||||
|
speaking,
|
||||||
|
video,
|
||||||
|
e2ee,
|
||||||
|
size = 'default',
|
||||||
|
focused = false,
|
||||||
|
onClick,
|
||||||
|
onContextMenu,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const small = size === 'small';
|
||||||
|
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}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
className={
|
||||||
|
'relative flex flex-col overflow-hidden rounded-[14px] border bg-surface-3 transition ' +
|
||||||
|
(onClick ? 'cursor-pointer ' : '') +
|
||||||
|
borderClass +
|
||||||
|
(small ? ' min-w-[140px]' : '')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{video ? (
|
||||||
|
<VideoStub {...props} small={small} />
|
||||||
|
) : (
|
||||||
|
<AudioContent {...props} small={small} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Speaking indicator visible regardless of content type (video or
|
||||||
|
audio). z-10 ensures it sits above the video element. */}
|
||||||
|
{speaking && (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute inset-0 z-10 rounded-[14px] border-[3px] border-emerald-400 shadow-[inset_0_0_18px_rgba(34,197,94,0.55)]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="pointer-events-none absolute inset-x-2 bottom-2 flex items-center justify-between gap-2 rounded-lg glass-chip px-2.5 py-1.5">
|
||||||
|
<div className="flex min-w-0 items-center gap-1.5 text-xs font-semibold">
|
||||||
|
{me && <CrownIcon className="h-3 w-3 shrink-0 text-amber-300" />}
|
||||||
|
<span className="truncate">
|
||||||
|
{displayName}
|
||||||
|
{me ? ' (du)' : ''}
|
||||||
|
</span>
|
||||||
|
{e2ee && (
|
||||||
|
<span
|
||||||
|
aria-label="E2E verschlüsselt"
|
||||||
|
title="E2E verschlüsselt"
|
||||||
|
className="flex opacity-70"
|
||||||
|
>
|
||||||
|
<LockIcon className="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
{muted && (
|
||||||
|
<span
|
||||||
|
aria-label="Mikro stumm"
|
||||||
|
title="Mikro stumm"
|
||||||
|
className="flex h-5 w-5 items-center justify-center rounded-md bg-rose-500/80 text-white"
|
||||||
|
>
|
||||||
|
<MicOffIcon className="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{deafened && (
|
||||||
|
<span
|
||||||
|
aria-label="Ton aus"
|
||||||
|
title="Ton aus"
|
||||||
|
className="flex h-5 w-5 items-center justify-center rounded-md bg-rose-500/80 text-white"
|
||||||
|
>
|
||||||
|
<HeadphonesOffIcon className="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AudioContent({
|
||||||
|
userId,
|
||||||
|
displayName,
|
||||||
|
avatarUrl,
|
||||||
|
speaking,
|
||||||
|
small,
|
||||||
|
}: ParticipantTileProps & { small: boolean }) {
|
||||||
|
const key = colorKeyFor(userId);
|
||||||
|
const colors = AVATAR_COLORS[key];
|
||||||
|
const letter = displayName.trim().charAt(0).toUpperCase() || '?';
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-0 flex-1 items-center justify-center p-4">
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'relative flex items-center justify-center rounded-full ' +
|
||||||
|
(small ? 'h-11 w-11' : 'h-[72px] w-[72px]')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{speaking && (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute -inset-1.5 animate-audio-pulse rounded-full border-2 border-emerald-500 dark:border-emerald-400"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{avatarUrl ? (
|
||||||
|
<img
|
||||||
|
src={avatarUrl}
|
||||||
|
alt=""
|
||||||
|
className="relative h-full w-full rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
'relative flex h-full w-full items-center justify-center rounded-full font-bold ' +
|
||||||
|
colors.bg + ' ' + colors.fg + ' ' +
|
||||||
|
(small ? 'text-lg' : 'text-2xl')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{letter}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VideoStub({
|
||||||
|
userId,
|
||||||
|
displayName,
|
||||||
|
avatarUrl,
|
||||||
|
videoTrack,
|
||||||
|
me,
|
||||||
|
small,
|
||||||
|
}: ParticipantTileProps & { small: boolean }) {
|
||||||
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const el = videoRef.current;
|
||||||
|
if (!el || !videoTrack) return;
|
||||||
|
el.srcObject = new MediaStream([videoTrack]);
|
||||||
|
return () => {
|
||||||
|
if (el.srcObject) {
|
||||||
|
(el.srcObject as MediaStream).getTracks().forEach((t) => {
|
||||||
|
// Don't stop the live track — other consumers may still need it.
|
||||||
|
// Just detach from this element.
|
||||||
|
void t;
|
||||||
|
});
|
||||||
|
el.srcObject = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [videoTrack]);
|
||||||
|
|
||||||
|
if (videoTrack) {
|
||||||
|
return (
|
||||||
|
<div className="relative flex min-h-0 flex-1 items-center justify-center bg-black">
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
autoPlay
|
||||||
|
playsInline
|
||||||
|
muted
|
||||||
|
className={
|
||||||
|
'h-full w-full object-cover ' +
|
||||||
|
(me ? 'scale-x-[-1]' : '') /* mirror local preview */
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Camera flag true but no track yet (publishing / subscribing race).
|
||||||
|
const key = colorKeyFor(userId);
|
||||||
|
const colors = AVATAR_COLORS[key];
|
||||||
|
const letter = displayName.trim().charAt(0).toUpperCase() || '?';
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-0 flex-1 items-center justify-center bg-gradient-to-br from-accent/20 to-surface-2 p-4">
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'flex items-center justify-center rounded-full font-bold ' +
|
||||||
|
colors.bg + ' ' + colors.fg + ' ' +
|
||||||
|
(small ? 'h-11 w-11 text-lg' : 'h-[72px] w-[72px] text-2xl')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{avatarUrl ? (
|
||||||
|
<img src={avatarUrl} alt="" className="h-full w-full rounded-full object-cover" />
|
||||||
|
) : (
|
||||||
|
letter
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,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,190 @@
|
|||||||
|
import {
|
||||||
|
type DeviceRecord,
|
||||||
|
restoreDeviceFromServerRecord,
|
||||||
|
} from '@chat-app/shared/auth';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import {
|
||||||
|
decodePrivateKeyFromBackup,
|
||||||
|
importDeviceBackup,
|
||||||
|
normalizeRecoveryCode,
|
||||||
|
} from '../lib/deviceBackup';
|
||||||
|
import { devLocalSecretStore } from '../lib/secretStore';
|
||||||
|
import { supabase } from '../lib/supabase';
|
||||||
|
import { writeLocalDeviceId } from '../lib/device';
|
||||||
|
import { AlertIcon, ArrowRightIcon, LockIcon, ShieldIcon, SpinnerIcon } from './icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
userId: string;
|
||||||
|
onRestored: (device: DeviceRecord) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restores a device from a user-provided backup string. The backup embeds
|
||||||
|
// userId + deviceId + X25519 private key; we verify userId matches the current
|
||||||
|
// session, confirm the device row still exists server-side, and then re-seed
|
||||||
|
// the local vault + cached deviceId so the app treats this install as the
|
||||||
|
// original device (conv-key bundles stay valid, no "awaiting" state).
|
||||||
|
export function DeviceRestore({ userId, onRestored }: Props) {
|
||||||
|
const { t } = useTranslation(['app', 'errors']);
|
||||||
|
const [backup, setBackup] = useState('');
|
||||||
|
const [passphrase, setPassphrase] = useState('');
|
||||||
|
const [mode, setMode] = useState<'passphrase' | 'recovery'>('passphrase');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!backup.trim() || passphrase.length < 1 || busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
let privateKey: Uint8Array | null = null;
|
||||||
|
try {
|
||||||
|
const secret =
|
||||||
|
mode === 'recovery' ? normalizeRecoveryCode(passphrase) : passphrase;
|
||||||
|
const payload = await importDeviceBackup(backup.trim(), secret);
|
||||||
|
privateKey = decodePrivateKeyFromBackup(payload);
|
||||||
|
|
||||||
|
const device = await restoreDeviceFromServerRecord({
|
||||||
|
client: supabase,
|
||||||
|
secretStore: devLocalSecretStore,
|
||||||
|
userId: payload.userId,
|
||||||
|
deviceId: payload.deviceId,
|
||||||
|
privateKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cache deviceId locally so findExistingDevice picks it up on next load.
|
||||||
|
writeLocalDeviceId(userId, device.id);
|
||||||
|
|
||||||
|
onRestored(device);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
if (privateKey) {
|
||||||
|
for (let i = 0; i < privateKey.length; i++) privateKey[i] = 0;
|
||||||
|
}
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[backup, passphrase, mode, userId, onRestored, busy],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="w-full max-w-md animate-slide-up rounded-2xl border border-white/10 bg-ink-900/70 p-7 shadow-glow backdrop-blur-xl sm:p-8"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-500/20 ring-1 ring-emerald-400/30">
|
||||||
|
<ShieldIcon className="h-5 w-5 text-emerald-300" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-display text-lg font-semibold text-white">
|
||||||
|
{t('app:backup.restore_title', { defaultValue: 'Backup wiederherstellen' })}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-neutral-400">
|
||||||
|
{t('app:backup.restore_subtitle', {
|
||||||
|
defaultValue: 'Bringe einen zuvor erstellten Backup-String + Passphrase mit.',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 space-y-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="block text-xs font-medium uppercase tracking-wide text-neutral-400">
|
||||||
|
{t('app:backup.backup_string', { defaultValue: 'Backup-String' })}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
required
|
||||||
|
rows={5}
|
||||||
|
value={backup}
|
||||||
|
onChange={(e) => setBackup(e.target.value)}
|
||||||
|
placeholder="chatapp-backup-v1…"
|
||||||
|
className="w-full resize-none rounded-lg border border-white/10 bg-ink-800 px-3 py-2.5 font-mono text-[11px] leading-relaxed text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="block text-xs font-medium uppercase tracking-wide text-neutral-400">
|
||||||
|
{mode === 'passphrase' ? 'Passphrase' : 'Recovery-Code'}
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setMode((m) => (m === 'passphrase' ? 'recovery' : 'passphrase'));
|
||||||
|
setPassphrase('');
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
className="cursor-pointer text-[11px] font-semibold text-brand-300 hover:underline"
|
||||||
|
>
|
||||||
|
{mode === 'passphrase'
|
||||||
|
? 'Passphrase vergessen? Recovery-Code nutzen'
|
||||||
|
: 'Stattdessen Passphrase eingeben'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type={mode === 'passphrase' ? 'password' : 'text'}
|
||||||
|
required
|
||||||
|
value={passphrase}
|
||||||
|
onChange={(e) => setPassphrase(e.target.value)}
|
||||||
|
placeholder={mode === 'recovery' ? 'XXXXXX-XXXXXX-XXXXXX-XXXXXX' : ''}
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete={mode === 'recovery' ? 'off' : 'current-password'}
|
||||||
|
className={
|
||||||
|
'w-full rounded-lg border border-white/10 bg-ink-800 px-3 py-2.5 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40 ' +
|
||||||
|
(mode === 'recovery' ? 'font-mono tracking-widest' : '')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{mode === 'recovery' && (
|
||||||
|
<p className="text-[11px] text-neutral-500">
|
||||||
|
Stattdessen den Recovery-Backup-String oben einfügen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 rounded-lg border border-brand-500/20 bg-brand-500/10 p-3 text-xs text-brand-100">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<LockIcon className="mt-0.5 h-4 w-4 shrink-0 text-brand-300" />
|
||||||
|
<span className="min-w-0 flex-1 break-words">
|
||||||
|
{t('app:backup.restore_hint', {
|
||||||
|
defaultValue:
|
||||||
|
'Nach Wiederherstellung übernimmt dieses Gerät die alte Identität — existierende Nachrichten sind wieder entschlüsselbar.',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={busy || backup.trim().length === 0 || passphrase.length === 0}
|
||||||
|
aria-busy={busy}
|
||||||
|
className="group mt-6 inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-gradient-to-br from-emerald-400 to-emerald-600 px-4 py-3 text-sm font-semibold text-white shadow-glow transition hover:from-emerald-300 hover:to-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-400/60 focus:ring-offset-2 focus:ring-offset-ink-900 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<>
|
||||||
|
<SpinnerIcon className="h-4 w-4" />
|
||||||
|
<span>{t('app:backup.restoring', { defaultValue: 'Wiederherstellen…' })}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span>{t('app:backup.restore_cta', { defaultValue: 'Gerät wiederherstellen' })}</span>
|
||||||
|
<ArrowRightIcon className="h-4 w-4 transition group-hover:translate-x-0.5" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="mt-4 flex items-start gap-3 rounded-lg border border-rose-500/20 bg-rose-500/10 p-3 text-sm text-rose-100"
|
||||||
|
>
|
||||||
|
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" />
|
||||||
|
<p className="min-w-0 flex-1 break-words">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { Component, type ErrorInfo, Fragment, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { SpinnerIcon } from './icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
/**
|
||||||
|
* Optional scope label shown in logs / devtools. Defaults to `root` — set
|
||||||
|
* per boundary (e.g. `route`, `conversation`) so multiple boundaries can be
|
||||||
|
* distinguished at a glance.
|
||||||
|
*/
|
||||||
|
scope?: string;
|
||||||
|
/**
|
||||||
|
* If the retry count exceeds this, the boundary stops auto-retrying and
|
||||||
|
* shows a more helpful message (still without a button — Discord-style,
|
||||||
|
* the app keeps trying but hints the user to hold on or check network).
|
||||||
|
*/
|
||||||
|
maxAutoRetries?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
error: Error | null;
|
||||||
|
retryKey: number;
|
||||||
|
attempt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RETRY_DELAYS_MS = [2000, 4000, 8000, 15000, 30000];
|
||||||
|
|
||||||
|
// Discord-style error boundary.
|
||||||
|
// - Catches render-time errors in its subtree.
|
||||||
|
// - Shows a centred spinner + status text. Never renders a manual "Reload"
|
||||||
|
// button; the boundary remounts its children on an exponential-backoff
|
||||||
|
// schedule so the UI self-heals once the underlying issue clears (typical
|
||||||
|
// causes: a realtime reconnect, a transient network blip, or a race that
|
||||||
|
// only fires once).
|
||||||
|
// - Escalates the label after each failed retry so the user sees that the
|
||||||
|
// app is trying, rather than silent infinite spinning.
|
||||||
|
export class ErrorBoundary extends Component<Props, State> {
|
||||||
|
state: State = { error: null, retryKey: 0, attempt: 0 };
|
||||||
|
|
||||||
|
private retryTimer: number | null = null;
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): Partial<State> {
|
||||||
|
return { error };
|
||||||
|
}
|
||||||
|
|
||||||
|
override componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||||
|
const scope = this.props.scope ?? 'root';
|
||||||
|
// We explicitly log here — the boundary itself swallows the error from
|
||||||
|
// React, so without this the failure would be invisible in production.
|
||||||
|
console.error('[ErrorBoundary:' + scope + '] caught render error', error, info);
|
||||||
|
}
|
||||||
|
|
||||||
|
override componentDidUpdate(_prev: Props, prevState: State): void {
|
||||||
|
if (this.state.error && !prevState.error) {
|
||||||
|
this.scheduleRetry();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override componentWillUnmount(): void {
|
||||||
|
if (this.retryTimer !== null) {
|
||||||
|
window.clearTimeout(this.retryTimer);
|
||||||
|
this.retryTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleRetry(): void {
|
||||||
|
if (this.retryTimer !== null) return;
|
||||||
|
const attempt = this.state.attempt;
|
||||||
|
const delay =
|
||||||
|
RETRY_DELAYS_MS[Math.min(attempt, RETRY_DELAYS_MS.length - 1)] ??
|
||||||
|
RETRY_DELAYS_MS[RETRY_DELAYS_MS.length - 1] ??
|
||||||
|
30000;
|
||||||
|
this.retryTimer = window.setTimeout(() => {
|
||||||
|
this.retryTimer = null;
|
||||||
|
this.setState((prev) => ({
|
||||||
|
error: null,
|
||||||
|
retryKey: prev.retryKey + 1,
|
||||||
|
attempt: prev.attempt + 1,
|
||||||
|
}));
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
override render(): ReactNode {
|
||||||
|
if (this.state.error) {
|
||||||
|
const max = this.props.maxAutoRetries ?? RETRY_DELAYS_MS.length;
|
||||||
|
const escalated = this.state.attempt >= max;
|
||||||
|
return <RetryingScreen escalated={escalated} attempt={this.state.attempt} />;
|
||||||
|
}
|
||||||
|
// `retryKey` forces a remount of the subtree so hooks re-run cleanly after
|
||||||
|
// an error (otherwise stale state from the crashed tree can immediately
|
||||||
|
// re-throw). Use a keyed Fragment so the boundary doesn't inject an extra
|
||||||
|
// wrapper div — that would break `flex h-full` chains (e.g. AppShell →
|
||||||
|
// Outlet → page column).
|
||||||
|
return <Fragment key={this.state.retryKey}>{this.props.children}</Fragment>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function RetryingScreen({ escalated, attempt }: { escalated: boolean; attempt: number }) {
|
||||||
|
const primary = escalated
|
||||||
|
? 'Verbindungsprobleme…'
|
||||||
|
: attempt === 0
|
||||||
|
? 'Einen Moment bitte'
|
||||||
|
: 'Versuche erneut zu laden…';
|
||||||
|
const secondary = escalated
|
||||||
|
? 'Prüfe deine Internetverbindung. Wir versuchen es weiter.'
|
||||||
|
: 'Die App lädt sich gleich selbst neu.';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
className="flex min-h-screen w-full items-center justify-center bg-surface-3 px-6"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center gap-4 text-center">
|
||||||
|
<div className="relative flex h-16 w-16 items-center justify-center">
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-0 rounded-full border-2 border-accent/20"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-0 rounded-full border-2 border-accent border-r-transparent border-b-transparent animate-spin"
|
||||||
|
/>
|
||||||
|
<SpinnerIcon className="hidden" />
|
||||||
|
</div>
|
||||||
|
<div className="max-w-sm space-y-1.5">
|
||||||
|
<p className="font-display text-lg font-semibold text-fg">{primary}</p>
|
||||||
|
<p className="text-sm text-fg-muted">{secondary}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
|
||||||
|
import {
|
||||||
|
type AttachmentHandle,
|
||||||
|
type DecryptedMessage,
|
||||||
|
downloadAndDecryptAttachment,
|
||||||
|
encryptAndUploadAttachment,
|
||||||
|
insertAttachmentRow,
|
||||||
|
parseMessagePayload,
|
||||||
|
sendEncryptedMessage,
|
||||||
|
} from '@chat-app/shared/chat';
|
||||||
|
import { extractErrorCode } from '@chat-app/shared/i18n';
|
||||||
|
import { bytesToPgHex } from '@chat-app/shared/supabase';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { useConversationsContext } from '../context/ConversationsContext';
|
||||||
|
import { devLocalSecretStore } from '../lib/secretStore';
|
||||||
|
import { supabase } from '../lib/supabase';
|
||||||
|
import { Avatar } from './Avatar';
|
||||||
|
import { ForwardIcon, SpinnerIcon, UsersIcon, XIcon } from './icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
message: DecryptedMessage | null;
|
||||||
|
currentConversationId: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forwards a message's plaintext to one or more conversations. Attachments are
|
||||||
|
// NOT carried over yet (would require re-uploading + re-encrypting under the
|
||||||
|
// new conversation key); only the text payload is forwarded for now and the
|
||||||
|
// preview hints at the dropped attachment.
|
||||||
|
export function ForwardDialog({ open, message, currentConversationId, onClose }: Props) {
|
||||||
|
const { t } = useTranslation(['app', 'errors']);
|
||||||
|
const { session, device } = useAuth();
|
||||||
|
const { conversations } = useConversationsContext();
|
||||||
|
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setSelected(new Set());
|
||||||
|
setError(null);
|
||||||
|
setDone(false);
|
||||||
|
}, [open, message?.id]);
|
||||||
|
|
||||||
|
const targets = useMemo(() => {
|
||||||
|
return conversations
|
||||||
|
.filter((c) => c.id !== currentConversationId && c.acceptedByMe)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const ta = a.lastMessageAt ?? a.createdAt;
|
||||||
|
const tb = b.lastMessageAt ?? b.createdAt;
|
||||||
|
return tb.localeCompare(ta);
|
||||||
|
});
|
||||||
|
}, [conversations, currentConversationId]);
|
||||||
|
|
||||||
|
const preview = useMemo(() => {
|
||||||
|
if (!message?.plaintext) return '';
|
||||||
|
const p = parseMessagePayload(message.plaintext);
|
||||||
|
if (p.kind !== 'text') return '';
|
||||||
|
return p.text.length > 140 ? p.text.slice(0, 140) + '…' : p.text;
|
||||||
|
}, [message]);
|
||||||
|
|
||||||
|
const sourceAttachments = useMemo<AttachmentHandle[]>(() => {
|
||||||
|
if (!message?.plaintext) return [];
|
||||||
|
const p = parseMessagePayload(message.plaintext);
|
||||||
|
return p.kind === 'text' ? p.attachments : [];
|
||||||
|
}, [message]);
|
||||||
|
|
||||||
|
if (!open || !message) return null;
|
||||||
|
|
||||||
|
function toggle(id: string) {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSend() {
|
||||||
|
if (!session?.user.id || !device?.id || !message) return;
|
||||||
|
if (selected.size === 0) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const priv = await loadDevicePrivateKey(devLocalSecretStore, session.user.id, device.id);
|
||||||
|
if (!priv) throw new Error('private key not loaded');
|
||||||
|
|
||||||
|
const hasAttachments = sourceAttachments.length > 0;
|
||||||
|
const text = preview || (hasAttachments ? '' : '');
|
||||||
|
if (!text && !hasAttachments) throw new Error('nothing to forward');
|
||||||
|
|
||||||
|
// Download+decrypt source attachments ONCE (same plaintext goes to every
|
||||||
|
// target). For each target conv we re-encrypt under fresh per-attachment
|
||||||
|
// keys and re-upload under the target conv's storage folder — source and
|
||||||
|
// target conv-keys differ, so the bytes must actually move.
|
||||||
|
const decryptedBlobs: { mime: string; size: number; width?: number; height?: number; blob: Blob }[] =
|
||||||
|
[];
|
||||||
|
for (const h of sourceAttachments) {
|
||||||
|
const blob = await downloadAndDecryptAttachment({ client: supabase, handle: h });
|
||||||
|
const entry: {
|
||||||
|
mime: string;
|
||||||
|
size: number;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
blob: Blob;
|
||||||
|
} = { mime: h.mimeType, size: h.sizeBytes, blob };
|
||||||
|
if (h.width !== undefined) entry.width = h.width;
|
||||||
|
if (h.height !== undefined) entry.height = h.height;
|
||||||
|
decryptedBlobs.push(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const convId of selected) {
|
||||||
|
const newHandles: AttachmentHandle[] = [];
|
||||||
|
const blobNonceHex = new Map<string, string>();
|
||||||
|
for (const src of decryptedBlobs) {
|
||||||
|
const res = await encryptAndUploadAttachment({
|
||||||
|
client: supabase,
|
||||||
|
conversationId: convId,
|
||||||
|
file: src.blob,
|
||||||
|
mimeType: src.mime,
|
||||||
|
sizeBytes: src.size,
|
||||||
|
...(src.width !== undefined ? { width: src.width } : {}),
|
||||||
|
...(src.height !== undefined ? { height: src.height } : {}),
|
||||||
|
});
|
||||||
|
newHandles.push(res.handle);
|
||||||
|
blobNonceHex.set(res.handle.id, bytesToPgHex(res.nonce));
|
||||||
|
}
|
||||||
|
|
||||||
|
const msg = await sendEncryptedMessage({
|
||||||
|
client: supabase,
|
||||||
|
conversationId: convId,
|
||||||
|
plaintext: text,
|
||||||
|
senderUserId: session.user.id,
|
||||||
|
senderDeviceId: device.id,
|
||||||
|
senderPrivateKey: priv,
|
||||||
|
...(newHandles.length > 0 ? { attachmentHandles: newHandles } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const h of newHandles) {
|
||||||
|
const bn = blobNonceHex.get(h.id) ?? '\\x';
|
||||||
|
await insertAttachmentRow(supabase, msg.id, h, bn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setDone(true);
|
||||||
|
window.setTimeout(onClose, 700);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const code = extractErrorCode(err);
|
||||||
|
setError(
|
||||||
|
code
|
||||||
|
? t('errors:' + code, { defaultValue: t('errors:generic') })
|
||||||
|
: err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: t('errors:generic'),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t('app:chats.forward', { defaultValue: 'Weiterleiten' })}
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="flex max-h-[80vh] w-full max-w-md flex-col overflow-hidden rounded-2xl border border-line bg-surface-3 shadow-xl"
|
||||||
|
>
|
||||||
|
<header className="flex items-center justify-between border-b border-line px-5 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ForwardIcon className="h-4 w-4 text-accent" />
|
||||||
|
<h3 className="font-display text-sm font-semibold text-fg">
|
||||||
|
{t('app:chats.forward', { defaultValue: 'Weiterleiten' })}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close"
|
||||||
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-2 hover:text-fg"
|
||||||
|
>
|
||||||
|
<XIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="border-b border-line bg-surface-2 px-5 py-3">
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||||
|
{t('app:chats.forward_preview', { defaultValue: 'Vorschau' })}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 line-clamp-3 break-words text-sm text-fg">
|
||||||
|
{preview || (sourceAttachments.length > 0 ? '📎' : '…')}
|
||||||
|
</p>
|
||||||
|
{sourceAttachments.length > 0 && (
|
||||||
|
<p className="mt-1 text-[11px] text-fg-muted">
|
||||||
|
📎{' '}
|
||||||
|
{t('app:chats.forward_attachments_count', {
|
||||||
|
count: sourceAttachments.length,
|
||||||
|
defaultValue: '{{count}} Anhang wird mit weitergeleitet',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-2">
|
||||||
|
{targets.length === 0 ? (
|
||||||
|
<p className="px-4 py-6 text-center text-sm text-fg-muted">
|
||||||
|
{t('app:chats.forward_no_targets', {
|
||||||
|
defaultValue: 'Keine anderen Unterhaltungen verfügbar.',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{targets.map((c) => {
|
||||||
|
const isGroup = c.type === 'group';
|
||||||
|
const title = isGroup ? c.name ?? '?' : c.peer?.displayName ?? '?';
|
||||||
|
const avatarUrl = isGroup ? c.avatarUrl ?? null : c.peer?.avatarUrl ?? null;
|
||||||
|
const checked = selected.has(c.id);
|
||||||
|
return (
|
||||||
|
<li key={c.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggle(c.id)}
|
||||||
|
className={
|
||||||
|
'flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||||
|
(checked ? 'bg-accent/15' : 'hover:bg-surface-2')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{avatarUrl ? (
|
||||||
|
<Avatar
|
||||||
|
url={avatarUrl}
|
||||||
|
displayName={title}
|
||||||
|
className="h-9 w-9 text-sm"
|
||||||
|
/>
|
||||||
|
) : isGroup ? (
|
||||||
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-accent/20 text-accent">
|
||||||
|
<UsersIcon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Avatar
|
||||||
|
url={null}
|
||||||
|
displayName={title}
|
||||||
|
className="h-9 w-9 text-sm"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="min-w-0 flex-1 truncate text-sm font-medium text-fg">
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={
|
||||||
|
'flex h-5 w-5 shrink-0 items-center justify-center rounded border ' +
|
||||||
|
(checked ? 'border-accent bg-accent text-accent-fg' : 'border-line bg-surface-2')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{checked && (
|
||||||
|
<svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
className="border-t border-rose-500/30 bg-rose-500/10 px-5 py-2 text-xs text-rose-700 dark:text-rose-200"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<footer className="flex items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg transition hover:bg-surface-2"
|
||||||
|
>
|
||||||
|
{t('app:friends.action_cancel', { defaultValue: 'Abbrechen' })}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy || selected.size === 0 || done}
|
||||||
|
onClick={() => void handleSend()}
|
||||||
|
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{busy && <SpinnerIcon className="h-4 w-4" />}
|
||||||
|
<span>
|
||||||
|
{done
|
||||||
|
? t('app:chats.forward_done', { defaultValue: 'Gesendet' })
|
||||||
|
: t('app:chats.forward_send', {
|
||||||
|
count: selected.size,
|
||||||
|
defaultValue: 'An {{count}} senden',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -123,15 +123,25 @@ export function GroupInfoPanel({ open, onClose, conversation }: Props) {
|
|||||||
{conversation.members.map((m) => {
|
{conversation.members.map((m) => {
|
||||||
const name = m.profile?.displayName ?? '?';
|
const name = m.profile?.displayName ?? '?';
|
||||||
const handle = m.profile?.username ? '@' + m.profile.username : '';
|
const handle = m.profile?.username ? '@' + m.profile.username : '';
|
||||||
|
const avatarUrl = m.profile?.avatarUrl ?? null;
|
||||||
const letter = name.trim().charAt(0).toUpperCase() || '?';
|
const letter = name.trim().charAt(0).toUpperCase() || '?';
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={m.userId}
|
key={m.userId}
|
||||||
className="flex items-center gap-3 rounded-lg px-2 py-1.5"
|
className="flex items-center gap-3 rounded-lg px-2 py-1.5"
|
||||||
>
|
>
|
||||||
|
{avatarUrl ? (
|
||||||
|
<img
|
||||||
|
src={avatarUrl}
|
||||||
|
alt=""
|
||||||
|
className="h-8 w-8 shrink-0 rounded-full object-cover"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-xs font-semibold text-white ring-1 ring-brand-400/30">
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-xs font-semibold text-white ring-1 ring-brand-400/30">
|
||||||
{letter}
|
{letter}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-sm text-white">
|
<p className="truncate text-sm text-white">
|
||||||
{name}
|
{name}
|
||||||
@@ -187,9 +197,18 @@ export function GroupInfoPanel({ open, onClose, conversation }: Props) {
|
|||||||
onClick={() => void handleAdd(f.userId)}
|
onClick={() => void handleAdd(f.userId)}
|
||||||
className="flex w-full cursor-pointer items-center gap-3 rounded-lg px-2 py-1.5 text-left transition hover:bg-white/5 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 disabled:opacity-60"
|
className="flex w-full cursor-pointer items-center gap-3 rounded-lg px-2 py-1.5 text-left transition hover:bg-white/5 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 disabled:opacity-60"
|
||||||
>
|
>
|
||||||
|
{f.avatarUrl ? (
|
||||||
|
<img
|
||||||
|
src={f.avatarUrl}
|
||||||
|
alt=""
|
||||||
|
className="h-8 w-8 shrink-0 rounded-full object-cover"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-xs font-semibold text-white ring-1 ring-brand-400/30">
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-xs font-semibold text-white ring-1 ring-brand-400/30">
|
||||||
{letter}
|
{letter}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-sm text-white">{f.displayName}</p>
|
<p className="truncate text-sm text-white">{f.displayName}</p>
|
||||||
<p className="truncate text-xs text-neutral-500">@{f.username}</p>
|
<p className="truncate text-xs text-neutral-500">@{f.username}</p>
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useCall } from '../context/CallContext';
|
||||||
|
import { useFriendshipsContext } from '../context/FriendshipsContext';
|
||||||
|
import { AvatarColorKey, colorKeyFor } from './CallParticipantTile';
|
||||||
|
import { LockIcon, PhoneIcon, PhoneOffIcon, VideoIcon } from './icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
conversation: ConversationSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PULSE_BG: Record<AvatarColorKey, string> = {
|
||||||
|
violet: 'bg-violet-200 text-violet-800 dark:bg-violet-900/60 dark:text-violet-200',
|
||||||
|
amber: 'bg-amber-200 text-amber-900 dark:bg-amber-900/60 dark:text-amber-200',
|
||||||
|
rose: 'bg-rose-200 text-rose-900 dark:bg-rose-900/60 dark:text-rose-200',
|
||||||
|
teal: 'bg-teal-200 text-teal-900 dark:bg-teal-900/60 dark:text-teal-200',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Docked incoming-call panel: replaces the chat header, chat + composer stay
|
||||||
|
// visible and usable below (per user decision). Full-bleed overlay lives in
|
||||||
|
// the shell-level toast for cross-conversation ring notifications.
|
||||||
|
export function IncomingCallPanel({ conversation }: Props) {
|
||||||
|
const { t } = useTranslation(['app']);
|
||||||
|
const { state, acceptIncoming, rejectIncoming } = useCall();
|
||||||
|
const { friendships } = useFriendshipsContext();
|
||||||
|
|
||||||
|
if (state.kind !== 'incoming' || state.conversationId !== conversation.id) return null;
|
||||||
|
|
||||||
|
const isGroup = conversation.type === 'group';
|
||||||
|
const callerProfile = conversation.members.find((m) => m.userId === state.fromUserId)?.profile ?? null;
|
||||||
|
const callerName =
|
||||||
|
callerProfile?.displayName ??
|
||||||
|
friendships.find((f) => f.peer.userId === state.fromUserId)?.peer.displayName ??
|
||||||
|
'?';
|
||||||
|
const groupName = isGroup ? (conversation.name ?? t('app:chats.new_group')) : null;
|
||||||
|
const title = isGroup ? groupName ?? callerName : callerName;
|
||||||
|
const letter = title.trim().charAt(0).toUpperCase() || '?';
|
||||||
|
const avatarUrl = isGroup
|
||||||
|
? (conversation.avatarUrl ?? callerProfile?.avatarUrl ?? null)
|
||||||
|
: callerProfile?.avatarUrl ?? null;
|
||||||
|
const colorKey = colorKeyFor(state.fromUserId);
|
||||||
|
const avatarTone = PULSE_BG[colorKey];
|
||||||
|
const mediaLabel =
|
||||||
|
state.mediaKind === 'video' ? 'Video Call' : 'Voice Call';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
role="region"
|
||||||
|
aria-label={t('app:call.incoming_title')}
|
||||||
|
className="relative overflow-hidden border-b border-line bg-surface-3"
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_30%_20%,rgba(79,70,229,0.10),transparent_60%),radial-gradient(circle_at_70%_80%,rgba(109,115,255,0.08),transparent_55%)]" />
|
||||||
|
<div className="relative mx-auto flex max-w-[480px] flex-col items-center px-6 py-8 text-center">
|
||||||
|
<p className="text-[12px] font-semibold uppercase tracking-[0.12em] text-fg-muted">
|
||||||
|
{t('app:call.incoming_title')}
|
||||||
|
</p>
|
||||||
|
<div className="relative my-4 flex h-[112px] w-[112px] items-center justify-center">
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute inset-0 rounded-full border-2 border-accent animate-pulse-ring"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute inset-0 rounded-full border-2 border-accent animate-pulse-ring"
|
||||||
|
style={{ animationDelay: '1s' }}
|
||||||
|
/>
|
||||||
|
{avatarUrl ? (
|
||||||
|
<img
|
||||||
|
src={avatarUrl}
|
||||||
|
alt={title}
|
||||||
|
className="h-24 w-24 rounded-full border-[3px] border-surface-3 object-cover"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'flex h-24 w-24 items-center justify-center rounded-full border-[3px] border-surface-3 font-display text-4xl font-bold ' +
|
||||||
|
avatarTone
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{letter}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="font-display text-2xl font-bold tracking-tight text-fg">{title}</p>
|
||||||
|
<p className="mt-1.5 flex items-center gap-1.5 text-xs text-fg-muted">
|
||||||
|
<LockIcon className="h-3.5 w-3.5" />
|
||||||
|
<span>E2E verschlüsselt · {mediaLabel}</span>
|
||||||
|
{isGroup && (
|
||||||
|
<>
|
||||||
|
<span className="text-fg-muted/50" aria-hidden="true">·</span>
|
||||||
|
<span>
|
||||||
|
{t('app:call.incoming_group_from', {
|
||||||
|
name: callerName,
|
||||||
|
defaultValue: callerName + ' ruft an',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div className="mt-5 flex w-full max-w-[440px] flex-wrap gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={rejectIncoming}
|
||||||
|
className="inline-flex flex-1 basis-[30%] cursor-pointer items-center justify-center gap-2 rounded-[14px] border border-rose-500/40 bg-transparent px-4 py-3.5 text-sm font-semibold text-rose-600 transition hover:bg-rose-500/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-500/40 dark:text-rose-400"
|
||||||
|
>
|
||||||
|
<PhoneOffIcon className="h-4 w-4" />
|
||||||
|
<span>{t('app:call.decline')}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void acceptIncoming('audio')}
|
||||||
|
className="inline-flex flex-1 basis-[30%] cursor-pointer items-center justify-center gap-2 rounded-[14px] bg-emerald-600 px-4 py-3.5 text-sm font-semibold text-white shadow-accept-btn transition hover:bg-emerald-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50"
|
||||||
|
>
|
||||||
|
<PhoneIcon className="h-4 w-4" />
|
||||||
|
<span>
|
||||||
|
{state.mediaKind === 'video'
|
||||||
|
? t('app:call.accept_audio', { defaultValue: 'Nur Audio' })
|
||||||
|
: t('app:call.accept')}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{state.mediaKind === 'video' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void acceptIncoming('video')}
|
||||||
|
className="inline-flex flex-1 basis-[30%] cursor-pointer items-center justify-center gap-2 rounded-[14px] bg-accent px-4 py-3.5 text-sm font-semibold text-accent-fg shadow-accept-btn transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
|
||||||
|
>
|
||||||
|
<VideoIcon className="h-4 w-4" />
|
||||||
|
<span>{t('app:call.accept_video', { defaultValue: 'Mit Video' })}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,30 +13,65 @@ import { useAuth } from '../context/AuthContext';
|
|||||||
import { devLocalSecretStore } from '../lib/secretStore';
|
import { devLocalSecretStore } from '../lib/secretStore';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
import type { AggregatedReaction } from '../lib/useMessageReactions';
|
import type { AggregatedReaction } from '../lib/useMessageReactions';
|
||||||
|
import { AttachmentAudio } from './AttachmentAudio';
|
||||||
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;
|
||||||
|
/** Delivery state for own messages: 'sent' / 'delivered' / 'read'. */
|
||||||
|
deliveryState?: 'sent' | 'delivered' | 'read';
|
||||||
|
/** Resolved quoted message info (parent does the lookup). */
|
||||||
|
quoted?: QuotedRef | null;
|
||||||
|
/** Tap-to-jump on quote bubble. Receives the quoted message's id. */
|
||||||
|
onJumpToMessage?: (id: string) => void;
|
||||||
|
/** Hover action: parent receives current message to start a reply. */
|
||||||
|
onReply?: (m: DecryptedMessage) => void;
|
||||||
|
/** Hover action: parent opens forward dialog for current message. */
|
||||||
|
onForward?: (m: DecryptedMessage) => void;
|
||||||
|
/** Highlighted state — set briefly after a jump. */
|
||||||
|
highlighted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageBubble({
|
export function MessageBubble({
|
||||||
message,
|
message,
|
||||||
mine,
|
mine,
|
||||||
groupedWithPrev,
|
groupedWithPrev,
|
||||||
|
isLastOfRun = false,
|
||||||
|
senderDisplayName,
|
||||||
|
senderAvatarUrl,
|
||||||
conversationId,
|
conversationId,
|
||||||
reactions,
|
reactions,
|
||||||
onToggleReaction,
|
onToggleReaction,
|
||||||
showSeen = false,
|
showSeen = false,
|
||||||
|
deliveryState,
|
||||||
|
quoted = null,
|
||||||
|
onJumpToMessage,
|
||||||
|
onReply,
|
||||||
|
onForward,
|
||||||
|
highlighted = false,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation(['app']);
|
const { t } = useTranslation(['app']);
|
||||||
const { session, device } = useAuth();
|
const { session, device } = useAuth();
|
||||||
@@ -143,8 +178,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>
|
||||||
@@ -156,14 +196,19 @@ export function MessageBubble({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'flex ' + (mine ? 'justify-end' : 'justify-start')}>
|
<div className={'flex items-end gap-2 ' + (mine ? 'flex-row-reverse' : '')}>
|
||||||
|
<AvatarSlot
|
||||||
|
show={isLastOfRun}
|
||||||
|
url={senderAvatarUrl ?? null}
|
||||||
|
displayName={senderDisplayName ?? null}
|
||||||
|
/>
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
'group relative max-w-[70%] ' + (groupedWithPrev ? 'mt-0.5' : 'mt-2')
|
'group relative max-w-[calc(70%-2.5rem)] ' + (groupedWithPrev ? 'mt-0.5' : 'mt-2')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<div className="rounded-2xl border border-brand-400/40 bg-ink-900/80 p-2 backdrop-blur-xl">
|
<div className="rounded-2xl border border-accent/40 bg-surface-2 p-2 shadow-sm">
|
||||||
<textarea
|
<textarea
|
||||||
value={editText}
|
value={editText}
|
||||||
onChange={(e) => setEditText(e.target.value)}
|
onChange={(e) => setEditText(e.target.value)}
|
||||||
@@ -179,10 +224,10 @@ export function MessageBubble({
|
|||||||
}}
|
}}
|
||||||
rows={2}
|
rows={2}
|
||||||
autoFocus
|
autoFocus
|
||||||
className="w-full resize-none rounded-md bg-ink-800 px-3 py-2 text-sm text-white outline-none focus:ring-2 focus:ring-brand-400/60"
|
className="w-full resize-none rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg outline-none focus:ring-2 focus:ring-accent/50"
|
||||||
/>
|
/>
|
||||||
{editError && (
|
{editError && (
|
||||||
<p className="mt-1.5 break-words rounded-md border border-rose-500/20 bg-rose-500/10 px-2 py-1 text-[11px] text-rose-200">
|
<p className="mt-1.5 break-words rounded-md border border-rose-500/30 bg-rose-500/10 px-2 py-1 text-[11px] text-rose-600 dark:text-rose-200">
|
||||||
{editError}
|
{editError}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -193,7 +238,7 @@ export function MessageBubble({
|
|||||||
setEditing(false);
|
setEditing(false);
|
||||||
setEditError(null);
|
setEditError(null);
|
||||||
}}
|
}}
|
||||||
className="cursor-pointer rounded-md border border-white/10 bg-white/5 px-3 py-1 text-xs text-neutral-200 hover:bg-white/10"
|
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-1 text-xs text-fg hover:bg-surface-2"
|
||||||
>
|
>
|
||||||
{t('app:friends.action_cancel')}
|
{t('app:friends.action_cancel')}
|
||||||
</button>
|
</button>
|
||||||
@@ -201,7 +246,7 @@ export function MessageBubble({
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() => void handleEditSave()}
|
onClick={() => void handleEditSave()}
|
||||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-brand-500/90 px-3 py-1 text-xs font-semibold text-white hover:bg-brand-400 disabled:opacity-60"
|
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-1 text-xs font-semibold text-accent-fg hover:brightness-110 disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{busy && <SpinnerIcon className="h-3.5 w-3.5" />}
|
{busy && <SpinnerIcon className="h-3.5 w-3.5" />}
|
||||||
<span>{t('common:save', { defaultValue: 'Save' })}</span>
|
<span>{t('common:save', { defaultValue: 'Save' })}</span>
|
||||||
@@ -210,27 +255,64 @@ 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>}
|
||||||
{attachments.map((a) => (
|
{attachments.map((a) =>
|
||||||
|
a.mimeType.startsWith('audio/') ? (
|
||||||
|
<AttachmentAudio key={a.id} handle={a} />
|
||||||
|
) : (
|
||||||
<AttachmentImage key={a.id} handle={a} />
|
<AttachmentImage key={a.id} handle={a} />
|
||||||
))}
|
),
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
'mt-1 flex items-center gap-1.5 text-[10px] ' +
|
'mt-1 flex items-center gap-1.5 text-[10px] ' +
|
||||||
(mine ? 'text-brand-100/70' : 'text-neutral-500')
|
(mine ? 'text-accent-fg/75' : 'text-fg-muted')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>{time}</span>
|
<span>{time}</span>
|
||||||
@@ -241,24 +323,27 @@ export function MessageBubble({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showSeen && mine && !editing && !message.deletedAt && (
|
{mine && !editing && !message.deletedAt && deliveryState && (
|
||||||
<p className="mt-0.5 text-right text-[10px] text-neutral-500">
|
<div className="mt-0.5 flex items-center justify-end gap-1 text-[10px] text-fg-muted">
|
||||||
{t('app:chats.seen')}
|
<DeliveryTicks state={deliveryState} />
|
||||||
</p>
|
{showSeen && <span>{t('app:chats.seen')}</span>}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{reactions.length > 0 && !editing && (
|
{reactions.length > 0 && !editing && (
|
||||||
<div className={'mt-1 flex flex-wrap gap-1 ' + (mine ? 'justify-end' : 'justify-start')}>
|
<div className={'mt-1 flex flex-wrap gap-1 ' + (mine ? 'justify-end' : 'justify-start')}>
|
||||||
{reactions.map((r) => (
|
{reactions.map((r) => (
|
||||||
<button
|
<button
|
||||||
key={r.emoji}
|
// Keying by emoji+count makes React remount the chip when the
|
||||||
|
// count flips, replaying the pop animation. Cheap visual cue.
|
||||||
|
key={r.emoji + ':' + r.count}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void onToggleReaction(r.emoji)}
|
onClick={() => void onToggleReaction(r.emoji)}
|
||||||
className={
|
className={
|
||||||
'inline-flex cursor-pointer items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' +
|
'inline-flex origin-bottom animate-reaction-pop cursor-pointer items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||||
(r.mine
|
(r.mine
|
||||||
? 'border-brand-400/40 bg-brand-500/20 text-brand-100'
|
? 'border-accent/40 bg-accent/20 text-accent'
|
||||||
: 'border-white/10 bg-white/5 text-neutral-200 hover:bg-white/10')
|
: 'border-line bg-surface-2 text-fg hover:bg-surface-3')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>{r.emoji}</span>
|
<span>{r.emoji}</span>
|
||||||
@@ -275,12 +360,26 @@ export function MessageBubble({
|
|||||||
(mine ? 'right-full pr-2' : 'left-full pl-2')
|
(mine ? 'right-full pr-2' : 'left-full pl-2')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-0.5 rounded-lg border border-white/10 bg-ink-900/90 p-1 shadow-lg backdrop-blur-xl">
|
<div className="flex items-center gap-0.5 rounded-lg border border-line bg-surface-3 p-1 shadow-lg">
|
||||||
<ActionButton
|
<ActionButton
|
||||||
label={t('app:friends.action_accept', { defaultValue: 'React' })}
|
label={t('app:friends.action_accept', { defaultValue: 'React' })}
|
||||||
onClick={() => setPickerOpen((v) => !v)}
|
onClick={() => setPickerOpen((v) => !v)}
|
||||||
icon={<SmileIcon className="h-4 w-4" />}
|
icon={<SmileIcon className="h-4 w-4" />}
|
||||||
/>
|
/>
|
||||||
|
{onReply && !message.deletedAt && (
|
||||||
|
<ActionButton
|
||||||
|
label={t('app:chats.reply', { defaultValue: 'Antworten' })}
|
||||||
|
onClick={() => onReply(message)}
|
||||||
|
icon={<ReplyIcon className="h-4 w-4" />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{onForward && !message.deletedAt && (
|
||||||
|
<ActionButton
|
||||||
|
label={t('app:chats.forward', { defaultValue: 'Weiterleiten' })}
|
||||||
|
onClick={() => onForward(message)}
|
||||||
|
icon={<ForwardIcon className="h-4 w-4" />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<ActionButton
|
<ActionButton
|
||||||
label="Edit"
|
label="Edit"
|
||||||
@@ -306,7 +405,7 @@ export function MessageBubble({
|
|||||||
ref={pickerRef}
|
ref={pickerRef}
|
||||||
role="menu"
|
role="menu"
|
||||||
className={
|
className={
|
||||||
'absolute z-30 mt-1 flex gap-1 rounded-lg border border-white/10 bg-ink-900/95 p-1.5 shadow-xl backdrop-blur-xl ' +
|
'absolute z-30 mt-1 flex gap-1 rounded-lg border border-line bg-surface-3 p-1.5 shadow-xl ' +
|
||||||
(mine ? 'right-0' : 'left-0')
|
(mine ? 'right-0' : 'left-0')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -315,7 +414,7 @@ export function MessageBubble({
|
|||||||
key={e}
|
key={e}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void handlePickEmoji(e)}
|
onClick={() => void handlePickEmoji(e)}
|
||||||
className="cursor-pointer rounded-md px-2 py-1 text-lg transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
|
className="cursor-pointer rounded-md px-2 py-1 text-lg transition hover:bg-surface-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||||
>
|
>
|
||||||
{e}
|
{e}
|
||||||
</button>
|
</button>
|
||||||
@@ -323,7 +422,7 @@ export function MessageBubble({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPickerOpen(false)}
|
onClick={() => setPickerOpen(false)}
|
||||||
className="cursor-pointer rounded-md px-1.5 py-1 text-neutral-500 transition hover:bg-white/10"
|
className="cursor-pointer rounded-md px-1.5 py-1 text-fg-muted transition hover:bg-surface-2"
|
||||||
>
|
>
|
||||||
<XIcon className="h-4 w-4" />
|
<XIcon className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -336,6 +435,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,
|
||||||
@@ -351,8 +471,8 @@ function CallEventRow({
|
|||||||
|
|
||||||
const Icon = isMissed ? PhoneOffIcon : PhoneIcon;
|
const Icon = isMissed ? PhoneOffIcon : PhoneIcon;
|
||||||
const tone = isMissed
|
const tone = isMissed
|
||||||
? 'border-rose-500/20 bg-rose-500/10 text-rose-200'
|
? 'border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-200'
|
||||||
: 'border-emerald-500/20 bg-emerald-500/10 text-emerald-200';
|
: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-200';
|
||||||
|
|
||||||
const label =
|
const label =
|
||||||
status === 'ended'
|
status === 'ended'
|
||||||
@@ -390,6 +510,45 @@ function formatDuration(totalSec: number): string {
|
|||||||
return m + ':' + s.toString().padStart(2, '0');
|
return m + ':' + s.toString().padStart(2, '0');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DeliveryTicks({ state }: { state: 'sent' | 'delivered' | 'read' }) {
|
||||||
|
// One checkmark for sent, two for delivered/read. Read shifts color to the
|
||||||
|
// accent to match WhatsApp/Telegram blue-tick convention.
|
||||||
|
const color =
|
||||||
|
state === 'read'
|
||||||
|
? 'text-sky-500 dark:text-sky-400'
|
||||||
|
: 'text-fg-muted';
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-label={
|
||||||
|
state === 'read'
|
||||||
|
? 'Gelesen'
|
||||||
|
: state === 'delivered'
|
||||||
|
? 'Zugestellt'
|
||||||
|
: 'Gesendet'
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
state === 'read'
|
||||||
|
? 'Gelesen'
|
||||||
|
: state === 'delivered'
|
||||||
|
? 'Zugestellt'
|
||||||
|
: 'Gesendet'
|
||||||
|
}
|
||||||
|
className={'flex items-center ' + color}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 16 12" width="14" height="10" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
{state === 'sent' ? (
|
||||||
|
<polyline points="2 7 6 11 14 1" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<polyline points="1 7 5 11 11 2" />
|
||||||
|
<polyline points="6 11 10 11 14 1" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ActionButton({
|
function ActionButton({
|
||||||
label,
|
label,
|
||||||
onClick,
|
onClick,
|
||||||
@@ -408,10 +567,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,87 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getParticipantVolume,
|
||||||
|
setParticipantVolume,
|
||||||
|
subscribeParticipantVolumes,
|
||||||
|
} from '../lib/participantVolumes';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
userId: string;
|
||||||
|
displayName: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MENU_W = 240;
|
||||||
|
const MENU_H = 84;
|
||||||
|
|
||||||
|
export function ParticipantVolumeMenu({
|
||||||
|
userId,
|
||||||
|
displayName,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
onClose,
|
||||||
|
}: Props) {
|
||||||
|
const [volume, setVolume] = useState<number>(() => getParticipantVolume(userId));
|
||||||
|
|
||||||
|
// Re-sync from store in case another menu instance changed the same user.
|
||||||
|
useEffect(() => subscribeParticipantVolumes(() => {
|
||||||
|
setVolume(getParticipantVolume(userId));
|
||||||
|
}), [userId]);
|
||||||
|
|
||||||
|
// Outside click + Esc to close.
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
const onDown = (e: MouseEvent) => {
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
if (target?.closest('[data-volume-menu]')) return;
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
window.addEventListener('mousedown', onDown);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onKey);
|
||||||
|
window.removeEventListener('mousedown', onDown);
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const left = Math.min(Math.max(8, x), window.innerWidth - MENU_W - 8);
|
||||||
|
const top = Math.min(Math.max(8, y), window.innerHeight - MENU_H - 8);
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
data-volume-menu
|
||||||
|
role="dialog"
|
||||||
|
aria-label={'Lautstärke ' + displayName}
|
||||||
|
style={{ left, top, width: MENU_W }}
|
||||||
|
className="fixed z-[80] rounded-xl border border-line bg-surface-2/95 p-3 shadow-xl backdrop-blur-md"
|
||||||
|
>
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-2 text-xs">
|
||||||
|
<span className="truncate font-semibold text-fg">{displayName}</span>
|
||||||
|
<span className="tabular-nums text-fg-muted">
|
||||||
|
{Math.round(volume * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
value={volume}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = Number(e.target.value);
|
||||||
|
setVolume(v);
|
||||||
|
setParticipantVolume(userId, v);
|
||||||
|
}}
|
||||||
|
aria-label={'Lautstärke ' + displayName}
|
||||||
|
className="w-full accent-accent"
|
||||||
|
/>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import {
|
||||||
|
type DisplaySurfaceHint,
|
||||||
|
getPresetParams,
|
||||||
|
getScreenShareSettings,
|
||||||
|
PRESET_ORDER,
|
||||||
|
type ScreenSharePreset,
|
||||||
|
} from '../lib/screenShareSettings';
|
||||||
|
import {
|
||||||
|
MonitorShareIcon,
|
||||||
|
SpinnerIcon,
|
||||||
|
XIcon,
|
||||||
|
} from './icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onStart: (opts: {
|
||||||
|
preset: ScreenSharePreset;
|
||||||
|
displaySurface: DisplaySurfaceHint;
|
||||||
|
framerate: number | null;
|
||||||
|
}) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FRAMERATE_OPTIONS: ReadonlyArray<{ value: number | null; label: string }> = [
|
||||||
|
{ value: null, label: 'Preset-Standard' },
|
||||||
|
{ value: 15, label: '15 fps' },
|
||||||
|
{ value: 30, label: '30 fps' },
|
||||||
|
{ value: 60, label: '60 fps' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Discord-style pre-share dialog. The OS still owns the final source picker
|
||||||
|
// (browser/OS limitation — only Chrome/Edge plus a native plugin can enumerate
|
||||||
|
// windows from JS), but we pre-filter with the `displaySurface` hint and lock
|
||||||
|
// in quality + framerate up-front so the user doesn't have to re-open the
|
||||||
|
// system picker to adjust them mid-call.
|
||||||
|
export function ScreenShareDialog({ open, onClose, onStart }: Props) {
|
||||||
|
const { t } = useTranslation(['app']);
|
||||||
|
const initial = getScreenShareSettings();
|
||||||
|
const [surface, setSurface] = useState<DisplaySurfaceHint>(initial.displaySurface);
|
||||||
|
const [preset, setPreset] = useState<ScreenSharePreset>(initial.preset);
|
||||||
|
const [framerate, setFramerate] = useState<number | null>(initial.framerateOverride);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
async function handleStart() {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await onStart({ preset, displaySurface: surface, framerate });
|
||||||
|
onClose();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : 'screenshare failed');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const presetParams = getPresetParams(preset);
|
||||||
|
const effectiveFps = framerate ?? presetParams.framerate;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t('app:call.share_dialog_title', { defaultValue: 'Bildschirm teilen' })}
|
||||||
|
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 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">
|
||||||
|
<MonitorShareIcon className="h-4 w-4 text-accent" />
|
||||||
|
<h3 className="font-display text-sm font-semibold text-fg">
|
||||||
|
{t('app:call.share_dialog_title', { defaultValue: 'Bildschirm teilen' })}
|
||||||
|
</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="space-y-5 p-5">
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-fg-muted">
|
||||||
|
{t('app:call.share_surface', { defaultValue: 'Quelle' })}
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<SurfaceOption
|
||||||
|
active={surface === null}
|
||||||
|
onClick={() => setSurface(null)}
|
||||||
|
label={t('app:call.share_any', { defaultValue: 'Alle anzeigen' })}
|
||||||
|
sub={t('app:call.share_any_sub', { defaultValue: 'Bildschirm + Fenster' })}
|
||||||
|
/>
|
||||||
|
<SurfaceOption
|
||||||
|
active={surface === 'monitor'}
|
||||||
|
onClick={() => setSurface('monitor')}
|
||||||
|
label={t('app:call.share_monitor', { defaultValue: 'Bildschirm' })}
|
||||||
|
sub={t('app:call.share_monitor_sub', { defaultValue: 'Ganzer Monitor' })}
|
||||||
|
/>
|
||||||
|
<SurfaceOption
|
||||||
|
active={surface === 'window'}
|
||||||
|
onClick={() => setSurface('window')}
|
||||||
|
label={t('app:call.share_window', { defaultValue: 'Fenster' })}
|
||||||
|
sub={t('app:call.share_window_sub', { defaultValue: 'Einzelnes Fenster' })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-fg-muted">
|
||||||
|
{t('app:call.share_quality', { defaultValue: 'Qualität' })}
|
||||||
|
</p>
|
||||||
|
<select
|
||||||
|
value={preset}
|
||||||
|
onChange={(e) => setPreset(e.target.value as ScreenSharePreset)}
|
||||||
|
className="w-full rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
|
||||||
|
>
|
||||||
|
{PRESET_ORDER.map((p) => (
|
||||||
|
<option key={p} value={p}>
|
||||||
|
{getPresetParams(p).label} · ≤ {getPresetParams(p).bitrateKbps} kbps
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-fg-muted">
|
||||||
|
{t('app:call.share_fps', { defaultValue: 'Bildrate' })}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{FRAMERATE_OPTIONS.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={String(opt.value)}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFramerate(opt.value)}
|
||||||
|
className={
|
||||||
|
'cursor-pointer rounded-lg border px-3 py-1.5 text-xs font-medium transition ' +
|
||||||
|
(framerate === opt.value
|
||||||
|
? 'border-accent bg-accent/15 text-accent'
|
||||||
|
: 'border-line bg-surface-2 text-fg hover:bg-surface')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-[11px] text-fg-muted">
|
||||||
|
{t('app:call.share_fps_effective', {
|
||||||
|
defaultValue: 'Effektiv: {{fps}} fps',
|
||||||
|
fps: effectiveFps,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="text-sm text-rose-600 dark:text-rose-300">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-[11px] text-fg-muted">
|
||||||
|
{t('app:call.share_hint', {
|
||||||
|
defaultValue:
|
||||||
|
'Nach "Teilen starten" öffnet das Betriebssystem den Quellen-Picker. Qualität + Bildrate werden bereits angewendet.',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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"
|
||||||
|
onClick={() => void handleStart()}
|
||||||
|
disabled={busy}
|
||||||
|
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:call.share_start', { defaultValue: 'Teilen starten' })}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SurfaceOption({
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
label,
|
||||||
|
sub,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
label: string;
|
||||||
|
sub: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={
|
||||||
|
'flex flex-col items-start gap-0.5 rounded-lg border p-2.5 text-left transition ' +
|
||||||
|
(active
|
||||||
|
? 'border-accent bg-accent/10 text-fg'
|
||||||
|
: 'border-line bg-surface-2 text-fg hover:bg-surface')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="text-xs font-semibold">{label}</span>
|
||||||
|
<span className="text-[10px] text-fg-muted">{sub}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import type { RemoteTrack } from 'livekit-client';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={
|
||||||
|
isFullscreen
|
||||||
|
? 'fixed inset-0 z-[60] flex h-screen w-screen flex-col overflow-hidden border-0 bg-black'
|
||||||
|
: 'flex h-full w-full flex-col 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 h-full w-full flex-1 cursor-zoom-in bg-black object-contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,107 +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,
|
||||||
LogoLockup,
|
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']);
|
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 px-5 pb-3 pt-5">
|
<div className="flex h-10 w-10 items-center justify-center" title="Netralax">
|
||||||
<LogoLockup tone="dark" className="h-8 w-auto" aria-label="Netralax" />
|
<LogoMark className="h-8 w-8" />
|
||||||
</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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { MicIcon, SpinnerIcon, XIcon } from './icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Called with the recorded audio file when the user confirms. The button
|
||||||
|
* ships as a single-attachment message, so the parent can feed it into
|
||||||
|
* the normal send flow. */
|
||||||
|
onComplete: (file: File) => Promise<void> | void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single-button voice recorder. Click to start; shows an inline pill with
|
||||||
|
// elapsed time + stop + cancel while recording. On stop, hands a File to the
|
||||||
|
// parent. No in-place preview yet — we lean on the optimistic message bubble
|
||||||
|
// to appear once the parent sends.
|
||||||
|
const MAX_RECORD_SEC = 60;
|
||||||
|
|
||||||
|
export function VoiceRecorder({ onComplete, disabled = false }: Props) {
|
||||||
|
const [state, setState] = useState<'idle' | 'recording' | 'finalizing'>('idle');
|
||||||
|
const [elapsedSec, setElapsedSec] = useState(0);
|
||||||
|
const [level, setLevel] = useState(0);
|
||||||
|
const recorderRef = useRef<MediaRecorder | null>(null);
|
||||||
|
const chunksRef = useRef<Blob[]>([]);
|
||||||
|
const streamRef = useRef<MediaStream | null>(null);
|
||||||
|
const startedAtRef = useRef<number>(0);
|
||||||
|
const cancelledRef = useRef(false);
|
||||||
|
const audioCtxRef = useRef<AudioContext | null>(null);
|
||||||
|
const analyserRef = useRef<AnalyserNode | null>(null);
|
||||||
|
const rafRef = useRef<number>(0);
|
||||||
|
|
||||||
|
// Tick elapsed + auto-stop at MAX_RECORD_SEC.
|
||||||
|
useEffect(() => {
|
||||||
|
if (state !== 'recording') return;
|
||||||
|
const id = window.setInterval(() => {
|
||||||
|
const sec = Math.floor((Date.now() - startedAtRef.current) / 1000);
|
||||||
|
setElapsedSec(sec);
|
||||||
|
if (sec >= MAX_RECORD_SEC) {
|
||||||
|
const rec = recorderRef.current;
|
||||||
|
if (rec && rec.state !== 'inactive') rec.stop();
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(id);
|
||||||
|
};
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
// Live mic level meter via Web Audio AnalyserNode. RMS on 0..1, smoothed.
|
||||||
|
useEffect(() => {
|
||||||
|
if (state !== 'recording') return;
|
||||||
|
const analyser = analyserRef.current;
|
||||||
|
if (!analyser) return;
|
||||||
|
const buf = new Uint8Array(analyser.frequencyBinCount);
|
||||||
|
let cancelled = false;
|
||||||
|
const tick = () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
analyser.getByteFrequencyData(buf as Uint8Array<ArrayBuffer>);
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < buf.length; i++) sum += buf[i]!;
|
||||||
|
setLevel(sum / (buf.length * 255));
|
||||||
|
rafRef.current = window.requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
rafRef.current = window.requestAnimationFrame(tick);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.cancelAnimationFrame(rafRef.current);
|
||||||
|
};
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
const stopStream = () => {
|
||||||
|
if (streamRef.current) {
|
||||||
|
streamRef.current.getTracks().forEach((t) => t.stop());
|
||||||
|
streamRef.current = null;
|
||||||
|
}
|
||||||
|
if (audioCtxRef.current) {
|
||||||
|
void audioCtxRef.current.close().catch(() => {});
|
||||||
|
audioCtxRef.current = null;
|
||||||
|
}
|
||||||
|
analyserRef.current = null;
|
||||||
|
setLevel(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const start = async () => {
|
||||||
|
if (disabled) return;
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
streamRef.current = stream;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ctx = new (window.AudioContext ||
|
||||||
|
(window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)();
|
||||||
|
const source = ctx.createMediaStreamSource(stream);
|
||||||
|
const analyser = ctx.createAnalyser();
|
||||||
|
analyser.fftSize = 256;
|
||||||
|
analyser.smoothingTimeConstant = 0.4;
|
||||||
|
source.connect(analyser);
|
||||||
|
audioCtxRef.current = ctx;
|
||||||
|
analyserRef.current = analyser;
|
||||||
|
} catch {
|
||||||
|
/* Analyser is best-effort; recording still works without level meter. */
|
||||||
|
}
|
||||||
|
|
||||||
|
const mime = pickMime();
|
||||||
|
const rec = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined);
|
||||||
|
recorderRef.current = rec;
|
||||||
|
chunksRef.current = [];
|
||||||
|
cancelledRef.current = false;
|
||||||
|
|
||||||
|
rec.ondataavailable = (ev: BlobEvent) => {
|
||||||
|
if (ev.data && ev.data.size > 0) chunksRef.current.push(ev.data);
|
||||||
|
};
|
||||||
|
rec.onstop = () => {
|
||||||
|
const blob = new Blob(chunksRef.current, { type: rec.mimeType || 'audio/webm' });
|
||||||
|
chunksRef.current = [];
|
||||||
|
stopStream();
|
||||||
|
if (cancelledRef.current || blob.size === 0) {
|
||||||
|
setState('idle');
|
||||||
|
setElapsedSec(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState('finalizing');
|
||||||
|
const ext = extFor(blob.type);
|
||||||
|
const filename = 'voice-' + new Date().toISOString().replace(/[:.]/g, '-') + '.' + ext;
|
||||||
|
const file = new File([blob], filename, { type: blob.type });
|
||||||
|
void Promise.resolve(onComplete(file)).finally(() => {
|
||||||
|
setState('idle');
|
||||||
|
setElapsedSec(0);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
rec.start();
|
||||||
|
startedAtRef.current = Date.now();
|
||||||
|
setElapsedSec(0);
|
||||||
|
setState('recording');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
stopStream();
|
||||||
|
// Surface device-permission denials or capture failures via console;
|
||||||
|
// the composer doesn't have space for inline errors here and the
|
||||||
|
// browser already shows a system-level permission prompt.
|
||||||
|
console.error('VoiceRecorder.start failed', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirm = () => {
|
||||||
|
const rec = recorderRef.current;
|
||||||
|
if (!rec) return;
|
||||||
|
if (rec.state !== 'inactive') rec.stop();
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancel = () => {
|
||||||
|
cancelledRef.current = true;
|
||||||
|
const rec = recorderRef.current;
|
||||||
|
if (rec && rec.state !== 'inactive') rec.stop();
|
||||||
|
else {
|
||||||
|
stopStream();
|
||||||
|
setState('idle');
|
||||||
|
setElapsedSec(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (state === 'idle') {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void start()}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label="Sprachnachricht aufnehmen"
|
||||||
|
title="Sprachnachricht aufnehmen"
|
||||||
|
className="inline-flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-surface-2 text-fg transition hover:bg-surface-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<MicIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 'finalizing') {
|
||||||
|
return (
|
||||||
|
<div className="inline-flex h-11 items-center gap-2 rounded-lg bg-surface-2 px-3 text-xs text-fg-muted">
|
||||||
|
<SpinnerIcon className="h-4 w-4" />
|
||||||
|
<span>Wird gesendet…</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Visual level: scale from 0..1 → 0..100% width. Floor at 4% so the bar
|
||||||
|
// stays visible when silent.
|
||||||
|
const levelPct = Math.max(4, Math.min(100, Math.round(level * 180)));
|
||||||
|
const remaining = Math.max(0, MAX_RECORD_SEC - elapsedSec);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="inline-flex h-11 items-center gap-2 rounded-lg border border-rose-500/40 bg-rose-500/10 px-3 text-xs font-semibold text-rose-700 dark:text-rose-200">
|
||||||
|
<span className="h-2 w-2 animate-pulse rounded-full bg-rose-500" />
|
||||||
|
<span className="tabular-nums">{formatTime(elapsedSec)}</span>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="relative h-1.5 w-20 overflow-hidden rounded-full bg-rose-500/20"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="absolute inset-y-0 left-0 rounded-full bg-rose-500 transition-[width] duration-75"
|
||||||
|
style={{ width: levelPct + '%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="tabular-nums text-[10px] text-rose-700/70 dark:text-rose-200/70">
|
||||||
|
−{remaining}s
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={cancel}
|
||||||
|
aria-label="Aufnahme abbrechen"
|
||||||
|
title="Abbrechen"
|
||||||
|
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
|
||||||
|
>
|
||||||
|
<XIcon className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={confirm}
|
||||||
|
aria-label="Aufnahme senden"
|
||||||
|
title="Senden"
|
||||||
|
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md bg-accent text-accent-fg transition hover:brightness-110"
|
||||||
|
>
|
||||||
|
<MicIcon className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickMime(): string | null {
|
||||||
|
const candidates = [
|
||||||
|
'audio/webm;codecs=opus',
|
||||||
|
'audio/webm',
|
||||||
|
'audio/ogg;codecs=opus',
|
||||||
|
'audio/mp4',
|
||||||
|
];
|
||||||
|
for (const c of candidates) {
|
||||||
|
if (typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported(c)) return c;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extFor(mime: string): string {
|
||||||
|
if (mime.includes('webm')) return 'webm';
|
||||||
|
if (mime.includes('ogg')) return 'ogg';
|
||||||
|
if (mime.includes('mp4')) return 'm4a';
|
||||||
|
return 'bin';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(sec: number): string {
|
||||||
|
const m = Math.floor(sec / 60);
|
||||||
|
const s = sec % 60;
|
||||||
|
return m + ':' + s.toString().padStart(2, '0');
|
||||||
|
}
|
||||||
@@ -336,6 +336,179 @@ 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 HeadphonesIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Base {...props}>
|
||||||
|
<path d="M3 14v-2a9 9 0 0 1 18 0v2" />
|
||||||
|
<path d="M3 14h4v7H5a2 2 0 0 1-2-2Z" />
|
||||||
|
<path d="M21 14h-4v7h2a2 2 0 0 0 2-2Z" />
|
||||||
|
</Base>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HeadphonesOffIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Base {...props}>
|
||||||
|
<path d="M3 14v-2a9 9 0 0 1 11.3-8.7" />
|
||||||
|
<path d="M21 14v-2a9 9 0 0 0-1.5-5" />
|
||||||
|
<path d="M3 14h4v7H5a2 2 0 0 1-2-2Z" />
|
||||||
|
<path d="M21 14h-4v7h2a2 2 0 0 0 2-2Z" />
|
||||||
|
<path d="M3 3 21 21" />
|
||||||
|
</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
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { findExistingDevice } from '../lib/device';
|
import { findExistingDevice } from '../lib/device';
|
||||||
import { setSecretStoreUser } from '../lib/secretStore';
|
import { setSecretStoreUser } from '../lib/secretStore';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
|
import { registerWebPush } from '../lib/webPush';
|
||||||
|
|
||||||
interface AuthContextValue {
|
interface AuthContextValue {
|
||||||
session: Session | null;
|
session: Session | null;
|
||||||
@@ -54,30 +55,34 @@ 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({ scope: 'local' }).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);
|
||||||
@@ -131,6 +136,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
});
|
});
|
||||||
}, [session, refreshProfile, refreshDevice]);
|
}, [session, refreshProfile, refreshDevice]);
|
||||||
|
|
||||||
|
// Best-effort web-push registration once we know the device id. No-op on
|
||||||
|
// Tauri (uses native notifications) or when VITE_VAPID_PUBLIC_KEY is unset.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!device?.id) return;
|
||||||
|
void registerWebPush(device.id);
|
||||||
|
}, [device?.id]);
|
||||||
|
|
||||||
const signOut = useCallback(async () => {
|
const signOut = useCallback(async () => {
|
||||||
await supabaseSignOut(supabase);
|
await supabaseSignOut(supabase);
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -40,15 +40,20 @@ import { getPttSettings, subscribePttSettings } from '../lib/pttSettings';
|
|||||||
import {
|
import {
|
||||||
getAudioQualityParams,
|
getAudioQualityParams,
|
||||||
getAudioSettings,
|
getAudioSettings,
|
||||||
|
updateAudioSettings,
|
||||||
} from '../lib/audioSettings';
|
} from '../lib/audioSettings';
|
||||||
import {
|
import {
|
||||||
createCallE2EE,
|
createCallE2EE,
|
||||||
getCallE2EESettings,
|
getCallE2EESettings,
|
||||||
isE2EESupported,
|
isE2EESupported,
|
||||||
} from '../lib/callE2EE';
|
} from '../lib/callE2EE';
|
||||||
|
import { getParticipantVolume } from '../lib/participantVolumes';
|
||||||
import {
|
import {
|
||||||
|
type DisplaySurfaceHint,
|
||||||
getPresetParams,
|
getPresetParams,
|
||||||
getScreenShareSettings,
|
getScreenShareSettings,
|
||||||
|
type ScreenSharePreset,
|
||||||
|
updateScreenShareSettings,
|
||||||
} from '../lib/screenShareSettings';
|
} from '../lib/screenShareSettings';
|
||||||
import { devLocalSecretStore } from '../lib/secretStore';
|
import { devLocalSecretStore } from '../lib/secretStore';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
@@ -90,6 +95,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;
|
||||||
@@ -97,20 +106,45 @@ interface CallContextValue {
|
|||||||
isMuted: boolean;
|
isMuted: boolean;
|
||||||
isE2EEActive: boolean;
|
isE2EEActive: boolean;
|
||||||
isScreenSharing: boolean;
|
isScreenSharing: boolean;
|
||||||
|
isCameraEnabled: boolean;
|
||||||
|
isDeafened: boolean;
|
||||||
|
/** identity -> their deafen state, received via data channel. */
|
||||||
|
remoteDeafen: Record<string, boolean>;
|
||||||
// Zero or more remote screen shares (LiveKit supports multiple simultaneous).
|
// Zero or more remote screen shares (LiveKit supports multiple simultaneous).
|
||||||
remoteScreenShares: RemoteScreenShare[];
|
remoteScreenShares: RemoteScreenShare[];
|
||||||
// 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>;
|
||||||
acceptIncoming: () => Promise<void>;
|
acceptIncoming: (override?: CallKind) => Promise<void>;
|
||||||
rejectIncoming: () => void;
|
rejectIncoming: () => void;
|
||||||
hangup: () => Promise<void>;
|
hangup: () => Promise<void>;
|
||||||
toggleMute: () => void;
|
toggleMute: () => void;
|
||||||
toggleScreenShare: () => Promise<void>;
|
toggleScreenShare: () => Promise<void>;
|
||||||
|
startScreenShare: (
|
||||||
|
overrides?: Partial<{
|
||||||
|
preset: ScreenSharePreset;
|
||||||
|
displaySurface: DisplaySurfaceHint;
|
||||||
|
framerate: number | null;
|
||||||
|
}>,
|
||||||
|
) => Promise<void>;
|
||||||
|
stopScreenShare: () => Promise<void>;
|
||||||
|
toggleCamera: () => Promise<void>;
|
||||||
|
toggleDeafen: () => 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);
|
||||||
@@ -133,8 +167,12 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
const [isMuted, setIsMuted] = useState(false);
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
const [isE2EEActive, setIsE2EEActive] = useState(false);
|
const [isE2EEActive, setIsE2EEActive] = useState(false);
|
||||||
const [isScreenSharing, setIsScreenSharing] = useState(false);
|
const [isScreenSharing, setIsScreenSharing] = useState(false);
|
||||||
|
const [isCameraEnabled, setIsCameraEnabled] = useState(false);
|
||||||
|
const [isDeafened, setIsDeafened] = 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);
|
||||||
@@ -149,6 +187,10 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
// For 1:1 calls the set has one entry so behaviour is unchanged; for
|
// For 1:1 calls the set has one entry so behaviour is unchanged; for
|
||||||
// groups, a single reject doesn't terminate the call while others ring.
|
// groups, a single reject doesn't terminate the call while others ring.
|
||||||
const pendingPeersRef = useRef<Set<string>>(new Set());
|
const pendingPeersRef = useRef<Set<string>>(new Set());
|
||||||
|
// identity -> their current deafen state. Populated via LiveKit data
|
||||||
|
// channel messages ({ type: 'presence', deafened: bool }). Exposed as
|
||||||
|
// state so consumer components re-render on change.
|
||||||
|
const [remoteDeafen, setRemoteDeafen] = useState<Record<string, boolean>>({});
|
||||||
const stateRef = useRef<CallState>(state);
|
const stateRef = useRef<CallState>(state);
|
||||||
stateRef.current = state;
|
stateRef.current = state;
|
||||||
// Keep latest conversations accessible from signal-channel closures without
|
// Keep latest conversations accessible from signal-channel closures without
|
||||||
@@ -225,6 +267,10 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
setRemoteParticipants([]);
|
setRemoteParticipants([]);
|
||||||
setRemoteScreenShares([]);
|
setRemoteScreenShares([]);
|
||||||
setIsScreenSharing(false);
|
setIsScreenSharing(false);
|
||||||
|
setIsCameraEnabled(false);
|
||||||
|
setIsDeafened(false);
|
||||||
|
deafenedActive = false;
|
||||||
|
setRemoteDeafen({});
|
||||||
setIsE2EEActive(false);
|
setIsE2EEActive(false);
|
||||||
|
|
||||||
const pres = presenceChannelRef.current;
|
const pres = presenceChannelRef.current;
|
||||||
@@ -445,6 +491,45 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Mute/unmute a camera doesn't publish or unpublish — the publication
|
||||||
|
// stays, just its `muted` flag flips. Without this listener, remote
|
||||||
|
// participants who toggle video mid-call appear as a frozen last frame
|
||||||
|
// or (worse) a black tile on every other client. Bumping the
|
||||||
|
// remoteParticipants state reference forces buildTiles to re-read
|
||||||
|
// `isCameraEnabled` and swap to the avatar placeholder.
|
||||||
|
const bumpParticipants = () => {
|
||||||
|
setRemoteParticipants(Array.from(r.remoteParticipants.values()));
|
||||||
|
};
|
||||||
|
r.on(RoomEvent.TrackMuted, bumpParticipants);
|
||||||
|
r.on(RoomEvent.TrackUnmuted, bumpParticipants);
|
||||||
|
// Remote deafen state is broadcast via the LiveKit data channel. We
|
||||||
|
// store incoming states in `remoteDeafenMapRef` and bump participants
|
||||||
|
// so buildTiles re-reads it.
|
||||||
|
r.on(
|
||||||
|
RoomEvent.DataReceived,
|
||||||
|
(payload: Uint8Array, participant?: RemoteParticipant | undefined) => {
|
||||||
|
if (!participant?.identity) return;
|
||||||
|
try {
|
||||||
|
const text = new TextDecoder().decode(payload);
|
||||||
|
const msg = JSON.parse(text) as { type?: string; deafened?: boolean };
|
||||||
|
if (msg.type === 'presence' && typeof msg.deafened === 'boolean') {
|
||||||
|
const id: string = participant.identity;
|
||||||
|
const deafened: boolean = msg.deafened;
|
||||||
|
setRemoteDeafen((prev) => {
|
||||||
|
if (prev[id] === deafened) return prev;
|
||||||
|
return { ...prev, [id]: deafened };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore malformed */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// When someone joins, re-send our current deafen state so they know.
|
||||||
|
r.on(RoomEvent.ParticipantConnected, () => {
|
||||||
|
void broadcastPresence(r, deafenedActive);
|
||||||
|
});
|
||||||
|
|
||||||
// Track my own screen-share state via LocalTrack events so the toggle
|
// Track my own screen-share state via LocalTrack events so the toggle
|
||||||
// stays in sync if the user stops sharing via the browser's native UI.
|
// stays in sync if the user stops sharing via the browser's native UI.
|
||||||
r.on(RoomEvent.LocalTrackPublished, (publication) => {
|
r.on(RoomEvent.LocalTrackPublished, (publication) => {
|
||||||
@@ -471,12 +556,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);
|
||||||
@@ -484,6 +573,7 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
if (mediaKind === 'video') {
|
if (mediaKind === 'video') {
|
||||||
try {
|
try {
|
||||||
await r.localParticipant.setCameraEnabled(true);
|
await r.localParticipant.setCameraEnabled(true);
|
||||||
|
setIsCameraEnabled(true);
|
||||||
} catch (camErr: unknown) {
|
} catch (camErr: unknown) {
|
||||||
console.error('setCameraEnabled failed', camErr);
|
console.error('setCameraEnabled failed', camErr);
|
||||||
}
|
}
|
||||||
@@ -628,10 +718,16 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
[myId, joinRoom, disconnectRoom],
|
[myId, joinRoom, disconnectRoom],
|
||||||
);
|
);
|
||||||
|
|
||||||
const acceptIncoming = useCallback(async () => {
|
const acceptIncoming = useCallback(
|
||||||
|
async (override?: CallKind) => {
|
||||||
const s = stateRef.current;
|
const s = stateRef.current;
|
||||||
if (s.kind !== 'incoming' || !myId) return;
|
if (s.kind !== 'incoming' || !myId) return;
|
||||||
const { callId, conversationId, mediaKind } = s;
|
const { callId, conversationId } = s;
|
||||||
|
// Caller's `mediaKind` is the INVITE kind (what they started with). The
|
||||||
|
// receiver can accept with audio even if the caller rang as video, or
|
||||||
|
// upgrade an audio invite to video on accept. `override` picks the
|
||||||
|
// receiver's choice.
|
||||||
|
const mediaKind: CallKind = override ?? s.mediaKind;
|
||||||
everConnectedRef.current = false;
|
everConnectedRef.current = false;
|
||||||
setLastCallConversationId(null);
|
setLastCallConversationId(null);
|
||||||
setState({ kind: 'connecting', callId, conversationId, mediaKind });
|
setState({ kind: 'connecting', callId, conversationId, mediaKind });
|
||||||
@@ -644,7 +740,9 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
});
|
});
|
||||||
await disconnectRoom();
|
await disconnectRoom();
|
||||||
}
|
}
|
||||||
}, [myId, joinRoom, disconnectRoom]);
|
},
|
||||||
|
[myId, joinRoom, disconnectRoom],
|
||||||
|
);
|
||||||
|
|
||||||
const rejectIncoming = useCallback(() => {
|
const rejectIncoming = useCallback(() => {
|
||||||
const s = stateRef.current;
|
const s = stateRef.current;
|
||||||
@@ -718,33 +816,129 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toggleScreenShare = useCallback(async () => {
|
const startScreenShare = useCallback(
|
||||||
|
async (
|
||||||
|
overrides?: Partial<{
|
||||||
|
preset: ScreenSharePreset;
|
||||||
|
displaySurface: DisplaySurfaceHint;
|
||||||
|
framerate: number | null;
|
||||||
|
}>,
|
||||||
|
) => {
|
||||||
const r = roomRef.current;
|
const r = roomRef.current;
|
||||||
if (!r) return;
|
if (!r) return;
|
||||||
const lp = r.localParticipant;
|
const lp = r.localParticipant;
|
||||||
const nextOn = !lp.isScreenShareEnabled;
|
if (lp.isScreenShareEnabled) return;
|
||||||
|
|
||||||
|
// Persist the user's choice so subsequent shares use the same config
|
||||||
|
// without re-opening the picker unless they want to change something.
|
||||||
|
const settings = getScreenShareSettings();
|
||||||
|
const preset = overrides?.preset ?? settings.preset;
|
||||||
|
const displaySurface =
|
||||||
|
overrides?.displaySurface !== undefined
|
||||||
|
? overrides.displaySurface
|
||||||
|
: settings.displaySurface;
|
||||||
|
const framerateOverride =
|
||||||
|
overrides?.framerate !== undefined
|
||||||
|
? overrides.framerate
|
||||||
|
: settings.framerateOverride;
|
||||||
|
updateScreenShareSettings({ preset, displaySurface, framerateOverride });
|
||||||
|
|
||||||
|
const ssParams = getPresetParams(preset);
|
||||||
|
const fps = framerateOverride ?? ssParams.framerate;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const ssParams = getPresetParams(getScreenShareSettings().preset);
|
await lp.setScreenShareEnabled(true, {
|
||||||
await lp.setScreenShareEnabled(nextOn, {
|
|
||||||
audio: false,
|
audio: false,
|
||||||
// Omitting `resolution` lets the browser return native source size —
|
|
||||||
// best possible input quality. Fixed presets pass explicit dims so
|
|
||||||
// the encoder has a predictable target.
|
|
||||||
...(ssParams.dims
|
...(ssParams.dims
|
||||||
? {
|
? {
|
||||||
resolution: {
|
resolution: {
|
||||||
width: ssParams.dims.width,
|
width: ssParams.dims.width,
|
||||||
height: ssParams.dims.height,
|
height: ssParams.dims.height,
|
||||||
frameRate: ssParams.framerate,
|
frameRate: fps,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
: {
|
||||||
|
resolution: {
|
||||||
|
width: 3840,
|
||||||
|
height: 2160,
|
||||||
|
frameRate: fps,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
// Hints the OS picker to pre-filter by source kind. `null` = no
|
||||||
|
// filter (show both). Cast because TS lib.dom doesn't know the
|
||||||
|
// field yet on all branches.
|
||||||
|
...(displaySurface
|
||||||
|
? ({ displaySurface } as { displaySurface: DisplaySurfaceHint })
|
||||||
: {}),
|
: {}),
|
||||||
contentHint: 'detail',
|
contentHint: 'detail',
|
||||||
});
|
});
|
||||||
setIsScreenSharing(nextOn);
|
setIsScreenSharing(true);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('setScreenShareEnabled failed', err);
|
console.error('setScreenShareEnabled failed', err);
|
||||||
// User cancelled or permission denied — leave state as-is.
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const stopScreenShare = useCallback(async () => {
|
||||||
|
const r = roomRef.current;
|
||||||
|
if (!r) return;
|
||||||
|
const lp = r.localParticipant;
|
||||||
|
if (!lp.isScreenShareEnabled) return;
|
||||||
|
try {
|
||||||
|
await lp.setScreenShareEnabled(false);
|
||||||
|
setIsScreenSharing(false);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error('stopScreenShare failed', err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Legacy toggle kept for convenience elsewhere — opens/closes with the
|
||||||
|
// last-persisted settings and no picker UI.
|
||||||
|
const toggleScreenShare = useCallback(async () => {
|
||||||
|
const r = roomRef.current;
|
||||||
|
if (!r) return;
|
||||||
|
if (r.localParticipant.isScreenShareEnabled) {
|
||||||
|
await stopScreenShare();
|
||||||
|
} else {
|
||||||
|
await startScreenShare();
|
||||||
|
}
|
||||||
|
}, [startScreenShare, stopScreenShare]);
|
||||||
|
|
||||||
|
const toggleDeafen = useCallback(() => {
|
||||||
|
setIsDeafened((prev) => {
|
||||||
|
const next = !prev;
|
||||||
|
deafenedActive = next;
|
||||||
|
// Apply to every currently-attached remote-audio element. Fresh tracks
|
||||||
|
// that attach during a deafened session are muted in attachTrack above.
|
||||||
|
const els = document.querySelectorAll<HTMLAudioElement>(
|
||||||
|
'audio[data-livekit-track]',
|
||||||
|
);
|
||||||
|
els.forEach((el) => {
|
||||||
|
el.muted = next;
|
||||||
|
});
|
||||||
|
// Broadcast via LiveKit data channel so peers' UIs can show the
|
||||||
|
// headphones-off badge. Data channel works on any LiveKit server
|
||||||
|
// version, unlike `setAttributes` which requires a newer server.
|
||||||
|
const r = roomRef.current;
|
||||||
|
if (r) void broadcastPresence(r, next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleCamera = useCallback(async () => {
|
||||||
|
const r = roomRef.current;
|
||||||
|
if (!r) return;
|
||||||
|
const lp = r.localParticipant;
|
||||||
|
const nextOn = !lp.isCameraEnabled;
|
||||||
|
try {
|
||||||
|
await lp.setCameraEnabled(nextOn);
|
||||||
|
setIsCameraEnabled(nextOn);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error('setCameraEnabled failed', err);
|
||||||
|
// Permission denied / no camera — keep state in sync with actual
|
||||||
|
// publication state so the button doesn't lie.
|
||||||
|
setIsCameraEnabled(lp.isCameraEnabled);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -954,6 +1148,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,
|
||||||
@@ -962,8 +1226,13 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
isMuted,
|
isMuted,
|
||||||
isE2EEActive,
|
isE2EEActive,
|
||||||
isScreenSharing,
|
isScreenSharing,
|
||||||
|
isCameraEnabled,
|
||||||
|
isDeafened,
|
||||||
|
remoteDeafen,
|
||||||
remoteScreenShares,
|
remoteScreenShares,
|
||||||
lastCallConversationId,
|
lastCallConversationId,
|
||||||
|
callMode,
|
||||||
|
focusedId,
|
||||||
startCall,
|
startCall,
|
||||||
joinActiveCall,
|
joinActiveCall,
|
||||||
acceptIncoming,
|
acceptIncoming,
|
||||||
@@ -971,7 +1240,15 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
hangup,
|
hangup,
|
||||||
toggleMute,
|
toggleMute,
|
||||||
toggleScreenShare,
|
toggleScreenShare,
|
||||||
|
startScreenShare,
|
||||||
|
stopScreenShare,
|
||||||
|
toggleCamera,
|
||||||
|
toggleDeafen,
|
||||||
dismissLastCall,
|
dismissLastCall,
|
||||||
|
setCallMode,
|
||||||
|
setFocusedId,
|
||||||
|
setAudioInputDevice,
|
||||||
|
setAudioOutputDevice,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
state,
|
state,
|
||||||
@@ -980,8 +1257,13 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
isMuted,
|
isMuted,
|
||||||
isE2EEActive,
|
isE2EEActive,
|
||||||
isScreenSharing,
|
isScreenSharing,
|
||||||
|
isCameraEnabled,
|
||||||
|
isDeafened,
|
||||||
|
remoteDeafen,
|
||||||
remoteScreenShares,
|
remoteScreenShares,
|
||||||
lastCallConversationId,
|
lastCallConversationId,
|
||||||
|
callMode,
|
||||||
|
focusedId,
|
||||||
startCall,
|
startCall,
|
||||||
joinActiveCall,
|
joinActiveCall,
|
||||||
acceptIncoming,
|
acceptIncoming,
|
||||||
@@ -989,7 +1271,15 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
hangup,
|
hangup,
|
||||||
toggleMute,
|
toggleMute,
|
||||||
toggleScreenShare,
|
toggleScreenShare,
|
||||||
|
startScreenShare,
|
||||||
|
stopScreenShare,
|
||||||
|
toggleCamera,
|
||||||
|
toggleDeafen,
|
||||||
dismissLastCall,
|
dismissLastCall,
|
||||||
|
setCallMode,
|
||||||
|
setFocusedId,
|
||||||
|
setAudioInputDevice,
|
||||||
|
setAudioOutputDevice,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1002,10 +1292,26 @@ export function useCall(): CallContextValue {
|
|||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shared flag so attachTrack (called from LiveKit event listeners, outside the
|
||||||
|
// React component) can apply the current deafen state to freshly-attached
|
||||||
|
// audio elements. Toggled by toggleDeafen in sync with the React state.
|
||||||
|
let deafenedActive = false;
|
||||||
|
|
||||||
|
async function broadcastPresence(room: Room, deafened: boolean): Promise<void> {
|
||||||
|
try {
|
||||||
|
const payload = new TextEncoder().encode(
|
||||||
|
JSON.stringify({ type: 'presence', deafened }),
|
||||||
|
);
|
||||||
|
await room.localParticipant.publishData(payload, { reliable: true });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('broadcastPresence failed', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function attachTrack(
|
function attachTrack(
|
||||||
track: RemoteTrack,
|
track: RemoteTrack,
|
||||||
_publication: RemoteTrackPublication,
|
_publication: RemoteTrackPublication,
|
||||||
_participant: RemoteParticipant,
|
participant: RemoteParticipant,
|
||||||
): void {
|
): void {
|
||||||
if (track.kind === Track.Kind.Audio) {
|
if (track.kind === Track.Kind.Audio) {
|
||||||
const audio = track.attach();
|
const audio = track.attach();
|
||||||
@@ -1013,7 +1319,21 @@ function attachTrack(
|
|||||||
audio.autoplay = true;
|
audio.autoplay = true;
|
||||||
audio.setAttribute('playsinline', 'true');
|
audio.setAttribute('playsinline', 'true');
|
||||||
audio.setAttribute('data-livekit-track', track.sid ?? '');
|
audio.setAttribute('data-livekit-track', track.sid ?? '');
|
||||||
|
if (participant.identity) {
|
||||||
|
audio.setAttribute('data-participant', participant.identity);
|
||||||
|
audio.volume = getParticipantVolume(participant.identity);
|
||||||
|
}
|
||||||
|
if (deafenedActive) audio.muted = true;
|
||||||
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,
|
||||||
@@ -95,7 +99,9 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
// Only flag loading on initial fetch so background re-syncs (visibility
|
||||||
|
// change, online event) don't blank the list each time.
|
||||||
|
setLoading((prev) => (conversationsRef.current.length === 0 ? true : prev));
|
||||||
const convs = await listConversations(supabase);
|
const convs = await listConversations(supabase);
|
||||||
setConversations(convs);
|
setConversations(convs);
|
||||||
const entries = await Promise.all(
|
const entries = await Promise.all(
|
||||||
@@ -151,6 +157,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 +183,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 +215,34 @@ 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.
|
||||||
|
let lastAwakeRefresh = 0;
|
||||||
|
const AWAKE_THROTTLE_MS = 30_000;
|
||||||
|
const onAwake = () => {
|
||||||
|
if (document.visibilityState !== 'visible') return;
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastAwakeRefresh < AWAKE_THROTTLE_MS) return;
|
||||||
|
lastAwakeRefresh = now;
|
||||||
|
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('online', onAwake);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
document.removeEventListener('visibilitychange', 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;
|
||||||
|
}
|
||||||
@@ -24,19 +24,24 @@ function rawFrom(table: string) {
|
|||||||
return (supabase as unknown as { from: (t: string) => any }).from(table);
|
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(
|
export function startConversationKeySync(
|
||||||
ownUserId: string,
|
ownUserId: string,
|
||||||
ownDeviceId: string,
|
ownDeviceId: string,
|
||||||
): () => void {
|
): () => void {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let priv: Uint8Array | null = null;
|
let priv: Uint8Array | null = null;
|
||||||
|
const dedupeKey = ownUserId + ':' + ownDeviceId;
|
||||||
|
|
||||||
void loadDevicePrivateKey(devLocalSecretStore, ownUserId, ownDeviceId).then(async (pk) => {
|
void loadDevicePrivateKey(devLocalSecretStore, ownUserId, ownDeviceId).then(async (pk) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
priv = pk;
|
priv = pk;
|
||||||
if (!priv) return;
|
if (!priv) return;
|
||||||
// Run a full backfill once we have the private key — covers devices that
|
if (backfilledKey.has(dedupeKey)) return;
|
||||||
// registered while we were offline.
|
backfilledKey.add(dedupeKey);
|
||||||
await syncAllExistingGaps({ myUserId: ownUserId, myDeviceId: ownDeviceId, priv });
|
await syncAllExistingGaps({ myUserId: ownUserId, myDeviceId: ownDeviceId, priv });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -164,9 +169,12 @@ async function syncOneConversationGaps(ctx: SyncCtx, convId: string): Promise<vo
|
|||||||
try {
|
try {
|
||||||
await shareConvKeyToDevice(supabase, convId, dev.id, pgHexToBytes(dev.public_key), ownCtx);
|
await shareConvKeyToDevice(supabase, convId, dev.id, pgHexToBytes(dev.public_key), ownCtx);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
// Most common: this device hasn't been wrapped for us yet either, so
|
// Backfill is best-effort. Most common silent failures:
|
||||||
// tryGetConvKey couldn't unwrap. Another peer with the key will fill
|
// - tryGetConvKey couldn't unwrap (another peer will fill the gap).
|
||||||
// the gap when they hit syncAllExistingGaps.
|
// - 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', {
|
console.warn('keySync: shareConvKeyToDevice gap-fill failed', {
|
||||||
convId,
|
convId,
|
||||||
recipient: dev.id,
|
recipient: dev.id,
|
||||||
@@ -176,6 +184,22 @@ async function syncOneConversationGaps(ctx: SyncCtx, convId: string): Promise<vo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
async function wrapForOneDevice(
|
||||||
ctx: SyncCtx,
|
ctx: SyncCtx,
|
||||||
newDeviceId: string,
|
newDeviceId: string,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getCryptoBackend } from '@chat-app/shared/crypto';
|
import { getCryptoBackend } from '@chat-app/shared/crypto';
|
||||||
import sodium from 'libsodium-wrappers';
|
import sodium from 'libsodium-wrappers-sumo';
|
||||||
|
|
||||||
// Encrypts/decrypts the device private key with a user-provided passphrase
|
// 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
|
// so the backup string can be safely written down or stored in a password
|
||||||
@@ -91,3 +91,108 @@ export async function importDeviceKey(
|
|||||||
s.memzero(key);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Recovery code
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Generates a high-entropy code shown to the user once at backup time. The
|
||||||
|
// same payload is encrypted twice — once with the user's passphrase, once
|
||||||
|
// with the recovery code — so either string can decrypt the device key.
|
||||||
|
//
|
||||||
|
// The recovery code is 24 chars from a 32-symbol alphabet (no ambiguous
|
||||||
|
// characters), grouped as 4×6. ~120 bits of entropy.
|
||||||
|
|
||||||
|
const RECOVERY_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||||
|
|
||||||
|
export interface BackupBundle {
|
||||||
|
passphraseBackup: string;
|
||||||
|
recoveryBackup: string;
|
||||||
|
recoveryCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportDeviceBackupWithRecovery(params: {
|
||||||
|
userId: string;
|
||||||
|
deviceId: string;
|
||||||
|
privateKey: Uint8Array;
|
||||||
|
passphrase: string;
|
||||||
|
}): Promise<BackupBundle> {
|
||||||
|
const recoveryCode = await generateRecoveryCode();
|
||||||
|
const [passphraseBackup, recoveryBackup] = await Promise.all([
|
||||||
|
exportDeviceBackup(params),
|
||||||
|
exportDeviceBackup({ ...params, passphrase: recoveryCode }),
|
||||||
|
]);
|
||||||
|
return { passphraseBackup, recoveryBackup, recoveryCode };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateRecoveryCode(): Promise<string> {
|
||||||
|
const s = await ensureSodium();
|
||||||
|
const raw = s.randombytes_buf(24);
|
||||||
|
let out = '';
|
||||||
|
for (let i = 0; i < raw.length; i++) {
|
||||||
|
out += RECOVERY_ALPHABET[raw[i]! % RECOVERY_ALPHABET.length];
|
||||||
|
if ((i + 1) % 6 === 0 && i !== raw.length - 1) out += '-';
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalizes a user-typed recovery code: strips dashes/spaces, uppercases,
|
||||||
|
// maps look-alike characters. Lets users enter the code with imperfect
|
||||||
|
// spacing without rejecting valid input.
|
||||||
|
export function normalizeRecoveryCode(input: string): string {
|
||||||
|
return input
|
||||||
|
.toUpperCase()
|
||||||
|
.replace(/[\s-]/g, '')
|
||||||
|
.split('')
|
||||||
|
.filter((c) => RECOVERY_ALPHABET.includes(c))
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
// Offline text-message outbox.
|
||||||
|
//
|
||||||
|
// When `sendEncryptedMessage` fails (network loss, server 5xx, transient
|
||||||
|
// realtime hiccups) we stash the plaintext in localStorage keyed by
|
||||||
|
// conversation. The UI shows it inline with a "sending…" indicator; the
|
||||||
|
// drain loop retries with exponential backoff until it lands.
|
||||||
|
//
|
||||||
|
// Attachments are intentionally NOT queued — they're too large to persist
|
||||||
|
// and require server-side upload that can't be deferred reliably. The
|
||||||
|
// composer surfaces an immediate error for those.
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'chat.outbox.v1';
|
||||||
|
const MAX_ATTEMPTS = 8;
|
||||||
|
|
||||||
|
export interface OutboxItem {
|
||||||
|
/** Local-only id (never collides with server UUIDs). */
|
||||||
|
id: string;
|
||||||
|
conversationId: string;
|
||||||
|
text: string;
|
||||||
|
replyToId: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
attempts: number;
|
||||||
|
/** Timestamp of the next permitted send attempt. */
|
||||||
|
nextAttemptAt: string;
|
||||||
|
/** Last error message for surface in the UI. */
|
||||||
|
lastError: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Store = Record<string, OutboxItem[]>; // keyed by conversationId
|
||||||
|
type Listener = (byConv: Store) => void;
|
||||||
|
|
||||||
|
function load(): Store {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return {};
|
||||||
|
const parsed: unknown = JSON.parse(raw);
|
||||||
|
if (!parsed || typeof parsed !== 'object') return {};
|
||||||
|
const out: Store = {};
|
||||||
|
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
|
||||||
|
if (Array.isArray(v)) {
|
||||||
|
out[k] = v.filter((x): x is OutboxItem => isItem(x));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isItem(x: unknown): x is OutboxItem {
|
||||||
|
if (!x || typeof x !== 'object') return false;
|
||||||
|
const o = x as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
typeof o.id === 'string' &&
|
||||||
|
typeof o.conversationId === 'string' &&
|
||||||
|
typeof o.text === 'string' &&
|
||||||
|
typeof o.createdAt === 'string'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let store: Store = load();
|
||||||
|
const listeners = new Set<Listener>();
|
||||||
|
|
||||||
|
function persist(): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
|
||||||
|
} catch {
|
||||||
|
/* quota exceeded — ignore; in-memory queue still retries */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function notify(): void {
|
||||||
|
for (const fn of listeners) fn(store);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeOutbox(fn: Listener): () => void {
|
||||||
|
listeners.add(fn);
|
||||||
|
fn(store);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(fn);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOutbox(conversationId: string): OutboxItem[] {
|
||||||
|
return store[conversationId] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enqueueOutbox(args: {
|
||||||
|
conversationId: string;
|
||||||
|
text: string;
|
||||||
|
replyToId: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}): OutboxItem {
|
||||||
|
const item: OutboxItem = {
|
||||||
|
id: 'local-' + crypto.randomUUID(),
|
||||||
|
conversationId: args.conversationId,
|
||||||
|
text: args.text,
|
||||||
|
replyToId: args.replyToId,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
attempts: 0,
|
||||||
|
nextAttemptAt: new Date().toISOString(),
|
||||||
|
lastError: args.error,
|
||||||
|
};
|
||||||
|
const bucket = store[args.conversationId] ?? [];
|
||||||
|
store = { ...store, [args.conversationId]: [...bucket, item] };
|
||||||
|
persist();
|
||||||
|
notify();
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeOutbox(conversationId: string, id: string): void {
|
||||||
|
const bucket = store[conversationId];
|
||||||
|
if (!bucket) return;
|
||||||
|
const next = bucket.filter((x) => x.id !== id);
|
||||||
|
if (next.length === bucket.length) return;
|
||||||
|
if (next.length === 0) {
|
||||||
|
const copy = { ...store };
|
||||||
|
delete copy[conversationId];
|
||||||
|
store = copy;
|
||||||
|
} else {
|
||||||
|
store = { ...store, [conversationId]: next };
|
||||||
|
}
|
||||||
|
persist();
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markAttempt(
|
||||||
|
conversationId: string,
|
||||||
|
id: string,
|
||||||
|
error: string | null,
|
||||||
|
): OutboxItem | null {
|
||||||
|
const bucket = store[conversationId];
|
||||||
|
if (!bucket) return null;
|
||||||
|
const idx = bucket.findIndex((x) => x.id === id);
|
||||||
|
if (idx < 0) return null;
|
||||||
|
const prev = bucket[idx]!;
|
||||||
|
const attempts = prev.attempts + 1;
|
||||||
|
// Exponential backoff: 2^n seconds, capped at 60s.
|
||||||
|
const delaySec = Math.min(60, Math.pow(2, attempts));
|
||||||
|
const next: OutboxItem = {
|
||||||
|
...prev,
|
||||||
|
attempts,
|
||||||
|
lastError: error,
|
||||||
|
nextAttemptAt: new Date(Date.now() + delaySec * 1000).toISOString(),
|
||||||
|
};
|
||||||
|
const nextBucket = [...bucket];
|
||||||
|
nextBucket[idx] = next;
|
||||||
|
store = { ...store, [conversationId]: nextBucket };
|
||||||
|
persist();
|
||||||
|
notify();
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldGiveUp(item: OutboxItem): boolean {
|
||||||
|
return item.attempts >= MAX_ATTEMPTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDue(item: OutboxItem): boolean {
|
||||||
|
return new Date(item.nextAttemptAt).getTime() <= Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function allConversationsWithItems(): string[] {
|
||||||
|
return Object.keys(store);
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// Per-participant output volume overrides. Values are 0..1 (HTMLMediaElement
|
||||||
|
// scale). Persisted so the user's mixing choices survive reconnects.
|
||||||
|
//
|
||||||
|
// The store is intentionally tiny — a flat Record keyed by LiveKit identity
|
||||||
|
// (our `userId`). `attachTrack` reads from here when a remote audio track
|
||||||
|
// first lands; live changes are pushed to any already-attached audio
|
||||||
|
// elements via the `data-participant` attribute selector.
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'call.participantVolumes.v1';
|
||||||
|
const DEFAULT_VOLUME = 1;
|
||||||
|
|
||||||
|
type VolumeMap = Record<string, number>;
|
||||||
|
type Listener = (map: VolumeMap) => void;
|
||||||
|
|
||||||
|
function load(): VolumeMap {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return {};
|
||||||
|
const parsed: unknown = JSON.parse(raw);
|
||||||
|
if (!parsed || typeof parsed !== 'object') return {};
|
||||||
|
const out: VolumeMap = {};
|
||||||
|
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
|
||||||
|
if (typeof v === 'number' && Number.isFinite(v)) {
|
||||||
|
out[k] = clamp(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(v: number): number {
|
||||||
|
if (v < 0) return 0;
|
||||||
|
if (v > 1) return 1;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
let current: VolumeMap = load();
|
||||||
|
const listeners = new Set<Listener>();
|
||||||
|
|
||||||
|
function persist(): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(current));
|
||||||
|
} catch {
|
||||||
|
/* ignore quota */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function notify(): void {
|
||||||
|
for (const fn of listeners) fn(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getParticipantVolume(userId: string): number {
|
||||||
|
return current[userId] ?? DEFAULT_VOLUME;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setParticipantVolume(userId: string, volume: number): void {
|
||||||
|
const next = clamp(volume);
|
||||||
|
if (next === (current[userId] ?? DEFAULT_VOLUME)) return;
|
||||||
|
current = { ...current, [userId]: next };
|
||||||
|
persist();
|
||||||
|
applyToAttachedElements(userId, next);
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeParticipantVolumes(fn: Listener): () => void {
|
||||||
|
listeners.add(fn);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(fn);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply a volume to any audio elements already attached for this user.
|
||||||
|
// Attached elements are tagged with `data-participant` in attachTrack.
|
||||||
|
function applyToAttachedElements(userId: string, volume: number): void {
|
||||||
|
const nodes = document.querySelectorAll<HTMLAudioElement>(
|
||||||
|
'audio[data-participant="' + cssEscape(userId) + '"]',
|
||||||
|
);
|
||||||
|
nodes.forEach((el) => {
|
||||||
|
el.volume = volume;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cssEscape(v: string): string {
|
||||||
|
if (typeof (globalThis as { CSS?: { escape?: (s: string) => string } }).CSS
|
||||||
|
?.escape === 'function') {
|
||||||
|
return (globalThis as { CSS: { escape: (s: string) => string } }).CSS.escape(v);
|
||||||
|
}
|
||||||
|
return v.replace(/"/g, '\\"');
|
||||||
|
}
|
||||||
@@ -14,12 +14,22 @@ export type ScreenSharePreset =
|
|||||||
| '1440p60'
|
| '1440p60'
|
||||||
| '4k60';
|
| '4k60';
|
||||||
|
|
||||||
|
// Browser getDisplayMedia `displaySurface` hint. The OS picker honours this
|
||||||
|
// to pre-filter the source list (monitor = whole display, window = single
|
||||||
|
// window). `null` leaves everything selectable.
|
||||||
|
export type DisplaySurfaceHint = 'monitor' | 'window' | null;
|
||||||
|
|
||||||
export interface ScreenShareSettings {
|
export interface ScreenShareSettings {
|
||||||
preset: ScreenSharePreset;
|
preset: ScreenSharePreset;
|
||||||
|
displaySurface: DisplaySurfaceHint;
|
||||||
|
// User-chosen framerate. `null` falls back to preset's default.
|
||||||
|
framerateOverride: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULTS: ScreenShareSettings = {
|
const DEFAULTS: ScreenShareSettings = {
|
||||||
preset: 'auto',
|
preset: 'auto',
|
||||||
|
displaySurface: null,
|
||||||
|
framerateOverride: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface PresetParams {
|
export interface PresetParams {
|
||||||
@@ -103,6 +113,14 @@ function read(): ScreenShareSettings {
|
|||||||
const parsed = JSON.parse(raw) as Partial<ScreenShareSettings>;
|
const parsed = JSON.parse(raw) as Partial<ScreenShareSettings>;
|
||||||
cached = {
|
cached = {
|
||||||
preset: isPreset(parsed.preset) ? parsed.preset : DEFAULTS.preset,
|
preset: isPreset(parsed.preset) ? parsed.preset : DEFAULTS.preset,
|
||||||
|
displaySurface:
|
||||||
|
parsed.displaySurface === 'monitor' || parsed.displaySurface === 'window'
|
||||||
|
? parsed.displaySurface
|
||||||
|
: DEFAULTS.displaySurface,
|
||||||
|
framerateOverride:
|
||||||
|
typeof parsed.framerateOverride === 'number' && parsed.framerateOverride > 0
|
||||||
|
? parsed.framerateOverride
|
||||||
|
: DEFAULTS.framerateOverride,
|
||||||
};
|
};
|
||||||
return cached;
|
return cached;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { SecretStore } from '@chat-app/shared/auth';
|
import type { SecretStore } from '@chat-app/shared/auth';
|
||||||
import { exists, mkdir, readFile, rename, writeFile } from '@tauri-apps/plugin-fs';
|
import { exists, mkdir, readFile, rename, writeFile } from '@tauri-apps/plugin-fs';
|
||||||
import { appLocalDataDir } from '@tauri-apps/api/path';
|
import { appLocalDataDir } from '@tauri-apps/api/path';
|
||||||
import sodium from 'libsodium-wrappers';
|
// sumo variant ships crypto_pwhash (Argon2id). Standard `libsodium-wrappers`
|
||||||
|
// is the compact build without Argon2 — vault KDF would error otherwise.
|
||||||
|
import sodium from 'libsodium-wrappers-sumo';
|
||||||
|
|
||||||
// Encrypted single-file SecretStore for Tauri. Replaces the flaky
|
// Encrypted single-file SecretStore for Tauri. Replaces the flaky
|
||||||
// tauri-plugin-stronghold implementation.
|
// tauri-plugin-stronghold implementation.
|
||||||
@@ -93,10 +95,13 @@ async function loadOrCreateVault(userId: string): Promise<VaultState> {
|
|||||||
const path = joinPath(dir, fileName);
|
const path = joinPath(dir, fileName);
|
||||||
const tmpPath = path + '.tmp';
|
const tmpPath = path + '.tmp';
|
||||||
|
|
||||||
try {
|
// First-run: AppLocalData dir may not exist yet. `mkdir(recursive)` is
|
||||||
|
// idempotent on macOS/Linux, but we need to surface genuine permission
|
||||||
|
// errors (silent catch masked a previous bug where the dir was never
|
||||||
|
// created and every subsequent writeFile failed with ENOENT).
|
||||||
|
const dirExists = await exists(dir).catch(() => false);
|
||||||
|
if (!dirExists) {
|
||||||
await mkdir(dir, { recursive: true });
|
await mkdir(dir, { recursive: true });
|
||||||
} catch {
|
|
||||||
/* parent likely already exists */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileExists = await exists(path).catch(() => false);
|
const fileExists = await exists(path).catch(() => false);
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// Clean-Rail theme: persisted `.dark` class on <html>. Default follows the
|
||||||
|
// system preference; user-toggle persists to localStorage and wins over it.
|
||||||
|
|
||||||
|
export type Theme = 'light' | 'dark';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'netralax.theme';
|
||||||
|
|
||||||
|
function readStoredTheme(): Theme | null {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (raw === 'light' || raw === 'dark') return raw;
|
||||||
|
} catch {
|
||||||
|
/* localStorage unavailable (private mode / sandbox) */
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeStoredTheme(theme: Theme): void {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, theme);
|
||||||
|
} catch {
|
||||||
|
/* localStorage unavailable */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function systemPrefersDark(): boolean {
|
||||||
|
return typeof window !== 'undefined'
|
||||||
|
&& typeof window.matchMedia === 'function'
|
||||||
|
&& window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInitialTheme(): Theme {
|
||||||
|
return readStoredTheme() ?? (systemPrefersDark() ? 'dark' : 'light');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyTheme(theme: Theme): void {
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.classList.toggle('dark', theme === 'dark');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setThemePersisted(theme: Theme): void {
|
||||||
|
writeStoredTheme(theme);
|
||||||
|
applyTheme(theme);
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import type { AudioTrack, Participant, Room } from 'livekit-client';
|
||||||
|
import { ParticipantEvent, RoomEvent, Track } from 'livekit-client';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
// Real-time speaking ring driven by Web Audio API AnalyserNodes directly on
|
||||||
|
// each participant's audio MediaStreamTrack. LiveKit's own `audioLevel` +
|
||||||
|
// `isSpeaking` are updated by a background monitor (default ~1s) — too
|
||||||
|
// laggy. AnalyserNodes give us raw PCM at browser frame rate, so the ring
|
||||||
|
// lights up within one animation frame of actual speech.
|
||||||
|
//
|
||||||
|
// Hold time of 250ms prevents flicker between words / short pauses.
|
||||||
|
const POLL_MS = 50;
|
||||||
|
const HOLD_MS = 250;
|
||||||
|
const THRESHOLD = 0.03; // RMS on 0..1 — tuned against soft speech
|
||||||
|
const FFT_SIZE = 256;
|
||||||
|
|
||||||
|
interface Probe {
|
||||||
|
ctx: AudioContext;
|
||||||
|
analyser: AnalyserNode;
|
||||||
|
source: MediaStreamAudioSourceNode;
|
||||||
|
buf: Uint8Array;
|
||||||
|
// Cached MediaStreamTrack reference — if a publication swaps tracks
|
||||||
|
// (mute/unmute, device switch) we rebuild the node chain.
|
||||||
|
trackId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeProbe(track: MediaStreamTrack): Probe | null {
|
||||||
|
try {
|
||||||
|
const ctx = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)();
|
||||||
|
const stream = new MediaStream([track]);
|
||||||
|
const source = ctx.createMediaStreamSource(stream);
|
||||||
|
const analyser = ctx.createAnalyser();
|
||||||
|
analyser.fftSize = FFT_SIZE;
|
||||||
|
analyser.smoothingTimeConstant = 0.2;
|
||||||
|
source.connect(analyser);
|
||||||
|
return {
|
||||||
|
ctx,
|
||||||
|
analyser,
|
||||||
|
source,
|
||||||
|
buf: new Uint8Array(analyser.frequencyBinCount),
|
||||||
|
trackId: track.id,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroyProbe(probe: Probe): void {
|
||||||
|
try {
|
||||||
|
probe.source.disconnect();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
void probe.ctx.close();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sampleRms(probe: Probe): number {
|
||||||
|
// `getByteFrequencyData` types expect `Uint8Array<ArrayBuffer>` (no
|
||||||
|
// SharedArrayBuffer). Our `probe.buf` satisfies that at runtime; the cast
|
||||||
|
// sidesteps the TS lib signature quirk.
|
||||||
|
probe.analyser.getByteFrequencyData(probe.buf as Uint8Array<ArrayBuffer>);
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < probe.buf.length; i++) sum += probe.buf[i]!;
|
||||||
|
return sum / (probe.buf.length * 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstAudioTrack(p: Participant): AudioTrack | null {
|
||||||
|
const pubs = p.audioTrackPublications;
|
||||||
|
for (const pub of pubs.values()) {
|
||||||
|
if (pub.kind === Track.Kind.Audio && pub.track) {
|
||||||
|
return pub.track as AudioTrack;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useActiveSpeakers(room: Room | null): Set<string> {
|
||||||
|
const [ids, setIds] = useState<Set<string>>(() => new Set());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!room) {
|
||||||
|
setIds(new Set());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const probes = new Map<string, Probe>();
|
||||||
|
const lastActive = new Map<string, number>();
|
||||||
|
|
||||||
|
const syncProbe = (p: Participant): void => {
|
||||||
|
if (!p.identity) return;
|
||||||
|
const track = firstAudioTrack(p);
|
||||||
|
const mst = track?.mediaStreamTrack ?? null;
|
||||||
|
const existing = probes.get(p.identity);
|
||||||
|
if (!mst) {
|
||||||
|
if (existing) {
|
||||||
|
destroyProbe(existing);
|
||||||
|
probes.delete(p.identity);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (existing && existing.trackId === mst.id) return;
|
||||||
|
if (existing) destroyProbe(existing);
|
||||||
|
const probe = makeProbe(mst);
|
||||||
|
if (probe) probes.set(p.identity, probe);
|
||||||
|
};
|
||||||
|
|
||||||
|
const bind = (p: Participant): void => {
|
||||||
|
p.on(ParticipantEvent.TrackPublished, () => syncProbe(p));
|
||||||
|
p.on(ParticipantEvent.TrackUnpublished, () => syncProbe(p));
|
||||||
|
p.on(ParticipantEvent.TrackSubscribed, () => syncProbe(p));
|
||||||
|
p.on(ParticipantEvent.TrackUnsubscribed, () => syncProbe(p));
|
||||||
|
p.on(ParticipantEvent.TrackMuted, () => syncProbe(p));
|
||||||
|
p.on(ParticipantEvent.TrackUnmuted, () => syncProbe(p));
|
||||||
|
syncProbe(p);
|
||||||
|
};
|
||||||
|
|
||||||
|
bind(room.localParticipant);
|
||||||
|
room.remoteParticipants.forEach(bind);
|
||||||
|
|
||||||
|
const onConnected = (p: Participant) => bind(p);
|
||||||
|
const onDisconnected = (p: Participant) => {
|
||||||
|
if (!p.identity) return;
|
||||||
|
const probe = probes.get(p.identity);
|
||||||
|
if (probe) {
|
||||||
|
destroyProbe(probe);
|
||||||
|
probes.delete(p.identity);
|
||||||
|
}
|
||||||
|
lastActive.delete(p.identity);
|
||||||
|
};
|
||||||
|
room.on(RoomEvent.ParticipantConnected, onConnected);
|
||||||
|
room.on(RoomEvent.ParticipantDisconnected, onDisconnected);
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
// Defensively resync every tick — covers the gap where local mic
|
||||||
|
// finishes publishing between effect mount and the first
|
||||||
|
// TrackPublished event, and handles event misses on Tauri WebView.
|
||||||
|
syncProbe(room.localParticipant);
|
||||||
|
room.remoteParticipants.forEach(syncProbe);
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [id, probe] of probes) {
|
||||||
|
if (sampleRms(probe) > THRESHOLD) lastActive.set(id, now);
|
||||||
|
}
|
||||||
|
const next = new Set<string>();
|
||||||
|
for (const [id, t] of lastActive) {
|
||||||
|
if (now - t <= HOLD_MS) next.add(id);
|
||||||
|
}
|
||||||
|
setIds((prev) => {
|
||||||
|
if (prev.size === next.size && Array.from(prev).every((v) => next.has(v))) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const interval = window.setInterval(tick, POLL_MS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(interval);
|
||||||
|
room.off(RoomEvent.ParticipantConnected, onConnected);
|
||||||
|
room.off(RoomEvent.ParticipantDisconnected, onDisconnected);
|
||||||
|
for (const probe of probes.values()) destroyProbe(probe);
|
||||||
|
probes.clear();
|
||||||
|
};
|
||||||
|
}, [room]);
|
||||||
|
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
import type { RealtimeChannel } from '@supabase/supabase-js';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import { supabase } from './supabase';
|
|
||||||
|
|
||||||
// Observes the `call-presence:<conversationId>` channel for every conversation
|
|
||||||
// the user is a member of and returns the first one (if any) that currently
|
|
||||||
// has peers other than the viewer in the call. Enables a "call is live —
|
|
||||||
// rejoin" affordance in the sidebar for conversations the viewer never
|
|
||||||
// joined, mirroring Discord's active-voice indicator.
|
|
||||||
//
|
|
||||||
// Uses polling on each channel's `presenceState()` to side-step Supabase's
|
|
||||||
// topic-based channel dedupe (see useCallPresence.ts).
|
|
||||||
export function useAnyActiveCall(
|
|
||||||
conversationIds: readonly string[],
|
|
||||||
myId: string | null,
|
|
||||||
): { conversationId: string; userIds: string[] } | null {
|
|
||||||
const [byConv, setByConv] = useState<Record<string, string[]>>({});
|
|
||||||
|
|
||||||
const key = conversationIds.join(',');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (conversationIds.length === 0) {
|
|
||||||
setByConv({});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tracked: Array<{ id: string; ch: RealtimeChannel; owns: boolean }> = [];
|
|
||||||
for (const id of conversationIds) {
|
|
||||||
const ch = supabase.channel('call-presence:' + id, {
|
|
||||||
config: {
|
|
||||||
presence: {
|
|
||||||
key: 'obs-' + Math.random().toString(36).slice(2, 8),
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const owns = ch.state === 'closed';
|
|
||||||
if (owns) void ch.subscribe();
|
|
||||||
tracked.push({ id, ch, owns });
|
|
||||||
}
|
|
||||||
|
|
||||||
const resync = () => {
|
|
||||||
setByConv((prev) => {
|
|
||||||
const next: Record<string, string[]> = {};
|
|
||||||
let changed = false;
|
|
||||||
for (const { id, ch } of tracked) {
|
|
||||||
const presState = ch.presenceState() as Record<
|
|
||||||
string,
|
|
||||||
Array<Record<string, unknown>>
|
|
||||||
>;
|
|
||||||
const ids = new Set<string>();
|
|
||||||
for (const list of Object.values(presState)) {
|
|
||||||
for (const e of list) {
|
|
||||||
const uid = e?.userId;
|
|
||||||
if (typeof uid === 'string') ids.add(uid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const arr = Array.from(ids).sort();
|
|
||||||
next[id] = arr;
|
|
||||||
const prevArr = prev[id] ?? [];
|
|
||||||
if (prevArr.length !== arr.length || !arr.every((v, i) => v === prevArr[i])) {
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Also pick up removed conv ids.
|
|
||||||
if (!changed && Object.keys(prev).length !== Object.keys(next).length) {
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
return changed ? next : prev;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
resync();
|
|
||||||
const pollId = window.setInterval(resync, 1500);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.clearInterval(pollId);
|
|
||||||
for (const { ch, owns } of tracked) {
|
|
||||||
if (owns) void supabase.removeChannel(ch);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [key]);
|
|
||||||
|
|
||||||
for (const id of conversationIds) {
|
|
||||||
const users = byConv[id] ?? [];
|
|
||||||
const others = myId ? users.filter((u) => u !== myId) : users;
|
|
||||||
if (others.length > 0) {
|
|
||||||
return { conversationId: id, userIds: others };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -6,11 +6,15 @@ import { supabase } from './supabase';
|
|||||||
// the set of userIds currently in that call. Independent of whether the
|
// the set of userIds currently in that call. Independent of whether the
|
||||||
// viewer is in the room — used to show "Active call · Join" affordances.
|
// viewer is in the room — used to show "Active call · Join" affordances.
|
||||||
//
|
//
|
||||||
// Supabase realtime 2.103+ dedupes channels by topic: calling
|
// Why presence-callbacks + no-removeChannel:
|
||||||
// `supabase.channel(topic)` returns an existing channel if one already exists.
|
// - Supabase realtime 2.103+ dedupes channels by topic. CallContext owns
|
||||||
// That means an observer can't safely register `.on('presence', ...)` because
|
// the same channel during a live call to track its own presence.
|
||||||
// the tracker (CallContext) may have already subscribed it. We side-step this
|
// - If this hook removed the channel on unmount (e.g. when ConversationHeader
|
||||||
// by polling `presenceState()` on the (shared or fresh) channel.
|
// hides during an active call), the live tracking connection dies and the
|
||||||
|
// banner never shows the rejoin option afterwards.
|
||||||
|
// - Binding our own presence callbacks (sync/join/leave) ensures the local
|
||||||
|
// presence state is updated even when CallContext also listens on the same
|
||||||
|
// channel — multiple callbacks can co-exist.
|
||||||
export function useCallPresence(conversationId: string | undefined): string[] {
|
export function useCallPresence(conversationId: string | undefined): string[] {
|
||||||
const [activeUserIds, setActiveUserIds] = useState<string[]>([]);
|
const [activeUserIds, setActiveUserIds] = useState<string[]>([]);
|
||||||
|
|
||||||
@@ -24,23 +28,13 @@ export function useCallPresence(conversationId: string | undefined): string[] {
|
|||||||
config: {
|
config: {
|
||||||
presence: {
|
presence: {
|
||||||
key: 'observer-' + Math.random().toString(36).slice(2, 8),
|
key: 'observer-' + Math.random().toString(36).slice(2, 8),
|
||||||
// Supabase realtime only sends presence_state/diff events to a
|
// Server only routes presence to channels that opt-in. Without this
|
||||||
// channel that has presence enabled. Without `enabled: true` (and
|
// flag we'd receive zero presence_state/presence_diff messages.
|
||||||
// no `.on('presence', ...)` bindings) the server treats the channel
|
|
||||||
// as non-presence and `presenceState()` never populates.
|
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// If Supabase returned a fresh (closed) channel, we own it and must
|
|
||||||
// subscribe/remove it. If it returned an already-subscribed channel
|
|
||||||
// (tracker owns it), we just read state.
|
|
||||||
const ownsChannel = channel.state === 'closed';
|
|
||||||
if (ownsChannel) {
|
|
||||||
void channel.subscribe();
|
|
||||||
}
|
|
||||||
|
|
||||||
const resync = () => {
|
const resync = () => {
|
||||||
const state = channel.presenceState() as Record<string, Array<Record<string, unknown>>>;
|
const state = channel.presenceState() as Record<string, Array<Record<string, unknown>>>;
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
@@ -57,14 +51,31 @@ export function useCallPresence(conversationId: string | undefined): string[] {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Supabase realtime forbids `.on()` after `.subscribe()`. So we only bind
|
||||||
|
// callbacks when we own the subscribe (channel was 'closed'). For deduped
|
||||||
|
// shared channels, the polling fallback below covers state changes.
|
||||||
|
if (channel.state === 'closed') {
|
||||||
|
channel
|
||||||
|
.on('presence', { event: 'sync' }, resync)
|
||||||
|
.on('presence', { event: 'join' }, resync)
|
||||||
|
.on('presence', { event: 'leave' }, resync);
|
||||||
|
void channel.subscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial poll covers the case where the channel is already 'joined' (we
|
||||||
|
// dedupe'd onto an existing subscription) and won't fire a fresh sync.
|
||||||
resync();
|
resync();
|
||||||
const pollId = window.setInterval(resync, 1500);
|
// Belt-and-suspenders: a slow poll catches any drift between callback and
|
||||||
|
// server state (e.g. if a presence event was dropped during reconnect).
|
||||||
|
const pollId = window.setInterval(resync, 3000);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.clearInterval(pollId);
|
window.clearInterval(pollId);
|
||||||
if (ownsChannel) {
|
// Don't removeChannel — the channel is shared with CallContext (and any
|
||||||
void supabase.removeChannel(channel);
|
// sibling observers) via Supabase's topic dedupe. Removing it would
|
||||||
}
|
// tear down a live call's presence tracker. The channel naturally
|
||||||
|
// outlives the hook; leaks are bounded by the small number of
|
||||||
|
// conversations a user holds open in a session.
|
||||||
};
|
};
|
||||||
}, [conversationId]);
|
}, [conversationId]);
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,16 @@ import {
|
|||||||
import { bytesToPgHex, pgBytesToBytes } from '@chat-app/shared/supabase';
|
import { bytesToPgHex, pgBytesToBytes } from '@chat-app/shared/supabase';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
enqueueOutbox,
|
||||||
|
getOutbox,
|
||||||
|
isDue,
|
||||||
|
markAttempt,
|
||||||
|
type OutboxItem,
|
||||||
|
removeOutbox,
|
||||||
|
shouldGiveUp,
|
||||||
|
subscribeOutbox,
|
||||||
|
} from './messageOutbox';
|
||||||
import { devLocalSecretStore } from './secretStore';
|
import { devLocalSecretStore } from './secretStore';
|
||||||
import { supabase } from './supabase';
|
import { supabase } from './supabase';
|
||||||
|
|
||||||
@@ -51,12 +61,28 @@ function rowToMessage(row: Record<string, unknown>): MessageWithCipher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useConversationMessages({ conversationId, userId, deviceId }: Args): State & {
|
export function useConversationMessages({ conversationId, userId, deviceId }: Args): State & {
|
||||||
send: (text: string, images?: File[]) => Promise<void>;
|
send: (text: string, images?: File[], replyToId?: string | null) => Promise<void>;
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
|
pending: OutboxItem[];
|
||||||
|
retryPending: (id: string) => void;
|
||||||
|
cancelPending: (id: string) => void;
|
||||||
} {
|
} {
|
||||||
const [state, setState] = useState<State>({ messages: [], loading: true, error: null });
|
const [state, setState] = useState<State>({ messages: [], loading: true, error: null });
|
||||||
|
const [pending, setPending] = useState<OutboxItem[]>(() =>
|
||||||
|
conversationId ? getOutbox(conversationId) : [],
|
||||||
|
);
|
||||||
const privateKeyRef = useRef<Uint8Array | null>(null);
|
const privateKeyRef = useRef<Uint8Array | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!conversationId) {
|
||||||
|
setPending([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return subscribeOutbox((byConv) => {
|
||||||
|
setPending(byConv[conversationId] ?? []);
|
||||||
|
});
|
||||||
|
}, [conversationId]);
|
||||||
|
|
||||||
// Load own private key once per (user, device).
|
// Load own private key once per (user, device).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
privateKeyRef.current = null;
|
privateKeyRef.current = null;
|
||||||
@@ -85,7 +111,12 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
if (!conversationId) return;
|
if (!conversationId) return;
|
||||||
try {
|
try {
|
||||||
setState((prev) => ({ ...prev, loading: true }));
|
// Only show the loading spinner on the *initial* fetch — subsequent
|
||||||
|
// refreshes (focus/visibility) replace messages in-place to avoid
|
||||||
|
// flickering an empty state on every wake.
|
||||||
|
setState((prev) =>
|
||||||
|
prev.messages.length === 0 ? { ...prev, loading: true } : prev,
|
||||||
|
);
|
||||||
const rows = await fetchConversationMessages(supabase, conversationId);
|
const rows = await fetchConversationMessages(supabase, conversationId);
|
||||||
const decrypted = await decryptBatch(rows);
|
const decrypted = await decryptBatch(rows);
|
||||||
setState({ messages: decrypted, loading: false, error: null });
|
setState({ messages: decrypted, loading: false, error: null });
|
||||||
@@ -192,18 +223,71 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
return { ...prev, messages: next };
|
return { ...prev, messages: next };
|
||||||
});
|
});
|
||||||
if (partial.editedAt && !partial.deletedAt) {
|
if (partial.editedAt && !partial.deletedAt) {
|
||||||
const [decrypted] = await decryptBatch([partial]);
|
// Realtime bytea encoding varies (base64 vs hex, even null for
|
||||||
|
// unchanged columns on some configs). Refetch via REST to get the
|
||||||
|
// canonical `\x…` hex then decrypt — same pattern as handleInsert.
|
||||||
|
if (!conversationId || !deviceId) return;
|
||||||
|
let decrypted: DecryptedMessage | null = null;
|
||||||
|
for (let attempt = 0; attempt < 6; attempt++) {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('messages')
|
||||||
|
.select(
|
||||||
|
'id, conversation_id, sender_id, sender_device_id, reply_to_id, edited_at, deleted_at, created_at, ciphertext, nonce, key_version',
|
||||||
|
)
|
||||||
|
.eq('id', partial.id)
|
||||||
|
.maybeSingle();
|
||||||
|
if (error) {
|
||||||
|
console.warn('handleUpdate refetch failed', error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!data) {
|
||||||
|
await new Promise((r) => window.setTimeout(r, 120 * (attempt + 1)));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const r = data as unknown as {
|
||||||
|
id: string;
|
||||||
|
conversation_id: string;
|
||||||
|
sender_id: string;
|
||||||
|
sender_device_id: string | null;
|
||||||
|
reply_to_id: string | null;
|
||||||
|
edited_at: string | null;
|
||||||
|
deleted_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
ciphertext: string;
|
||||||
|
nonce: string;
|
||||||
|
key_version: number;
|
||||||
|
};
|
||||||
|
const msg: MessageWithCipher = {
|
||||||
|
id: r.id,
|
||||||
|
conversationId: r.conversation_id,
|
||||||
|
senderId: r.sender_id,
|
||||||
|
senderDeviceId: r.sender_device_id,
|
||||||
|
replyToId: r.reply_to_id,
|
||||||
|
editedAt: r.edited_at,
|
||||||
|
deletedAt: r.deleted_at,
|
||||||
|
createdAt: r.created_at,
|
||||||
|
ciphertext: pgBytesToBytes(String(r.ciphertext)),
|
||||||
|
nonce: pgBytesToBytes(String(r.nonce)),
|
||||||
|
keyVersion: r.key_version,
|
||||||
|
};
|
||||||
|
const [d] = await decryptBatch([msg]);
|
||||||
|
if (d) {
|
||||||
|
decrypted = d;
|
||||||
|
if (d.plaintext !== null) break;
|
||||||
|
}
|
||||||
|
await new Promise((r) => window.setTimeout(r, 200 * (attempt + 1)));
|
||||||
|
}
|
||||||
if (!decrypted) return;
|
if (!decrypted) return;
|
||||||
setState((prev) => {
|
setState((prev) => {
|
||||||
const idx = prev.messages.findIndex((m) => m.id === decrypted.id);
|
const idx = prev.messages.findIndex((m) => m.id === decrypted!.id);
|
||||||
if (idx === -1) return prev;
|
if (idx === -1) return prev;
|
||||||
const next = [...prev.messages];
|
const next = [...prev.messages];
|
||||||
next[idx] = decrypted;
|
next[idx] = decrypted!;
|
||||||
return { ...prev, messages: next };
|
return { ...prev, messages: next };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[decryptBatch],
|
[conversationId, deviceId, decryptBatch],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDelete = useCallback((row: Record<string, unknown>) => {
|
const handleDelete = useCallback((row: Record<string, unknown>) => {
|
||||||
@@ -256,18 +340,86 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
)
|
)
|
||||||
.subscribe();
|
.subscribe();
|
||||||
|
|
||||||
|
// Refresh + reconnect on wake from background throttle (mostly Windows
|
||||||
|
// WebView2). Without this, messages inserted while the window is
|
||||||
|
// minimised never arrive until the user explicitly reloads. Throttled to
|
||||||
|
// at most once per AWAKE_THROTTLE_MS so the inevitable cluster of
|
||||||
|
// visibility/online events on focus does not cause a flicker storm.
|
||||||
|
let lastAwakeRefresh = 0;
|
||||||
|
const AWAKE_THROTTLE_MS = 30_000;
|
||||||
|
const onAwake = () => {
|
||||||
|
if (document.visibilityState !== 'visible') return;
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastAwakeRefresh < AWAKE_THROTTLE_MS) return;
|
||||||
|
lastAwakeRefresh = now;
|
||||||
|
void refresh();
|
||||||
|
try {
|
||||||
|
channel.subscribe();
|
||||||
|
} catch {
|
||||||
|
/* already live */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('visibilitychange', onAwake);
|
||||||
|
window.addEventListener('online', onAwake);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
document.removeEventListener('visibilitychange', onAwake);
|
||||||
|
window.removeEventListener('online', onAwake);
|
||||||
void supabase.removeChannel(channel);
|
void supabase.removeChannel(channel);
|
||||||
};
|
};
|
||||||
}, [conversationId, userId, deviceId, refresh, handleInsert, handleUpdate, handleDelete]);
|
}, [conversationId, userId, deviceId, refresh, handleInsert, handleUpdate, handleDelete]);
|
||||||
|
|
||||||
|
const sendText = useCallback(
|
||||||
|
async (convId: string, uid: string, did: string, priv: Uint8Array, text: string, replyToId: string | null): Promise<void> => {
|
||||||
|
const msg = await sendEncryptedMessage({
|
||||||
|
client: supabase,
|
||||||
|
conversationId: convId,
|
||||||
|
plaintext: text,
|
||||||
|
senderUserId: uid,
|
||||||
|
senderDeviceId: did,
|
||||||
|
senderPrivateKey: priv,
|
||||||
|
...(replyToId ? { replyToId } : {}),
|
||||||
|
});
|
||||||
|
setState((prev) => {
|
||||||
|
if (prev.messages.some((m) => m.id === msg.id)) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
messages: [
|
||||||
|
...prev.messages,
|
||||||
|
{ ...msg, plaintext: text } as DecryptedMessage,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const send = useCallback(
|
const send = useCallback(
|
||||||
async (text: string, images: File[] = []) => {
|
async (text: string, images: File[] = [], replyToId: string | null = null) => {
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
if ((!trimmed && images.length === 0) || !conversationId || !userId || !deviceId) return;
|
if ((!trimmed && images.length === 0) || !conversationId || !userId || !deviceId) return;
|
||||||
const priv = privateKeyRef.current;
|
const priv = privateKeyRef.current;
|
||||||
if (!priv) throw new Error('private key not loaded');
|
if (!priv) throw new Error('private key not loaded');
|
||||||
|
|
||||||
|
// Text-only path is retryable — if the network is down or the server
|
||||||
|
// rejects transiently, stash in the outbox and keep the UI optimistic.
|
||||||
|
// Attachments can't be deferred (large payloads, uploaded separately),
|
||||||
|
// so those still surface the error immediately.
|
||||||
|
if (images.length === 0) {
|
||||||
|
try {
|
||||||
|
await sendText(conversationId, userId, deviceId, priv, trimmed, replyToId);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'send failed';
|
||||||
|
enqueueOutbox({
|
||||||
|
conversationId,
|
||||||
|
text: trimmed,
|
||||||
|
replyToId,
|
||||||
|
error: msg,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Upload + encrypt each image. Collect handles + raw blob nonces
|
// 1. Upload + encrypt each image. Collect handles + raw blob nonces
|
||||||
// (so the public attachment row can reference the blob-level nonce).
|
// (so the public attachment row can reference the blob-level nonce).
|
||||||
const handles: AttachmentHandle[] = [];
|
const handles: AttachmentHandle[] = [];
|
||||||
@@ -299,6 +451,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
senderDeviceId: deviceId,
|
senderDeviceId: deviceId,
|
||||||
senderPrivateKey: priv,
|
senderPrivateKey: priv,
|
||||||
...(handles.length > 0 ? { attachmentHandles: handles } : {}),
|
...(handles.length > 0 ? { attachmentHandles: handles } : {}),
|
||||||
|
...(replyToId ? { replyToId } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. Optimistic insert — we already have the plaintext in hand and the
|
// 3. Optimistic insert — we already have the plaintext in hand and the
|
||||||
@@ -328,10 +481,104 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
await insertAttachmentRow(supabase, msg.id, h, blobNonce);
|
await insertAttachmentRow(supabase, msg.id, h, blobNonce);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[conversationId, userId, deviceId],
|
[conversationId, userId, deviceId, sendText],
|
||||||
);
|
);
|
||||||
|
|
||||||
return useMemo(() => ({ ...state, send, refresh }), [state, send, refresh]);
|
// Drain outbox: retry due items, remove on success, record attempt on fail.
|
||||||
|
// Runs on online event, window focus, and a 10s interval.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!conversationId || !userId || !deviceId) return;
|
||||||
|
|
||||||
|
let draining = false;
|
||||||
|
const drain = async (): Promise<void> => {
|
||||||
|
if (draining) return;
|
||||||
|
const priv = privateKeyRef.current;
|
||||||
|
if (!priv) return;
|
||||||
|
if (typeof navigator !== 'undefined' && navigator.onLine === false) return;
|
||||||
|
draining = true;
|
||||||
|
try {
|
||||||
|
const items = getOutbox(conversationId).filter(isDue);
|
||||||
|
for (const item of items) {
|
||||||
|
if (shouldGiveUp(item)) continue;
|
||||||
|
try {
|
||||||
|
await sendText(
|
||||||
|
conversationId,
|
||||||
|
userId,
|
||||||
|
deviceId,
|
||||||
|
priv,
|
||||||
|
item.text,
|
||||||
|
item.replyToId,
|
||||||
|
);
|
||||||
|
removeOutbox(conversationId, item.id);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
markAttempt(
|
||||||
|
conversationId,
|
||||||
|
item.id,
|
||||||
|
err instanceof Error ? err.message : 'send failed',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
draining = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const interval = window.setInterval(() => {
|
||||||
|
void drain();
|
||||||
|
}, 10_000);
|
||||||
|
const onOnline = () => void drain();
|
||||||
|
window.addEventListener('online', onOnline);
|
||||||
|
window.addEventListener('focus', onOnline);
|
||||||
|
// Kick once immediately on mount for stale queued items.
|
||||||
|
void drain();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(interval);
|
||||||
|
window.removeEventListener('online', onOnline);
|
||||||
|
window.removeEventListener('focus', onOnline);
|
||||||
|
};
|
||||||
|
}, [conversationId, userId, deviceId, sendText]);
|
||||||
|
|
||||||
|
const retryPending = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
if (!conversationId) return;
|
||||||
|
// Force due now; the drain loop will pick it up on next tick.
|
||||||
|
markAttempt(conversationId, id, null);
|
||||||
|
// Manual kick: mutate nextAttemptAt by re-enqueuing? Simpler — just
|
||||||
|
// trigger a drain-ish by queueing a microtask. The interval picks up
|
||||||
|
// due items within 10s, but for UX we also eagerly try here.
|
||||||
|
const priv = privateKeyRef.current;
|
||||||
|
if (!priv || !userId || !deviceId) return;
|
||||||
|
const item = getOutbox(conversationId).find((x) => x.id === id);
|
||||||
|
if (!item) return;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await sendText(conversationId, userId, deviceId, priv, item.text, item.replyToId);
|
||||||
|
removeOutbox(conversationId, id);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
markAttempt(
|
||||||
|
conversationId,
|
||||||
|
id,
|
||||||
|
err instanceof Error ? err.message : 'send failed',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
[conversationId, userId, deviceId, sendText],
|
||||||
|
);
|
||||||
|
|
||||||
|
const cancelPending = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
if (!conversationId) return;
|
||||||
|
removeOutbox(conversationId, id);
|
||||||
|
},
|
||||||
|
[conversationId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({ ...state, send, refresh, pending, retryPending, cancelPending }),
|
||||||
|
[state, send, refresh, pending, retryPending, cancelPending],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Best-effort image dimension probe. Falls back silently on non-images.
|
// Best-effort image dimension probe. Falls back silently on non-images.
|
||||||
|
|||||||
@@ -44,7 +44,29 @@ export function useFriendships(userId: string | undefined): FriendshipsState & {
|
|||||||
})
|
})
|
||||||
.subscribe();
|
.subscribe();
|
||||||
|
|
||||||
|
// Windows WebView2 throttles background sockets — refresh on wake.
|
||||||
|
// Throttled + visibility-only so a normal click into the window does not
|
||||||
|
// re-fetch on every focus.
|
||||||
|
let lastAwakeRefresh = 0;
|
||||||
|
const AWAKE_THROTTLE_MS = 30_000;
|
||||||
|
const onAwake = () => {
|
||||||
|
if (document.visibilityState !== 'visible') return;
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastAwakeRefresh < AWAKE_THROTTLE_MS) return;
|
||||||
|
lastAwakeRefresh = now;
|
||||||
|
void refresh();
|
||||||
|
try {
|
||||||
|
channel.subscribe();
|
||||||
|
} catch {
|
||||||
|
/* already live */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('visibilitychange', onAwake);
|
||||||
|
window.addEventListener('online', onAwake);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
document.removeEventListener('visibilitychange', onAwake);
|
||||||
|
window.removeEventListener('online', onAwake);
|
||||||
void supabase.removeChannel(channel);
|
void supabase.removeChannel(channel);
|
||||||
};
|
};
|
||||||
}, [userId, refresh]);
|
}, [userId, refresh]);
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import {
|
||||||
|
listGroupDeliveriesForMessages,
|
||||||
|
listGroupReadsForMessages,
|
||||||
|
} from '@chat-app/shared/chat';
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { supabase } from './supabase';
|
||||||
|
|
||||||
|
export interface GroupReceiptState {
|
||||||
|
// message_id → set of user ids who have delivered/read.
|
||||||
|
deliveredByMessage: Map<string, Set<string>>;
|
||||||
|
readByMessage: Map<string, Set<string>>;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregated delivery + read receipts for group conversations. Returns the
|
||||||
|
// set of user ids per message; consumers join with conversation members to
|
||||||
|
// figure out who has not yet acknowledged.
|
||||||
|
export function useGroupReceipts(
|
||||||
|
messageIds: string[],
|
||||||
|
selfUserId: string | undefined,
|
||||||
|
active: boolean,
|
||||||
|
): GroupReceiptState {
|
||||||
|
const idsKey = useMemo(() => messageIds.join(','), [messageIds]);
|
||||||
|
const [deliveredByMessage, setDelivered] = useState<Map<string, Set<string>>>(new Map());
|
||||||
|
const [readByMessage, setRead] = useState<Map<string, Set<string>>>(new Map());
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
if (!active || !selfUserId || messageIds.length === 0) {
|
||||||
|
setDelivered(new Map());
|
||||||
|
setRead(new Map());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const [delivered, read] = await Promise.all([
|
||||||
|
listGroupDeliveriesForMessages(supabase, messageIds, selfUserId),
|
||||||
|
listGroupReadsForMessages(supabase, messageIds, selfUserId),
|
||||||
|
]);
|
||||||
|
setDelivered(toIdSets(delivered));
|
||||||
|
setRead(toIdSets(read));
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('useGroupReceipts refresh failed', err);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [active, selfUserId, idsKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
if (!active || !selfUserId) return;
|
||||||
|
|
||||||
|
// Listen on both tables. RLS already enforces visibility.
|
||||||
|
const reads = supabase
|
||||||
|
.channel('group-reads:' + selfUserId)
|
||||||
|
.on(
|
||||||
|
'postgres_changes',
|
||||||
|
{ event: 'INSERT', schema: 'public', table: 'message_reads' },
|
||||||
|
() => {
|
||||||
|
void refresh();
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.subscribe();
|
||||||
|
const deliveries = supabase
|
||||||
|
.channel('group-deliv:' + selfUserId)
|
||||||
|
.on(
|
||||||
|
'postgres_changes',
|
||||||
|
{ event: 'INSERT', schema: 'public', table: 'message_deliveries' },
|
||||||
|
() => {
|
||||||
|
void refresh();
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.subscribe();
|
||||||
|
return () => {
|
||||||
|
void supabase.removeChannel(reads);
|
||||||
|
void supabase.removeChannel(deliveries);
|
||||||
|
};
|
||||||
|
}, [active, selfUserId, refresh]);
|
||||||
|
|
||||||
|
return { deliveredByMessage, readByMessage, refresh };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toIdSets(input: Map<string, Map<string, string>>): Map<string, Set<string>> {
|
||||||
|
const out = new Map<string, Set<string>>();
|
||||||
|
for (const [mid, inner] of input) {
|
||||||
|
out.set(mid, new Set(inner.keys()));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import {
|
||||||
|
listPeerDeliveriesForMessages,
|
||||||
|
markMessagesDelivered,
|
||||||
|
} from '@chat-app/shared/chat';
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { supabase } from './supabase';
|
||||||
|
|
||||||
|
// Tracks peer delivery acknowledgements for our own messages. Single peer
|
||||||
|
// (DM). For groups this would need a per-user map; keep parity with
|
||||||
|
// `useMessageReads` for now.
|
||||||
|
export function useMessageDeliveries(
|
||||||
|
messageIds: string[],
|
||||||
|
peerUserId: string | undefined,
|
||||||
|
): { peerDeliveredSet: Set<string>; refresh: () => Promise<void> } {
|
||||||
|
const idsKey = useMemo(() => messageIds.join(','), [messageIds]);
|
||||||
|
const [peerDeliveredSet, setPeerDeliveredSet] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
if (!peerUserId || messageIds.length === 0) {
|
||||||
|
setPeerDeliveredSet(new Set());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const s = await listPeerDeliveriesForMessages(supabase, messageIds, peerUserId);
|
||||||
|
setPeerDeliveredSet(s);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error('listPeerDeliveriesForMessages failed', err);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [peerUserId, idsKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
if (!peerUserId) return;
|
||||||
|
const channel = supabase
|
||||||
|
.channel('deliveries:' + peerUserId)
|
||||||
|
.on(
|
||||||
|
'postgres_changes',
|
||||||
|
{
|
||||||
|
event: 'INSERT',
|
||||||
|
schema: 'public',
|
||||||
|
table: 'message_deliveries',
|
||||||
|
filter: 'user_id=eq.' + peerUserId,
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
void refresh();
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.subscribe();
|
||||||
|
return () => {
|
||||||
|
void supabase.removeChannel(channel);
|
||||||
|
};
|
||||||
|
}, [peerUserId, refresh]);
|
||||||
|
|
||||||
|
return { peerDeliveredSet, refresh };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markDelivered(messageIds: string[]): Promise<void> {
|
||||||
|
await markMessagesDelivered(supabase, messageIds);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// Web Push subscription registration. Browser-only; Tauri WebView2 / WebKit
|
||||||
|
// do not install service workers. The Tauri build relies on native OS
|
||||||
|
// notifications via osNotify.ts instead.
|
||||||
|
//
|
||||||
|
// Flow:
|
||||||
|
// 1. Register /sw.js if not already.
|
||||||
|
// 2. Subscribe with the VAPID public key (Vite injects it via env).
|
||||||
|
// 3. Persist {endpoint, keys} JSON-encoded into push_tokens.token for the
|
||||||
|
// current device. Server-side fan-out reads this row to send pushes.
|
||||||
|
|
||||||
|
import { isTauriRuntime } from './globalShortcut';
|
||||||
|
import { supabase } from './supabase';
|
||||||
|
|
||||||
|
const VAPID_PUBLIC_KEY: string | undefined =
|
||||||
|
(import.meta as unknown as { env?: { VITE_VAPID_PUBLIC_KEY?: string } }).env
|
||||||
|
?.VITE_VAPID_PUBLIC_KEY;
|
||||||
|
|
||||||
|
export async function registerWebPush(deviceId: string): Promise<void> {
|
||||||
|
// Tauri uses native notifications — no service worker.
|
||||||
|
if (isTauriRuntime()) return;
|
||||||
|
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return;
|
||||||
|
if (!('PushManager' in window)) return;
|
||||||
|
if (!VAPID_PUBLIC_KEY) {
|
||||||
|
console.warn('VITE_VAPID_PUBLIC_KEY not set — push disabled');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const reg = await navigator.serviceWorker.register('/sw.js');
|
||||||
|
await navigator.serviceWorker.ready;
|
||||||
|
let sub = await reg.pushManager.getSubscription();
|
||||||
|
if (!sub) {
|
||||||
|
const permission = await Notification.requestPermission();
|
||||||
|
if (permission !== 'granted') return;
|
||||||
|
sub = await reg.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY) as BufferSource,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = JSON.stringify({
|
||||||
|
endpoint: sub.endpoint,
|
||||||
|
keys: sub.toJSON().keys ?? {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('push_tokens')
|
||||||
|
.upsert(
|
||||||
|
{ device_id: deviceId, platform: 'web' as never, token },
|
||||||
|
{ onConflict: 'device_id' },
|
||||||
|
);
|
||||||
|
if (error) {
|
||||||
|
console.warn('push_tokens upsert failed', error);
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error('registerWebPush failed', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unregisterWebPush(deviceId: string): Promise<void> {
|
||||||
|
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return;
|
||||||
|
try {
|
||||||
|
const reg = await navigator.serviceWorker.getRegistration();
|
||||||
|
if (reg) {
|
||||||
|
const sub = await reg.pushManager.getSubscription();
|
||||||
|
if (sub) await sub.unsubscribe();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await supabase.from('push_tokens').delete().eq('device_id', deviceId);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('push_tokens delete failed', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// VAPID public key arrives as URL-safe base64; PushManager expects a raw byte
|
||||||
|
// array. Standard conversion.
|
||||||
|
function urlBase64ToUint8Array(base64: string): Uint8Array {
|
||||||
|
const padding = '='.repeat((4 - (base64.length % 4)) % 4);
|
||||||
|
const padded = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const raw = atob(padded);
|
||||||
|
const out = new Uint8Array(raw.length);
|
||||||
|
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
|
type AdminAuditEntry,
|
||||||
|
type AdminConversationRow,
|
||||||
type AdminProfileFlag,
|
type AdminProfileFlag,
|
||||||
type AdminProfileRow,
|
type AdminProfileRow,
|
||||||
type AdminSetting,
|
type AdminSetting,
|
||||||
@@ -6,8 +8,11 @@ import {
|
|||||||
deleteInvite,
|
deleteInvite,
|
||||||
type InviteRecord,
|
type InviteRecord,
|
||||||
listAdminSettings,
|
listAdminSettings,
|
||||||
|
listAllConversations,
|
||||||
listAllProfiles,
|
listAllProfiles,
|
||||||
|
listAuditLog,
|
||||||
listInvites,
|
listInvites,
|
||||||
|
logAdminAction,
|
||||||
setInviteDisabled,
|
setInviteDisabled,
|
||||||
setUserFlag,
|
setUserFlag,
|
||||||
updateAdminSetting,
|
updateAdminSetting,
|
||||||
@@ -16,6 +21,7 @@ import { extractErrorCode } from '@chat-app/shared/i18n';
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { Avatar } from '../components/Avatar';
|
||||||
import {
|
import {
|
||||||
AlertIcon,
|
AlertIcon,
|
||||||
CopyIcon,
|
CopyIcon,
|
||||||
@@ -30,20 +36,26 @@ export function AdminPage() {
|
|||||||
const [settings, setSettings] = useState<AdminSetting[]>([]);
|
const [settings, setSettings] = useState<AdminSetting[]>([]);
|
||||||
const [invites, setInvites] = useState<InviteRecord[]>([]);
|
const [invites, setInvites] = useState<InviteRecord[]>([]);
|
||||||
const [users, setUsers] = useState<AdminProfileRow[]>([]);
|
const [users, setUsers] = useState<AdminProfileRow[]>([]);
|
||||||
|
const [conversations, setConversations] = useState<AdminConversationRow[]>([]);
|
||||||
|
const [audit, setAudit] = useState<AdminAuditEntry[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const [s, i, u] = await Promise.all([
|
const [s, i, u, c, a] = await Promise.all([
|
||||||
listAdminSettings(supabase),
|
listAdminSettings(supabase),
|
||||||
listInvites(supabase),
|
listInvites(supabase),
|
||||||
listAllProfiles(supabase),
|
listAllProfiles(supabase),
|
||||||
|
listAllConversations(supabase),
|
||||||
|
listAuditLog(supabase, 50),
|
||||||
]);
|
]);
|
||||||
setSettings(s);
|
setSettings(s);
|
||||||
setInvites(i);
|
setInvites(i);
|
||||||
setUsers(u);
|
setUsers(u);
|
||||||
|
setConversations(c);
|
||||||
|
setAudit(a);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const code = extractErrorCode(err);
|
const code = extractErrorCode(err);
|
||||||
@@ -64,9 +76,10 @@ export function AdminPage() {
|
|||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="min-h-full bg-surface-3 text-fg">
|
||||||
<div className="mx-auto flex max-w-4xl flex-col gap-6 px-6 py-8">
|
<div className="mx-auto flex max-w-4xl flex-col gap-6 px-6 py-8">
|
||||||
<header>
|
<header>
|
||||||
<h1 className="font-display text-2xl font-semibold tracking-tight text-white">
|
<h1 className="font-display text-2xl font-semibold tracking-tight text-fg">
|
||||||
{t('app:admin.title')}
|
{t('app:admin.title')}
|
||||||
</h1>
|
</h1>
|
||||||
</header>
|
</header>
|
||||||
@@ -74,33 +87,36 @@ export function AdminPage() {
|
|||||||
{error && (
|
{error && (
|
||||||
<div
|
<div
|
||||||
role="alert"
|
role="alert"
|
||||||
className="flex items-start gap-3 rounded-lg border border-rose-500/20 bg-rose-500/10 p-3 text-sm text-rose-100"
|
className="flex items-start gap-3 rounded-lg border border-rose-500/30 bg-rose-500/10 p-3 text-sm text-rose-700 dark:text-rose-100"
|
||||||
>
|
>
|
||||||
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" />
|
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-500 dark:text-rose-400" />
|
||||||
<p className="min-w-0 flex-1 break-words">{error}</p>
|
<p className="min-w-0 flex-1 break-words">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center gap-2 text-sm text-neutral-500">
|
<div className="flex items-center gap-2 text-sm text-fg-muted">
|
||||||
<SpinnerIcon className="h-4 w-4 text-brand-400" />
|
<SpinnerIcon className="h-4 w-4 text-accent" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<SettingsSection settings={settings} onRefresh={refresh} />
|
<SettingsSection settings={settings} onRefresh={refresh} />
|
||||||
<InvitesSection invites={invites} onRefresh={refresh} />
|
<InvitesSection invites={invites} onRefresh={refresh} />
|
||||||
<UsersSection users={users} onRefresh={refresh} />
|
<UsersSection users={users} onRefresh={refresh} />
|
||||||
|
<ConversationsSection conversations={conversations} />
|
||||||
|
<AuditSection entries={audit} users={users} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Section({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) {
|
function Section({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<section className="rounded-2xl border border-white/10 bg-ink-900/60 p-5 backdrop-blur-xl">
|
<section className="rounded-2xl border border-line bg-surface-2 p-5">
|
||||||
<header className="mb-4 flex items-center justify-between">
|
<header className="mb-4 flex items-center justify-between">
|
||||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-neutral-400">{title}</h2>
|
<h2 className="text-xs font-semibold uppercase tracking-[0.1em] text-fg-muted">{title}</h2>
|
||||||
{action}
|
{action}
|
||||||
</header>
|
</header>
|
||||||
{children}
|
{children}
|
||||||
@@ -199,7 +215,7 @@ function InvitesSection({ invites, onRefresh }: { invites: InviteRecord[]; onRef
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => void handleCreate()}
|
onClick={() => void handleCreate()}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-brand-500/90 px-3 py-1.5 text-xs font-semibold text-white transition 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.5 text-xs font-semibold text-accent-fg transition hover:brightness-110 disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{busy ? <SpinnerIcon className="h-3.5 w-3.5" /> : <PlusIcon className="h-3.5 w-3.5" />}
|
{busy ? <SpinnerIcon className="h-3.5 w-3.5" /> : <PlusIcon className="h-3.5 w-3.5" />}
|
||||||
<span>{t('app:admin.invites_create')}</span>
|
<span>{t('app:admin.invites_create')}</span>
|
||||||
@@ -207,11 +223,11 @@ function InvitesSection({ invites, onRefresh }: { invites: InviteRecord[]; onRef
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{invites.length === 0 ? (
|
{invites.length === 0 ? (
|
||||||
<p className="text-sm text-neutral-500">{t('app:admin.invites_empty')}</p>
|
<p className="text-sm text-fg-muted">{t('app:admin.invites_empty')}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left text-sm">
|
<table className="w-full text-left text-sm">
|
||||||
<thead className="text-[10px] uppercase tracking-wide text-neutral-500">
|
<thead className="text-[10px] uppercase tracking-[0.1em] text-fg-muted">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="py-2 pr-3">{t('app:admin.invite_col_code')}</th>
|
<th className="py-2 pr-3">{t('app:admin.invite_col_code')}</th>
|
||||||
<th className="py-2 pr-3">{t('app:admin.invite_col_uses')}</th>
|
<th className="py-2 pr-3">{t('app:admin.invite_col_uses')}</th>
|
||||||
@@ -220,7 +236,7 @@ function InvitesSection({ invites, onRefresh }: { invites: InviteRecord[]; onRef
|
|||||||
<th className="py-2" />
|
<th className="py-2" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-white/5">
|
<tbody className="divide-y divide-line">
|
||||||
{invites.map((inv) => {
|
{invites.map((inv) => {
|
||||||
const expired = inv.expiresAt ? new Date(inv.expiresAt) < new Date() : false;
|
const expired = inv.expiresAt ? new Date(inv.expiresAt) < new Date() : false;
|
||||||
const status = inv.disabled
|
const status = inv.disabled
|
||||||
@@ -229,10 +245,10 @@ function InvitesSection({ invites, onRefresh }: { invites: InviteRecord[]; onRef
|
|||||||
? t('app:admin.invite_status_expired')
|
? t('app:admin.invite_status_expired')
|
||||||
: t('app:admin.invite_status_active');
|
: t('app:admin.invite_status_active');
|
||||||
const statusColor = inv.disabled
|
const statusColor = inv.disabled
|
||||||
? 'text-rose-300'
|
? 'text-rose-600 dark:text-rose-300'
|
||||||
: expired
|
: expired
|
||||||
? 'text-amber-300'
|
? 'text-amber-600 dark:text-amber-300'
|
||||||
: 'text-emerald-300';
|
: 'text-emerald-600 dark:text-emerald-300';
|
||||||
const usesText = inv.usesLimit
|
const usesText = inv.usesLimit
|
||||||
? inv.usesCount + '/' + inv.usesLimit
|
? inv.usesCount + '/' + inv.usesLimit
|
||||||
: inv.usesCount + '/∞';
|
: inv.usesCount + '/∞';
|
||||||
@@ -241,9 +257,9 @@ function InvitesSection({ invites, onRefresh }: { invites: InviteRecord[]; onRef
|
|||||||
: t('app:admin.invite_expires_never');
|
: t('app:admin.invite_expires_never');
|
||||||
return (
|
return (
|
||||||
<tr key={inv.code} className="py-2">
|
<tr key={inv.code} className="py-2">
|
||||||
<td className="py-2 pr-3 font-mono text-xs">{inv.code}</td>
|
<td className="py-2 pr-3 font-mono text-xs text-fg">{inv.code}</td>
|
||||||
<td className="py-2 pr-3 text-xs text-neutral-400">{usesText}</td>
|
<td className="py-2 pr-3 text-xs text-fg-muted">{usesText}</td>
|
||||||
<td className="py-2 pr-3 text-xs text-neutral-400">{expiresText}</td>
|
<td className="py-2 pr-3 text-xs text-fg-muted">{expiresText}</td>
|
||||||
<td className={'py-2 pr-3 text-xs font-medium ' + statusColor}>{status}</td>
|
<td className={'py-2 pr-3 text-xs font-medium ' + statusColor}>{status}</td>
|
||||||
<td className="py-2 text-right">
|
<td className="py-2 text-right">
|
||||||
<div className="inline-flex gap-1">
|
<div className="inline-flex gap-1">
|
||||||
@@ -260,7 +276,7 @@ function InvitesSection({ invites, onRefresh }: { invites: InviteRecord[]; onRef
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void handleToggle(inv.code, inv.disabled)}
|
onClick={() => void handleToggle(inv.code, inv.disabled)}
|
||||||
className="rounded-md border border-white/10 bg-white/5 px-2 py-1 text-[11px] font-medium text-neutral-300 transition hover:bg-white/10"
|
className="rounded-md border border-line bg-surface-3 px-2 py-1 text-[11px] font-medium text-fg transition hover:brightness-95"
|
||||||
>
|
>
|
||||||
{inv.disabled ? t('app:admin.invites_enable') : t('app:admin.invites_disable')}
|
{inv.disabled ? t('app:admin.invites_enable') : t('app:admin.invites_disable')}
|
||||||
</button>
|
</button>
|
||||||
@@ -292,6 +308,13 @@ function UsersSection({ users, onRefresh }: { users: AdminProfileRow[]; onRefres
|
|||||||
async function toggle(userId: string, flag: AdminProfileFlag, value: boolean) {
|
async function toggle(userId: string, flag: AdminProfileFlag, value: boolean) {
|
||||||
try {
|
try {
|
||||||
await setUserFlag(supabase, userId, flag, value);
|
await setUserFlag(supabase, userId, flag, value);
|
||||||
|
// Best-effort audit; don't fail the UI if the log write fails.
|
||||||
|
void logAdminAction(
|
||||||
|
supabase,
|
||||||
|
'user.set_flag',
|
||||||
|
{ type: 'user', id: userId },
|
||||||
|
{ flag, value },
|
||||||
|
).catch(() => undefined);
|
||||||
await onRefresh();
|
await onRefresh();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -301,20 +324,19 @@ function UsersSection({ users, onRefresh }: { users: AdminProfileRow[]; onRefres
|
|||||||
return (
|
return (
|
||||||
<Section title={t('app:admin.users_title')}>
|
<Section title={t('app:admin.users_title')}>
|
||||||
{users.length === 0 ? (
|
{users.length === 0 ? (
|
||||||
<p className="text-sm text-neutral-500">{t('app:admin.users_empty')}</p>
|
<p className="text-sm text-fg-muted">{t('app:admin.users_empty')}</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="divide-y divide-white/5">
|
<ul className="divide-y divide-line">
|
||||||
{users.map((u) => {
|
{users.map((u) => {
|
||||||
const letter =
|
|
||||||
(u.displayName ?? u.username ?? '?').trim().charAt(0).toUpperCase() || '?';
|
|
||||||
return (
|
return (
|
||||||
<li key={u.userId} className="flex items-center gap-3 py-3">
|
<li key={u.userId} className="flex items-center gap-3 py-3">
|
||||||
<div className="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">
|
<Avatar
|
||||||
{letter}
|
displayName={u.displayName ?? u.username}
|
||||||
</div>
|
className="h-9 w-9 text-sm"
|
||||||
|
/>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-sm font-semibold text-white">{u.displayName}</p>
|
<p className="truncate text-sm font-semibold text-fg">{u.displayName}</p>
|
||||||
<p className="truncate text-xs text-neutral-500">@{u.username}</p>
|
<p className="truncate text-xs text-fg-muted">@{u.username}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<FlagChip
|
<FlagChip
|
||||||
@@ -343,6 +365,106 @@ function UsersSection({ users, onRefresh }: { users: AdminProfileRow[]; onRefres
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Conversations ---------------------------------------------------------
|
||||||
|
|
||||||
|
function ConversationsSection({ conversations }: { conversations: AdminConversationRow[] }) {
|
||||||
|
if (conversations.length === 0) {
|
||||||
|
return (
|
||||||
|
<Section title="Unterhaltungen">
|
||||||
|
<p className="text-sm text-fg-muted">Keine Unterhaltungen.</p>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Section title={'Unterhaltungen (' + conversations.length + ')'}>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead className="text-[10px] uppercase tracking-[0.1em] text-fg-muted">
|
||||||
|
<tr>
|
||||||
|
<th className="py-2 pr-3">Name/ID</th>
|
||||||
|
<th className="py-2 pr-3">Typ</th>
|
||||||
|
<th className="py-2 pr-3">Members</th>
|
||||||
|
<th className="py-2 pr-3">Letzte Nachricht</th>
|
||||||
|
<th className="py-2 pr-3">Erstellt</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-line">
|
||||||
|
{conversations.map((c) => (
|
||||||
|
<tr key={c.id}>
|
||||||
|
<td className="py-2 pr-3 font-mono text-[11px] text-fg">
|
||||||
|
{c.name ?? c.id.slice(0, 12)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-3 text-xs text-fg-muted">{c.type}</td>
|
||||||
|
<td className="py-2 pr-3 text-xs text-fg">{c.memberCount}</td>
|
||||||
|
<td className="py-2 pr-3 text-xs text-fg-muted">
|
||||||
|
{c.lastMessageAt
|
||||||
|
? new Date(c.lastMessageAt).toLocaleString()
|
||||||
|
: '—'}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-3 text-xs text-fg-muted">
|
||||||
|
{new Date(c.createdAt).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Audit log -------------------------------------------------------------
|
||||||
|
|
||||||
|
function AuditSection({
|
||||||
|
entries,
|
||||||
|
users,
|
||||||
|
}: {
|
||||||
|
entries: AdminAuditEntry[];
|
||||||
|
users: AdminProfileRow[];
|
||||||
|
}) {
|
||||||
|
const nameByUserId = new Map(users.map((u) => [u.userId, u.displayName]));
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return (
|
||||||
|
<Section title="Audit-Log">
|
||||||
|
<p className="text-sm text-fg-muted">Keine Einträge.</p>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Section title={'Audit-Log (letzte ' + entries.length + ')'}>
|
||||||
|
<ul className="divide-y divide-line text-sm">
|
||||||
|
{entries.map((e) => (
|
||||||
|
<li key={e.id} className="flex items-start gap-3 py-2">
|
||||||
|
<span className="w-32 shrink-0 font-mono text-[10px] text-fg-muted">
|
||||||
|
{new Date(e.createdAt).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-xs">
|
||||||
|
<span className="font-semibold text-fg">
|
||||||
|
{e.actorId ? nameByUserId.get(e.actorId) ?? e.actorId.slice(0, 8) : '—'}
|
||||||
|
</span>{' '}
|
||||||
|
<span className="text-accent">{e.action}</span>
|
||||||
|
{e.targetId && (
|
||||||
|
<span className="text-fg-muted">
|
||||||
|
{' '}
|
||||||
|
on {e.targetType}:{e.targetId.slice(0, 8)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{Object.keys(e.metadata).length > 0 && (
|
||||||
|
<p className="mt-0.5 font-mono text-[10px] text-fg-muted">
|
||||||
|
{JSON.stringify(e.metadata)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Bits ------------------------------------------------------------------
|
// --- Bits ------------------------------------------------------------------
|
||||||
|
|
||||||
function Toggle({
|
function Toggle({
|
||||||
@@ -361,8 +483,8 @@ function Toggle({
|
|||||||
return (
|
return (
|
||||||
<label className="flex cursor-pointer items-start justify-between gap-4">
|
<label className="flex cursor-pointer items-start justify-between gap-4">
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="block text-sm text-neutral-200">{label}</span>
|
<span className="block text-sm text-fg">{label}</span>
|
||||||
{hint && <span className="mt-1 block text-xs text-neutral-500">{hint}</span>}
|
{hint && <span className="mt-1 block text-xs text-fg-muted">{hint}</span>}
|
||||||
</span>
|
</span>
|
||||||
<span className="relative mt-0.5 inline-flex h-6 w-11 shrink-0">
|
<span className="relative mt-0.5 inline-flex h-6 w-11 shrink-0">
|
||||||
<input
|
<input
|
||||||
@@ -372,8 +494,8 @@ function Toggle({
|
|||||||
onChange={(e) => onChange(e.target.checked)}
|
onChange={(e) => onChange(e.target.checked)}
|
||||||
className="peer sr-only"
|
className="peer sr-only"
|
||||||
/>
|
/>
|
||||||
<span className="inline-block h-6 w-11 rounded-full bg-neutral-700 transition peer-checked:bg-brand-500/70 peer-disabled:opacity-50" />
|
<span className="inline-block h-6 w-11 rounded-full bg-surface transition peer-checked:bg-accent peer-disabled:opacity-50" />
|
||||||
<span className="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition peer-checked:translate-x-5" />
|
<span className="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white shadow transition peer-checked:translate-x-5" />
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
@@ -399,8 +521,8 @@ function IconButton({
|
|||||||
className={
|
className={
|
||||||
'flex h-7 w-7 cursor-pointer items-center justify-center rounded-md border transition focus:outline-none focus-visible:ring-2 ' +
|
'flex h-7 w-7 cursor-pointer items-center justify-center rounded-md border transition focus:outline-none focus-visible:ring-2 ' +
|
||||||
(tone === 'danger'
|
(tone === 'danger'
|
||||||
? 'border-rose-500/30 bg-rose-500/10 text-rose-200 hover:bg-rose-500/20 focus-visible:ring-rose-400/40'
|
? 'border-rose-500/40 bg-rose-500/10 text-rose-600 hover:bg-rose-500/20 focus-visible:ring-rose-400/40 dark:text-rose-300'
|
||||||
: 'border-white/10 bg-white/5 text-neutral-300 hover:bg-white/10 focus-visible:ring-brand-400/40')
|
: 'border-line bg-surface-3 text-fg hover:brightness-95 focus-visible:ring-accent/40')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -421,15 +543,15 @@ function FlagChip({
|
|||||||
}) {
|
}) {
|
||||||
const activeClass =
|
const activeClass =
|
||||||
tone === 'danger'
|
tone === 'danger'
|
||||||
? 'border-rose-400/40 bg-rose-500/20 text-rose-100'
|
? 'border-rose-500/40 bg-rose-500/20 text-rose-700 dark:text-rose-100'
|
||||||
: 'border-brand-400/40 bg-brand-500/20 text-brand-100';
|
: 'border-accent/40 bg-accent/20 text-accent';
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onToggle(!active)}
|
onClick={() => onToggle(!active)}
|
||||||
className={
|
className={
|
||||||
'cursor-pointer rounded-full border px-2.5 py-0.5 text-[11px] font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' +
|
'cursor-pointer rounded-full border px-2.5 py-0.5 text-[11px] font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||||
(active ? activeClass : 'border-white/10 bg-white/5 text-neutral-400 hover:bg-white/10')
|
(active ? activeClass : 'border-line bg-surface-3 text-fg-muted hover:brightness-95')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ type UiState =
|
|||||||
| { kind: 'sent'; email: string }
|
| { kind: 'sent'; email: string }
|
||||||
| { kind: 'error'; message: string };
|
| { kind: 'error'; message: string };
|
||||||
|
|
||||||
const USERNAME_PATTERN = /^[a-z0-9_]{3,32}$/;
|
const USERNAME_PATTERN = /^[A-Za-z0-9_]{3,32}$/;
|
||||||
|
|
||||||
export function AuthPage() {
|
export function AuthPage() {
|
||||||
const { session } = useAuth();
|
const { session } = useAuth();
|
||||||
@@ -314,7 +314,7 @@ function FormCard({
|
|||||||
required
|
required
|
||||||
placeholder={t('auth:fields.username_placeholder')}
|
placeholder={t('auth:fields.username_placeholder')}
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => onUsernameChange(e.target.value.toLowerCase())}
|
onChange={(e) => onUsernameChange(e.target.value)}
|
||||||
pattern={USERNAME_PATTERN.source}
|
pattern={USERNAME_PATTERN.source}
|
||||||
className="w-full rounded-lg border border-white/10 bg-ink-800 py-2.5 pl-10 pr-3 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
|
className="w-full rounded-lg border border-white/10 bg-ink-800 py-2.5 pl-10 pr-3 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,11 +1,25 @@
|
|||||||
import { acceptDm, type ConversationSummary } from '@chat-app/shared/chat';
|
import {
|
||||||
|
acceptDm,
|
||||||
|
type ConversationSummary,
|
||||||
|
isConversationMuted,
|
||||||
|
} from '@chat-app/shared/chat';
|
||||||
import { extractErrorCode } from '@chat-app/shared/i18n';
|
import { extractErrorCode } from '@chat-app/shared/i18n';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { NavLink, Outlet, useParams } from 'react-router-dom';
|
import { NavLink, Outlet, useParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { ConversationRowMenu } from '../components/ConversationRowMenu';
|
||||||
import { CreateGroupDialog } from '../components/CreateGroupDialog';
|
import { CreateGroupDialog } from '../components/CreateGroupDialog';
|
||||||
import { ChatBubbleIcon, PlusIcon, SpinnerIcon, UsersIcon } from '../components/icons';
|
import {
|
||||||
|
AddUserIcon,
|
||||||
|
ArchiveIcon,
|
||||||
|
BellOffIcon,
|
||||||
|
ChatBubbleIcon,
|
||||||
|
SearchIcon,
|
||||||
|
SpinnerIcon,
|
||||||
|
UsersIcon,
|
||||||
|
} from '../components/icons';
|
||||||
|
import { UserBar } from '../components/UserBar';
|
||||||
import { useConversationsContext } from '../context/ConversationsContext';
|
import { useConversationsContext } from '../context/ConversationsContext';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
|
|
||||||
@@ -13,6 +27,8 @@ export function ChatsPage() {
|
|||||||
const { conversations, loading, error, refresh, unread } = useConversationsContext();
|
const { conversations, loading, error, refresh, unread } = useConversationsContext();
|
||||||
const { id: activeId } = useParams<{ id: string }>();
|
const { id: activeId } = useParams<{ id: string }>();
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
|
|
||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
return [...conversations].sort((a, b) => {
|
return [...conversations].sort((a, b) => {
|
||||||
@@ -22,14 +38,40 @@ export function ChatsPage() {
|
|||||||
});
|
});
|
||||||
}, [conversations]);
|
}, [conversations]);
|
||||||
|
|
||||||
|
const queryFiltered = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return sorted;
|
||||||
|
return sorted.filter((c) => {
|
||||||
|
const title = (c.type === 'dm' ? c.peer?.displayName : c.name) ?? '';
|
||||||
|
const handle = c.type === 'dm' ? c.peer?.username ?? '' : '';
|
||||||
|
return title.toLowerCase().includes(q) || handle.toLowerCase().includes(q);
|
||||||
|
});
|
||||||
|
}, [sorted, query]);
|
||||||
|
|
||||||
|
// Split into active vs archived — the user toggles which bucket shows in the
|
||||||
|
// main list. Archived conversations with unread messages still surface so
|
||||||
|
// the user can't accidentally silence an ongoing conversation permanently.
|
||||||
|
const activeItems = useMemo(
|
||||||
|
() => queryFiltered.filter((c) => !c.archived),
|
||||||
|
[queryFiltered],
|
||||||
|
);
|
||||||
|
const archivedItems = useMemo(
|
||||||
|
() => queryFiltered.filter((c) => c.archived),
|
||||||
|
[queryFiltered],
|
||||||
|
);
|
||||||
|
|
||||||
|
const archivedUnread = archivedItems.reduce((s, c) => s + (unread[c.id] ?? 0), 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full">
|
<div className="flex h-full">
|
||||||
<ConversationList
|
<ConversationList
|
||||||
items={sorted}
|
items={showArchived ? archivedItems : activeItems}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
error={error}
|
error={error}
|
||||||
activeId={activeId}
|
activeId={activeId}
|
||||||
unread={unread}
|
unread={unread}
|
||||||
|
query={query}
|
||||||
|
onQueryChange={setQuery}
|
||||||
onAccept={async (id) => {
|
onAccept={async (id) => {
|
||||||
try {
|
try {
|
||||||
await acceptDm(supabase, id);
|
await acceptDm(supabase, id);
|
||||||
@@ -40,8 +82,11 @@ export function ChatsPage() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onNewGroup={() => setCreateOpen(true)}
|
onNewGroup={() => setCreateOpen(true)}
|
||||||
|
showArchived={showArchived}
|
||||||
|
onToggleArchived={() => setShowArchived((v) => !v)}
|
||||||
|
archivedUnread={archivedUnread}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 border-l border-white/5">
|
<div className="flex-1 border-l border-line bg-surface-3">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
<CreateGroupDialog open={createOpen} onClose={() => setCreateOpen(false)} />
|
<CreateGroupDialog open={createOpen} onClose={() => setCreateOpen(false)} />
|
||||||
@@ -49,62 +94,140 @@ export function ChatsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ConversationListProps {
|
||||||
|
items: ConversationSummary[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
activeId: string | undefined;
|
||||||
|
unread: Record<string, number>;
|
||||||
|
query: string;
|
||||||
|
onQueryChange: (q: string) => void;
|
||||||
|
onAccept: (id: string) => void;
|
||||||
|
onNewGroup: () => void;
|
||||||
|
showArchived: boolean;
|
||||||
|
onToggleArchived: () => void;
|
||||||
|
archivedUnread: number;
|
||||||
|
}
|
||||||
|
|
||||||
function ConversationList({
|
function ConversationList({
|
||||||
items,
|
items,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
activeId,
|
activeId,
|
||||||
unread,
|
unread,
|
||||||
|
query,
|
||||||
|
onQueryChange,
|
||||||
onAccept,
|
onAccept,
|
||||||
onNewGroup,
|
onNewGroup,
|
||||||
}: {
|
showArchived,
|
||||||
items: ConversationSummary[];
|
onToggleArchived,
|
||||||
loading: boolean;
|
archivedUnread,
|
||||||
error: string | null;
|
}: ConversationListProps) {
|
||||||
activeId: string | undefined;
|
|
||||||
unread: Record<string, number>;
|
|
||||||
onAccept: (id: string) => void;
|
|
||||||
onNewGroup: () => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation(['app']);
|
const { t } = useTranslation(['app']);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
aria-label="Conversations"
|
aria-label="Conversations"
|
||||||
className="flex h-full w-[320px] shrink-0 flex-col bg-ink-900/40"
|
className="flex h-full w-[320px] shrink-0 flex-col bg-surface-2"
|
||||||
>
|
>
|
||||||
<header className="flex items-center justify-between px-4 pb-3 pt-5">
|
<header className="flex items-center justify-between px-4 pb-3 pt-5">
|
||||||
<h2 className="font-display text-base font-semibold tracking-tight text-white">
|
<h2 className="font-display text-base font-semibold tracking-tight text-fg">
|
||||||
{t('app:nav.chats')}
|
{showArchived
|
||||||
|
? t('app:chats.archived_title', { defaultValue: 'Archiv' })
|
||||||
|
: t('app:nav.chats')}
|
||||||
</h2>
|
</h2>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggleArchived}
|
||||||
|
aria-label={
|
||||||
|
showArchived
|
||||||
|
? t('app:chats.show_active', { defaultValue: 'Aktive anzeigen' })
|
||||||
|
: t('app:chats.show_archived', { defaultValue: 'Archiv anzeigen' })
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
showArchived
|
||||||
|
? t('app:chats.show_active', { defaultValue: 'Aktive anzeigen' })
|
||||||
|
: t('app:chats.show_archived', { defaultValue: 'Archiv anzeigen' })
|
||||||
|
}
|
||||||
|
className={
|
||||||
|
'relative flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||||
|
(showArchived
|
||||||
|
? 'bg-accent/15 text-accent'
|
||||||
|
: 'text-fg-muted hover:bg-surface-3 hover:text-fg')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ArchiveIcon className="h-4 w-4" />
|
||||||
|
{!showArchived && archivedUnread > 0 && (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute -right-0.5 -top-0.5 flex min-w-[16px] items-center justify-center rounded-full bg-accent px-1 text-[9px] font-bold leading-tight text-accent-fg"
|
||||||
|
>
|
||||||
|
{archivedUnread > 9 ? '9+' : archivedUnread}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{!showArchived && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onNewGroup}
|
onClick={onNewGroup}
|
||||||
aria-label={t('app:chats.new_group')}
|
aria-label={t('app:chats.new_group')}
|
||||||
title={t('app:chats.new_group')}
|
title={t('app:chats.new_group')}
|
||||||
className="flex h-8 cursor-pointer items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2.5 text-xs font-medium text-neutral-300 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-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||||
>
|
>
|
||||||
<UsersIcon className="h-3.5 w-3.5" />
|
<AddUserIcon className="h-4 w-4" />
|
||||||
<PlusIcon className="h-3 w-3" />
|
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<div className="px-3 pb-2">
|
||||||
|
<label className="relative block">
|
||||||
|
<span className="sr-only">{t('app:chats.search', { defaultValue: 'Suche' })}</span>
|
||||||
|
<SearchIcon
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-fg-muted"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => onQueryChange(e.target.value)}
|
||||||
|
placeholder={t('app:chats.search_placeholder', { defaultValue: 'Suche…' })}
|
||||||
|
className="w-full rounded-lg border border-line bg-surface-3 py-2 pl-9 pr-3 text-sm text-fg placeholder-fg-muted transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center gap-2 px-4 py-2 text-xs text-neutral-500">
|
<div className="flex items-center gap-2 px-4 py-2 text-xs text-fg-muted">
|
||||||
<SpinnerIcon className="h-3.5 w-3.5 text-brand-400" />
|
<SpinnerIcon className="h-3.5 w-3.5 text-accent" />
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<p className="px-4 py-2 text-xs text-rose-300">{error}</p>
|
<p className="px-4 py-2 text-xs text-rose-500 dark:text-rose-300">{error}</p>
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
|
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-white/10 bg-white/5 text-brand-300">
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-line bg-surface-3 text-accent">
|
||||||
|
{showArchived ? (
|
||||||
|
<ArchiveIcon className="h-5 w-5" />
|
||||||
|
) : (
|
||||||
<ChatBubbleIcon className="h-5 w-5" />
|
<ChatBubbleIcon className="h-5 w-5" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-neutral-400">{t('app:chats.empty_title')}</p>
|
<p className="text-sm text-fg-muted">
|
||||||
<p className="text-xs text-neutral-500">{t('app:chats.empty_subtitle')}</p>
|
{showArchived
|
||||||
|
? t('app:chats.archived_empty_title', { defaultValue: 'Nichts archiviert' })
|
||||||
|
: t('app:chats.empty_title')}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-fg-muted/80">
|
||||||
|
{showArchived
|
||||||
|
? t('app:chats.archived_empty_subtitle', {
|
||||||
|
defaultValue: 'Archivierte Unterhaltungen erscheinen hier.',
|
||||||
|
})
|
||||||
|
: t('app:chats.empty_subtitle')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="flex-1 overflow-y-auto px-2 pb-4">
|
<ul className="flex-1 overflow-y-auto px-2 pb-2">
|
||||||
{items.map((c) => (
|
{items.map((c) => (
|
||||||
<li key={c.id}>
|
<li key={c.id}>
|
||||||
<ConversationRow
|
<ConversationRow
|
||||||
@@ -117,6 +240,10 @@ function ConversationList({
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="shrink-0 border-t border-line bg-surface-2 p-2">
|
||||||
|
<UserBar />
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -136,16 +263,22 @@ function ConversationRow({
|
|||||||
const title =
|
const title =
|
||||||
item.type === 'dm' ? (item.peer?.displayName ?? '?') : (item.name ?? '?');
|
item.type === 'dm' ? (item.peer?.displayName ?? '?') : (item.name ?? '?');
|
||||||
const handle = item.type === 'dm' ? '@' + (item.peer?.username ?? '?') : '';
|
const handle = item.type === 'dm' ? '@' + (item.peer?.username ?? '?') : '';
|
||||||
const letter = title.trim().charAt(0).toUpperCase() || '?';
|
const avatarUrl =
|
||||||
|
item.type === 'dm' ? (item.peer?.avatarUrl ?? null) : (item.avatarUrl ?? null);
|
||||||
|
const preview =
|
||||||
|
item.type === 'dm' ? handle : t('app:chats.group_preview', {
|
||||||
|
count: item.members.length,
|
||||||
|
defaultValue: `${item.members.length} Mitglieder`,
|
||||||
|
});
|
||||||
|
|
||||||
if (!item.acceptedByMe) {
|
if (!item.acceptedByMe) {
|
||||||
return (
|
return (
|
||||||
<div className="my-1 rounded-xl border border-amber-500/20 bg-amber-500/5 p-3">
|
<div className="my-1 rounded-xl border border-amber-500/30 bg-amber-500/5 p-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Avatar letter={letter} />
|
<ConvAvatar url={avatarUrl} title={title} isGroup={item.type === 'group'} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-sm font-semibold text-white">{title}</p>
|
<p className="truncate text-sm font-semibold text-fg">{title}</p>
|
||||||
<p className="truncate text-xs text-amber-200/80">
|
<p className="truncate text-xs text-amber-600 dark:text-amber-200/80">
|
||||||
{t('app:friends.incoming_request')}
|
{t('app:friends.incoming_request')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -153,7 +286,7 @@ function ConversationRow({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onAccept(item.id)}
|
onClick={() => onAccept(item.id)}
|
||||||
className="mt-3 w-full cursor-pointer rounded-md bg-amber-500/20 px-3 py-1.5 text-xs font-semibold text-amber-100 transition hover:bg-amber-500/30 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/40"
|
className="mt-3 w-full cursor-pointer rounded-md bg-amber-500/20 px-3 py-1.5 text-xs font-semibold text-amber-700 transition hover:bg-amber-500/30 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/40 dark:text-amber-100"
|
||||||
>
|
>
|
||||||
{t('app:friends.action_accept')}
|
{t('app:friends.action_accept')}
|
||||||
</button>
|
</button>
|
||||||
@@ -161,41 +294,88 @@ function ConversationRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const muted = isConversationMuted(item.mutedUntil);
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<NavLink
|
||||||
to={'/chats/' + item.id}
|
to={'/chats/' + item.id}
|
||||||
className={
|
className={
|
||||||
'my-1 flex cursor-pointer items-center gap-3 rounded-xl px-3 py-2.5 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' +
|
'group relative my-1 flex cursor-pointer items-center gap-3 rounded-xl px-3 py-2.5 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||||
(active ? 'bg-brand-500/15 ring-1 ring-brand-400/30' : 'hover:bg-white/5')
|
(active
|
||||||
|
? 'bg-accent/15 text-fg'
|
||||||
|
: 'text-fg hover:bg-surface-3/70')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Avatar letter={letter} />
|
<ConvAvatar url={avatarUrl} title={title} isGroup={item.type === 'group'} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
<p
|
<p
|
||||||
className={
|
className={
|
||||||
'truncate text-sm ' +
|
'truncate text-sm ' +
|
||||||
(unreadCount > 0 ? 'font-bold text-white' : 'font-semibold text-white')
|
(unreadCount > 0 && !muted ? 'font-bold' : 'font-semibold')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</p>
|
</p>
|
||||||
<p className="truncate text-xs text-neutral-500">{handle}</p>
|
{muted && (
|
||||||
|
<BellOffIcon
|
||||||
|
aria-hidden="true"
|
||||||
|
className="h-3 w-3 shrink-0 text-fg-muted"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="truncate text-xs text-fg-muted">{preview}</p>
|
||||||
</div>
|
</div>
|
||||||
{unreadCount > 0 && (
|
{unreadCount > 0 && (
|
||||||
<span
|
<span
|
||||||
aria-label={'Unread: ' + unreadCount}
|
aria-label={'Unread: ' + unreadCount}
|
||||||
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"
|
className={
|
||||||
|
'inline-flex min-w-[20px] items-center justify-center rounded-full px-1.5 text-[10px] font-bold leading-tight ' +
|
||||||
|
(muted
|
||||||
|
? 'bg-fg-muted/30 text-fg-muted'
|
||||||
|
: 'bg-accent text-accent-fg')
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{unreadCount > 99 ? '99+' : unreadCount}
|
{unreadCount > 99 ? '99+' : unreadCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
<ConversationRowMenu
|
||||||
|
conversationId={item.id}
|
||||||
|
archived={item.archived}
|
||||||
|
mutedUntil={item.mutedUntil}
|
||||||
|
/>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Avatar({ letter }: { letter: string }) {
|
function ConvAvatar({
|
||||||
|
url,
|
||||||
|
title,
|
||||||
|
isGroup,
|
||||||
|
}: {
|
||||||
|
url: string | null;
|
||||||
|
title: string;
|
||||||
|
isGroup: boolean;
|
||||||
|
}) {
|
||||||
|
if (url) {
|
||||||
return (
|
return (
|
||||||
<div className="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">
|
<img
|
||||||
|
src={url}
|
||||||
|
alt=""
|
||||||
|
className="h-9 w-9 shrink-0 rounded-full object-cover"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (isGroup) {
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const letter = title.trim().charAt(0).toUpperCase() || '?';
|
||||||
|
return (
|
||||||
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-accent/20 text-sm font-semibold text-accent">
|
||||||
{letter}
|
{letter}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -206,13 +386,13 @@ export function ChatsEmptyState() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center p-10">
|
<div className="flex h-full items-center justify-center p-10">
|
||||||
<div className="max-w-md text-center">
|
<div className="max-w-md text-center">
|
||||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border border-white/10 bg-white/5 text-brand-300">
|
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border border-line bg-surface-2 text-accent">
|
||||||
<ChatBubbleIcon className="h-6 w-6" />
|
<ChatBubbleIcon className="h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="font-display text-2xl font-semibold tracking-tight text-white">
|
<h2 className="font-display text-2xl font-semibold tracking-tight text-fg">
|
||||||
{t('app:chats.select_prompt')}
|
{t('app:chats.select_prompt')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-2 text-sm text-neutral-400">{t('app:chats.select_subtitle')}</p>
|
<p className="mt-2 text-sm text-fg-muted">{t('app:chats.select_subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,18 +1,37 @@
|
|||||||
|
import { parseMessagePayload } from '@chat-app/shared/chat';
|
||||||
import { extractErrorCode } from '@chat-app/shared/i18n';
|
import { extractErrorCode } from '@chat-app/shared/i18n';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
|
|
||||||
import { ConversationHeader } from '../components/ConversationHeader';
|
import { ConversationHeader } from '../components/ConversationHeader';
|
||||||
|
import { ForwardDialog } from '../components/ForwardDialog';
|
||||||
import { GroupInfoPanel } from '../components/GroupInfoPanel';
|
import { GroupInfoPanel } from '../components/GroupInfoPanel';
|
||||||
import { AlertIcon, ArrowRightIcon, PlusIcon, SpinnerIcon, XIcon } from '../components/icons';
|
import {
|
||||||
|
AlertIcon,
|
||||||
|
ArrowRightIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronUpIcon,
|
||||||
|
PlusIcon,
|
||||||
|
ReplyIcon,
|
||||||
|
SearchIcon,
|
||||||
|
SpinnerIcon,
|
||||||
|
XIcon,
|
||||||
|
} from '../components/icons';
|
||||||
import { InCallPanel } from '../components/InCallPanel';
|
import { InCallPanel } from '../components/InCallPanel';
|
||||||
import { MessageBubble } from '../components/MessageBubble';
|
import { IncomingCallPanel } from '../components/IncomingCallPanel';
|
||||||
|
import { MessageBubble, type QuotedRef } from '../components/MessageBubble';
|
||||||
import { TypingIndicator } from '../components/TypingIndicator';
|
import { TypingIndicator } from '../components/TypingIndicator';
|
||||||
|
import { VoiceRecorder } from '../components/VoiceRecorder';
|
||||||
|
import type { DecryptedMessage } from '@chat-app/shared/chat';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { useCall } from '../context/CallContext';
|
||||||
import { useConversationsContext } from '../context/ConversationsContext';
|
import { useConversationsContext } from '../context/ConversationsContext';
|
||||||
|
import type { OutboxItem } from '../lib/messageOutbox';
|
||||||
import { useConversationMessages } from '../lib/useConversationMessages';
|
import { useConversationMessages } from '../lib/useConversationMessages';
|
||||||
import { useMessageReactions } from '../lib/useMessageReactions';
|
import { useMessageReactions } from '../lib/useMessageReactions';
|
||||||
|
import { useGroupReceipts } from '../lib/useGroupReceipts';
|
||||||
|
import { markDelivered, useMessageDeliveries } from '../lib/useMessageDeliveries';
|
||||||
import { markRead as markMessagesReadRemote, useMessageReads } from '../lib/useMessageReads';
|
import { markRead as markMessagesReadRemote, useMessageReads } from '../lib/useMessageReads';
|
||||||
import { usePeerPresence } from '../lib/usePeerPresence';
|
import { usePeerPresence } from '../lib/usePeerPresence';
|
||||||
import { useTypingChannel } from '../lib/useTypingChannel';
|
import { useTypingChannel } from '../lib/useTypingChannel';
|
||||||
@@ -32,7 +51,8 @@ export function ConversationPage() {
|
|||||||
const peerId = conversation?.peer?.userId;
|
const peerId = conversation?.peer?.userId;
|
||||||
const peerPresence = usePeerPresence(peerId);
|
const peerPresence = usePeerPresence(peerId);
|
||||||
|
|
||||||
const { messages, loading, error, send } = useConversationMessages({
|
const { messages, loading, error, send, pending, retryPending, cancelPending } =
|
||||||
|
useConversationMessages({
|
||||||
conversationId: id,
|
conversationId: id,
|
||||||
userId: session?.user.id,
|
userId: session?.user.id,
|
||||||
deviceId: device?.id,
|
deviceId: device?.id,
|
||||||
@@ -51,6 +71,36 @@ export function ConversationPage() {
|
|||||||
[messages, myId],
|
[messages, myId],
|
||||||
);
|
);
|
||||||
const { peerReadSet } = useMessageReads(ownMessageIds, peerId);
|
const { peerReadSet } = useMessageReads(ownMessageIds, peerId);
|
||||||
|
const { peerDeliveredSet } = useMessageDeliveries(ownMessageIds, peerId);
|
||||||
|
|
||||||
|
// Group receipts: only meaningful when conversation is a group. We feed it
|
||||||
|
// ownMessageIds since we only render delivery state on the sender side.
|
||||||
|
const isGroup = conversation?.type === 'group';
|
||||||
|
const { deliveredByMessage: groupDelivered, readByMessage: groupRead } =
|
||||||
|
useGroupReceipts(ownMessageIds, myId, !!isGroup);
|
||||||
|
const groupRecipientCount = useMemo(() => {
|
||||||
|
if (!isGroup || !conversation) return 0;
|
||||||
|
return conversation.members.filter((m) => m.userId !== myId).length;
|
||||||
|
}, [isGroup, conversation, myId]);
|
||||||
|
|
||||||
|
// Mark every peer-authored message as delivered on our side. Idempotent,
|
||||||
|
// so rerunning for already-acknowledged ids is a no-op server-side.
|
||||||
|
const deliveredTrackedRef = useRef<Set<string>>(new Set());
|
||||||
|
useEffect(() => {
|
||||||
|
if (!myId || messages.length === 0) return;
|
||||||
|
const toMark: string[] = [];
|
||||||
|
for (const m of messages) {
|
||||||
|
if (m.senderId === myId) continue;
|
||||||
|
if (deliveredTrackedRef.current.has(m.id)) continue;
|
||||||
|
deliveredTrackedRef.current.add(m.id);
|
||||||
|
toMark.push(m.id);
|
||||||
|
}
|
||||||
|
if (toMark.length > 0) {
|
||||||
|
void markDelivered(toMark).catch((err: unknown) => {
|
||||||
|
console.warn('markDelivered failed', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [messages, myId]);
|
||||||
|
|
||||||
const lastSeenMessageId = useMemo(() => {
|
const lastSeenMessageId = useMemo(() => {
|
||||||
for (let i = messages.length - 1; i >= 0; i--) {
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
@@ -80,8 +130,162 @@ export function ConversationPage() {
|
|||||||
const [stickToBottom, setStickToBottom] = useState(true);
|
const [stickToBottom, setStickToBottom] = useState(true);
|
||||||
const [attachments, setAttachments] = useState<File[]>([]);
|
const [attachments, setAttachments] = useState<File[]>([]);
|
||||||
const [infoPanelOpen, setInfoPanelOpen] = useState(false);
|
const [infoPanelOpen, setInfoPanelOpen] = useState(false);
|
||||||
|
const [replyTo, setReplyTo] = useState<DecryptedMessage | null>(null);
|
||||||
|
const [forwardTarget, setForwardTarget] = useState<DecryptedMessage | null>(null);
|
||||||
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [searchIdx, setSearchIdx] = useState(0);
|
||||||
|
const [searchSenderId, setSearchSenderId] = useState<string>('');
|
||||||
|
const [searchAttachmentsOnly, setSearchAttachmentsOnly] = useState(false);
|
||||||
|
const [searchDateFrom, setSearchDateFrom] = useState<string>('');
|
||||||
|
const [searchDateTo, setSearchDateTo] = useState<string>('');
|
||||||
|
const [highlightedId, setHighlightedId] = useState<string | null>(null);
|
||||||
|
const [displayCount, setDisplayCount] = useState<number>(150);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const loadMoreSentinelRef = useRef<HTMLDivElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const composerRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
// Drop reply-to / clear search state when switching conversation.
|
||||||
|
useEffect(() => {
|
||||||
|
setReplyTo(null);
|
||||||
|
setForwardTarget(null);
|
||||||
|
setSearchOpen(false);
|
||||||
|
setSearchQuery('');
|
||||||
|
setDisplayCount(150);
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
// Expand window when the "load older" sentinel scrolls into view. Doubles
|
||||||
|
// effective window on each trigger so scrolling up quickly converges to
|
||||||
|
// rendering everything.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = loadMoreSentinelRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
if (displayCount >= messages.length) return;
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0]?.isIntersecting) {
|
||||||
|
setDisplayCount((n) => Math.min(messages.length, n * 2));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ root: scrollRef.current, rootMargin: '200px 0px' },
|
||||||
|
);
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [displayCount, messages.length]);
|
||||||
|
|
||||||
|
const messageById = useMemo(() => {
|
||||||
|
const m = new Map<string, DecryptedMessage>();
|
||||||
|
for (const msg of messages) m.set(msg.id, msg);
|
||||||
|
return m;
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
const senderNameFor = useCallback(
|
||||||
|
(senderId: string): string => {
|
||||||
|
if (senderId === myId) return t('app:chats.you', { defaultValue: 'Du' });
|
||||||
|
const profile =
|
||||||
|
conversation?.members.find((mm) => mm.userId === senderId)?.profile ??
|
||||||
|
(senderId !== myId ? conversation?.peer ?? null : null);
|
||||||
|
return profile?.displayName ?? '?';
|
||||||
|
},
|
||||||
|
[conversation, myId, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const buildQuoted = useCallback(
|
||||||
|
(replyToId: string | null): QuotedRef | null => {
|
||||||
|
if (!replyToId) return null;
|
||||||
|
const target = messageById.get(replyToId);
|
||||||
|
if (!target) {
|
||||||
|
return {
|
||||||
|
id: replyToId,
|
||||||
|
senderName: '…',
|
||||||
|
snippet: t('app:chats.quote_unavailable', { defaultValue: 'Nachricht nicht verfügbar' }),
|
||||||
|
isAttachment: false,
|
||||||
|
deleted: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const parsed = parseMessagePayload(target.plaintext);
|
||||||
|
const text = parsed.kind === 'text' ? parsed.text : '';
|
||||||
|
const hasAttachment = parsed.kind === 'text' && parsed.attachments.length > 0;
|
||||||
|
return {
|
||||||
|
id: target.id,
|
||||||
|
senderName: senderNameFor(target.senderId),
|
||||||
|
snippet: text.length > 120 ? text.slice(0, 120) + '…' : text,
|
||||||
|
isAttachment: hasAttachment,
|
||||||
|
deleted: !!target.deletedAt,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[messageById, senderNameFor, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const jumpToMessage = useCallback((targetId: string) => {
|
||||||
|
const el = scrollRef.current?.querySelector<HTMLElement>(
|
||||||
|
'[data-message-id="' + CSS.escape(targetId) + '"]',
|
||||||
|
);
|
||||||
|
if (!el) return;
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
setHighlightedId(targetId);
|
||||||
|
window.setTimeout(() => setHighlightedId((cur) => (cur === targetId ? null : cur)), 1600);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleReply = useCallback((m: DecryptedMessage) => {
|
||||||
|
setReplyTo(m);
|
||||||
|
composerRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleForward = useCallback((m: DecryptedMessage) => {
|
||||||
|
setForwardTarget(m);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Search matches: messages matching query + filters. Empty query is allowed
|
||||||
|
// when filters are active, so users can e.g. show "all attachments from
|
||||||
|
// alice in the last week" without a text query.
|
||||||
|
const searchActive = useMemo(
|
||||||
|
() =>
|
||||||
|
searchQuery.trim().length > 0 ||
|
||||||
|
searchSenderId !== '' ||
|
||||||
|
searchAttachmentsOnly ||
|
||||||
|
searchDateFrom !== '' ||
|
||||||
|
searchDateTo !== '',
|
||||||
|
[searchQuery, searchSenderId, searchAttachmentsOnly, searchDateFrom, searchDateTo],
|
||||||
|
);
|
||||||
|
const searchMatches = useMemo(() => {
|
||||||
|
if (!searchActive) return [] as DecryptedMessage[];
|
||||||
|
const q = searchQuery.trim().toLowerCase();
|
||||||
|
const fromTs = searchDateFrom ? new Date(searchDateFrom).getTime() : null;
|
||||||
|
// Date inputs cover whole days — bump 'to' to end-of-day.
|
||||||
|
const toTs = searchDateTo
|
||||||
|
? new Date(searchDateTo).getTime() + 24 * 3600 * 1000 - 1
|
||||||
|
: null;
|
||||||
|
return messages.filter((m) => {
|
||||||
|
if (searchSenderId && m.senderId !== searchSenderId) return false;
|
||||||
|
const created = new Date(m.createdAt).getTime();
|
||||||
|
if (fromTs !== null && created < fromTs) return false;
|
||||||
|
if (toTs !== null && created > toTs) return false;
|
||||||
|
if (!m.plaintext) return false;
|
||||||
|
const parsed = parseMessagePayload(m.plaintext);
|
||||||
|
if (parsed.kind !== 'text') return false;
|
||||||
|
if (searchAttachmentsOnly && parsed.attachments.length === 0) return false;
|
||||||
|
if (q && !parsed.text.toLowerCase().includes(q)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [messages, searchActive, searchQuery, searchSenderId, searchAttachmentsOnly, searchDateFrom, searchDateTo]);
|
||||||
|
|
||||||
|
// Reset/clamp the active match index when the match set changes.
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchMatches.length === 0) {
|
||||||
|
setSearchIdx(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSearchIdx((cur) => Math.min(cur, searchMatches.length - 1));
|
||||||
|
}, [searchMatches.length]);
|
||||||
|
|
||||||
|
// Auto-jump to current match.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!searchOpen || searchMatches.length === 0) return;
|
||||||
|
const target = searchMatches[searchIdx];
|
||||||
|
if (target) jumpToMessage(target.id);
|
||||||
|
}, [searchOpen, searchMatches, searchIdx, jumpToMessage]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
@@ -120,9 +324,10 @@ export function ConversationPage() {
|
|||||||
setSending(true);
|
setSending(true);
|
||||||
setSendError(null);
|
setSendError(null);
|
||||||
try {
|
try {
|
||||||
await send(text, attachments);
|
await send(text, attachments, replyTo?.id ?? null);
|
||||||
setText('');
|
setText('');
|
||||||
setAttachments([]);
|
setAttachments([]);
|
||||||
|
setReplyTo(null);
|
||||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
setStickToBottom(true);
|
setStickToBottom(true);
|
||||||
notifyStopTyping();
|
notifyStopTyping();
|
||||||
@@ -156,15 +361,62 @@ export function ConversationPage() {
|
|||||||
setAttachments((prev) => [...prev, ...next].slice(0, 4));
|
setAttachments((prev) => [...prev, ...next].slice(0, 4));
|
||||||
}
|
}
|
||||||
|
|
||||||
const isGroup = conversation?.type === 'group';
|
const { state: callState } = useCall();
|
||||||
|
// Hide the chat header while this conversation hosts an active call — the
|
||||||
|
// call topbar inside the dock already shows the channel name + duration,
|
||||||
|
// and fullscreen cinema needs the whole slot.
|
||||||
|
const callHereActive =
|
||||||
|
(callState.kind === 'connected' ||
|
||||||
|
callState.kind === 'connecting' ||
|
||||||
|
callState.kind === 'outgoing') &&
|
||||||
|
callState.conversationId === id;
|
||||||
|
const incomingHere =
|
||||||
|
callState.kind === 'incoming' && callState.conversationId === id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-full flex-col">
|
<div className="relative flex h-full flex-col">
|
||||||
|
{!callHereActive && (
|
||||||
<ConversationHeader
|
<ConversationHeader
|
||||||
conversation={conversation}
|
conversation={conversation}
|
||||||
peerPresence={peerPresence}
|
peerPresence={peerPresence}
|
||||||
|
onSearchClick={() => setSearchOpen((v) => !v)}
|
||||||
{...(isGroup ? { onInfoClick: () => setInfoPanelOpen(true) } : {})}
|
{...(isGroup ? { onInfoClick: () => setInfoPanelOpen(true) } : {})}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!callHereActive && searchOpen && (
|
||||||
|
<SearchBar
|
||||||
|
query={searchQuery}
|
||||||
|
onQueryChange={setSearchQuery}
|
||||||
|
matches={searchMatches.length}
|
||||||
|
activeIdx={searchIdx}
|
||||||
|
senderId={searchSenderId}
|
||||||
|
onSenderChange={setSearchSenderId}
|
||||||
|
attachmentsOnly={searchAttachmentsOnly}
|
||||||
|
onAttachmentsOnlyChange={setSearchAttachmentsOnly}
|
||||||
|
dateFrom={searchDateFrom}
|
||||||
|
onDateFromChange={setSearchDateFrom}
|
||||||
|
dateTo={searchDateTo}
|
||||||
|
onDateToChange={setSearchDateTo}
|
||||||
|
members={conversation?.members ?? []}
|
||||||
|
onPrev={() =>
|
||||||
|
setSearchIdx((cur) =>
|
||||||
|
searchMatches.length === 0 ? 0 : (cur - 1 + searchMatches.length) % searchMatches.length,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onNext={() =>
|
||||||
|
setSearchIdx((cur) => (searchMatches.length === 0 ? 0 : (cur + 1) % searchMatches.length))
|
||||||
|
}
|
||||||
|
onClose={() => {
|
||||||
|
setSearchOpen(false);
|
||||||
|
setSearchQuery('');
|
||||||
|
setSearchSenderId('');
|
||||||
|
setSearchAttachmentsOnly(false);
|
||||||
|
setSearchDateFrom('');
|
||||||
|
setSearchDateTo('');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{isGroup && conversation && (
|
{isGroup && conversation && (
|
||||||
<GroupInfoPanel
|
<GroupInfoPanel
|
||||||
@@ -174,36 +426,101 @@ export function ConversationPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{incomingHere && conversation && <IncomingCallPanel conversation={conversation} />}
|
||||||
{conversation && <InCallPanel conversation={conversation} />}
|
{conversation && <InCallPanel conversation={conversation} />}
|
||||||
|
|
||||||
<div ref={scrollRef} onScroll={handleScroll} className="flex-1 overflow-y-auto px-6 py-4">
|
<div ref={scrollRef} onScroll={handleScroll} className="flex-1 overflow-y-auto bg-surface-3 px-6 py-4">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center gap-2 text-xs text-neutral-500">
|
<div className="flex items-center gap-2 text-xs text-fg-muted">
|
||||||
<SpinnerIcon className="h-3.5 w-3.5 text-brand-400" />
|
<SpinnerIcon className="h-3.5 w-3.5 text-accent" />
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<Banner>{error}</Banner>
|
<Banner>{error}</Banner>
|
||||||
) : messages.length === 0 ? (
|
) : messages.length === 0 ? (
|
||||||
<p className="text-center text-sm text-neutral-500">…</p>
|
<p className="text-center text-sm text-fg-muted">…</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-0.5">
|
<ul className="space-y-0.5">
|
||||||
{messages.map((m, idx) => {
|
{displayCount < messages.length && (
|
||||||
const prev = messages[idx - 1];
|
<li>
|
||||||
const grouped = idx > 0 && prev?.senderId === m.senderId;
|
<div
|
||||||
|
ref={loadMoreSentinelRef}
|
||||||
|
className="flex items-center justify-center py-2 text-xs text-fg-muted"
|
||||||
|
>
|
||||||
|
Lade ältere Nachrichten…
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{messages.slice(Math.max(0, messages.length - displayCount)).map((m, sliceIdx) => {
|
||||||
|
const idx = Math.max(0, messages.length - displayCount) + sliceIdx;
|
||||||
|
// A "run" is consecutive bubbles from the same sender with
|
||||||
|
// nothing between them. Call-event separators break the run —
|
||||||
|
// a bubble whose immediate next neighbour is a call_event must
|
||||||
|
// anchor the avatar, even if another bubble from the same
|
||||||
|
// sender appears after the separator.
|
||||||
|
const prevRaw = messages[idx - 1];
|
||||||
|
const nextRaw = messages[idx + 1];
|
||||||
|
const prevIsCallEvent =
|
||||||
|
!!prevRaw && parseMessagePayload(prevRaw.plaintext).kind === 'call_event';
|
||||||
|
const nextIsCallEvent =
|
||||||
|
!!nextRaw && parseMessagePayload(nextRaw.plaintext).kind === 'call_event';
|
||||||
|
const grouped =
|
||||||
|
!!prevRaw && prevRaw.senderId === m.senderId && !prevIsCallEvent;
|
||||||
|
// Anchor avatar on the LAST message of a run so it aligns with
|
||||||
|
// the bubble's tail (bottom corner). Tail is bottom-left for
|
||||||
|
// mine, bottom-right for peer — see rounded-[…_4px_…] above.
|
||||||
|
const isLastOfRun =
|
||||||
|
!nextRaw || nextRaw.senderId !== m.senderId || nextIsCallEvent;
|
||||||
|
// DM fallback: if member lookup fails (e.g. transient sync), fall
|
||||||
|
// back to conversation.peer so the peer's avatar still resolves.
|
||||||
|
const memberProfile =
|
||||||
|
conversation?.members.find((mm) => mm.userId === m.senderId)?.profile ?? null;
|
||||||
|
const senderProfile =
|
||||||
|
memberProfile ??
|
||||||
|
(m.senderId !== myId ? (conversation?.peer ?? null) : null);
|
||||||
return (
|
return (
|
||||||
<li key={m.id}>
|
<li key={m.id}>
|
||||||
<MessageBubble
|
<MessageBubble
|
||||||
message={m}
|
message={m}
|
||||||
mine={m.senderId === myId}
|
mine={m.senderId === myId}
|
||||||
groupedWithPrev={grouped}
|
groupedWithPrev={grouped}
|
||||||
|
isLastOfRun={isLastOfRun}
|
||||||
|
senderDisplayName={senderProfile?.displayName}
|
||||||
|
senderAvatarUrl={senderProfile?.avatarUrl}
|
||||||
conversationId={id ?? ''}
|
conversationId={id ?? ''}
|
||||||
reactions={reactionsByMessage.get(m.id) ?? []}
|
reactions={reactionsByMessage.get(m.id) ?? []}
|
||||||
onToggleReaction={(emoji) => toggleReaction(m.id, emoji)}
|
onToggleReaction={(emoji) => toggleReaction(m.id, emoji)}
|
||||||
showSeen={m.id === lastSeenMessageId}
|
showSeen={m.id === lastSeenMessageId}
|
||||||
|
{...(m.senderId === myId
|
||||||
|
? {
|
||||||
|
deliveryState: computeDeliveryState({
|
||||||
|
messageId: m.id,
|
||||||
|
isGroup: !!isGroup,
|
||||||
|
recipientCount: groupRecipientCount,
|
||||||
|
peerReadSet,
|
||||||
|
peerDeliveredSet,
|
||||||
|
groupRead,
|
||||||
|
groupDelivered,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
: {})}
|
||||||
|
quoted={buildQuoted(m.replyToId)}
|
||||||
|
onJumpToMessage={jumpToMessage}
|
||||||
|
onReply={handleReply}
|
||||||
|
onForward={handleForward}
|
||||||
|
highlighted={highlightedId === m.id}
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{pending.map((p) => (
|
||||||
|
<li key={p.id}>
|
||||||
|
<PendingBubble
|
||||||
|
item={p}
|
||||||
|
onRetry={() => retryPending(p.id)}
|
||||||
|
onCancel={() => cancelPending(p.id)}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -213,13 +530,45 @@ export function ConversationPage() {
|
|||||||
members={conversation?.members ?? []}
|
members={conversation?.members ?? []}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<form onSubmit={handleSend} className="border-t border-white/5 p-4">
|
<form onSubmit={handleSend} className="border-t border-line bg-surface-3 p-4">
|
||||||
{sendError && (
|
{sendError && (
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<Banner>{sendError}</Banner>
|
<Banner>{sendError}</Banner>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{replyTo && (
|
||||||
|
<div className="mb-2 flex items-stretch gap-2 rounded-lg border border-line bg-surface-2 p-2 text-sm">
|
||||||
|
<span aria-hidden="true" className="w-1 shrink-0 rounded-full bg-accent" />
|
||||||
|
<ReplyIcon className="mt-0.5 h-4 w-4 shrink-0 text-accent" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-xs font-semibold text-fg">
|
||||||
|
{t('app:chats.replying_to', {
|
||||||
|
name: senderNameFor(replyTo.senderId),
|
||||||
|
defaultValue: 'Antwort an ' + senderNameFor(replyTo.senderId),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-xs italic text-fg-muted">
|
||||||
|
{(() => {
|
||||||
|
if (!replyTo.plaintext) return '…';
|
||||||
|
const p = parseMessagePayload(replyTo.plaintext);
|
||||||
|
if (p.kind !== 'text') return '';
|
||||||
|
if (!p.text && p.attachments.length > 0) return '📎';
|
||||||
|
return p.text;
|
||||||
|
})()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setReplyTo(null)}
|
||||||
|
aria-label={t('app:friends.action_cancel', { defaultValue: 'Abbrechen' })}
|
||||||
|
className="shrink-0 cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg"
|
||||||
|
>
|
||||||
|
<XIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{attachments.length > 0 && (
|
{attachments.length > 0 && (
|
||||||
<div className="mb-2 flex flex-wrap gap-2">
|
<div className="mb-2 flex flex-wrap gap-2">
|
||||||
{attachments.map((file, idx) => (
|
{attachments.map((file, idx) => (
|
||||||
@@ -248,11 +597,24 @@ export function ConversationPage() {
|
|||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
aria-label="Bild anhängen"
|
aria-label="Bild anhängen"
|
||||||
title="Bild anhängen"
|
title="Bild anhängen"
|
||||||
className="inline-flex h-11 w-11 cursor-pointer items-center justify-center rounded-lg border border-white/10 bg-white/5 text-neutral-300 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
|
className="inline-flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-accent text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
|
||||||
>
|
>
|
||||||
<PlusIcon className="h-4 w-4" />
|
<PlusIcon className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
<VoiceRecorder
|
||||||
|
disabled={sending}
|
||||||
|
onComplete={async (file) => {
|
||||||
|
try {
|
||||||
|
await send('', [file], replyTo?.id ?? null);
|
||||||
|
setReplyTo(null);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'send failed';
|
||||||
|
setSendError(msg);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
|
ref={composerRef}
|
||||||
value={text}
|
value={text}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setText(e.target.value);
|
setText(e.target.value);
|
||||||
@@ -265,19 +627,247 @@ export function ConversationPage() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
rows={1}
|
rows={1}
|
||||||
placeholder="…"
|
placeholder="Nachricht schreiben…"
|
||||||
className="max-h-40 min-h-[44px] flex-1 resize-none rounded-lg border border-white/10 bg-ink-900/60 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"
|
className="max-h-40 min-h-[44px] flex-1 resize-none 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"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={sending || (text.trim().length === 0 && attachments.length === 0)}
|
disabled={sending || (text.trim().length === 0 && attachments.length === 0)}
|
||||||
aria-busy={sending}
|
aria-busy={sending}
|
||||||
className="inline-flex h-11 w-11 cursor-pointer items-center justify-center rounded-lg bg-gradient-to-br from-brand-400 to-brand-600 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="inline-flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-accent 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"
|
||||||
>
|
>
|
||||||
{sending ? <SpinnerIcon className="h-4 w-4" /> : <ArrowRightIcon className="h-4 w-4" />}
|
{sending ? <SpinnerIcon className="h-4 w-4" /> : <ArrowRightIcon className="h-4 w-4" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<ForwardDialog
|
||||||
|
open={forwardTarget !== null}
|
||||||
|
message={forwardTarget}
|
||||||
|
currentConversationId={id ?? null}
|
||||||
|
onClose={() => setForwardTarget(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchBarProps {
|
||||||
|
query: string;
|
||||||
|
onQueryChange: (q: string) => void;
|
||||||
|
matches: number;
|
||||||
|
activeIdx: number;
|
||||||
|
senderId: string;
|
||||||
|
onSenderChange: (id: string) => void;
|
||||||
|
attachmentsOnly: boolean;
|
||||||
|
onAttachmentsOnlyChange: (v: boolean) => void;
|
||||||
|
dateFrom: string;
|
||||||
|
onDateFromChange: (v: string) => void;
|
||||||
|
dateTo: string;
|
||||||
|
onDateToChange: (v: string) => void;
|
||||||
|
members: { userId: string; profile: { displayName?: string | null } | null }[];
|
||||||
|
onPrev: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchBar({
|
||||||
|
query,
|
||||||
|
onQueryChange,
|
||||||
|
matches,
|
||||||
|
activeIdx,
|
||||||
|
senderId,
|
||||||
|
onSenderChange,
|
||||||
|
attachmentsOnly,
|
||||||
|
onAttachmentsOnlyChange,
|
||||||
|
dateFrom,
|
||||||
|
onDateFromChange,
|
||||||
|
dateTo,
|
||||||
|
onDateToChange,
|
||||||
|
members,
|
||||||
|
onPrev,
|
||||||
|
onNext,
|
||||||
|
onClose,
|
||||||
|
}: SearchBarProps) {
|
||||||
|
const { t } = useTranslation(['app']);
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 border-b border-line bg-surface-2 px-4 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<SearchIcon className="h-4 w-4 shrink-0 text-fg-muted" />
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
autoFocus
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => onQueryChange(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.shiftKey) onPrev();
|
||||||
|
else onNext();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={t('app:chats.search_in_conv', { defaultValue: 'In Unterhaltung suchen…' })}
|
||||||
|
className="min-w-0 flex-1 bg-transparent text-sm text-fg placeholder-fg-muted outline-none"
|
||||||
|
/>
|
||||||
|
<span className="shrink-0 text-xs tabular-nums text-fg-muted">
|
||||||
|
{matches === 0
|
||||||
|
? query.trim().length > 0
|
||||||
|
? t('app:chats.search_none', { defaultValue: 'Keine Treffer' })
|
||||||
|
: ''
|
||||||
|
: activeIdx + 1 + ' / ' + matches}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onPrev}
|
||||||
|
disabled={matches === 0}
|
||||||
|
aria-label="Previous"
|
||||||
|
className="cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<ChevronUpIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={matches === 0}
|
||||||
|
aria-label="Next"
|
||||||
|
className="cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close"
|
||||||
|
className="cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg"
|
||||||
|
>
|
||||||
|
<XIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs text-fg-muted">
|
||||||
|
<select
|
||||||
|
value={senderId}
|
||||||
|
onChange={(e) => onSenderChange(e.target.value)}
|
||||||
|
className="cursor-pointer rounded-md border border-line bg-surface-3 px-2 py-1 text-fg outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
<option value="">Alle Sender</option>
|
||||||
|
{members.map((m) => (
|
||||||
|
<option key={m.userId} value={m.userId}>
|
||||||
|
{m.profile?.displayName ?? m.userId.slice(0, 6)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<label className="flex cursor-pointer items-center gap-1 rounded-md border border-line bg-surface-3 px-2 py-1 text-fg">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={attachmentsOnly}
|
||||||
|
onChange={(e) => onAttachmentsOnlyChange(e.target.checked)}
|
||||||
|
className="accent-accent"
|
||||||
|
/>
|
||||||
|
<span>Nur Anhänge</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-1">
|
||||||
|
<span>Von</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dateFrom}
|
||||||
|
onChange={(e) => onDateFromChange(e.target.value)}
|
||||||
|
className="rounded-md border border-line bg-surface-3 px-2 py-1 text-fg outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-1">
|
||||||
|
<span>Bis</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dateTo}
|
||||||
|
onChange={(e) => onDateToChange(e.target.value)}
|
||||||
|
className="rounded-md border border-line bg-surface-3 px-2 py-1 text-fg outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walks `messages` from `idx + step` skipping call_event entries until a
|
||||||
|
// regular bubble is found or the array boundary is reached. Used to decide
|
||||||
|
// run-grouping for avatar placement so call separators don't bleed into
|
||||||
|
// sender continuity.
|
||||||
|
function computeDeliveryState(args: {
|
||||||
|
messageId: string;
|
||||||
|
isGroup: boolean;
|
||||||
|
recipientCount: number;
|
||||||
|
peerReadSet: Set<string>;
|
||||||
|
peerDeliveredSet: Set<string>;
|
||||||
|
groupRead: Map<string, Set<string>>;
|
||||||
|
groupDelivered: Map<string, Set<string>>;
|
||||||
|
}): 'sent' | 'delivered' | 'read' {
|
||||||
|
// DM: single peer ack flips state.
|
||||||
|
if (!args.isGroup) {
|
||||||
|
if (args.peerReadSet.has(args.messageId)) return 'read';
|
||||||
|
if (args.peerDeliveredSet.has(args.messageId)) return 'delivered';
|
||||||
|
return 'sent';
|
||||||
|
}
|
||||||
|
// Group: state advances only when ALL recipients have acknowledged. With
|
||||||
|
// 0 recipients (admin-only group), we keep 'sent' so we don't show
|
||||||
|
// misleading completed ticks.
|
||||||
|
if (args.recipientCount === 0) return 'sent';
|
||||||
|
const reads = args.groupRead.get(args.messageId);
|
||||||
|
if (reads && reads.size >= args.recipientCount) return 'read';
|
||||||
|
const delivered = args.groupDelivered.get(args.messageId);
|
||||||
|
if (delivered && delivered.size >= args.recipientCount) return 'delivered';
|
||||||
|
return 'sent';
|
||||||
|
}
|
||||||
|
|
||||||
|
function PendingBubble({
|
||||||
|
item,
|
||||||
|
onRetry,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
item: OutboxItem;
|
||||||
|
onRetry: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
const failed = item.attempts >= 8;
|
||||||
|
return (
|
||||||
|
<div className="flex justify-end py-0.5">
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'max-w-[72%] rounded-[18px] px-3.5 py-2 text-sm ' +
|
||||||
|
(failed
|
||||||
|
? 'border border-rose-500/40 bg-rose-500/10 text-rose-700 dark:text-rose-100'
|
||||||
|
: 'border border-dashed border-accent/50 bg-accent/10 text-fg opacity-80')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="whitespace-pre-wrap break-words">{item.text}</p>
|
||||||
|
<div className="mt-1 flex items-center justify-end gap-2 text-[10px] uppercase tracking-wider text-fg-muted">
|
||||||
|
{failed ? (
|
||||||
|
<>
|
||||||
|
<span>{item.lastError ?? 'Senden fehlgeschlagen'}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRetry}
|
||||||
|
className="cursor-pointer rounded px-1.5 py-0.5 font-semibold text-accent hover:underline"
|
||||||
|
>
|
||||||
|
Erneut
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="cursor-pointer rounded px-1.5 py-0.5 font-semibold text-rose-600 hover:underline dark:text-rose-300"
|
||||||
|
>
|
||||||
|
Verwerfen
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<SpinnerIcon className="h-3 w-3 animate-spin" />
|
||||||
|
<span>
|
||||||
|
{item.attempts === 0 ? 'Senden…' : 'Wiederhole (' + item.attempts + ')'}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -286,9 +876,9 @@ function Banner({ children }: { children: React.ReactNode }) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="alert"
|
role="alert"
|
||||||
className="flex items-start gap-3 rounded-lg border border-rose-500/20 bg-rose-500/10 p-3 text-sm text-rose-100"
|
className="flex items-start gap-3 rounded-lg border border-rose-500/30 bg-rose-500/10 p-3 text-sm text-rose-700 dark:text-rose-100"
|
||||||
>
|
>
|
||||||
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" />
|
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-500 dark:text-rose-400" />
|
||||||
<p className="min-w-0 flex-1 break-words">{children}</p>
|
<p className="min-w-0 flex-1 break-words">{children}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -302,7 +892,7 @@ function AttachmentPreview({ file, onRemove }: { file: File; onRemove: () => voi
|
|||||||
return () => URL.revokeObjectURL(u);
|
return () => URL.revokeObjectURL(u);
|
||||||
}, [file]);
|
}, [file]);
|
||||||
return (
|
return (
|
||||||
<div className="relative overflow-hidden rounded-lg border border-white/10 bg-ink-900/60">
|
<div className="relative overflow-hidden rounded-lg border border-line bg-surface-2">
|
||||||
{url ? (
|
{url ? (
|
||||||
<img src={url} alt={file.name} className="block h-20 w-20 object-cover" />
|
<img src={url} alt={file.name} className="block h-20 w-20 object-cover" />
|
||||||
) : (
|
) : (
|
||||||
@@ -312,7 +902,7 @@ function AttachmentPreview({ file, onRemove }: { file: File; onRemove: () => voi
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={onRemove}
|
onClick={onRemove}
|
||||||
aria-label="Entfernen"
|
aria-label="Entfernen"
|
||||||
className="absolute right-1 top-1 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-ink-950/80 text-neutral-200 transition hover:bg-rose-500/70 hover:text-white"
|
className="absolute right-1 top-1 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/70 text-white transition hover:bg-rose-500/80"
|
||||||
>
|
>
|
||||||
<XIcon className="h-3 w-3" />
|
<XIcon className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import { DeviceRegistration } from '../components/DeviceRegistration';
|
import { DeviceRegistration } from '../components/DeviceRegistration';
|
||||||
|
import { DeviceRestore } from '../components/DeviceRestore';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
|
type Mode = 'register' | 'restore';
|
||||||
|
|
||||||
export function DevicePage() {
|
export function DevicePage() {
|
||||||
|
const { t } = useTranslation(['app']);
|
||||||
const { session, profile, setDevice } = useAuth();
|
const { session, profile, setDevice } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [mode, setMode] = useState<Mode>('register');
|
||||||
|
|
||||||
if (!session) return null; // Guarded by RequireAuth, but be defensive.
|
if (!session) return null; // Guarded by RequireAuth, but be defensive.
|
||||||
|
|
||||||
@@ -15,20 +22,81 @@ export function DevicePage() {
|
|||||||
return (
|
return (
|
||||||
<main className="relative flex min-h-screen items-center justify-center overflow-hidden bg-ink-950 px-5 py-10 text-neutral-100 sm:px-8">
|
<main className="relative flex min-h-screen items-center justify-center overflow-hidden bg-ink-950 px-5 py-10 text-neutral-100 sm:px-8">
|
||||||
<BackgroundStage />
|
<BackgroundStage />
|
||||||
<div className="relative z-10 w-full max-w-md">
|
<div className="relative z-10 w-full max-w-md space-y-3">
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
aria-label={t('app:backup.mode_label', { defaultValue: 'Gerätemodus' })}
|
||||||
|
className="flex gap-1 rounded-xl border border-white/10 bg-ink-900/60 p-1 backdrop-blur-xl"
|
||||||
|
>
|
||||||
|
<TabButton
|
||||||
|
active={mode === 'register'}
|
||||||
|
onClick={() => setMode('register')}
|
||||||
|
label={t('app:backup.mode_register', { defaultValue: 'Neu einrichten' })}
|
||||||
|
/>
|
||||||
|
<TabButton
|
||||||
|
active={mode === 'restore'}
|
||||||
|
onClick={() => setMode('restore')}
|
||||||
|
label={t('app:backup.mode_restore', { defaultValue: 'Backup wiederherstellen' })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === 'register' ? (
|
||||||
<DeviceRegistration
|
<DeviceRegistration
|
||||||
userId={session.user.id}
|
userId={session.user.id}
|
||||||
defaultName={defaultName}
|
defaultName={defaultName}
|
||||||
onRegistered={(device) => {
|
onRegistered={(device) => {
|
||||||
setDevice(device);
|
setDevice(device);
|
||||||
|
// Signal the chats page to open the post-registration backup
|
||||||
|
// prompt (AppShell reads this on mount).
|
||||||
|
try {
|
||||||
|
window.sessionStorage.setItem('chatapp.backup.prompt', '1');
|
||||||
|
} catch {
|
||||||
|
/* storage disabled — skip hint */
|
||||||
|
}
|
||||||
navigate('/chats', { replace: true });
|
navigate('/chats', { replace: true });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<DeviceRestore
|
||||||
|
userId={session.user.id}
|
||||||
|
onRestored={(device) => {
|
||||||
|
setDevice(device);
|
||||||
|
navigate('/chats', { replace: true });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TabButton({
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
label: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={active}
|
||||||
|
onClick={onClick}
|
||||||
|
className={
|
||||||
|
'flex-1 cursor-pointer rounded-lg px-3 py-2 text-xs font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' +
|
||||||
|
(active
|
||||||
|
? 'bg-brand-500/20 text-brand-100 ring-1 ring-brand-400/40'
|
||||||
|
: 'text-neutral-400 hover:bg-white/5 hover:text-neutral-100')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function BackgroundStage() {
|
function BackgroundStage() {
|
||||||
return (
|
return (
|
||||||
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
|
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import {
|
|||||||
import { extractErrorCode } from '@chat-app/shared/i18n';
|
import { extractErrorCode } from '@chat-app/shared/i18n';
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { Avatar } from '../components/Avatar';
|
||||||
import {
|
import {
|
||||||
AlertIcon,
|
AlertIcon,
|
||||||
ChatBubbleIcon,
|
ChatBubbleIcon,
|
||||||
@@ -28,6 +30,7 @@ type Tab = 'friends' | 'pending' | 'requests';
|
|||||||
export function FriendsPage() {
|
export function FriendsPage() {
|
||||||
const { t } = useTranslation(['app', 'errors']);
|
const { t } = useTranslation(['app', 'errors']);
|
||||||
const { friendships, loading, error, refresh } = useFriendshipsContext();
|
const { friendships, loading, error, refresh } = useFriendshipsContext();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [tab, setTab] = useState<Tab>('friends');
|
const [tab, setTab] = useState<Tab>('friends');
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [debounced, setDebounced] = useState('');
|
const [debounced, setDebounced] = useState('');
|
||||||
@@ -104,25 +107,26 @@ export function FriendsPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex h-full max-w-3xl flex-col gap-5 px-6 py-8">
|
<div className="min-h-full bg-surface-3 text-fg">
|
||||||
|
<div className="mx-auto flex min-h-full max-w-3xl flex-col gap-5 px-6 py-8">
|
||||||
<header className="flex items-center justify-between gap-4">
|
<header className="flex items-center justify-between gap-4">
|
||||||
<h1 className="font-display text-2xl font-semibold tracking-tight text-white">
|
<h1 className="font-display text-2xl font-semibold tracking-tight text-fg">
|
||||||
{t('app:friends.title')}
|
{t('app:friends.title')}
|
||||||
</h1>
|
</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<SearchIcon className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-neutral-500" />
|
<SearchIcon className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-fg-muted" />
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value.toLowerCase())}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder={t('app:friends.search_placeholder')}
|
placeholder={t('app:friends.search_placeholder')}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
className="w-full rounded-lg border border-white/10 bg-ink-900/60 py-2.5 pl-10 pr-3 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
|
className="w-full rounded-lg border border-line bg-surface-2 py-2.5 pl-10 pr-3 text-sm text-fg placeholder-fg-muted transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{query.length > 0 && (
|
{query.length > 0 && (
|
||||||
@@ -138,7 +142,7 @@ export function FriendsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 border-b border-white/5">
|
<div className="flex gap-2 border-b border-line">
|
||||||
<TabButton active={tab === 'friends'} onClick={() => setTab('friends')}>
|
<TabButton active={tab === 'friends'} onClick={() => setTab('friends')}>
|
||||||
{t('app:friends.tab_friends')} · {accepted.length}
|
{t('app:friends.tab_friends')} · {accepted.length}
|
||||||
</TabButton>
|
</TabButton>
|
||||||
@@ -165,7 +169,7 @@ export function FriendsPage() {
|
|||||||
onMessage={() =>
|
onMessage={() =>
|
||||||
performAction('msg-' + f.peer.userId, async () => {
|
performAction('msg-' + f.peer.userId, async () => {
|
||||||
const id = await createDm(supabase, f.peer.userId);
|
const id = await createDm(supabase, f.peer.userId);
|
||||||
navigateToChat(id);
|
navigate('/chats/' + id);
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
onUnfriend={() =>
|
onUnfriend={() =>
|
||||||
@@ -207,15 +211,10 @@ export function FriendsPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigateToChat(id: string): void {
|
|
||||||
// Push history + dispatch popstate so React Router re-evaluates the route.
|
|
||||||
window.history.pushState(null, '', '/chats/' + id);
|
|
||||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
|
||||||
}
|
|
||||||
|
|
||||||
function translateError(err: unknown, t: ReturnType<typeof useTranslation>['t']): string {
|
function translateError(err: unknown, t: ReturnType<typeof useTranslation>['t']): string {
|
||||||
const code = extractErrorCode(err);
|
const code = extractErrorCode(err);
|
||||||
if (code) return t('errors:' + code, { defaultValue: t('errors:generic') });
|
if (code) return t('errors:' + code, { defaultValue: t('errors:generic') });
|
||||||
@@ -241,8 +240,8 @@ function TabButton({
|
|||||||
className={
|
className={
|
||||||
'-mb-px cursor-pointer border-b-2 px-3 pb-2.5 text-sm font-medium transition focus:outline-none ' +
|
'-mb-px cursor-pointer border-b-2 px-3 pb-2.5 text-sm font-medium transition focus:outline-none ' +
|
||||||
(active
|
(active
|
||||||
? 'border-brand-400 text-white'
|
? 'border-accent text-fg'
|
||||||
: 'border-transparent text-neutral-400 hover:text-neutral-200')
|
: 'border-transparent text-fg-muted hover:text-fg')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -262,10 +261,11 @@ function FriendList({
|
|||||||
const { t } = useTranslation(['app']);
|
const { t } = useTranslation(['app']);
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col items-center justify-center text-center text-sm text-neutral-500">
|
<div className="flex flex-1 flex-col items-center justify-center text-center text-sm text-fg-muted">
|
||||||
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-2xl border border-white/10 bg-white/5 text-neutral-400">
|
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-2xl border border-line bg-surface-2 text-fg-muted">
|
||||||
<UsersIcon className="h-5 w-5" />
|
<UsersIcon className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p>{t(emptyKey)}</p>
|
<p>{t(emptyKey)}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -283,26 +283,21 @@ function FriendList({
|
|||||||
|
|
||||||
function FriendRow({ profile, children }: { profile: ProfileBrief; children: React.ReactNode }) {
|
function FriendRow({ profile, children }: { profile: ProfileBrief; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 rounded-xl border border-white/5 bg-ink-900/50 px-4 py-3 backdrop-blur-sm">
|
<div className="flex items-center gap-3 rounded-xl border border-line bg-surface-2 px-4 py-3">
|
||||||
<Avatar profile={profile} />
|
<Avatar
|
||||||
|
url={profile.avatarUrl}
|
||||||
|
displayName={profile.displayName ?? profile.username}
|
||||||
|
className="h-9 w-9 text-sm"
|
||||||
|
/>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-sm font-semibold text-white">{profile.displayName}</p>
|
<p className="truncate text-sm font-semibold text-fg">{profile.displayName}</p>
|
||||||
<p className="truncate text-xs text-neutral-500">@{profile.username}</p>
|
<p className="truncate text-xs text-fg-muted">@{profile.username}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 gap-2">{children}</div>
|
<div className="flex shrink-0 gap-2">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Avatar({ profile }: { profile: ProfileBrief }) {
|
|
||||||
const letter = (profile.displayName ?? profile.username ?? '?').trim().charAt(0).toUpperCase();
|
|
||||||
return (
|
|
||||||
<div className="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">
|
|
||||||
{letter || '?'}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FriendActions({
|
function FriendActions({
|
||||||
busy,
|
busy,
|
||||||
onMessage,
|
onMessage,
|
||||||
@@ -371,7 +366,7 @@ function PrimaryButton({
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-brand-500/90 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-brand-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 disabled:cursor-not-allowed disabled:opacity-60"
|
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{busy ? <SpinnerIcon className="h-3.5 w-3.5" /> : icon}
|
{busy ? <SpinnerIcon className="h-3.5 w-3.5" /> : icon}
|
||||||
<span>{children}</span>
|
<span>{children}</span>
|
||||||
@@ -393,7 +388,7 @@ function SecondaryButton({
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-xs font-medium text-neutral-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30 disabled:cursor-not-allowed disabled:opacity-60"
|
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md border border-line bg-surface-3 px-3 py-1.5 text-xs font-medium text-fg transition hover:brightness-95 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{busy && <SpinnerIcon className="h-3.5 w-3.5" />}
|
{busy && <SpinnerIcon className="h-3.5 w-3.5" />}
|
||||||
<span>{children}</span>
|
<span>{children}</span>
|
||||||
@@ -415,7 +410,7 @@ function DangerButton({
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md border border-rose-500/30 bg-rose-500/10 px-3 py-1.5 text-xs font-medium text-rose-200 transition hover:bg-rose-500/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/40 disabled:cursor-not-allowed disabled:opacity-60"
|
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md border border-rose-500/40 bg-rose-500/10 px-3 py-1.5 text-xs font-medium text-rose-600 transition hover:bg-rose-500/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/40 disabled:cursor-not-allowed disabled:opacity-60 dark:text-rose-300"
|
||||||
>
|
>
|
||||||
{busy && <SpinnerIcon className="h-3.5 w-3.5" />}
|
{busy && <SpinnerIcon className="h-3.5 w-3.5" />}
|
||||||
<span>{children}</span>
|
<span>{children}</span>
|
||||||
@@ -425,8 +420,8 @@ function DangerButton({
|
|||||||
|
|
||||||
function LoadingRow() {
|
function LoadingRow() {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 text-sm text-neutral-500">
|
<div className="flex items-center gap-3 text-sm text-fg-muted">
|
||||||
<SpinnerIcon className="h-4 w-4 text-brand-400" />
|
<SpinnerIcon className="h-4 w-4 text-accent" />
|
||||||
<span>…</span>
|
<span>…</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -440,14 +435,14 @@ function Banner({ kind, children }: { kind: 'error' | 'info'; children: React.Re
|
|||||||
className={
|
className={
|
||||||
'flex items-start gap-3 rounded-lg border p-3 text-sm ' +
|
'flex items-start gap-3 rounded-lg border p-3 text-sm ' +
|
||||||
(isError
|
(isError
|
||||||
? 'border-rose-500/20 bg-rose-500/10 text-rose-100'
|
? 'border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-100'
|
||||||
: 'border-emerald-500/20 bg-emerald-500/10 text-emerald-100')
|
: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-100')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isError ? (
|
{isError ? (
|
||||||
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" />
|
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-500 dark:text-rose-400" />
|
||||||
) : (
|
) : (
|
||||||
<CheckCircleIcon className="mt-0.5 h-5 w-5 shrink-0 text-emerald-400" />
|
<CheckCircleIcon className="mt-0.5 h-5 w-5 shrink-0 text-emerald-600 dark:text-emerald-400" />
|
||||||
)}
|
)}
|
||||||
<p className="min-w-0 flex-1 break-words">{children}</p>
|
<p className="min-w-0 flex-1 break-words">{children}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -474,24 +469,24 @@ function SearchResults({
|
|||||||
const { t } = useTranslation(['app']);
|
const { t } = useTranslation(['app']);
|
||||||
|
|
||||||
if (query.length < 2) {
|
if (query.length < 2) {
|
||||||
return <p className="text-xs text-neutral-500">{t('app:friends.search_min_chars')}</p>;
|
return <p className="text-xs text-fg-muted">{t('app:friends.search_min_chars')}</p>;
|
||||||
}
|
}
|
||||||
if (searching) {
|
if (searching) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 text-xs text-neutral-500">
|
<div className="flex items-center gap-2 text-xs text-fg-muted">
|
||||||
<SpinnerIcon className="h-3.5 w-3.5 text-brand-400" />
|
<SpinnerIcon className="h-3.5 w-3.5 text-accent" />
|
||||||
<span>…</span>
|
<span>…</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (error) return <Banner kind="error">{error}</Banner>;
|
if (error) return <Banner kind="error">{error}</Banner>;
|
||||||
if (results.length === 0) {
|
if (results.length === 0) {
|
||||||
return <p className="text-xs text-neutral-500">{t('app:friends.search_no_results')}</p>;
|
return <p className="text-xs text-fg-muted">{t('app:friends.search_no_results')}</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-neutral-500">
|
<p className="mb-2 text-xs font-medium uppercase tracking-[0.1em] text-fg-muted">
|
||||||
{t('app:friends.search_results_title')}
|
{t('app:friends.search_results_title')}
|
||||||
</p>
|
</p>
|
||||||
<ul className="space-y-1.5">
|
<ul className="space-y-1.5">
|
||||||
@@ -530,7 +525,7 @@ function SearchActionButton({
|
|||||||
|
|
||||||
if (existing?.status === 'accepted') {
|
if (existing?.status === 'accepted') {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1.5 rounded-md border border-emerald-500/20 bg-emerald-500/10 px-3 py-1.5 text-xs font-medium text-emerald-200">
|
<span className="inline-flex items-center gap-1.5 rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-1.5 text-xs font-medium text-emerald-700 dark:text-emerald-200">
|
||||||
<CheckCircleIcon className="h-3.5 w-3.5" />
|
<CheckCircleIcon className="h-3.5 w-3.5" />
|
||||||
{t('app:friends.already_friends')}
|
{t('app:friends.already_friends')}
|
||||||
</span>
|
</span>
|
||||||
@@ -539,7 +534,7 @@ function SearchActionButton({
|
|||||||
|
|
||||||
if (existing?.status === 'pending' && existing.direction === 'outgoing') {
|
if (existing?.status === 'pending' && existing.direction === 'outgoing') {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1.5 rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-neutral-300">
|
<span className="inline-flex items-center gap-1.5 rounded-md border border-line bg-surface-2 px-3 py-1.5 text-xs text-fg-muted">
|
||||||
{t('app:friends.request_sent')}
|
{t('app:friends.request_sent')}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,13 +4,16 @@ import {
|
|||||||
SUPPORTED_LOCALES,
|
SUPPORTED_LOCALES,
|
||||||
type SupportedLocale,
|
type SupportedLocale,
|
||||||
} from '@chat-app/shared/i18n';
|
} from '@chat-app/shared/i18n';
|
||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { Avatar } from '../components/Avatar';
|
||||||
|
import { BackupExportDialog } from '../components/BackupExportDialog';
|
||||||
import { LockIcon } from '../components/icons';
|
import { LockIcon } from '../components/icons';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { loadDevicePrivateKey, saveDevicePrivateKey } from '@chat-app/shared/auth';
|
import { useCall } from '../context/CallContext';
|
||||||
import { exportDeviceKey, importDeviceKey } from '../lib/deviceBackup';
|
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
|
||||||
|
import { deleteAvatarObject, uploadAvatar } from '../lib/avatarUpload';
|
||||||
import { devLocalSecretStore } from '../lib/secretStore';
|
import { devLocalSecretStore } from '../lib/secretStore';
|
||||||
import {
|
import {
|
||||||
getPttSettings,
|
getPttSettings,
|
||||||
@@ -74,15 +77,20 @@ export function SettingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="min-h-full bg-surface-3 text-fg">
|
||||||
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
|
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
|
||||||
<header className="mb-2">
|
<header className="mb-2">
|
||||||
<h1 className="font-display text-2xl font-semibold tracking-tight text-white">
|
<h1 className="font-display text-2xl font-semibold tracking-tight text-fg">
|
||||||
{t('app:settings.title')}
|
{t('app:settings.title')}
|
||||||
</h1>
|
</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Account */}
|
{/* Account */}
|
||||||
<Section title={t('app:settings.section_account')}>
|
<Section title={t('app:settings.section_account')}>
|
||||||
|
<AvatarControls
|
||||||
|
patchProfile={patchProfile}
|
||||||
|
busy={busy}
|
||||||
|
/>
|
||||||
<Row label={t('auth:signed_in.username')} value={profile?.username ?? '—'} />
|
<Row label={t('auth:signed_in.username')} value={profile?.username ?? '—'} />
|
||||||
<Row label={t('auth:signed_in.display_name')} value={profile?.displayName ?? '—'} />
|
<Row label={t('auth:signed_in.display_name')} value={profile?.displayName ?? '—'} />
|
||||||
<Row label={t('auth:signed_in.email')} value={profile?.userId ?? '—'} mono />
|
<Row label={t('auth:signed_in.email')} value={profile?.userId ?? '—'} mono />
|
||||||
@@ -91,7 +99,7 @@ export function SettingsPage() {
|
|||||||
{/* Appearance */}
|
{/* Appearance */}
|
||||||
<Section title={t('app:settings.section_appearance')}>
|
<Section title={t('app:settings.section_appearance')}>
|
||||||
<SettingRow label={t('app:settings.language')}>
|
<SettingRow label={t('app:settings.language')}>
|
||||||
<div className="inline-flex rounded-lg border border-white/10 bg-ink-800 p-1">
|
<div className="inline-flex rounded-lg border border-line bg-surface-3 p-1">
|
||||||
{SUPPORTED_LOCALES.map((locale) => {
|
{SUPPORTED_LOCALES.map((locale) => {
|
||||||
const active = (i18n.resolvedLanguage ?? i18n.language) === locale;
|
const active = (i18n.resolvedLanguage ?? i18n.language) === locale;
|
||||||
return (
|
return (
|
||||||
@@ -101,10 +109,10 @@ export function SettingsPage() {
|
|||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() => void handleLocaleChange(locale)}
|
onClick={() => void handleLocaleChange(locale)}
|
||||||
className={
|
className={
|
||||||
'cursor-pointer rounded-md px-3 py-1.5 text-xs font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' +
|
'cursor-pointer rounded-md px-3 py-1.5 text-xs font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||||
(active
|
(active
|
||||||
? 'bg-brand-500/25 text-white ring-1 ring-brand-400/40'
|
? 'bg-accent text-accent-fg'
|
||||||
: 'text-neutral-400 hover:text-neutral-200')
|
: 'text-fg-muted hover:text-fg')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{LOCALE_LABELS[locale]}
|
{LOCALE_LABELS[locale]}
|
||||||
@@ -135,11 +143,14 @@ export function SettingsPage() {
|
|||||||
|
|
||||||
{/* Voice / Push-to-Talk + Audio Quality + E2EE */}
|
{/* Voice / Push-to-Talk + Audio Quality + E2EE */}
|
||||||
<Section title={t('app:settings.section_voice', { defaultValue: 'Sprache' })}>
|
<Section title={t('app:settings.section_voice', { defaultValue: 'Sprache' })}>
|
||||||
|
<AudioDeviceControls />
|
||||||
|
<div className="mt-3 border-t border-line pt-3">
|
||||||
<AudioQualityControls />
|
<AudioQualityControls />
|
||||||
<div className="mt-3 border-t border-white/5 pt-3">
|
</div>
|
||||||
|
<div className="mt-3 border-t border-line pt-3">
|
||||||
<PttControls />
|
<PttControls />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 border-t border-white/5 pt-3">
|
<div className="mt-3 border-t border-line pt-3">
|
||||||
<CallE2EEControls />
|
<CallE2EEControls />
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
@@ -152,8 +163,8 @@ export function SettingsPage() {
|
|||||||
{/* Devices */}
|
{/* Devices */}
|
||||||
<Section title={t('app:settings.section_devices')}>
|
<Section title={t('app:settings.section_devices')}>
|
||||||
{device && (
|
{device && (
|
||||||
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/5 p-4">
|
<div className="rounded-xl border border-emerald-500/30 bg-emerald-500/10 p-4">
|
||||||
<div className="flex items-center gap-2 text-sm font-semibold text-emerald-200">
|
<div className="flex items-center gap-2 text-sm font-semibold text-emerald-700 dark:text-emerald-200">
|
||||||
<LockIcon className="h-4 w-4" />
|
<LockIcon className="h-4 w-4" />
|
||||||
{t('app:settings.this_device')}
|
{t('app:settings.this_device')}
|
||||||
</div>
|
</div>
|
||||||
@@ -172,12 +183,13 @@ export function SettingsPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void signOut()}
|
onClick={() => void signOut()}
|
||||||
className="cursor-pointer rounded-lg border border-rose-500/30 bg-rose-500/10 px-4 py-2.5 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/50"
|
className="cursor-pointer rounded-lg border border-rose-500/40 bg-rose-500/10 px-4 py-2.5 text-sm font-semibold text-rose-600 transition hover:bg-rose-500/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/50 dark:text-rose-300"
|
||||||
>
|
>
|
||||||
{t('app:settings.sign_out')}
|
{t('app:settings.sign_out')}
|
||||||
</button>
|
</button>
|
||||||
</Section>
|
</Section>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,10 +235,10 @@ function PttControls() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setCapturing((v) => !v)}
|
onClick={() => setCapturing((v) => !v)}
|
||||||
className={
|
className={
|
||||||
'inline-flex min-w-[7rem] cursor-pointer items-center justify-center rounded-lg border px-3 py-1.5 text-xs font-mono font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' +
|
'inline-flex min-w-[7rem] cursor-pointer items-center justify-center rounded-lg border px-3 py-1.5 text-xs font-mono font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||||
(capturing
|
(capturing
|
||||||
? 'border-brand-400 bg-brand-500/20 text-white animate-pulse'
|
? 'border-accent bg-accent/20 text-fg animate-pulse'
|
||||||
: 'border-white/10 bg-ink-800 text-neutral-200 hover:bg-ink-700')
|
: 'border-line bg-surface-3 text-fg hover:brightness-95')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{capturing
|
{capturing
|
||||||
@@ -283,7 +295,7 @@ function AudioQualityControls() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SettingRow label={t('app:settings.audio_quality', { defaultValue: 'Audio-Qualität' })}>
|
<SettingRow label={t('app:settings.audio_quality', { defaultValue: 'Audio-Qualität' })}>
|
||||||
<div className="inline-flex rounded-lg border border-white/10 bg-ink-800 p-1">
|
<div className="inline-flex rounded-lg border border-line bg-surface-2 p-1">
|
||||||
{AUDIO_QUALITY_ORDER.map((q) => {
|
{AUDIO_QUALITY_ORDER.map((q) => {
|
||||||
const active = cfg.quality === q;
|
const active = cfg.quality === q;
|
||||||
return (
|
return (
|
||||||
@@ -292,10 +304,10 @@ function AudioQualityControls() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => updateAudioSettings({ quality: q })}
|
onClick={() => updateAudioSettings({ quality: q })}
|
||||||
className={
|
className={
|
||||||
'cursor-pointer rounded-md px-3 py-1 text-xs font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' +
|
'cursor-pointer rounded-md px-3 py-1 text-xs font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||||
(active
|
(active
|
||||||
? 'bg-brand-500/25 text-white ring-1 ring-brand-400/40'
|
? 'bg-accent text-accent-fg'
|
||||||
: 'text-neutral-400 hover:text-neutral-200')
|
: 'text-fg-muted hover:text-fg')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{labels[q]}
|
{labels[q]}
|
||||||
@@ -304,7 +316,7 @@ function AudioQualityControls() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<div className="rounded-lg border border-white/5 bg-ink-900/40 p-3 text-[11px] text-neutral-400">
|
<div className="rounded-lg border border-line bg-surface-3 p-3 text-[11px] text-fg-muted">
|
||||||
<div className="grid grid-cols-4 gap-3">
|
<div className="grid grid-cols-4 gap-3">
|
||||||
<Stat label="Bitrate" value={params.bitrateKbps + ' kbps'} />
|
<Stat label="Bitrate" value={params.bitrateKbps + ' kbps'} />
|
||||||
<Stat label="Channels" value={params.stereo ? 'Stereo' : 'Mono'} />
|
<Stat label="Channels" value={params.stereo ? 'Stereo' : 'Mono'} />
|
||||||
@@ -312,7 +324,7 @@ function AudioQualityControls() {
|
|||||||
<Stat label="DSP" value={params.echoCancellation ? 'On' : 'Off'} />
|
<Stat label="DSP" value={params.echoCancellation ? 'On' : 'Off'} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-neutral-500">
|
<p className="text-xs text-fg-muted">
|
||||||
{cfg.quality === 'hifi'
|
{cfg.quality === 'hifi'
|
||||||
? t('app:settings.audio_hifi_hint', {
|
? t('app:settings.audio_hifi_hint', {
|
||||||
defaultValue:
|
defaultValue:
|
||||||
@@ -327,155 +339,177 @@ function AudioQualityControls() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DeviceKeyBackupControls() {
|
interface AvatarControlsProps {
|
||||||
|
patchProfile: (patch: Parameters<typeof updateOwnProfile>[1]) => Promise<void>;
|
||||||
|
busy: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarControls({ patchProfile, busy }: AvatarControlsProps) {
|
||||||
const { t } = useTranslation(['app']);
|
const { t } = useTranslation(['app']);
|
||||||
const { profile, device } = useAuth();
|
const { profile } = useAuth();
|
||||||
const [busy, setBusy] = useState(false);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const [backupOut, setBackupOut] = useState<string | null>(null);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [exportPass, setExportPass] = useState('');
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [importPass, setImportPass] = useState('');
|
|
||||||
const [importBlob, setImportBlob] = useState('');
|
|
||||||
const [msg, setMsg] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
|
|
||||||
|
|
||||||
const canRun = !!profile?.userId && !!device?.id;
|
const userId = profile?.userId;
|
||||||
|
const url = profile?.avatarUrl ?? null;
|
||||||
|
|
||||||
async function handleExport() {
|
async function handleFile(file: File) {
|
||||||
if (!canRun) return;
|
if (!userId) return;
|
||||||
setMsg(null);
|
setError(null);
|
||||||
setBusy(true);
|
setUploading(true);
|
||||||
try {
|
try {
|
||||||
const priv = await loadDevicePrivateKey(devLocalSecretStore, profile.userId, device.id);
|
const newUrl = await uploadAvatar(userId, file);
|
||||||
if (!priv) throw new Error('No device key on this install');
|
const oldUrl = url;
|
||||||
const out = await exportDeviceKey(priv, exportPass);
|
await patchProfile({ avatarUrl: newUrl });
|
||||||
setBackupOut(out);
|
if (oldUrl) {
|
||||||
setExportPass('');
|
// Best-effort cleanup of the previous file (don't block on it).
|
||||||
setMsg({
|
void deleteAvatarObject(oldUrl).catch(() => {
|
||||||
kind: 'ok',
|
/* ignore */
|
||||||
text: t('app:settings.backup_export_ok', {
|
|
||||||
defaultValue: 'Backup erstellt — kopiere und bewahre es sicher auf.',
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setMsg({
|
setError(err instanceof Error ? err.message : 'upload failed');
|
||||||
kind: 'err',
|
|
||||||
text: err instanceof Error ? err.message : 'export failed',
|
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setUploading(false);
|
||||||
|
if (inputRef.current) inputRef.current.value = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleImport() {
|
async function handleRemove() {
|
||||||
if (!canRun) return;
|
if (!userId || !url) return;
|
||||||
setMsg(null);
|
setError(null);
|
||||||
setBusy(true);
|
setUploading(true);
|
||||||
try {
|
try {
|
||||||
const priv = await importDeviceKey(importBlob.trim(), importPass);
|
await patchProfile({ avatarUrl: null });
|
||||||
await saveDevicePrivateKey(devLocalSecretStore, profile.userId, device.id, priv);
|
void deleteAvatarObject(url).catch(() => undefined);
|
||||||
setImportBlob('');
|
|
||||||
setImportPass('');
|
|
||||||
setMsg({
|
|
||||||
kind: 'ok',
|
|
||||||
text: t('app:settings.backup_import_ok', {
|
|
||||||
defaultValue:
|
|
||||||
'Schlüssel importiert. Beim nächsten Reload sollten alte Nachrichten lesbar sein.',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setMsg({
|
setError(err instanceof Error ? err.message : 'remove failed');
|
||||||
kind: 'err',
|
|
||||||
text: err instanceof Error ? err.message : 'import failed',
|
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setUploading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 rounded-xl border border-white/10 bg-ink-900/40 p-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="text-sm font-semibold text-white">
|
<Avatar
|
||||||
{t('app:settings.device_key_backup', { defaultValue: 'Geräteschlüssel-Backup' })}
|
url={url}
|
||||||
</div>
|
displayName={profile?.displayName ?? profile?.username}
|
||||||
<p className="mt-1 text-xs text-neutral-400">
|
className="h-16 w-16 text-2xl"
|
||||||
{t('app:settings.device_key_backup_hint', {
|
/>
|
||||||
defaultValue:
|
|
||||||
'Sichere deinen privaten Schlüssel passwortgeschützt, damit du auf neuen Geräten alte Nachrichten weiter lesen kannst.',
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="mt-4 space-y-2">
|
<div className="flex flex-1 flex-col gap-1">
|
||||||
<div className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
<div className="text-sm font-medium text-fg">
|
||||||
{t('app:settings.backup_export', { defaultValue: 'Export' })}
|
{t('app:settings.avatar', { defaultValue: 'Avatar' })}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="text-xs text-fg-muted">
|
||||||
<input
|
{t('app:settings.avatar_hint', {
|
||||||
type="password"
|
defaultValue: 'Quadratisch, max 512×512, WebP. Sichtbar für deine Freunde.',
|
||||||
value={exportPass}
|
})}
|
||||||
onChange={(e) => setExportPass(e.target.value)}
|
|
||||||
placeholder={t('app:settings.backup_passphrase', { defaultValue: 'Passphrase (min 8)' })}
|
|
||||||
className="flex-1 rounded-lg border border-white/10 bg-ink-800 px-3 py-2 text-sm text-white placeholder-neutral-500 focus:border-brand-400 focus:outline-none"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={busy || exportPass.length < 8 || !canRun}
|
|
||||||
onClick={() => void handleExport()}
|
|
||||||
className="cursor-pointer rounded-lg bg-brand-500/80 px-4 text-sm font-semibold text-white transition hover:bg-brand-400 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{t('app:settings.backup_create', { defaultValue: 'Erstellen' })}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{backupOut && (
|
{error && (
|
||||||
<textarea
|
<div className="text-xs text-rose-500 dark:text-rose-300">{error}</div>
|
||||||
readOnly
|
|
||||||
value={backupOut}
|
|
||||||
onClick={(e) => (e.target as HTMLTextAreaElement).select()}
|
|
||||||
rows={3}
|
|
||||||
className="w-full rounded-lg border border-emerald-500/30 bg-ink-950/60 p-2 font-mono text-[10px] text-emerald-200"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 space-y-2 border-t border-white/5 pt-4">
|
|
||||||
<div className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
|
||||||
{t('app:settings.backup_import', { defaultValue: 'Import' })}
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
value={importBlob}
|
|
||||||
onChange={(e) => setImportBlob(e.target.value)}
|
|
||||||
rows={3}
|
|
||||||
placeholder={t('app:settings.backup_blob_placeholder', {
|
|
||||||
defaultValue: 'chatapp-backup-v1.…',
|
|
||||||
})}
|
|
||||||
className="w-full rounded-lg border border-white/10 bg-ink-800 p-2 font-mono text-[11px] text-white placeholder-neutral-500 focus:border-brand-400 focus:outline-none"
|
|
||||||
/>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
<input
|
||||||
type="password"
|
ref={inputRef}
|
||||||
value={importPass}
|
type="file"
|
||||||
onChange={(e) => setImportPass(e.target.value)}
|
accept="image/*"
|
||||||
placeholder={t('app:settings.backup_passphrase', { defaultValue: 'Passphrase' })}
|
className="hidden"
|
||||||
className="flex-1 rounded-lg border border-white/10 bg-ink-800 px-3 py-2 text-sm text-white placeholder-neutral-500 focus:border-brand-400 focus:outline-none"
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (f) void handleFile(f);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={busy || !importBlob || !importPass || !canRun}
|
onClick={() => inputRef.current?.click()}
|
||||||
onClick={() => void handleImport()}
|
disabled={busy || uploading}
|
||||||
className="cursor-pointer rounded-lg bg-emerald-500/80 px-4 text-sm font-semibold text-white transition hover:bg-emerald-400 disabled:cursor-not-allowed disabled:opacity-50"
|
className="cursor-pointer rounded-lg bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{t('app:settings.backup_restore', { defaultValue: 'Wiederherstellen' })}
|
{uploading
|
||||||
|
? t('app:settings.avatar_uploading', { defaultValue: 'Lade…' })
|
||||||
|
: url
|
||||||
|
? t('app:settings.avatar_change', { defaultValue: 'Ändern' })
|
||||||
|
: t('app:settings.avatar_upload', { defaultValue: 'Hochladen' })}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
{url && (
|
||||||
</div>
|
<button
|
||||||
|
type="button"
|
||||||
{msg && (
|
onClick={() => void handleRemove()}
|
||||||
<p
|
disabled={busy || uploading}
|
||||||
className={
|
className="cursor-pointer rounded-lg border border-rose-500/40 bg-rose-500/10 px-3 py-1.5 text-xs font-semibold text-rose-600 transition hover:bg-rose-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:text-rose-300"
|
||||||
'mt-3 text-xs ' +
|
|
||||||
(msg.kind === 'ok' ? 'text-emerald-300' : 'text-rose-300')
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{msg.text}
|
{t('app:settings.avatar_remove', { defaultValue: 'Entfernen' })}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeviceKeyBackupControls() {
|
||||||
|
const { t } = useTranslation(['app']);
|
||||||
|
const { profile, device } = useAuth();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [privateKey, setPrivateKey] = useState<Uint8Array | null>(null);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const canRun = !!profile?.userId && !!device?.id;
|
||||||
|
|
||||||
|
async function handleOpen() {
|
||||||
|
if (!canRun) return;
|
||||||
|
setErr(null);
|
||||||
|
try {
|
||||||
|
const priv = await loadDevicePrivateKey(devLocalSecretStore, profile.userId, device.id);
|
||||||
|
if (!priv) throw new Error(t('app:backup.no_key_here', {
|
||||||
|
defaultValue: 'Kein Geräteschlüssel auf dieser Installation.',
|
||||||
|
}));
|
||||||
|
setPrivateKey(priv);
|
||||||
|
setOpen(true);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setErr(e instanceof Error ? e.message : 'failed to load key');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
setOpen(false);
|
||||||
|
if (privateKey) {
|
||||||
|
for (let i = 0; i < privateKey.length; i++) privateKey[i] = 0;
|
||||||
|
}
|
||||||
|
setPrivateKey(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-4 rounded-xl border border-line bg-surface-3 p-4">
|
||||||
|
<div className="text-sm font-semibold text-fg">
|
||||||
|
{t('app:settings.device_key_backup', { defaultValue: 'Geräteschlüssel-Backup' })}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-fg-muted">
|
||||||
|
{t('app:settings.device_key_backup_hint_v2', {
|
||||||
|
defaultValue:
|
||||||
|
'Backup deines Geräteschlüssels. Ohne Backup kein Zugriff auf alte Nachrichten wenn Gerät oder Browser-Storage verloren geht. Wiederherstellung passiert im Gerät-Einrichtungsbildschirm.',
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!canRun}
|
||||||
|
onClick={() => void handleOpen()}
|
||||||
|
className="mt-3 inline-flex cursor-pointer items-center gap-1.5 rounded-lg bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('app:backup.export_open', { defaultValue: 'Backup erstellen' })}
|
||||||
|
</button>
|
||||||
|
{err && (
|
||||||
|
<p className="mt-2 text-xs text-rose-600 dark:text-rose-300">{err}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{open && profile && device && privateKey && (
|
||||||
|
<BackupExportDialog
|
||||||
|
open={open}
|
||||||
|
userId={profile.userId}
|
||||||
|
deviceId={device.id}
|
||||||
|
privateKey={privateKey}
|
||||||
|
onClose={handleClose}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -497,7 +531,7 @@ function ScreenShareControls() {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateScreenShareSettings({ preset: e.target.value as ScreenSharePreset })
|
updateScreenShareSettings({ preset: e.target.value as ScreenSharePreset })
|
||||||
}
|
}
|
||||||
className="cursor-pointer rounded-lg border border-white/10 bg-ink-800 px-3 py-1.5 text-xs text-neutral-200 focus:border-brand-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
|
className="cursor-pointer rounded-lg border border-line bg-surface-3 px-3 py-1.5 text-xs text-fg focus:border-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||||
>
|
>
|
||||||
{PRESET_ORDER.map((p) => (
|
{PRESET_ORDER.map((p) => (
|
||||||
<option key={p} value={p}>
|
<option key={p} value={p}>
|
||||||
@@ -506,7 +540,7 @@ function ScreenShareControls() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<div className="rounded-lg border border-white/5 bg-ink-900/40 p-3 text-[11px] text-neutral-400">
|
<div className="rounded-lg border border-line bg-surface-3 p-3 text-[11px] text-fg-muted">
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<Stat label="Bitrate (max)" value={formatBitrate(params.bitrateKbps)} />
|
<Stat label="Bitrate (max)" value={formatBitrate(params.bitrateKbps)} />
|
||||||
<Stat
|
<Stat
|
||||||
@@ -516,7 +550,7 @@ function ScreenShareControls() {
|
|||||||
<Stat label="Framerate" value={params.framerate + ' fps'} />
|
<Stat label="Framerate" value={params.framerate + ' fps'} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-neutral-500">
|
<p className="text-xs text-fg-muted">
|
||||||
{t('app:settings.screen_share_hint', {
|
{t('app:settings.screen_share_hint', {
|
||||||
defaultValue:
|
defaultValue:
|
||||||
'WebRTC passt Bitrate + Auflösung dynamisch an die Netzwerkqualität an (SVC/VP9). Die Werte sind Obergrenzen. Änderungen greifen beim nächsten Call.',
|
'WebRTC passt Bitrate + Auflösung dynamisch an die Netzwerkqualität an (SVC/VP9). Die Werte sind Obergrenzen. Änderungen greifen beim nächsten Call.',
|
||||||
@@ -529,8 +563,8 @@ function ScreenShareControls() {
|
|||||||
function Stat({ label, value }: { label: string; value: string }) {
|
function Stat({ label, value }: { label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[10px] uppercase tracking-wide text-neutral-500">{label}</div>
|
<div className="text-[10px] uppercase tracking-[0.1em] text-fg-muted">{label}</div>
|
||||||
<div className="mt-0.5 font-mono text-neutral-200">{value}</div>
|
<div className="mt-0.5 font-mono text-fg">{value}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -544,8 +578,8 @@ function formatBitrate(kbps: number): string {
|
|||||||
|
|
||||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<section className="rounded-2xl border border-white/10 bg-ink-900/60 p-5 backdrop-blur-xl">
|
<section className="rounded-2xl border border-line bg-surface-2 p-5">
|
||||||
<h2 className="mb-4 text-xs font-semibold uppercase tracking-wide text-neutral-400">
|
<h2 className="mb-4 text-xs font-semibold uppercase tracking-[0.1em] text-fg-muted">
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="space-y-3">{children}</div>
|
<div className="space-y-3">{children}</div>
|
||||||
@@ -556,10 +590,10 @@ function Section({ title, children }: { title: string; children: React.ReactNode
|
|||||||
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<dt className="text-sm text-neutral-500">{label}</dt>
|
<dt className="text-sm text-fg-muted">{label}</dt>
|
||||||
<dd
|
<dd
|
||||||
className={
|
className={
|
||||||
'max-w-[60%] truncate text-right text-sm text-neutral-200 ' +
|
'max-w-[60%] truncate text-right text-sm text-fg ' +
|
||||||
(mono ? 'font-mono text-xs' : '')
|
(mono ? 'font-mono text-xs' : '')
|
||||||
}
|
}
|
||||||
title={value}
|
title={value}
|
||||||
@@ -573,7 +607,7 @@ function Row({ label, value, mono }: { label: string; value: string; mono?: bool
|
|||||||
function SettingRow({ label, children }: { label: string; children: React.ReactNode }) {
|
function SettingRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<span className="text-sm text-neutral-200">{label}</span>
|
<span className="text-sm text-fg">{label}</span>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -595,8 +629,8 @@ function Toggle({
|
|||||||
return (
|
return (
|
||||||
<label className="flex cursor-pointer items-start justify-between gap-4">
|
<label className="flex cursor-pointer items-start justify-between gap-4">
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="block text-sm text-neutral-200">{label}</span>
|
<span className="block text-sm text-fg">{label}</span>
|
||||||
{hint && <span className="mt-1 block text-xs text-neutral-500">{hint}</span>}
|
{hint && <span className="mt-1 block text-xs text-fg-muted">{hint}</span>}
|
||||||
</span>
|
</span>
|
||||||
<span className="relative mt-0.5 inline-flex h-6 w-11 shrink-0">
|
<span className="relative mt-0.5 inline-flex h-6 w-11 shrink-0">
|
||||||
<input
|
<input
|
||||||
@@ -606,9 +640,202 @@ function Toggle({
|
|||||||
onChange={(e) => onChange(e.target.checked)}
|
onChange={(e) => onChange(e.target.checked)}
|
||||||
className="peer sr-only"
|
className="peer sr-only"
|
||||||
/>
|
/>
|
||||||
<span className="inline-block h-6 w-11 rounded-full bg-neutral-700 transition peer-checked:bg-brand-500/70 peer-disabled:opacity-50" />
|
<span className="inline-block h-6 w-11 rounded-full bg-surface transition peer-checked:bg-accent peer-disabled:opacity-50" />
|
||||||
<span className="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition peer-checked:translate-x-5" />
|
<span className="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white shadow transition peer-checked:translate-x-5" />
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Audio device selection — persisted input + output deviceIds, hot-swap on
|
||||||
|
// active calls. Output swap uses HTMLMediaElement.setSinkId on our attached
|
||||||
|
// <audio> elements (LiveKit's switchActiveDevice only tracks elements it
|
||||||
|
// attached itself).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function AudioDeviceControls() {
|
||||||
|
const { t } = useTranslation(['app']);
|
||||||
|
const { setAudioInputDevice, setAudioOutputDevice } = useCall();
|
||||||
|
const [inputs, setInputs] = useState<MediaDeviceInfo[]>([]);
|
||||||
|
const [outputs, setOutputs] = useState<MediaDeviceInfo[]>([]);
|
||||||
|
const [inputId, setInputId] = useState<string | null>(
|
||||||
|
() => getAudioSettings().inputDeviceId,
|
||||||
|
);
|
||||||
|
const [outputId, setOutputId] = useState<string | null>(
|
||||||
|
() => getAudioSettings().outputDeviceId,
|
||||||
|
);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [permission, setPermission] = useState<'unknown' | 'granted' | 'denied'>(
|
||||||
|
'unknown',
|
||||||
|
);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const list = await navigator.mediaDevices.enumerateDevices();
|
||||||
|
setInputs(list.filter((d) => d.kind === 'audioinput'));
|
||||||
|
setOutputs(list.filter((d) => d.kind === 'audiooutput'));
|
||||||
|
// If labels are empty, permission hasn't been granted yet — browsers
|
||||||
|
// mask device names until a getUserMedia call succeeds at least once.
|
||||||
|
const hasLabels = list.some(
|
||||||
|
(d) => (d.kind === 'audioinput' || d.kind === 'audiooutput') && d.label.length > 0,
|
||||||
|
);
|
||||||
|
setPermission(hasLabels ? 'granted' : 'unknown');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : 'enumerateDevices failed');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
const onChange = () => void refresh();
|
||||||
|
try {
|
||||||
|
navigator.mediaDevices.addEventListener('devicechange', onChange);
|
||||||
|
} catch {
|
||||||
|
/* some browsers omit devicechange */
|
||||||
|
}
|
||||||
|
const unsubSettings = subscribeAudioSettings((s) => {
|
||||||
|
setInputId(s.inputDeviceId);
|
||||||
|
setOutputId(s.outputDeviceId);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
try {
|
||||||
|
navigator.mediaDevices.removeEventListener('devicechange', onChange);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
unsubSettings();
|
||||||
|
};
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const requestPermission = useCallback(async () => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
stream.getTracks().forEach((t) => t.stop());
|
||||||
|
setPermission('granted');
|
||||||
|
await refresh();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setPermission('denied');
|
||||||
|
setError(err instanceof Error ? err.message : 'permission denied');
|
||||||
|
}
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const handleInput = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
const next = id === '' ? null : id;
|
||||||
|
setInputId(next);
|
||||||
|
await setAudioInputDevice(next);
|
||||||
|
},
|
||||||
|
[setAudioInputDevice],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOutput = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
const next = id === '' ? null : id;
|
||||||
|
setOutputId(next);
|
||||||
|
await setAudioOutputDevice(next);
|
||||||
|
},
|
||||||
|
[setAudioOutputDevice],
|
||||||
|
);
|
||||||
|
|
||||||
|
const outputSupported =
|
||||||
|
typeof HTMLAudioElement !== 'undefined' &&
|
||||||
|
typeof HTMLAudioElement.prototype.setSinkId === 'function';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-fg">
|
||||||
|
{t('app:settings.mic_title', { defaultValue: 'Mikrofon' })}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-fg-muted">
|
||||||
|
{t('app:settings.mic_hint', {
|
||||||
|
defaultValue: 'Eingabegerät. Bei aktivem Anruf wird live umgeschaltet.',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={inputId ?? ''}
|
||||||
|
onChange={(e) => void handleInput(e.target.value)}
|
||||||
|
className="flex-1 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{t('app:settings.mic_default', { defaultValue: 'Systemstandard' })}
|
||||||
|
</option>
|
||||||
|
{inputs.map((d) => (
|
||||||
|
<option key={d.deviceId} value={d.deviceId}>
|
||||||
|
{d.label || t('app:settings.mic_unnamed', { defaultValue: 'Unbenannt' })}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void refresh()}
|
||||||
|
className="cursor-pointer rounded-lg border border-line bg-surface-2 px-3 py-2 text-xs font-medium text-fg transition hover:bg-surface-3"
|
||||||
|
>
|
||||||
|
{t('app:settings.mic_refresh', { defaultValue: 'Neu laden' })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-fg">
|
||||||
|
{t('app:settings.speaker_title', { defaultValue: 'Ausgabegerät' })}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-fg-muted">
|
||||||
|
{t('app:settings.speaker_hint', {
|
||||||
|
defaultValue:
|
||||||
|
'Lautsprecher oder Kopfhörer. Wird auf alle aktiven Audio-Streams angewendet.',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
{!outputSupported && (
|
||||||
|
<p className="mt-1 text-xs text-amber-600 dark:text-amber-300">
|
||||||
|
{t('app:settings.speaker_unsupported', {
|
||||||
|
defaultValue: 'Browser unterstützt setSinkId nicht — Ausgabe folgt System-Default.',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={outputId ?? ''}
|
||||||
|
disabled={!outputSupported}
|
||||||
|
onChange={(e) => void handleOutput(e.target.value)}
|
||||||
|
className="flex-1 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{t('app:settings.mic_default', { defaultValue: 'Systemstandard' })}
|
||||||
|
</option>
|
||||||
|
{outputs.map((d) => (
|
||||||
|
<option key={d.deviceId} value={d.deviceId}>
|
||||||
|
{d.label || t('app:settings.mic_unnamed', { defaultValue: 'Unbenannt' })}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{permission !== 'granted' && (
|
||||||
|
<div className="flex items-center justify-between gap-3 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-700 dark:text-amber-200">
|
||||||
|
<span>
|
||||||
|
{t('app:settings.mic_permission_hint', {
|
||||||
|
defaultValue:
|
||||||
|
'Ohne Mikrofon-Freigabe erscheinen Gerätenamen nicht. Einmal erlauben und Liste lädt neu.',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void requestPermission()}
|
||||||
|
className="shrink-0 cursor-pointer rounded-md bg-amber-600 px-2.5 py-1 text-xs font-semibold text-white transition hover:bg-amber-500"
|
||||||
|
>
|
||||||
|
{t('app:settings.mic_grant', { defaultValue: 'Freigeben' })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-xs text-rose-600 dark:text-rose-300">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,11 +4,42 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
@layer base {
|
/* ---------------------------------------------------------------------------
|
||||||
:root {
|
* Clean-Rail design tokens.
|
||||||
color-scheme: dark;
|
*
|
||||||
}
|
* `--bg-rgb / --fg-rgb / --accent-rgb` are *space-separated rgb triplets*
|
||||||
|
* (no `rgb(...)` wrapper) so Tailwind's `rgb(var(--x) / <alpha-value>)`
|
||||||
|
* alpha modifier works (e.g. `bg-surface/40`).
|
||||||
|
*
|
||||||
|
* `--line` is a full color value with baked-in alpha — theme decides whether
|
||||||
|
* borders are inked-on-white or white-on-ink.
|
||||||
|
* ------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-rgb: 250 250 249;
|
||||||
|
--bg-2-rgb: 244 244 242;
|
||||||
|
--bg-3-rgb: 255 255 255;
|
||||||
|
--fg-rgb: 10 10 15;
|
||||||
|
--fg-muted-rgb: 115 115 128;
|
||||||
|
--accent-rgb: 79 70 229;
|
||||||
|
--accent-fg-rgb: 255 255 255;
|
||||||
|
--line: rgba(10, 10, 15, 0.08);
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--bg-rgb: 5 5 7;
|
||||||
|
--bg-2-rgb: 17 17 24;
|
||||||
|
--bg-3-rgb: 10 10 15;
|
||||||
|
--fg-rgb: 244 244 245;
|
||||||
|
--fg-muted-rgb: 138 138 153;
|
||||||
|
--accent-rgb: 109 115 255;
|
||||||
|
--accent-fg-rgb: 255 255 255;
|
||||||
|
--line: rgba(255, 255, 255, 0.07);
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
html,
|
html,
|
||||||
body,
|
body,
|
||||||
#root {
|
#root {
|
||||||
@@ -16,12 +47,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-ink-950 text-neutral-100 font-sans antialiased;
|
@apply bg-surface text-fg font-sans antialiased;
|
||||||
font-feature-settings: 'cv11', 'ss01';
|
font-feature-settings: 'cv11', 'ss01';
|
||||||
}
|
}
|
||||||
|
|
||||||
::selection {
|
::selection {
|
||||||
@apply bg-brand-500/40 text-white;
|
@apply bg-accent/40 text-white;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
@@ -46,4 +77,15 @@
|
|||||||
background-size: 32px 32px;
|
background-size: 32px 32px;
|
||||||
background-position: -1px -1px;
|
background-position: -1px -1px;
|
||||||
}
|
}
|
||||||
|
/* Glass pill used for the fullscreen call controls bar. */
|
||||||
|
.glass-pill {
|
||||||
|
background: rgba(10, 10, 15, 0.7);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
.glass-chip {
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// Shim sumo types to the non-sumo types package. sumo is an API superset of
|
||||||
|
// libsodium-wrappers; its runtime exports match the standard wrappers module
|
||||||
|
// and additionally include `crypto_pwhash` (Argon2id). Reuse the existing
|
||||||
|
// `@types/libsodium-wrappers` definitions rather than duplicating them.
|
||||||
|
declare module 'libsodium-wrappers-sumo' {
|
||||||
|
import sodium from 'libsodium-wrappers';
|
||||||
|
export default sodium;
|
||||||
|
export * from 'libsodium-wrappers';
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
|
darkMode: 'class',
|
||||||
content: [
|
content: [
|
||||||
'./index.html',
|
'./index.html',
|
||||||
'./src/**/*.{ts,tsx}',
|
'./src/**/*.{ts,tsx}',
|
||||||
@@ -51,12 +52,29 @@ export default {
|
|||||||
800: '#312E81',
|
800: '#312E81',
|
||||||
900: '#1E1B4B',
|
900: '#1E1B4B',
|
||||||
},
|
},
|
||||||
|
// Clean-Rail design tokens. RGB-tuple form so alpha modifiers
|
||||||
|
// (e.g. bg-surface/50) work under both themes.
|
||||||
|
surface: 'rgb(var(--bg-rgb) / <alpha-value>)',
|
||||||
|
'surface-2': 'rgb(var(--bg-2-rgb) / <alpha-value>)',
|
||||||
|
'surface-3': 'rgb(var(--bg-3-rgb) / <alpha-value>)',
|
||||||
|
fg: 'rgb(var(--fg-rgb) / <alpha-value>)',
|
||||||
|
'fg-muted': 'rgb(var(--fg-muted-rgb) / <alpha-value>)',
|
||||||
|
accent: 'rgb(var(--accent-rgb) / <alpha-value>)',
|
||||||
|
'accent-fg': 'rgb(var(--accent-fg-rgb) / <alpha-value>)',
|
||||||
|
// Line has baked-in theme-specific alpha — direct var, no modifier.
|
||||||
|
line: 'var(--line)',
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
'blob-a': 'blob 22s ease-in-out infinite',
|
'blob-a': 'blob 22s ease-in-out infinite',
|
||||||
'blob-b': 'blob 28s ease-in-out infinite reverse',
|
'blob-b': 'blob 28s ease-in-out infinite reverse',
|
||||||
'fade-in': 'fadeIn 400ms ease-out',
|
'fade-in': 'fadeIn 400ms ease-out',
|
||||||
'slide-up': 'slideUp 350ms ease-out',
|
'slide-up': 'slideUp 350ms ease-out',
|
||||||
|
'pulse-ring': 'pulseRing 2s ease-out infinite',
|
||||||
|
'audio-pulse': 'audioPulse 1.3s ease-out infinite',
|
||||||
|
'live-dot': 'liveDotPulse 1.5s ease-in-out infinite',
|
||||||
|
'slide-in-call': 'slideInCall 0.4s cubic-bezier(0.22,1,0.36,1)',
|
||||||
|
'fs-hint': 'fsHintFade 3.5s ease-out forwards',
|
||||||
|
'reaction-pop': 'reactionPop 220ms cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||||
},
|
},
|
||||||
keyframes: {
|
keyframes: {
|
||||||
blob: {
|
blob: {
|
||||||
@@ -72,9 +90,39 @@ export default {
|
|||||||
from: { opacity: '0', transform: 'translateY(8px)' },
|
from: { opacity: '0', transform: 'translateY(8px)' },
|
||||||
to: { opacity: '1', transform: 'translateY(0)' },
|
to: { opacity: '1', transform: 'translateY(0)' },
|
||||||
},
|
},
|
||||||
|
pulseRing: {
|
||||||
|
'0%': { transform: 'scale(0.85)', opacity: '0.6' },
|
||||||
|
'100%': { transform: 'scale(1.25)', opacity: '0' },
|
||||||
|
},
|
||||||
|
audioPulse: {
|
||||||
|
'0%': { transform: 'scale(1)', opacity: '0.8' },
|
||||||
|
'100%': { transform: 'scale(1.3)', opacity: '0' },
|
||||||
|
},
|
||||||
|
liveDotPulse: {
|
||||||
|
'0%, 100%': { opacity: '1' },
|
||||||
|
'50%': { opacity: '0.4' },
|
||||||
|
},
|
||||||
|
slideInCall: {
|
||||||
|
from: { opacity: '0', transform: 'translateY(20px)' },
|
||||||
|
to: { opacity: '1', transform: 'translateY(0)' },
|
||||||
|
},
|
||||||
|
fsHintFade: {
|
||||||
|
'0%': { opacity: '0', transform: 'translate(-50%, -6px)' },
|
||||||
|
'15%, 75%': { opacity: '1', transform: 'translate(-50%, 0)' },
|
||||||
|
'100%': { opacity: '0', transform: 'translate(-50%, -6px)' },
|
||||||
|
},
|
||||||
|
reactionPop: {
|
||||||
|
'0%': { opacity: '0', transform: 'scale(0.4)' },
|
||||||
|
'70%': { opacity: '1', transform: 'scale(1.15)' },
|
||||||
|
'100%': { opacity: '1', transform: 'scale(1)' },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
boxShadow: {
|
boxShadow: {
|
||||||
glow: '0 0 40px -8px rgba(99, 102, 241, 0.35)',
|
glow: '0 0 40px -8px rgba(99, 102, 241, 0.35)',
|
||||||
|
'call-card': '0 20px 60px rgba(0,0,0,0.1)',
|
||||||
|
'call-card-dark': '0 20px 60px rgba(0,0,0,0.5)',
|
||||||
|
'pip-call': '0 12px 40px rgba(0,0,0,0.2), 0 0 0 3px rgba(79,70,229,0.15)',
|
||||||
|
'accept-btn': '0 4px 14px rgba(22,163,74,0.3)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,11 +16,11 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
// libsodium-wrappers 0.7.16 ships a broken "import" condition in its
|
// libsodium-wrappers-sumo (and the compact variant) ship broken "import"
|
||||||
// package exports (the ESM bundle references a sibling ./libsodium.mjs
|
// conditions in package exports — the ESM bundle references a sibling
|
||||||
// that isn't in the published artefact). Force esbuild to pick the
|
// ./libsodium.mjs that isn't in the published artefact. Force esbuild to
|
||||||
// "require" condition so the self-contained CJS build is used.
|
// pick the "require" condition so the self-contained CJS build is used.
|
||||||
include: ['libsodium-wrappers'],
|
include: ['libsodium-wrappers-sumo'],
|
||||||
esbuildOptions: {
|
esbuildOptions: {
|
||||||
conditions: ['require', 'node', 'default'],
|
conditions: ['require', 'node', 'default'],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>ChatApp Redesign — 6 Varianten</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Geist:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@500;600;700;800&family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&family=Fraunces:wght@600;700;800&family=Archivo+Black&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="styles/base.css">
|
||||||
|
<link rel="stylesheet" href="styles/variants.css">
|
||||||
|
<link rel="stylesheet" href="styles/rail.css">
|
||||||
|
<link rel="stylesheet" href="styles/call.css">
|
||||||
|
<link rel="stylesheet" href="styles/pages.css">
|
||||||
|
<style>
|
||||||
|
/* Cache-busting inline override for rail page-mode grid */
|
||||||
|
.rail-layout.page-mode { grid-template-columns: 64px 1fr !important; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- TWEAKS state -->
|
||||||
|
<script>
|
||||||
|
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
|
||||||
|
"showCall": false,
|
||||||
|
"density": "normal",
|
||||||
|
"font": "default"
|
||||||
|
}/*EDITMODE-END*/;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Variant switcher -->
|
||||||
|
<div class="variant-switcher" id="variant-switcher"></div>
|
||||||
|
|
||||||
|
<div class="variant-container" id="root"></div>
|
||||||
|
|
||||||
|
<!-- Tweaks panel -->
|
||||||
|
<div class="tweaks-panel" id="tweaks-panel">
|
||||||
|
<h3>Tweaks</h3>
|
||||||
|
<div class="tweaks-row">
|
||||||
|
<label>Incoming call overlay</label>
|
||||||
|
<select id="tweak-call">
|
||||||
|
<option value="false">Off</option>
|
||||||
|
<option value="true">Show</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="tweaks-row">
|
||||||
|
<label>Content density</label>
|
||||||
|
<select id="tweak-density">
|
||||||
|
<option value="normal">Normal</option>
|
||||||
|
<option value="compact">Compact</option>
|
||||||
|
<option value="spacious">Spacious</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="tweaks-row" style="font-size: 11px; opacity: 0.6; margin-top: 16px;">
|
||||||
|
Tipp: Klicke oben auf die Varianten um zu wechseln.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="components/icons.jsx" type="text/babel"></script>
|
||||||
|
<script src="components/data.jsx" type="text/babel"></script>
|
||||||
|
<script src="components/call.jsx" type="text/babel"></script>
|
||||||
|
<script src="components/pages.jsx" type="text/babel"></script>
|
||||||
|
<script src="components/app.jsx" type="text/babel"></script>
|
||||||
|
|
||||||
|
<script type="text/babel">
|
||||||
|
const VARIANTS = [
|
||||||
|
{ key: 'clean', label: 'Clean Minimal', layout: 'topnav' },
|
||||||
|
{ key: 'clean-rail', label: 'Clean Rail', baseKey: 'clean', layout: 'rail' },
|
||||||
|
{ key: 'playful', label: 'Playful', layout: 'topnav' },
|
||||||
|
{ key: 'y2k', label: 'Y2K Glass', layout: 'topnav' },
|
||||||
|
{ key: 'cyber', label: 'Cyberpunk', layout: 'topnav' },
|
||||||
|
{ key: 'warm', label: 'Warm Soft', layout: 'topnav' },
|
||||||
|
{ key: 'brutal', label: 'Brutal Gaming', layout: 'topnav' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const App = () => {
|
||||||
|
const [activeVariant, setActiveVariant] = React.useState(() => {
|
||||||
|
return localStorage.getItem('chatapp-variant') || 'clean';
|
||||||
|
});
|
||||||
|
const [isDark, setIsDark] = React.useState(() => {
|
||||||
|
return localStorage.getItem('chatapp-dark') === 'true';
|
||||||
|
});
|
||||||
|
const [tweaks, setTweaks] = React.useState(TWEAK_DEFAULTS);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
localStorage.setItem('chatapp-variant', activeVariant);
|
||||||
|
}, [activeVariant]);
|
||||||
|
React.useEffect(() => {
|
||||||
|
localStorage.setItem('chatapp-dark', String(isDark));
|
||||||
|
}, [isDark]);
|
||||||
|
|
||||||
|
// Tweaks wiring
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handler = (e) => {
|
||||||
|
if (e.data?.type === '__activate_edit_mode') {
|
||||||
|
document.getElementById('tweaks-panel').classList.add('open');
|
||||||
|
} else if (e.data?.type === '__deactivate_edit_mode') {
|
||||||
|
document.getElementById('tweaks-panel').classList.remove('open');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('message', handler);
|
||||||
|
window.parent.postMessage({type: '__edit_mode_available'}, '*');
|
||||||
|
|
||||||
|
const callSel = document.getElementById('tweak-call');
|
||||||
|
const densSel = document.getElementById('tweak-density');
|
||||||
|
callSel.value = String(tweaks.showCall);
|
||||||
|
densSel.value = tweaks.density;
|
||||||
|
callSel.onchange = () => {
|
||||||
|
const v = callSel.value === 'true';
|
||||||
|
setTweaks(t => ({...t, showCall: v}));
|
||||||
|
window.parent.postMessage({type: '__edit_mode_set_keys', edits: {showCall: v}}, '*');
|
||||||
|
};
|
||||||
|
densSel.onchange = () => {
|
||||||
|
setTweaks(t => ({...t, density: densSel.value}));
|
||||||
|
window.parent.postMessage({type: '__edit_mode_set_keys', edits: {density: densSel.value}}, '*');
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => window.removeEventListener('message', handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Render variant switcher buttons
|
||||||
|
React.useEffect(() => {
|
||||||
|
const sw = document.getElementById('variant-switcher');
|
||||||
|
sw.innerHTML = '';
|
||||||
|
VARIANTS.forEach((v, i) => {
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.textContent = `${String(i+1).padStart(2,'0')} ${v.label}`;
|
||||||
|
if (v.key === activeVariant) b.classList.add('active');
|
||||||
|
b.onclick = () => setActiveVariant(v.key);
|
||||||
|
sw.appendChild(b);
|
||||||
|
});
|
||||||
|
}, [activeVariant]);
|
||||||
|
|
||||||
|
const idx = VARIANTS.findIndex(v => v.key === activeVariant);
|
||||||
|
const variant = { ...VARIANTS[idx], idx };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChatApp
|
||||||
|
key={activeVariant}
|
||||||
|
variant={variant}
|
||||||
|
showCall={tweaks.showCall}
|
||||||
|
showProfile={false}
|
||||||
|
isDark={isDark}
|
||||||
|
onToggleDark={() => setIsDark(d => !d)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
# Handoff: ChatApp — Clean Rail Redesign (Chat + Friends + Admin + Settings + Calls)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Full design spec for redesigning the existing ChatApp (Netralax) interface around a **Clean Rail** layout aesthetic. Covers **four main screens** plus the complete call UX:
|
||||||
|
|
||||||
|
1. **Chat** — icon-rail + chat list + conversation area
|
||||||
|
2. **Friends** — friends list, outgoing requests, incoming requests, user search
|
||||||
|
3. **Admin** — stats, invite-code management, user administration
|
||||||
|
4. **Settings** — account, appearance, privacy, voice/audio, screenshare, devices, danger zone
|
||||||
|
5. **Calls** — incoming overlay → in-call dock → fullscreen → picture-in-picture
|
||||||
|
|
||||||
|
All screens work in **light** and **dark** mode.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## About the Design Files
|
||||||
|
|
||||||
|
The files in this bundle are **design references created in HTML/React/CSS** — prototypes showing the intended look, layout, and behavior. They are **not production code** to copy verbatim.
|
||||||
|
|
||||||
|
Your task is to **recreate these designs inside the existing ChatApp codebase** using its established patterns (existing React components, styling conventions, state management, icon library, etc.). If some tokens or helper components don't exist yet in the codebase, add them but keep them aligned with what's already there.
|
||||||
|
|
||||||
|
File layout:
|
||||||
|
- `ChatApp Redesign.html` — entry HTML (open in a browser to see the prototype)
|
||||||
|
- `components/app.jsx` — main layout (rail + chat list + chat area + call state machine + tab switching)
|
||||||
|
- `components/pages.jsx` — **Friends, Admin, Settings page components** plus shared page primitives (PageHeader, Section, Toggle, SegmentedControl, Row, Empty, Banner, FlagChip, FriendRow)
|
||||||
|
- `components/call.jsx` — call UI (IncomingCallScreen, ActiveCallScreen, ParticipantTile, CallControls, PipCall)
|
||||||
|
- `components/icons.jsx` — SVG icon set
|
||||||
|
- `components/data.jsx` — dummy chat/message data
|
||||||
|
- `styles/base.css` — shared base tokens + layout primitives
|
||||||
|
- `styles/variants.css` — `variant-clean` light + dark token definitions
|
||||||
|
- `styles/rail.css` — rail-layout specifics
|
||||||
|
- `styles/call.css` — call UI styles
|
||||||
|
- `styles/pages.css` — **Friends/Admin/Settings page styles**
|
||||||
|
- `screenshots/` — reference images
|
||||||
|
|
||||||
|
## Fidelity
|
||||||
|
|
||||||
|
**High-fidelity.** Exact colors, radii, spacing, typography and motion are specified. Recreate pixel-perfectly using the codebase's existing component library — but prefer the codebase's primitives over duplicating the prototype's raw CSS where equivalents exist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Tokens (Clean Rail)
|
||||||
|
|
||||||
|
### Colors — Light mode (`.variant-clean`)
|
||||||
|
```
|
||||||
|
--bg: #fafaf9
|
||||||
|
--bg-2: #f4f4f2
|
||||||
|
--bg-3: #ffffff
|
||||||
|
--fg: #0a0a0f
|
||||||
|
--fg-muted: #737380
|
||||||
|
--line: rgba(10,10,15,0.08)
|
||||||
|
--accent: #4F46E5
|
||||||
|
--accent-fg: #ffffff
|
||||||
|
```
|
||||||
|
|
||||||
|
### Colors — Dark mode (`.variant-clean.dark`)
|
||||||
|
```
|
||||||
|
--bg: #050507
|
||||||
|
--bg-2: #111118
|
||||||
|
--bg-3: #0A0A0F
|
||||||
|
--fg: #f4f4f5
|
||||||
|
--fg-muted: #8a8a99
|
||||||
|
--line: rgba(255,255,255,0.07)
|
||||||
|
--accent: #6D73FF
|
||||||
|
--accent-fg: #ffffff
|
||||||
|
```
|
||||||
|
|
||||||
|
### Semantic Colors
|
||||||
|
```
|
||||||
|
Success / Online / E2EE: #16a34a (light) · #4ade80 / #22c55e (dark)
|
||||||
|
Danger / Decline: #dc2626 / #b91c1c hover · #fb7185 (dark accent for borders/text)
|
||||||
|
Warning / Expired: #f59e0b · #d97706 light · #fbbf24 dark
|
||||||
|
Live / Recording: #ef4444
|
||||||
|
```
|
||||||
|
|
||||||
|
### Avatar swatches (assigned per user)
|
||||||
|
```
|
||||||
|
violet: bg #ddd6fe / fg #5b21b6 (dark: #312E81 / #C4B5FD)
|
||||||
|
amber: bg #fde68a / fg #78350f
|
||||||
|
rose: bg #fecdd3 / fg #881337
|
||||||
|
teal: bg #99f6e4 / fg #134e4a (dark: #134e4a / #99f6e4)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Typography
|
||||||
|
- Family: `'Inter', system-ui, sans-serif` base; display `'Outfit'` (page titles, call participant name, stat-card values); mono `'JetBrains Mono'` (invite codes, device IDs, keyboard hotkeys, inline stats).
|
||||||
|
- Scale:
|
||||||
|
- Page title (`Freunde`, `Admin`, `Einstellungen`, call participant): 28px (page) / 24px (call) / 700 / Outfit / letter-spacing -0.02em
|
||||||
|
- Section label (uppercase eyebrow): 11px / 600 / tracked 0.08em
|
||||||
|
- Body labels, chat name, friend name: 14–15px / 600
|
||||||
|
- Body text, bubbles, settings hint: 12–14px / 400–500
|
||||||
|
- Meta, timestamps, handles, stat labels: 11–12px / 500, uppercase 10–11px tracked 0.06em
|
||||||
|
- Mono values: 11–12px / JetBrains Mono / 500
|
||||||
|
|
||||||
|
### Spacing / Radius
|
||||||
|
- Page content max-width: **820px**, centered, 28px top padding, 32px horizontal.
|
||||||
|
- Section card radius 16, section header padded 16×20 with bottom border, section body padded 16×20 gap 14.
|
||||||
|
- Button radius 8–10, chip/pill 999, stat card 12, device card 12.
|
||||||
|
- Toggle switch: 40×22 with 16×16 thumb, 2px inset, transitions all 0.2s.
|
||||||
|
|
||||||
|
### Shadows
|
||||||
|
- Elevated card light: `0 20px 60px rgba(0,0,0,0.1)`
|
||||||
|
- Elevated card dark: `0 20px 60px rgba(0,0,0,0.5)`
|
||||||
|
- Accent button: `0 4px 14px rgba(79,70,229,0.25)`
|
||||||
|
- Avatar-large glow: `0 2px 6px rgba(0,0,0,0.08)`
|
||||||
|
|
||||||
|
### Motion
|
||||||
|
- Standard UI transition: `all 0.15s`
|
||||||
|
- Toggle switch thumb: `transform 0.2s`
|
||||||
|
- PTT hotkey capture pulse: `keyPulse 1.2s ease-in-out infinite` (opacity 1↔0.6)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tab Switching Behavior
|
||||||
|
|
||||||
|
The icon rail (`.icon-rail`) holds 4 primary tabs: **Chats**, **Friends**, **Admin**, **Settings**. Clicking an icon sets `activeTab`. Layout adapts:
|
||||||
|
|
||||||
|
- **`activeTab === 'chats'`** → renders icon-rail + chat-list + chat-area (3 columns: `64px 280px 1fr`).
|
||||||
|
- **`activeTab !== 'chats'`** → renders icon-rail + page content (2 columns: `64px 1fr`). Chat list is removed. The page component (`FriendsPageContent`, `AdminPageContent`, `SettingsPageContent`) renders inside the chat-area slot with its own scroll container and 820px centered inner.
|
||||||
|
|
||||||
|
The `.rail-layout.page-mode` class toggles the grid-template-columns. **IMPORTANT:** this override is inlined in the entry HTML as a `<style>` block to avoid CSS-cache issues during dev. In production, put it in your main stylesheet.
|
||||||
|
|
||||||
|
The **call dock** and **call screen switcher** are only shown on the chats tab.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Screen 1: Chat
|
||||||
|
|
||||||
|
**Structure (left → right):**
|
||||||
|
- **Icon Rail** (64px): brand logo, tab buttons (Chats/Friends/Admin), spacer, Settings + theme-toggle at bottom. Active tab shows a vertical pill indicator on the left edge.
|
||||||
|
- **Chat List** (280px): header with title + "new chat" icon, search input, list of `ChatItem`s (avatar + name + time + preview, optional unread dot), `rail-user` footer showing own profile + quick mute/settings.
|
||||||
|
- **Chat Area** (flex): chat header (counterpart avatar + name + presence/handle + search/voice/video), messages, composer.
|
||||||
|
|
||||||
|
**Message bubbles:**
|
||||||
|
- Mine: filled accent bg, white text, radius `18px 18px 4px 18px`.
|
||||||
|
- Theirs: transparent bg, 1px line border, fg text, radius `18px 18px 18px 4px`.
|
||||||
|
- Avatars only on the **last message of a run**.
|
||||||
|
- Reactions: pill row below, `.mine` = filled accent.
|
||||||
|
- System events: centered `event-pill`, muted bg/fg, 11px.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Screen 2: Friends
|
||||||
|
|
||||||
|
Component: **`FriendsPageContent`** (`components/pages.jsx`)
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
- `PageHeader` with title "Freunde" and subtitle `"{N} Freunde · {M} offene Anfragen"`.
|
||||||
|
- **Global search bar** (sticky at top of scroll): icon + input, placeholder "Benutzer suchen oder hinzufügen…". Focused state → border becomes accent.
|
||||||
|
- **Tabs** (only when not searching): 3 pill tabs with inline counts — `Freunde`, `Ausgehend`, `Anfragen`. When there are pending incoming requests, the tab count badge is red (`.tab-count.highlight`).
|
||||||
|
- **List area**: either tab content OR search results, mutually exclusive.
|
||||||
|
|
||||||
|
### Search behavior
|
||||||
|
- Trigger: input.length ≥ 2. Below that, tabs show.
|
||||||
|
- Filters a global user directory (API: `GET /api/users/search?q={query}` — returns up to 20 matches).
|
||||||
|
- Each result renders a `FriendRow` with a single primary button: "Anfrage senden" (icon + label). On click → `POST /api/friendships` with `{ targetUserId }`, optimistically updates the row to "Angefragt".
|
||||||
|
- Empty state: "Keine Ergebnisse für „{query}"" with Search icon.
|
||||||
|
|
||||||
|
### Tabs
|
||||||
|
|
||||||
|
**Freunde** (`status === 'accepted'`):
|
||||||
|
- FriendRow with: Nachricht (primary button opens conversation), Voice icon button (starts call), "Entfernen" (danger-ghost, confirms before `DELETE /api/friendships/{id}`).
|
||||||
|
- Empty: "Noch keine Freunde. Suche oben nach Benutzern um anzufangen."
|
||||||
|
|
||||||
|
**Ausgehend** (`status === 'pending' && direction === 'outgoing'`):
|
||||||
|
- "Wartet auf Antwort…" italicized + `btn-ghost` "Abbrechen" → `DELETE /api/friendships/{id}`.
|
||||||
|
- Empty: "Keine ausgehenden Anfragen."
|
||||||
|
|
||||||
|
**Anfragen** (`status === 'pending' && direction === 'incoming'`):
|
||||||
|
- `btn-primary` "Annehmen" (check icon) → `PATCH /api/friendships/{id}` with `{ accept: true }`; `btn-ghost` "Ablehnen" → same endpoint `{ accept: false }`.
|
||||||
|
- Empty: "Keine eingehenden Anfragen."
|
||||||
|
|
||||||
|
### FriendRow (shared)
|
||||||
|
12px gap row, hover → `bg-2`. Avatar (with presence indicator on accepted friends), name (14px/600), handle `@{username}` (12px muted), actions on the right.
|
||||||
|
|
||||||
|
### Data shapes
|
||||||
|
```ts
|
||||||
|
type Friendship = {
|
||||||
|
id: string;
|
||||||
|
userId: string; // the OTHER user's ID
|
||||||
|
displayName: string;
|
||||||
|
username: string; // @handle
|
||||||
|
avatarColor: 'violet'|'amber'|'rose'|'teal';
|
||||||
|
initial: string; // derived from displayName[0] uppercase
|
||||||
|
status: 'accepted' | 'pending';
|
||||||
|
direction: 'outgoing' | 'incoming' | null; // null when accepted
|
||||||
|
presence?: 'online' | 'idle' | 'dnd' | 'offline';
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Screen 3: Admin
|
||||||
|
|
||||||
|
Component: **`AdminPageContent`** (`components/pages.jsx`)
|
||||||
|
|
||||||
|
Gated route — only accessible if `currentUser.isAdmin === true`. If not, render 403 "Kein Zugriff" page.
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
1. **`PageHeader`** "Admin" + subtitle `"{userCount} User · {activeInviteCount} aktive Invites"`.
|
||||||
|
2. **Stats grid** (4 cards, 1fr × 4 @ ≥1200px, 2×2 below):
|
||||||
|
- Gesamt User
|
||||||
|
- Admins
|
||||||
|
- Gebannt (value in `#dc2626` / dark `#fb7185`)
|
||||||
|
- Aktive Invites
|
||||||
|
- Each card: 11px uppercase tracked label, 28px Outfit bold value, tabular-nums.
|
||||||
|
3. **System section**: single `Toggle` for `invites_enabled`. Hint: "Wenn aus, können keine neuen User per Invite-Code beitreten."
|
||||||
|
- Maps to `PATCH /api/admin/settings { invitesEnabled: boolean }`.
|
||||||
|
4. **Invite-Codes section**:
|
||||||
|
- Header action: **+ Neuer Code** primary button → opens modal (out of scope here) with fields for `code` (optional; auto-generated if blank), `usesLimit` (int or ∞), `expiresAt` (date or never).
|
||||||
|
- Table columns: Code · Nutzungen · Ablauf · Status · actions.
|
||||||
|
- Code column: mono 12px.
|
||||||
|
- Status pill: `Aktiv` (green), `Abgelaufen` (amber), `Deaktiviert` (red).
|
||||||
|
- Actions (per row, right-aligned): Copy icon (shows check on copy, 1.4s timeout), "Deaktivieren/Aktivieren" ghost xs button, red Trash icon.
|
||||||
|
- API:
|
||||||
|
- List: `GET /api/admin/invites`
|
||||||
|
- Create: `POST /api/admin/invites`
|
||||||
|
- Toggle: `PATCH /api/admin/invites/{code} { disabled: boolean }`
|
||||||
|
- Delete: `DELETE /api/admin/invites/{code}`
|
||||||
|
5. **Benutzer section**:
|
||||||
|
- Row per user: avatar + name (with **Admin badge** if admin — pill with crown icon, accent bg + `accent-fg`) + `@handle · beigetreten {date}` meta.
|
||||||
|
- Flag chips on the right:
|
||||||
|
- **Admin** (toggle accent when active)
|
||||||
|
- **Invite-Block** (toggle accent when active) — prevents this user from creating invites
|
||||||
|
- **Bann** (`tone="danger"` — red when active)
|
||||||
|
- Click a chip → `PATCH /api/admin/users/{userId} { [flag]: boolean }`. Optimistic update.
|
||||||
|
- No delete — banning is the terminal state.
|
||||||
|
|
||||||
|
### Data shapes
|
||||||
|
```ts
|
||||||
|
type Invite = {
|
||||||
|
code: string;
|
||||||
|
usesCount: number;
|
||||||
|
usesLimit: number | null; // null = unlimited
|
||||||
|
expiresAt: string | null; // ISO date
|
||||||
|
disabled: boolean;
|
||||||
|
createdBy: string; // userId
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AdminUser = {
|
||||||
|
userId: string;
|
||||||
|
displayName: string;
|
||||||
|
username: string;
|
||||||
|
initial: string;
|
||||||
|
avatarColor: string;
|
||||||
|
isAdmin: boolean;
|
||||||
|
blockedFromInviting: boolean;
|
||||||
|
banned: boolean;
|
||||||
|
joinedAt: string; // display-formatted in list
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Screen 4: Settings
|
||||||
|
|
||||||
|
Component: **`SettingsPageContent`** (`components/pages.jsx`)
|
||||||
|
|
||||||
|
### Sections (in this exact order)
|
||||||
|
|
||||||
|
**1. Account**
|
||||||
|
- `avatar-control` row: 64px circular avatar with initial, label + hint, "Ändern" primary, "Entfernen" danger-ghost.
|
||||||
|
- Upload: 512×512 max, WebP only, client-side crop, `POST /api/me/avatar`.
|
||||||
|
- `row-stack` with 3 `Row` entries:
|
||||||
|
- Benutzername (editable — click to inline-edit, `PATCH /api/me { username }`, uniqueness check)
|
||||||
|
- Anzeigename (editable, `PATCH /api/me { displayName }`)
|
||||||
|
- User-ID (mono, read-only)
|
||||||
|
|
||||||
|
**2. Aussehen**
|
||||||
|
- Sprache: `SegmentedControl` Deutsch/English → `PATCH /api/me { locale }` and updates i18n context.
|
||||||
|
- Theme: `SegmentedControl` ☀ Light / ☾ Dark → toggles dark mode globally. Wire to your theme provider; persist in localStorage + `PATCH /api/me { theme }`.
|
||||||
|
|
||||||
|
**3. Datenschutz**
|
||||||
|
- `Toggle` Lesebestätigungen (bidirectional — hint explains).
|
||||||
|
- `Toggle` DMs von Fremden erlauben.
|
||||||
|
|
||||||
|
**4. Sprache & Audio**
|
||||||
|
- `SegmentedControl` Audio-Qualität: `Sprache` (default) vs `HiFi`.
|
||||||
|
- **Sprache**: 48 kbps Mono Opus, 48 kHz, DSP on (noise suppression + echo cancellation + auto-gain).
|
||||||
|
- **HiFi**: 510 kbps Stereo Opus, 48 kHz, DSP off.
|
||||||
|
- Hint text updates live with the mode.
|
||||||
|
- Inline `stats-inline` box showing the 4 resolved parameters (Bitrate / Channels / Sample / DSP).
|
||||||
|
- Divider.
|
||||||
|
- `Toggle` Push-to-Talk. When enabled, an extra row appears: label "Hotkey" + `.key-capture` button showing the current key. Click → enters capture mode (pulsing animation), next keydown sets the key. Esc cancels.
|
||||||
|
- Divider.
|
||||||
|
- `Toggle` E2EE (default on). Hint: all participants must have it on.
|
||||||
|
|
||||||
|
**5. Bildschirmfreigabe**
|
||||||
|
- `SegmentedControl` Flüssig / Balanced / Scharf:
|
||||||
|
- Flüssig: 3 Mbps, 1280×720, 60 fps
|
||||||
|
- Balanced: 6 Mbps, 1920×1080, 30 fps (default)
|
||||||
|
- Scharf: 12 Mbps, 2560×1440, 30 fps
|
||||||
|
- Inline `stats-inline` shows resolved params.
|
||||||
|
|
||||||
|
**6. Geräte**
|
||||||
|
- `device-card` (green-tinted): lock icon + "Dieses Gerät", followed by Row stack: Name, Plattform, Device-ID (mono).
|
||||||
|
- `device-backup` card: password input + "Export erstellen" button → generates an encrypted JSON file containing the device's private key + room keys, downloaded client-side (never sent to server).
|
||||||
|
|
||||||
|
**7. Danger Zone**
|
||||||
|
- Single `btn-danger` "Abmelden" (full red button). Triggers sign-out + clears all local state.
|
||||||
|
|
||||||
|
### SegmentedControl
|
||||||
|
Inline group with 3px padding, transparent buttons, active button elevated with white bg (light) or elevated surface (dark) + shadow. 12px/500.
|
||||||
|
|
||||||
|
### Toggle
|
||||||
|
40×22 track, 16×16 thumb, 999 radius. Off: `bg-2` bg + `line` border. On: `accent` bg+border. Thumb translates 18px on. Full click target = entire row (via `<label>`).
|
||||||
|
|
||||||
|
### State persistence
|
||||||
|
Every setting writes to `PATCH /api/me/settings { [key]: value }` on change, with a debounced save (300ms for toggles is fine, immediate for segmented controls). Show a subtle top-right toast "Gespeichert" on success (not shown in this prototype).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Screen 5: Calls (unchanged from previous scope)
|
||||||
|
|
||||||
|
See previous sections — Incoming → Active Dock → Fullscreen Cinema → PiP flow with `callScreen` + `callMode` state machine, Esc key handling, Discord-style dock above messages.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shared Page Primitives
|
||||||
|
|
||||||
|
All in `components/pages.jsx`. Re-use these across Friends/Admin/Settings; they define the aesthetic rhythm of the whole app.
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<PageHeader title="…" subtitle="…" actions={<button />} />
|
||||||
|
<Section title="…" action={<button />}>…</Section>
|
||||||
|
<Toggle label="…" hint="…" checked={} onChange={} />
|
||||||
|
<SegmentedControl value={} onChange={} options={[{value, label}]} />
|
||||||
|
<Row label="…" value="…" mono /> // key/value line in a stack
|
||||||
|
<Stat label="…" value="…" /> // used inside stats-inline
|
||||||
|
<Banner kind="error|success">…</Banner>
|
||||||
|
<Empty icon={Icon.X}>…</Empty> // for empty states
|
||||||
|
<FlagChip active={} tone="danger" onToggle={}>Admin</FlagChip>
|
||||||
|
<FriendRow profile={}>{actions}</FriendRow>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints (used by pages)
|
||||||
|
|
||||||
|
Friends:
|
||||||
|
```
|
||||||
|
GET /api/friendships → { accepted, outgoing, incoming }
|
||||||
|
POST /api/friendships { targetUserId } → creates outgoing
|
||||||
|
PATCH /api/friendships/{id} { accept: boolean }
|
||||||
|
DELETE /api/friendships/{id}
|
||||||
|
GET /api/users/search?q=… → up to 20 users (friendships excluded)
|
||||||
|
```
|
||||||
|
|
||||||
|
Admin:
|
||||||
|
```
|
||||||
|
GET /api/admin/stats → { userCount, adminCount, bannedCount, activeInviteCount }
|
||||||
|
GET /api/admin/settings → { invitesEnabled }
|
||||||
|
PATCH /api/admin/settings { invitesEnabled }
|
||||||
|
GET /api/admin/invites → Invite[]
|
||||||
|
POST /api/admin/invites { code?, usesLimit?, expiresAt? } → Invite
|
||||||
|
PATCH /api/admin/invites/{code} { disabled }
|
||||||
|
DELETE /api/admin/invites/{code}
|
||||||
|
GET /api/admin/users → AdminUser[]
|
||||||
|
PATCH /api/admin/users/{userId} { isAdmin? | blockedFromInviting? | banned? }
|
||||||
|
```
|
||||||
|
|
||||||
|
Settings / Me:
|
||||||
|
```
|
||||||
|
GET /api/me → profile + settings
|
||||||
|
PATCH /api/me { username? | displayName? | locale? | theme? }
|
||||||
|
PATCH /api/me/settings { readReceipts? | allowDmsFromStrangers? | audioQuality? | ptt? | pttKey? | e2ee? | screenShareQuality? }
|
||||||
|
POST /api/me/avatar multipart → new avatar URL
|
||||||
|
DELETE /api/me/avatar
|
||||||
|
GET /api/me/devices → Device[]
|
||||||
|
POST /api/me/devices/backup { passphrase } → encrypted blob download
|
||||||
|
POST /api/auth/signout
|
||||||
|
```
|
||||||
|
|
||||||
|
Scope assumption: your codebase already has most of these (see `FriendshipsContext.tsx`, `AdminPage.tsx`, `SettingsPage.tsx`). Wire the new UI to the existing hooks — don't rebuild the data layer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Call State Machine (unchanged)
|
||||||
|
|
||||||
|
```js
|
||||||
|
const [callScreen, setCallScreen] = useState('none'); // 'none' | 'incoming' | 'active' | 'pip'
|
||||||
|
const [callMode, setCallMode] = useState('grid'); // 'grid' | 'focus' | 'fullscreen'
|
||||||
|
const [focusedId, setFocusedId] = useState(firstSharingOrSpeakerId);
|
||||||
|
const [callState, setCallState] = useState({ muted: false, sharing: false, video: false, duration });
|
||||||
|
```
|
||||||
|
|
||||||
|
Triggers, keyboard and visibility rules identical to previous spec.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Checklist
|
||||||
|
|
||||||
|
1. **Tokens**: add the Clean variant light + dark CSS variables to your theme layer.
|
||||||
|
2. **Rail layout**: refactor top-nav to icon-rail + chat list + chat area (`.rail-layout`). Add `.rail-layout.page-mode` (2-column) for non-chat tabs.
|
||||||
|
3. **Tab switching**: lift `activeTab` state. Conditionally render chat area vs page content.
|
||||||
|
4. **Page primitives**: implement `PageHeader`, `Section`, `Toggle`, `SegmentedControl`, `Row`, `Stat`, `Banner`, `Empty`, `FlagChip`, `FriendRow` using your existing component library where possible.
|
||||||
|
5. **FriendsPage**: wire to `FriendshipsContext` + user-search endpoint. Tabs + search + optimistic mutations.
|
||||||
|
6. **AdminPage**: stats grid + system toggle + invites table (copy-to-clipboard, enable/disable, delete) + users list (flag chips with optimistic updates).
|
||||||
|
7. **SettingsPage**: all 7 sections with two-way binding to user profile + settings. Debounced saves. Theme toggle wired to theme context.
|
||||||
|
8. **Message bubbles + call UX** (already specified in previous scope — unchanged).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes for Claude Code
|
||||||
|
|
||||||
|
- The Clean Rail variant is **Variante 2** in the prototype's variant switcher — ignore the other 5 aesthetic variants (Playful, Y2K, Cyberpunk, Warm, Brutal).
|
||||||
|
- `components/pages.jsx` uses **dummy data** for display. Replace with your live contexts (`FriendshipsContext`, admin API hooks, user/settings hooks).
|
||||||
|
- Prefer composition over copying raw CSS. If the codebase has a `Card`, `Button`, `Toggle`, use them.
|
||||||
|
- Keep German copy as-is — it matches the existing product. English translations via i18n are a separate task.
|
||||||
|
- The `.rail-layout.page-mode` grid override is inlined in the HTML in this prototype. Move it to your main stylesheet.
|
||||||
|
- All settings that affect media (audio quality, PTT, E2EE, screen-share quality) must be read at **call-start time** by the existing call code — they do not hot-apply to an active call.
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
// Main ChatApp component — layout shared across all variants
|
||||||
|
|
||||||
|
const Avatar = ({ chat, size = '' }) => (
|
||||||
|
<div className={`avatar ${size}`} data-color={chat.color}>
|
||||||
|
{chat.initial}
|
||||||
|
{chat.presence && <div className={`presence ${chat.presence}`}></div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ChatItem = ({ chat, onClick }) => (
|
||||||
|
<div className={`chat-item ${chat.active ? 'active' : ''}`} onClick={onClick}>
|
||||||
|
<Avatar chat={chat} />
|
||||||
|
<div className="chat-item-body">
|
||||||
|
<div className="chat-item-name">
|
||||||
|
<span>{chat.name}</span>
|
||||||
|
<span className="chat-item-time">{chat.time}</span>
|
||||||
|
</div>
|
||||||
|
<div className="chat-item-preview">
|
||||||
|
{chat.preview}
|
||||||
|
{chat.unread && <span className="unread-dot" style={{background: 'var(--accent, #a78bfa)', display: 'inline-block'}}></span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Reactions = ({ reactions }) => (
|
||||||
|
<div className="reactions">
|
||||||
|
{reactions.map((r, i) => (
|
||||||
|
<div key={i} className={`reaction ${r.mine ? 'mine' : ''}`}>
|
||||||
|
<span>{r.emoji}</span>
|
||||||
|
<span className="count">{r.count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Bubble = ({ msg }) => (
|
||||||
|
<div className={`bubble ${msg.from}`}>
|
||||||
|
{msg.text}
|
||||||
|
<span className="ts">{msg.time}{msg.read && msg.from === 'me' ? ' · Gelesen' : ''}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const MediaBubble = ({ msg }) => (
|
||||||
|
<div className="bubble me" style={{padding: 4}}>
|
||||||
|
<div className="media-preview">
|
||||||
|
<div className="media-image">{msg.label}</div>
|
||||||
|
<div className="media-meta">
|
||||||
|
<span>{msg.filename}</span>
|
||||||
|
<span>{msg.size}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="ts" style={{paddingLeft: 8, paddingBottom: 4}}>{msg.time}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const EventPill = ({ evt }) => {
|
||||||
|
const IconComp = evt.icon === 'phone' ? Icon.Phone : Icon.PhoneOff;
|
||||||
|
return (
|
||||||
|
<div className="event-row">
|
||||||
|
<div className={`event-pill ${evt.kind === 'rejected' ? 'rejected' : ''}`}>
|
||||||
|
<IconComp />
|
||||||
|
<span>{evt.text}</span>
|
||||||
|
{evt.duration && <span>· {evt.duration}</span>}
|
||||||
|
<span>· {evt.time}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TypingIndicator = () => (
|
||||||
|
<div className="bubble them" style={{padding: '10px 14px'}}>
|
||||||
|
<div className="typing"><span></span><span></span><span></span></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const MessageItem = ({ msg, showAvatar, chat, myChat }) => {
|
||||||
|
if (msg.type === 'event') return <EventPill evt={msg} />;
|
||||||
|
|
||||||
|
const wrapWithAvatar = (inner) => (
|
||||||
|
<div className={`message-row ${msg.from}`}>
|
||||||
|
{msg.from === 'them' && (
|
||||||
|
<div className="message-avatar-slot">
|
||||||
|
{showAvatar && <Avatar chat={{...chat, presence: null}} size="sm" />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={`message-group ${msg.from}`}>
|
||||||
|
{inner}
|
||||||
|
</div>
|
||||||
|
{msg.from === 'me' && (
|
||||||
|
<div className="message-avatar-slot">
|
||||||
|
{showAvatar && <Avatar chat={{...myChat, presence: null}} size="sm" />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (msg.type === 'typing') {
|
||||||
|
return wrapWithAvatar(<TypingIndicator />);
|
||||||
|
}
|
||||||
|
if (msg.type === 'media') {
|
||||||
|
return wrapWithAvatar(<MediaBubble msg={msg} />);
|
||||||
|
}
|
||||||
|
return wrapWithAvatar(
|
||||||
|
<>
|
||||||
|
<Bubble msg={msg} />
|
||||||
|
{msg.reactions && <Reactions reactions={msg.reactions} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProfileCard = ({ chat, position }) => (
|
||||||
|
<div className="profile-card" style={{top: position.top, left: position.left}}>
|
||||||
|
<div className="profile-card-banner"></div>
|
||||||
|
<div className="profile-card-body">
|
||||||
|
<div className="profile-card-avatar" data-color={chat.color} style={{background: 'var(--accent, #a78bfa)', color: '#fff'}}>
|
||||||
|
{chat.initial}
|
||||||
|
</div>
|
||||||
|
<div className="profile-card-name">{chat.name}</div>
|
||||||
|
<div className="profile-card-handle">{chat.handle}</div>
|
||||||
|
<div className="profile-card-meta">
|
||||||
|
<div>🟢 Online · seit 2h</div>
|
||||||
|
<div>📍 Beigetreten am 14. Mär 2026</div>
|
||||||
|
<div>🎮 Zockt gerade: Valorant</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CallOverlay = ({ chat }) => (
|
||||||
|
<div className="call-overlay">
|
||||||
|
<Avatar chat={{...chat, presence: null}} size="lg" />
|
||||||
|
<div className="call-info">
|
||||||
|
<div className="call-name">Max ruft an…</div>
|
||||||
|
<div className="call-sub">Voice Call · klingelt</div>
|
||||||
|
</div>
|
||||||
|
<div className="call-actions">
|
||||||
|
<button className="call-btn decline"><Icon.PhoneOff /></button>
|
||||||
|
<button className="call-btn accept"><Icon.Phone /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ChatApp = ({ variant, showCall, showProfile, isDark, onToggleDark }) => {
|
||||||
|
const [activeChat, setActiveChat] = React.useState(CHATS[0]);
|
||||||
|
const [activeTab, setActiveTab] = React.useState('chats');
|
||||||
|
|
||||||
|
// Call state: 'none' | 'incoming' | 'active' | 'pip'
|
||||||
|
const [callScreen, setCallScreen] = React.useState('none');
|
||||||
|
const [callMode, setCallMode] = React.useState('grid'); // grid | focus | fullscreen
|
||||||
|
const [focusedId, setFocusedId] = React.useState('max');
|
||||||
|
const [callState, setCallState] = React.useState({
|
||||||
|
muted: false, sharing: false, video: false, duration: '00:12:47'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Esc exits fullscreen
|
||||||
|
React.useEffect(() => {
|
||||||
|
const onKey = (e) => {
|
||||||
|
if (e.key === 'Escape' && callScreen === 'active' && callMode === 'fullscreen') {
|
||||||
|
setCallMode('grid');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [callScreen, callMode]);
|
||||||
|
|
||||||
|
const chats = CHATS.map(c => ({...c, active: c.id === activeChat.id}));
|
||||||
|
const messagesEndRef = React.useRef(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (messagesEndRef.current) {
|
||||||
|
messagesEndRef.current.parentElement.scrollTop = messagesEndRef.current.parentElement.scrollHeight;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`variant variant-${variant.baseKey || variant.key} ${isDark ? 'dark' : ''} ${variant.layout === 'rail' ? 'layout-rail' : 'layout-topnav'} active`} data-screen-label={`0${variant.idx+1} ${variant.label}`}>
|
||||||
|
<div className="app-window" style={{position: 'relative'}}>
|
||||||
|
<div className="window-bar">
|
||||||
|
<div className="dots">
|
||||||
|
<div className="dot" style={{background: '#ff5f57'}}></div>
|
||||||
|
<div className="dot" style={{background: '#febc2e'}}></div>
|
||||||
|
<div className="dot" style={{background: '#28c840'}}></div>
|
||||||
|
</div>
|
||||||
|
<div className="title">ChatApp — {variant.label}</div>
|
||||||
|
<div style={{width: 52}}></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="app-body">
|
||||||
|
{variant.layout === 'rail' ? (
|
||||||
|
<div className={`rail-layout ${activeTab !== 'chats' ? 'page-mode' : ''}`}>
|
||||||
|
{/* Icon rail left */}
|
||||||
|
<div className="icon-rail">
|
||||||
|
<div className="rail-brand" title="Netralax"><Icon.Logo /></div>
|
||||||
|
<div className="rail-divider"></div>
|
||||||
|
<button className={`rail-btn ${activeTab === 'chats' ? 'active' : ''}`} onClick={() => setActiveTab('chats')} title="Chats">
|
||||||
|
<Icon.Chat />
|
||||||
|
{activeTab === 'chats' && <div className="rail-pill"></div>}
|
||||||
|
</button>
|
||||||
|
<button className={`rail-btn ${activeTab === 'friends' ? 'active' : ''}`} onClick={() => setActiveTab('friends')} title="Freunde">
|
||||||
|
<Icon.Friends />
|
||||||
|
{activeTab === 'friends' && <div className="rail-pill"></div>}
|
||||||
|
</button>
|
||||||
|
<button className={`rail-btn ${activeTab === 'admin' ? 'active' : ''}`} onClick={() => setActiveTab('admin')} title="Admin">
|
||||||
|
<Icon.Shield />
|
||||||
|
{activeTab === 'admin' && <div className="rail-pill"></div>}
|
||||||
|
</button>
|
||||||
|
<div style={{flex: 1}}></div>
|
||||||
|
<button className={`rail-btn ${activeTab === 'settings' ? 'active' : ''}`} onClick={() => setActiveTab('settings')} title="Einstellungen">
|
||||||
|
<Icon.Settings />
|
||||||
|
</button>
|
||||||
|
<button className="rail-btn" onClick={onToggleDark} title={isDark ? 'Light mode' : 'Dark mode'}>
|
||||||
|
{isDark ? <Icon.Sun /> : <Icon.Moon />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* Chatlist + user profile at bottom — only on chats tab */}
|
||||||
|
{activeTab === 'chats' && (
|
||||||
|
<div className="chat-list rail-chatlist">
|
||||||
|
<div className="chat-list-header">
|
||||||
|
<div className="chat-list-title">Chats</div>
|
||||||
|
<button className="icon-btn" title="Neuer Chat"><Icon.AddUser /></button>
|
||||||
|
</div>
|
||||||
|
<input className="chat-search" placeholder="Suche…" />
|
||||||
|
<div className="chat-items">
|
||||||
|
{chats.map(c => (
|
||||||
|
<ChatItem key={c.id} chat={c} onClick={() => setActiveChat(c)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="rail-user">
|
||||||
|
<Avatar chat={{initial: 'D', color: 'teal', presence: 'online'}} size="sm" />
|
||||||
|
<div style={{fontSize: 12, flex: 1, minWidth: 0}}>
|
||||||
|
<div style={{fontWeight: 600}}>dennis</div>
|
||||||
|
<div style={{opacity: 0.6, fontSize: 10}}>● Online</div>
|
||||||
|
</div>
|
||||||
|
<button className="icon-btn" title="Mute"><Icon.Mic /></button>
|
||||||
|
<button className="icon-btn" title="Einstellungen"><Icon.Settings /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Chat area OR page content */}
|
||||||
|
<div className={`chat-area ${activeTab !== 'chats' ? 'has-page' : ''}`}>
|
||||||
|
{activeTab === 'friends' && <FriendsPageContent />}
|
||||||
|
{activeTab === 'admin' && <AdminPageContent />}
|
||||||
|
{activeTab === 'settings' && <SettingsPageContent isDark={isDark} onToggleDark={onToggleDark} />}
|
||||||
|
{activeTab === 'chats' && <>
|
||||||
|
{!(callScreen === 'active' && callMode !== 'fullscreen') && (
|
||||||
|
<div className="chat-header">
|
||||||
|
<Avatar chat={activeChat} />
|
||||||
|
<div className="chat-header-info">
|
||||||
|
<div className="chat-header-name">{activeChat.name}</div>
|
||||||
|
<div className="chat-header-status">
|
||||||
|
{activeChat.presence === 'online' ? '● Online' :
|
||||||
|
activeChat.presence === 'idle' ? '● Abwesend' :
|
||||||
|
activeChat.presence === 'dnd' ? '● Nicht stören' :
|
||||||
|
'● Offline · zuletzt vor 2h'}
|
||||||
|
{' · '}{activeChat.handle}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="chat-header-actions">
|
||||||
|
<button className="icon-btn" title="Suche"><Icon.Search /></button>
|
||||||
|
<button className="icon-btn" title="Voice" onClick={() => setCallScreen('active')}><Icon.Phone /></button>
|
||||||
|
<button className="icon-btn" title="Video" onClick={() => { setCallState(s => ({...s, video: true})); setCallScreen('active'); }}><Icon.Video /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Call dock — rendered above messages, Discord-style */}
|
||||||
|
{callScreen === 'active' && callMode !== 'fullscreen' && (
|
||||||
|
<div className="call-dock">
|
||||||
|
<ActiveCallScreen
|
||||||
|
participants={CALL_PARTICIPANTS}
|
||||||
|
mode={callMode}
|
||||||
|
setMode={setCallMode}
|
||||||
|
focused={focusedId}
|
||||||
|
setFocused={setFocusedId}
|
||||||
|
onHangup={() => setCallScreen('none')}
|
||||||
|
callState={callState}
|
||||||
|
setCallState={setCallState}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="messages">
|
||||||
|
{MESSAGES.map((m, i) => {
|
||||||
|
const next = MESSAGES[i+1];
|
||||||
|
const isLastOfRun = (m.from === 'them' || m.from === 'me') && (!next || next.from !== m.from || next.type === 'event');
|
||||||
|
const myChat = { initial: 'D', color: 'teal' };
|
||||||
|
return <MessageItem key={i} msg={m} showAvatar={isLastOfRun} chat={activeChat} myChat={myChat} />;
|
||||||
|
})}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
<div className="composer">
|
||||||
|
<button className="composer-btn" title="Attach"><Icon.Plus /></button>
|
||||||
|
<div className="composer-box">
|
||||||
|
<input placeholder="Nachricht schreiben…" />
|
||||||
|
<button className="icon-btn"><Icon.Smile /></button>
|
||||||
|
<button className="icon-btn"><Icon.Mic /></button>
|
||||||
|
</div>
|
||||||
|
<button className="composer-btn"><Icon.Send /></button>
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="top-nav">
|
||||||
|
<div className="nav-brand">
|
||||||
|
<Icon.Logo />
|
||||||
|
<span>Netralax</span>
|
||||||
|
</div>
|
||||||
|
<div className="nav-tabs">
|
||||||
|
<button className={`nav-tab ${activeTab === 'chats' ? 'active' : ''}`} onClick={() => setActiveTab('chats')}>
|
||||||
|
<Icon.Chat /> Chats
|
||||||
|
</button>
|
||||||
|
<button className={`nav-tab ${activeTab === 'friends' ? 'active' : ''}`} onClick={() => setActiveTab('friends')}>
|
||||||
|
<Icon.Friends /> Freunde
|
||||||
|
</button>
|
||||||
|
<button className={`nav-tab ${activeTab === 'settings' ? 'active' : ''}`} onClick={() => setActiveTab('settings')}>
|
||||||
|
<Icon.Settings /> Einstellungen
|
||||||
|
</button>
|
||||||
|
<button className={`nav-tab ${activeTab === 'admin' ? 'active' : ''}`} onClick={() => setActiveTab('admin')}>
|
||||||
|
<Icon.Shield /> Admin
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="nav-user">
|
||||||
|
<Avatar chat={{initial: 'D', color: 'teal', presence: 'online'}} size="sm" />
|
||||||
|
<div style={{fontSize: 12}}>
|
||||||
|
<div style={{fontWeight: 600}}>dennis</div>
|
||||||
|
<div style={{opacity: 0.6, fontSize: 10}}>@dennis</div>
|
||||||
|
</div>
|
||||||
|
<Icon.Chevron />
|
||||||
|
</div>
|
||||||
|
{variant.key === 'clean' && (
|
||||||
|
<button className="icon-btn theme-toggle" onClick={onToggleDark} title={isDark ? 'Light mode' : 'Dark mode'} style={{marginLeft: 6}}>
|
||||||
|
{isDark ? <Icon.Sun /> : <Icon.Moon />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="main-area">
|
||||||
|
{activeTab === 'friends' && <div className="chat-area has-page" style={{width: '100%'}}><FriendsPageContent /></div>}
|
||||||
|
{activeTab === 'admin' && <div className="chat-area has-page" style={{width: '100%'}}><AdminPageContent /></div>}
|
||||||
|
{activeTab === 'settings' && <div className="chat-area has-page" style={{width: '100%'}}><SettingsPageContent isDark={isDark} onToggleDark={onToggleDark} /></div>}
|
||||||
|
{activeTab === 'chats' && <>
|
||||||
|
<div className="chat-list">
|
||||||
|
<div className="chat-list-header">
|
||||||
|
<div className="chat-list-title">Chats</div>
|
||||||
|
<button className="icon-btn" title="Neuer Chat"><Icon.AddUser /></button>
|
||||||
|
</div>
|
||||||
|
<input className="chat-search" placeholder="Suche…" />
|
||||||
|
<div className="chat-items">
|
||||||
|
{chats.map(c => (
|
||||||
|
<ChatItem key={c.id} chat={c} onClick={() => setActiveChat(c)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="chat-area">
|
||||||
|
<div className="chat-header">
|
||||||
|
<Avatar chat={activeChat} />
|
||||||
|
<div className="chat-header-info">
|
||||||
|
<div className="chat-header-name">{activeChat.name}</div>
|
||||||
|
<div className="chat-header-status">
|
||||||
|
{activeChat.presence === 'online' ? '● Online' :
|
||||||
|
activeChat.presence === 'idle' ? '● Abwesend' :
|
||||||
|
activeChat.presence === 'dnd' ? '● Nicht stören' :
|
||||||
|
'● Offline · zuletzt vor 2h'}
|
||||||
|
{' · '}{activeChat.handle}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="chat-header-actions">
|
||||||
|
<button className="icon-btn" title="Suche"><Icon.Search /></button>
|
||||||
|
<button className="icon-btn" title="Voice"><Icon.Phone /></button>
|
||||||
|
<button className="icon-btn" title="Video"><Icon.Video /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="messages">
|
||||||
|
{MESSAGES.map((m, i) => {
|
||||||
|
const next = MESSAGES[i+1];
|
||||||
|
const isLastOfRun = (m.from === 'them' || m.from === 'me') && (!next || next.from !== m.from || next.type === 'event');
|
||||||
|
const myChat = { initial: 'D', color: 'teal' };
|
||||||
|
return <MessageItem key={i} msg={m} showAvatar={isLastOfRun} chat={activeChat} myChat={myChat} />;
|
||||||
|
})}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="composer">
|
||||||
|
<button className="composer-btn" title="Attach"><Icon.Plus /></button>
|
||||||
|
<div className="composer-box">
|
||||||
|
<input placeholder="Nachricht schreiben…" />
|
||||||
|
<button className="icon-btn"><Icon.Smile /></button>
|
||||||
|
<button className="icon-btn"><Icon.Mic /></button>
|
||||||
|
</div>
|
||||||
|
<button className="composer-btn"><Icon.Send /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Call screen switcher — only in Clean Rail, only on chats tab */}
|
||||||
|
{variant.layout === 'rail' && activeTab === 'chats' && (
|
||||||
|
<div className="screen-switcher">
|
||||||
|
<button className={callScreen === 'none' ? 'active' : ''} onClick={() => setCallScreen('none')}>Chat</button>
|
||||||
|
<button className={callScreen === 'incoming' ? 'active' : ''} onClick={() => setCallScreen('incoming')}>Incoming</button>
|
||||||
|
<button className={callScreen === 'active' ? 'active' : ''} onClick={() => setCallScreen('active')}>In-Call</button>
|
||||||
|
<button className={callScreen === 'pip' ? 'active' : ''} onClick={() => setCallScreen('pip')}>PiP</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Incoming call is a full overlay */}
|
||||||
|
{callScreen === 'incoming' && (
|
||||||
|
<div className="call-screen">
|
||||||
|
<IncomingCallScreen
|
||||||
|
chat={activeChat}
|
||||||
|
onAccept={() => setCallScreen('active')}
|
||||||
|
onDecline={() => setCallScreen('none')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Fullscreen call hides chat entirely */}
|
||||||
|
{callScreen === 'active' && callMode === 'fullscreen' && (
|
||||||
|
<div className="call-screen">
|
||||||
|
<ActiveCallScreen
|
||||||
|
participants={CALL_PARTICIPANTS}
|
||||||
|
mode={callMode}
|
||||||
|
setMode={setCallMode}
|
||||||
|
focused={focusedId}
|
||||||
|
setFocused={setFocusedId}
|
||||||
|
onHangup={() => setCallScreen('none')}
|
||||||
|
callState={callState}
|
||||||
|
setCallState={setCallState}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{callScreen === 'pip' && (
|
||||||
|
<PipCall
|
||||||
|
participants={CALL_PARTICIPANTS}
|
||||||
|
onExpand={() => setCallScreen('active')}
|
||||||
|
onHangup={() => setCallScreen('none')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showCall && callScreen === 'none' && <CallOverlay chat={activeChat} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.assign(window, { ChatApp, Avatar, ChatItem, MessageItem, CallOverlay });
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
// Call UI components — Discord-style, matched to Clean-Rail aesthetic
|
||||||
|
|
||||||
|
const CALL_PARTICIPANTS = [
|
||||||
|
{ id: 'dennis', name: 'dennis', initial: 'D', color: 'teal', speaking: false, muted: false, video: false, me: true, e2ee: true },
|
||||||
|
{ id: 'max', name: 'Max', initial: 'M', color: 'teal', speaking: true, muted: false, video: true, sharing: true, e2ee: true },
|
||||||
|
{ id: 'lena', name: 'Lena', initial: 'L', color: 'amber', speaking: false, muted: true, video: false, e2ee: true },
|
||||||
|
{ id: 'tom', name: 'Tom', initial: 'T', color: 'violet', speaking: false, muted: false, video: false, e2ee: true },
|
||||||
|
{ id: 'nico', name: 'nico', initial: 'N', color: 'teal', speaking: true, muted: false, video: false, e2ee: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ParticipantTile = ({ p, focused, onClick, small }) => (
|
||||||
|
<div className={`participant-tile ${p.speaking ? 'speaking' : ''} ${focused ? 'focused' : ''} ${small ? 'small' : ''}`} onClick={onClick}>
|
||||||
|
{p.video || p.sharing ? (
|
||||||
|
<div className="participant-video">
|
||||||
|
{p.sharing ? (
|
||||||
|
<div className="screenshare-stub">
|
||||||
|
<div className="ss-window">
|
||||||
|
<div className="ss-chrome"><span></span><span></span><span></span></div>
|
||||||
|
<div className="ss-content">
|
||||||
|
<div className="ss-line" style={{width: '60%'}}></div>
|
||||||
|
<div className="ss-line" style={{width: '80%'}}></div>
|
||||||
|
<div className="ss-line" style={{width: '40%'}}></div>
|
||||||
|
<div className="ss-block"></div>
|
||||||
|
<div className="ss-line" style={{width: '70%'}}></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ss-badge">
|
||||||
|
<Icon.Monitor /> {p.name} teilt Bildschirm
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="video-stub">
|
||||||
|
<Avatar chat={{initial: p.initial, color: p.color}} size="lg" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="participant-audio">
|
||||||
|
<div className={`audio-ring ${p.speaking ? 'pulse' : ''}`}>
|
||||||
|
<Avatar chat={{initial: p.initial, color: p.color}} size="lg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="participant-meta">
|
||||||
|
<div className="participant-name">
|
||||||
|
{p.me && <Icon.Crown />}
|
||||||
|
<span>{p.name}{p.me ? ' (du)' : ''}</span>
|
||||||
|
{p.e2ee && <span className="e2ee-dot" title="End-to-End verschlüsselt"><Icon.Lock /></span>}
|
||||||
|
</div>
|
||||||
|
<div className="participant-icons">
|
||||||
|
{p.muted && <span className="pi muted"><Icon.MicOff /></span>}
|
||||||
|
{p.sharing && <span className="pi sharing"><Icon.Monitor /></span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CallControls = ({ muted, onToggleMute, sharing, onToggleShare, video, onToggleVideo, onHangup, compact }) => (
|
||||||
|
<div className={`call-controls ${compact ? 'compact' : ''}`}>
|
||||||
|
<button className={`cc-btn ${muted ? 'active-danger' : ''}`} onClick={onToggleMute} title={muted ? 'Unmute' : 'Mute'}>
|
||||||
|
{muted ? <Icon.MicOff /> : <Icon.Mic />}
|
||||||
|
</button>
|
||||||
|
<button className={`cc-btn ${video ? 'active-brand' : ''}`} onClick={onToggleVideo} title="Video">
|
||||||
|
<Icon.Video />
|
||||||
|
</button>
|
||||||
|
<button className={`cc-btn ${sharing ? 'active-brand' : ''}`} onClick={onToggleShare} title="Bildschirm teilen">
|
||||||
|
<Icon.Monitor />
|
||||||
|
</button>
|
||||||
|
<button className="cc-btn" title="Teilnehmer"><Icon.Friends /></button>
|
||||||
|
<button className="cc-btn hangup" onClick={onHangup} title="Auflegen"><Icon.PhoneOff /></button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const IncomingCallScreen = ({ chat, onAccept, onDecline }) => (
|
||||||
|
<div className="incoming-call">
|
||||||
|
<div className="incoming-card">
|
||||||
|
<div className="incoming-ringing">Eingehender Anruf</div>
|
||||||
|
<div className="incoming-avatar">
|
||||||
|
<div className="pulse-ring"></div>
|
||||||
|
<div className="pulse-ring d2"></div>
|
||||||
|
<Avatar chat={{initial: chat.initial, color: chat.color}} size="lg" />
|
||||||
|
</div>
|
||||||
|
<div className="incoming-name">{chat.name}</div>
|
||||||
|
<div className="incoming-sub">
|
||||||
|
<Icon.Lock /> E2E verschlüsselt · Voice Call
|
||||||
|
</div>
|
||||||
|
<div className="incoming-actions">
|
||||||
|
<button className="incoming-btn decline" onClick={onDecline}>
|
||||||
|
<Icon.PhoneOff /> Ablehnen
|
||||||
|
</button>
|
||||||
|
<button className="incoming-btn accept" onClick={onAccept}>
|
||||||
|
<Icon.Phone /> Annehmen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ActiveCallScreen = ({ participants, mode, setMode, focused, setFocused, onHangup, callState, setCallState }) => {
|
||||||
|
const speaker = participants.find(p => p.id === focused) || participants.find(p => p.sharing) || participants[0];
|
||||||
|
const others = participants.filter(p => p.id !== speaker.id);
|
||||||
|
const sharingParticipant = participants.find(p => p.sharing);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`active-call mode-${mode}`}>
|
||||||
|
<div className="call-topbar">
|
||||||
|
<div className="call-topbar-left">
|
||||||
|
<div className="call-title">
|
||||||
|
<Icon.Friends />
|
||||||
|
<span>Die Squad</span>
|
||||||
|
<span className="call-dot">·</span>
|
||||||
|
<span className="call-duration">{callState.duration}</span>
|
||||||
|
</div>
|
||||||
|
<div className="call-e2ee"><Icon.Lock /> E2E verschlüsselt</div>
|
||||||
|
</div>
|
||||||
|
<div className="call-topbar-right">
|
||||||
|
<button className={`cc-btn sm ${mode === 'grid' ? 'active-brand' : ''}`} onClick={() => setMode('grid')} title="Grid"><Icon.Grid /></button>
|
||||||
|
<button className={`cc-btn sm ${mode === 'focus' ? 'active-brand' : ''}`} onClick={() => setMode('focus')} title="Fokus"><Icon.Focus /></button>
|
||||||
|
<button className={`cc-btn sm ${mode === 'fullscreen' ? 'active-brand' : ''}`} onClick={() => setMode('fullscreen')} title="Vollbild"><Icon.Maximize /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="call-stage">
|
||||||
|
{mode === 'grid' && (
|
||||||
|
<div className={`grid grid-${participants.length}`}>
|
||||||
|
{participants.map(p => (
|
||||||
|
<ParticipantTile key={p.id} p={p} onClick={() => { setFocused(p.id); setMode('focus'); }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{mode === 'focus' && (
|
||||||
|
<div className="focus-layout">
|
||||||
|
<div className="focus-main">
|
||||||
|
<ParticipantTile p={speaker} focused />
|
||||||
|
</div>
|
||||||
|
<div className="focus-strip">
|
||||||
|
{others.map(p => (
|
||||||
|
<ParticipantTile key={p.id} p={p} small onClick={() => setFocused(p.id)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{mode === 'fullscreen' && (
|
||||||
|
<div className="fullscreen-layout">
|
||||||
|
<ParticipantTile p={speaker} focused />
|
||||||
|
<div className="fs-strip">
|
||||||
|
{others.slice(0,4).map(p => (
|
||||||
|
<ParticipantTile key={p.id} p={p} small onClick={() => setFocused(p.id)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="fs-hint">Esc zum Verlassen</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CallControls
|
||||||
|
muted={callState.muted}
|
||||||
|
onToggleMute={() => setCallState(s => ({...s, muted: !s.muted}))}
|
||||||
|
sharing={callState.sharing}
|
||||||
|
onToggleShare={() => setCallState(s => ({...s, sharing: !s.sharing}))}
|
||||||
|
video={callState.video}
|
||||||
|
onToggleVideo={() => setCallState(s => ({...s, video: !s.video}))}
|
||||||
|
onHangup={onHangup}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const PipCall = ({ onExpand, onHangup, participants }) => {
|
||||||
|
const speaker = participants.find(p => p.sharing) || participants.find(p => p.speaking) || participants[0];
|
||||||
|
return (
|
||||||
|
<div className="pip-call" onClick={onExpand}>
|
||||||
|
<div className="pip-preview">
|
||||||
|
{speaker.sharing ? (
|
||||||
|
<div className="screenshare-stub small"><div className="ss-window mini"></div></div>
|
||||||
|
) : (
|
||||||
|
<Avatar chat={{initial: speaker.initial, color: speaker.color}} size="sm" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="pip-info">
|
||||||
|
<div className="pip-title">Die Squad · {participants.length}</div>
|
||||||
|
<div className="pip-sub">
|
||||||
|
<span className="live-dot"></span>
|
||||||
|
Live · tippe zum Öffnen
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="pip-hangup" onClick={(e) => { e.stopPropagation(); onHangup(); }}>
|
||||||
|
<Icon.PhoneOff />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.assign(window, { CALL_PARTICIPANTS, ParticipantTile, CallControls, IncomingCallScreen, ActiveCallScreen, PipCall });
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// Sample data
|
||||||
|
|
||||||
|
const CHATS = [
|
||||||
|
{ id: 'test', name: 'test', handle: '@test', color: 'violet', initial: 'T', preview: 'Hallo 👋', time: '21:13', unread: false, presence: 'offline', active: true },
|
||||||
|
{ id: 'max', name: 'Max', handle: '@maxx', color: 'teal', initial: 'M', preview: 'bro du musst das sehen', time: '20:44', unread: true, presence: 'online' },
|
||||||
|
{ id: 'squad', name: 'Die Squad', handle: '5 Mitglieder', color: 'rose', initial: 'S', preview: 'leon: bin gleich da', time: '19:12', unread: true, presence: 'online', isGroup: true },
|
||||||
|
{ id: 'lena', name: 'Lena', handle: '@lenae', color: 'amber', initial: 'L', preview: 'Du: ok passt', time: 'Gestern', unread: false, presence: 'idle' },
|
||||||
|
{ id: 'tom', name: 'Tom', handle: '@tomg', color: 'violet', initial: 'T', preview: 'sent a photo', time: 'Gestern', unread: false, presence: 'dnd' },
|
||||||
|
{ id: 'nico', name: 'nico', handle: '@nicoo', color: 'teal', initial: 'N', preview: 'gg wp', time: 'Mo.', unread: false, presence: 'offline' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MESSAGES = [
|
||||||
|
{ type: 'message', from: 'them', text: 'Hi', time: '20:12' },
|
||||||
|
{ type: 'message', from: 'them', text: 'Lol', time: '20:14' },
|
||||||
|
{ type: 'message', from: 'them', text: 'Wie gehts', time: '20:24' },
|
||||||
|
{ type: 'message', from: 'them', text: 'einer da?', time: '20:25' },
|
||||||
|
{ type: 'message', from: 'them', text: 'Hallo', time: '21:03', reactions: [{emoji: '👋', count: 1, mine: true}] },
|
||||||
|
{ type: 'event', kind: 'call', icon: 'phone', text: 'Eingehender Anruf', duration: '1:01', time: '21:06' },
|
||||||
|
{ type: 'event', kind: 'rejected', icon: 'phone-off', text: 'Anruf abgelehnt', time: '21:06' },
|
||||||
|
{ type: 'event', kind: 'rejected', icon: 'phone-off', text: 'Anruf abgelehnt', time: '21:06' },
|
||||||
|
{ type: 'message', from: 'me', text: 'sry war afk', time: '21:09', reactions: [{emoji: '😅', count: 2}, {emoji: '❤️', count: 1, mine: true}] },
|
||||||
|
{ type: 'media', from: 'me', filename: 'IMG_4120.jpg', size: '2.4 MB', label: '[ PLACEHOLDER IMAGE ]', time: '21:10' },
|
||||||
|
{ type: 'message', from: 'them', text: 'pushhh sick', time: '21:12' },
|
||||||
|
{ type: 'message', from: 'me', text: 'Hallo', time: '21:13', read: true },
|
||||||
|
{ type: 'typing', from: 'them' },
|
||||||
|
];
|
||||||
|
|
||||||
|
Object.assign(window, { CHATS, MESSAGES });
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
// Shared SVG icon components
|
||||||
|
const Icon = {
|
||||||
|
Logo: () => (
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M4 6 L12 2 L20 6 L20 18 L12 22 L4 18 Z" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round"/>
|
||||||
|
<path d="M4 6 L12 10 L20 6" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round"/>
|
||||||
|
<path d="M12 10 L12 22" stroke="currentColor" strokeWidth="1.8"/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
Chat: () => (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||||
|
),
|
||||||
|
Friends: () => (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||||
|
),
|
||||||
|
Settings: () => (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||||
|
),
|
||||||
|
Shield: () => (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||||
|
),
|
||||||
|
Phone: () => (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg>
|
||||||
|
),
|
||||||
|
PhoneOff: () => (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7 2 2 0 0 1 1.72 2v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.42 19.42 0 0 1-3.33-2.67m-2.67-3.34a19.79 19.79 0 0 1-3.07-8.63A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91"/><line x1="23" y1="1" x2="1" y2="23"/></svg>
|
||||||
|
),
|
||||||
|
Video: () => (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg>
|
||||||
|
),
|
||||||
|
Search: () => (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||||
|
),
|
||||||
|
Plus: () => (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||||
|
),
|
||||||
|
Send: () => (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
||||||
|
),
|
||||||
|
AddUser: () => (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></svg>
|
||||||
|
),
|
||||||
|
Chevron: () => (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
|
||||||
|
),
|
||||||
|
Paperclip: () => (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||||
|
),
|
||||||
|
Smile: () => (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>
|
||||||
|
),
|
||||||
|
Mic: () => (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
|
||||||
|
),
|
||||||
|
Sun: () => (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><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"/></svg>
|
||||||
|
),
|
||||||
|
Moon: () => (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
||||||
|
),
|
||||||
|
MicOff: () => (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><line x1="1" y1="1" x2="23" y2="23"/><path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"/><path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"/><line x1="12" y1="19" x2="12" y2="23"/></svg>
|
||||||
|
),
|
||||||
|
Monitor: () => (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||||||
|
),
|
||||||
|
Lock: () => (
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||||
|
),
|
||||||
|
Crown: () => (
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M5 16l-2-8 5 4 4-7 4 7 5-4-2 8z"/></svg>
|
||||||
|
),
|
||||||
|
Grid: () => (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
|
||||||
|
),
|
||||||
|
Focus: () => (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="4" width="18" height="12" rx="1"/><rect x="3" y="18" width="4" height="3" rx="0.5"/><rect x="9" y="18" width="4" height="3" rx="0.5"/><rect x="15" y="18" width="4" height="3" rx="0.5"/></svg>
|
||||||
|
),
|
||||||
|
Maximize: () => (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></svg>
|
||||||
|
),
|
||||||
|
Minimize: () => (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 14h6v6M20 10h-6V4M14 10l7-7M3 21l7-7"/></svg>
|
||||||
|
),
|
||||||
|
Copy: () => (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||||
|
),
|
||||||
|
Trash: () => (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||||||
|
),
|
||||||
|
Alert: () => (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||||
|
),
|
||||||
|
Check: () => (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.assign(window, { Icon });
|
||||||
@@ -0,0 +1,591 @@
|
|||||||
|
// Freunde / Admin / Settings pages — Clean Rail aesthetic
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// SHARED PRIMITIVES
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
const PageHeader = ({ title, subtitle, actions }) => (
|
||||||
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 className="page-title">{title}</h1>
|
||||||
|
{subtitle && <p className="page-subtitle">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
{actions && <div className="page-header-actions">{actions}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Section = ({ title, children, action }) => (
|
||||||
|
<section className="page-section">
|
||||||
|
<div className="page-section-header">
|
||||||
|
<h2 className="page-section-title">{title}</h2>
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
<div className="page-section-body">{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Toggle = ({ checked, onChange, label, hint, disabled }) => (
|
||||||
|
<label className={`toggle-row ${disabled ? 'disabled' : ''}`}>
|
||||||
|
<div className="toggle-copy">
|
||||||
|
<div className="toggle-label">{label}</div>
|
||||||
|
{hint && <div className="toggle-hint">{hint}</div>}
|
||||||
|
</div>
|
||||||
|
<span className={`toggle-switch ${checked ? 'on' : ''}`} onClick={() => !disabled && onChange(!checked)}>
|
||||||
|
<span className="toggle-thumb"></span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SegmentedControl = ({ options, value, onChange }) => (
|
||||||
|
<div className="segmented">
|
||||||
|
{options.map(opt => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
className={`seg-btn ${value === opt.value ? 'active' : ''}`}
|
||||||
|
onClick={() => onChange(opt.value)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Row = ({ label, value, mono }) => (
|
||||||
|
<div className="row-kv">
|
||||||
|
<span className="row-label">{label}</span>
|
||||||
|
<span className={`row-value ${mono ? 'mono' : ''}`}>{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Stat = ({ label, value }) => (
|
||||||
|
<div className="stat">
|
||||||
|
<div className="stat-label">{label}</div>
|
||||||
|
<div className="stat-value">{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Banner = ({ kind = 'info', children }) => (
|
||||||
|
<div className={`banner ${kind}`}>
|
||||||
|
{kind === 'error' && <Icon.Alert />}
|
||||||
|
{kind === 'success' && <Icon.Check />}
|
||||||
|
<span>{children}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Empty = ({ icon: IconComp = Icon.Friends, children }) => (
|
||||||
|
<div className="empty-state">
|
||||||
|
<div className="empty-icon"><IconComp /></div>
|
||||||
|
<p>{children}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// FRIENDS PAGE
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
const FRIENDS_DATA = [
|
||||||
|
{ id: 'max', userId: 'max', displayName: 'Max', username: 'maxx', initial: 'M', color: 'teal', status: 'accepted', direction: null, presence: 'online' },
|
||||||
|
{ id: 'lena', userId: 'lena', displayName: 'Lena', username: 'lena_k', initial: 'L', color: 'amber', status: 'accepted', direction: null, presence: 'online' },
|
||||||
|
{ id: 'tom', userId: 'tom', displayName: 'Tom', username: 'tomtom', initial: 'T', color: 'violet', status: 'accepted', direction: null, presence: 'idle' },
|
||||||
|
{ id: 'nico', userId: 'nico', displayName: 'nico', username: 'niklas', initial: 'N', color: 'teal', status: 'accepted', direction: null, presence: 'offline' },
|
||||||
|
{ id: 'julia', userId: 'julia', displayName: 'Julia', username: 'jul', initial: 'J', color: 'rose', status: 'accepted', direction: null, presence: 'dnd' },
|
||||||
|
{ id: 'ben', userId: 'ben', displayName: 'Ben', username: 'benny', initial: 'B', color: 'amber', status: 'pending', direction: 'outgoing' },
|
||||||
|
{ id: 'anna', userId: 'anna', displayName: 'Anna', username: 'anna_m', initial: 'A', color: 'violet', status: 'pending', direction: 'outgoing' },
|
||||||
|
{ id: 'kai', userId: 'kai', displayName: 'Kai', username: 'kaiser', initial: 'K', color: 'teal', status: 'pending', direction: 'incoming' },
|
||||||
|
{ id: 'sara', userId: 'sara', displayName: 'Sara', username: 'sari', initial: 'S', color: 'rose', status: 'pending', direction: 'incoming' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SEARCH_RESULTS = [
|
||||||
|
{ id: 'felix', userId: 'felix', displayName: 'Felix', username: 'felix_x', initial: 'F', color: 'violet' },
|
||||||
|
{ id: 'mia', userId: 'mia', displayName: 'Mia', username: 'mia_w', initial: 'M', color: 'amber' },
|
||||||
|
{ id: 'paul', userId: 'paul', displayName: 'Paul', username: 'paulk', initial: 'P', color: 'teal' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const FriendRow = ({ profile, children }) => (
|
||||||
|
<div className="friend-row">
|
||||||
|
<Avatar chat={{initial: profile.initial, color: profile.color, presence: profile.presence}} />
|
||||||
|
<div className="friend-body">
|
||||||
|
<div className="friend-name">{profile.displayName}</div>
|
||||||
|
<div className="friend-handle">@{profile.username}</div>
|
||||||
|
</div>
|
||||||
|
<div className="friend-actions">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const FriendsPageContent = () => {
|
||||||
|
const [tab, setTab] = React.useState('friends');
|
||||||
|
const [query, setQuery] = React.useState('');
|
||||||
|
|
||||||
|
const accepted = FRIENDS_DATA.filter(f => f.status === 'accepted');
|
||||||
|
const outgoing = FRIENDS_DATA.filter(f => f.status === 'pending' && f.direction === 'outgoing');
|
||||||
|
const incoming = FRIENDS_DATA.filter(f => f.status === 'pending' && f.direction === 'incoming');
|
||||||
|
|
||||||
|
const showSearch = query.trim().length >= 2;
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ key: 'friends', label: 'Freunde', count: accepted.length },
|
||||||
|
{ key: 'pending', label: 'Ausgehend', count: outgoing.length },
|
||||||
|
{ key: 'requests', label: 'Anfragen', count: incoming.length, highlight: incoming.length > 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const currentList = tab === 'friends' ? accepted : tab === 'pending' ? outgoing : incoming;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-scroll">
|
||||||
|
<div className="page-inner friends-page">
|
||||||
|
<PageHeader title="Freunde" subtitle={`${accepted.length} Freunde · ${incoming.length} offene Anfragen`} />
|
||||||
|
|
||||||
|
<div className="search-wrap">
|
||||||
|
<Icon.Search />
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
placeholder="Benutzer suchen oder hinzufügen…"
|
||||||
|
value={query}
|
||||||
|
onChange={e => setQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showSearch && (
|
||||||
|
<div className="page-section">
|
||||||
|
<div className="page-section-header">
|
||||||
|
<h2 className="page-section-title">Suchergebnisse</h2>
|
||||||
|
</div>
|
||||||
|
<div className="friend-list">
|
||||||
|
{SEARCH_RESULTS.filter(r =>
|
||||||
|
r.displayName.toLowerCase().includes(query.toLowerCase()) ||
|
||||||
|
r.username.toLowerCase().includes(query.toLowerCase())
|
||||||
|
).map(p => (
|
||||||
|
<FriendRow key={p.id} profile={p}>
|
||||||
|
<button className="btn btn-primary">
|
||||||
|
<Icon.AddUser /> Anfrage senden
|
||||||
|
</button>
|
||||||
|
</FriendRow>
|
||||||
|
))}
|
||||||
|
{SEARCH_RESULTS.filter(r =>
|
||||||
|
r.displayName.toLowerCase().includes(query.toLowerCase()) ||
|
||||||
|
r.username.toLowerCase().includes(query.toLowerCase())
|
||||||
|
).length === 0 && (
|
||||||
|
<Empty icon={Icon.Search}>Keine Ergebnisse für „{query}"</Empty>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!showSearch && (
|
||||||
|
<>
|
||||||
|
<div className="tab-row">
|
||||||
|
{tabs.map(t => (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
className={`tab-btn ${tab === t.key ? 'active' : ''}`}
|
||||||
|
onClick={() => setTab(t.key)}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
<span className={`tab-count ${t.highlight ? 'highlight' : ''}`}>{t.count}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="friend-list">
|
||||||
|
{currentList.length === 0 && (
|
||||||
|
<Empty>
|
||||||
|
{tab === 'friends' && 'Noch keine Freunde. Suche oben nach Benutzern um anzufangen.'}
|
||||||
|
{tab === 'pending' && 'Keine ausgehenden Anfragen.'}
|
||||||
|
{tab === 'requests' && 'Keine eingehenden Anfragen.'}
|
||||||
|
</Empty>
|
||||||
|
)}
|
||||||
|
{currentList.map(f => (
|
||||||
|
<FriendRow key={f.id} profile={f}>
|
||||||
|
{tab === 'friends' && (
|
||||||
|
<>
|
||||||
|
<button className="btn btn-primary">
|
||||||
|
<Icon.Chat /> Nachricht
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-ghost" title="Voice"><Icon.Phone /></button>
|
||||||
|
<button className="btn btn-danger-ghost">Entfernen</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{tab === 'pending' && (
|
||||||
|
<>
|
||||||
|
<span className="pending-label">Wartet auf Antwort…</span>
|
||||||
|
<button className="btn btn-ghost">Abbrechen</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{tab === 'requests' && (
|
||||||
|
<>
|
||||||
|
<button className="btn btn-primary">
|
||||||
|
<Icon.Check /> Annehmen
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-ghost">Ablehnen</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</FriendRow>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// ADMIN PAGE
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
const INVITES_DATA = [
|
||||||
|
{ code: 'SQUAD-2k26', usesCount: 3, usesLimit: 10, expiresAt: '2026-05-01', disabled: false },
|
||||||
|
{ code: 'FRIENDS-OF-DENNIS', usesCount: 1, usesLimit: null, expiresAt: null, disabled: false },
|
||||||
|
{ code: 'BETA-ROUND-1', usesCount: 12, usesLimit: 12, expiresAt: null, disabled: false },
|
||||||
|
{ code: 'OLD-UNUSED', usesCount: 0, usesLimit: 5, expiresAt: '2026-03-01', disabled: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const USERS_DATA = [
|
||||||
|
{ userId: 'dennis', displayName: 'dennis', username: 'dennis', initial: 'D', color: 'teal', isAdmin: true, blockedFromInviting: false, banned: false, joinedAt: '14. Mär 2026' },
|
||||||
|
{ userId: 'max', displayName: 'Max', username: 'maxx', initial: 'M', color: 'teal', isAdmin: false, blockedFromInviting: false, banned: false, joinedAt: '16. Mär 2026' },
|
||||||
|
{ userId: 'lena', displayName: 'Lena', username: 'lena_k', initial: 'L', color: 'amber', isAdmin: false, blockedFromInviting: false, banned: false, joinedAt: '02. Apr 2026' },
|
||||||
|
{ userId: 'tom', displayName: 'Tom', username: 'tomtom', initial: 'T', color: 'violet', isAdmin: false, blockedFromInviting: true, banned: false, joinedAt: '08. Apr 2026' },
|
||||||
|
{ userId: 'spam', displayName: 'spam_user_42', username: 'spam42', initial: 'S', color: 'rose', isAdmin: false, blockedFromInviting: false, banned: true, joinedAt: '11. Apr 2026' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const FlagChip = ({ active, tone, onToggle, children }) => (
|
||||||
|
<button
|
||||||
|
className={`flag-chip ${active ? 'active' : ''} ${tone === 'danger' ? 'danger' : ''}`}
|
||||||
|
onClick={() => onToggle(!active)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
const AdminPageContent = () => {
|
||||||
|
const [invitesEnabled, setInvitesEnabled] = React.useState(true);
|
||||||
|
const [invites, setInvites] = React.useState(INVITES_DATA);
|
||||||
|
const [users, setUsers] = React.useState(USERS_DATA);
|
||||||
|
const [copiedCode, setCopiedCode] = React.useState(null);
|
||||||
|
|
||||||
|
const handleCopy = (code) => {
|
||||||
|
setCopiedCode(code);
|
||||||
|
setTimeout(() => setCopiedCode(null), 1400);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleUserFlag = (userId, flag) => {
|
||||||
|
setUsers(us => us.map(u => u.userId === userId ? {...u, [flag]: !u[flag]} : u));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleInvite = (code) => {
|
||||||
|
setInvites(is => is.map(i => i.code === code ? {...i, disabled: !i.disabled} : i));
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteInvite = (code) => {
|
||||||
|
setInvites(is => is.filter(i => i.code !== code));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-scroll">
|
||||||
|
<div className="page-inner admin-page">
|
||||||
|
<PageHeader
|
||||||
|
title="Admin"
|
||||||
|
subtitle={`${users.length} User · ${invites.filter(i => !i.disabled).length} aktive Invites`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="stats-grid">
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-card-label">Gesamt User</div>
|
||||||
|
<div className="stat-card-value">{users.length}</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-card-label">Admins</div>
|
||||||
|
<div className="stat-card-value">{users.filter(u => u.isAdmin).length}</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-card-label">Gebannt</div>
|
||||||
|
<div className="stat-card-value danger">{users.filter(u => u.banned).length}</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-card-label">Aktive Invites</div>
|
||||||
|
<div className="stat-card-value">{invites.filter(i => !i.disabled).length}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Section title="System">
|
||||||
|
<Toggle
|
||||||
|
label="Invites aktiviert"
|
||||||
|
hint="Wenn aus, können keine neuen User per Invite-Code beitreten."
|
||||||
|
checked={invitesEnabled}
|
||||||
|
onChange={setInvitesEnabled}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section
|
||||||
|
title="Invite-Codes"
|
||||||
|
action={
|
||||||
|
<button className="btn btn-primary">
|
||||||
|
<Icon.Plus /> Neuer Code
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table className="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Code</th>
|
||||||
|
<th>Nutzungen</th>
|
||||||
|
<th>Ablauf</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{invites.map(inv => {
|
||||||
|
const expired = inv.expiresAt && new Date(inv.expiresAt) < new Date('2026-04-19');
|
||||||
|
const status = inv.disabled ? 'disabled' : expired ? 'expired' : 'active';
|
||||||
|
const usesText = inv.usesLimit ? `${inv.usesCount}/${inv.usesLimit}` : `${inv.usesCount}/∞`;
|
||||||
|
return (
|
||||||
|
<tr key={inv.code}>
|
||||||
|
<td className="mono">{inv.code}</td>
|
||||||
|
<td className="muted">{usesText}</td>
|
||||||
|
<td className="muted">{inv.expiresAt || 'nie'}</td>
|
||||||
|
<td><span className={`status-pill ${status}`}>{status === 'active' ? 'Aktiv' : status === 'expired' ? 'Abgelaufen' : 'Deaktiviert'}</span></td>
|
||||||
|
<td>
|
||||||
|
<div className="row-actions">
|
||||||
|
<button className="icon-btn sm" title={copiedCode === inv.code ? 'Kopiert!' : 'Kopieren'} onClick={() => handleCopy(inv.code)}>
|
||||||
|
{copiedCode === inv.code ? <Icon.Check /> : <Icon.Copy />}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-xs btn-ghost" onClick={() => toggleInvite(inv.code)}>
|
||||||
|
{inv.disabled ? 'Aktivieren' : 'Deaktivieren'}
|
||||||
|
</button>
|
||||||
|
<button className="icon-btn sm danger" title="Löschen" onClick={() => deleteInvite(inv.code)}>
|
||||||
|
<Icon.Trash />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Benutzer">
|
||||||
|
<div className="user-admin-list">
|
||||||
|
{users.map(u => (
|
||||||
|
<div key={u.userId} className="user-admin-row">
|
||||||
|
<Avatar chat={{initial: u.initial, color: u.color}} />
|
||||||
|
<div className="user-admin-body">
|
||||||
|
<div className="user-admin-name">
|
||||||
|
{u.displayName}
|
||||||
|
{u.isAdmin && <span className="admin-badge"><Icon.Crown /> Admin</span>}
|
||||||
|
</div>
|
||||||
|
<div className="user-admin-meta">@{u.username} · beigetreten {u.joinedAt}</div>
|
||||||
|
</div>
|
||||||
|
<div className="user-admin-flags">
|
||||||
|
<FlagChip active={u.isAdmin} onToggle={() => toggleUserFlag(u.userId, 'isAdmin')}>Admin</FlagChip>
|
||||||
|
<FlagChip active={u.blockedFromInviting} onToggle={() => toggleUserFlag(u.userId, 'blockedFromInviting')}>Invite-Block</FlagChip>
|
||||||
|
<FlagChip active={u.banned} tone="danger" onToggle={() => toggleUserFlag(u.userId, 'banned')}>Bann</FlagChip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// SETTINGS PAGE
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
const SettingsPageContent = ({ isDark, onToggleDark }) => {
|
||||||
|
const [language, setLanguage] = React.useState('de');
|
||||||
|
const [readReceipts, setReadReceipts] = React.useState(true);
|
||||||
|
const [allowDms, setAllowDms] = React.useState(true);
|
||||||
|
const [audioQuality, setAudioQuality] = React.useState('voice');
|
||||||
|
const [ptt, setPtt] = React.useState(false);
|
||||||
|
const [pttKey, setPttKey] = React.useState('Space');
|
||||||
|
const [capturingKey, setCapturingKey] = React.useState(false);
|
||||||
|
const [e2ee, setE2ee] = React.useState(true);
|
||||||
|
const [screenShareQuality, setScreenShareQuality] = React.useState('balanced');
|
||||||
|
|
||||||
|
const audioParams = audioQuality === 'hifi'
|
||||||
|
? { bitrate: '510 kbps', channels: 'Stereo', sample: '48 kHz', dsp: 'Off' }
|
||||||
|
: { bitrate: '48 kbps', channels: 'Mono', sample: '48 kHz', dsp: 'On' };
|
||||||
|
|
||||||
|
const screenParams = {
|
||||||
|
smooth: { bitrate: '3 Mbps', resolution: '1280×720', framerate: '60 fps' },
|
||||||
|
balanced: { bitrate: '6 Mbps', resolution: '1920×1080', framerate: '30 fps' },
|
||||||
|
crisp: { bitrate: '12 Mbps', resolution: '2560×1440', framerate: '30 fps' },
|
||||||
|
}[screenShareQuality];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-scroll">
|
||||||
|
<div className="page-inner settings-page">
|
||||||
|
<PageHeader title="Einstellungen" />
|
||||||
|
|
||||||
|
<Section title="Account">
|
||||||
|
<div className="avatar-control">
|
||||||
|
<div className="avatar-large" data-color="teal">D</div>
|
||||||
|
<div className="avatar-copy">
|
||||||
|
<div className="avatar-label">Avatar</div>
|
||||||
|
<div className="avatar-hint">Quadratisch, max 512×512, WebP. Sichtbar für deine Freunde.</div>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary">Ändern</button>
|
||||||
|
<button className="btn btn-danger-ghost">Entfernen</button>
|
||||||
|
</div>
|
||||||
|
<div className="row-stack">
|
||||||
|
<Row label="Benutzername" value="dennis" />
|
||||||
|
<Row label="Anzeigename" value="dennis" />
|
||||||
|
<Row label="User-ID" value="b4f2-a912-c7e8-d091" mono />
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Aussehen">
|
||||||
|
<div className="toggle-row">
|
||||||
|
<div className="toggle-copy">
|
||||||
|
<div className="toggle-label">Sprache</div>
|
||||||
|
<div className="toggle-hint">Sprache der App-Oberfläche</div>
|
||||||
|
</div>
|
||||||
|
<SegmentedControl
|
||||||
|
value={language}
|
||||||
|
onChange={setLanguage}
|
||||||
|
options={[{value: 'de', label: 'Deutsch'}, {value: 'en', label: 'English'}]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="toggle-row">
|
||||||
|
<div className="toggle-copy">
|
||||||
|
<div className="toggle-label">Theme</div>
|
||||||
|
<div className="toggle-hint">Dark Mode ist dezenter für späte Gaming-Sessions.</div>
|
||||||
|
</div>
|
||||||
|
<SegmentedControl
|
||||||
|
value={isDark ? 'dark' : 'light'}
|
||||||
|
onChange={v => { if ((v === 'dark') !== isDark) onToggleDark(); }}
|
||||||
|
options={[{value: 'light', label: '☀ Light'}, {value: 'dark', label: '☾ Dark'}]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Datenschutz">
|
||||||
|
<Toggle
|
||||||
|
label="Lesebestätigungen anzeigen"
|
||||||
|
hint="Freunde sehen wann du ihre Nachrichten gelesen hast. Beidseitig — du siehst dann auch ihre."
|
||||||
|
checked={readReceipts}
|
||||||
|
onChange={setReadReceipts}
|
||||||
|
/>
|
||||||
|
<Toggle
|
||||||
|
label="DMs von Fremden erlauben"
|
||||||
|
hint="Erlaubt Direktnachrichten von Usern mit denen du (noch) nicht befreundet bist."
|
||||||
|
checked={allowDms}
|
||||||
|
onChange={setAllowDms}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Sprache & Audio">
|
||||||
|
<div className="toggle-row">
|
||||||
|
<div className="toggle-copy">
|
||||||
|
<div className="toggle-label">Audio-Qualität</div>
|
||||||
|
<div className="toggle-hint">
|
||||||
|
{audioQuality === 'hifi'
|
||||||
|
? 'Stereo 510 kbps Opus ohne Noise-Suppression — bester Musik/Broadcast-Sound.'
|
||||||
|
: 'Mono 48 kbps mit Noise-Suppression, Echo-Cancellation und Auto-Gain — optimiert für Sprache.'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<SegmentedControl
|
||||||
|
value={audioQuality}
|
||||||
|
onChange={setAudioQuality}
|
||||||
|
options={[{value: 'voice', label: 'Sprache'}, {value: 'hifi', label: 'HiFi'}]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="stats-inline">
|
||||||
|
<Stat label="Bitrate" value={audioParams.bitrate} />
|
||||||
|
<Stat label="Channels" value={audioParams.channels} />
|
||||||
|
<Stat label="Sample" value={audioParams.sample} />
|
||||||
|
<Stat label="DSP" value={audioParams.dsp} />
|
||||||
|
</div>
|
||||||
|
<div className="divider"></div>
|
||||||
|
<Toggle
|
||||||
|
label="Push-to-Talk"
|
||||||
|
hint="Mic bleibt stumm bis die Taste gedrückt wird. Überschreibt den Mute-Button."
|
||||||
|
checked={ptt}
|
||||||
|
onChange={setPtt}
|
||||||
|
/>
|
||||||
|
{ptt && (
|
||||||
|
<div className="toggle-row">
|
||||||
|
<div className="toggle-copy">
|
||||||
|
<div className="toggle-label">Hotkey</div>
|
||||||
|
<div className="toggle-hint">Klick zum Ändern. Esc zum Abbrechen.</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className={`key-capture ${capturingKey ? 'active' : ''}`}
|
||||||
|
onClick={() => setCapturingKey(!capturingKey)}
|
||||||
|
>
|
||||||
|
{capturingKey ? 'Taste drücken…' : pttKey}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="divider"></div>
|
||||||
|
<Toggle
|
||||||
|
label="Ende-zu-Ende-Verschlüsselung für Calls"
|
||||||
|
hint="Audio + Video werden vor dem Upload verschlüsselt. Der Server sieht nur Ciphertext. Alle Teilnehmer müssen die Option aktiv haben."
|
||||||
|
checked={e2ee}
|
||||||
|
onChange={setE2ee}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Bildschirmfreigabe">
|
||||||
|
<div className="toggle-row">
|
||||||
|
<div className="toggle-copy">
|
||||||
|
<div className="toggle-label">Qualität</div>
|
||||||
|
<div className="toggle-hint">
|
||||||
|
WebRTC passt Bitrate + Auflösung dynamisch an die Netzwerkqualität an. Die Werte sind Obergrenzen.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<SegmentedControl
|
||||||
|
value={screenShareQuality}
|
||||||
|
onChange={setScreenShareQuality}
|
||||||
|
options={[
|
||||||
|
{value: 'smooth', label: 'Flüssig'},
|
||||||
|
{value: 'balanced', label: 'Balanced'},
|
||||||
|
{value: 'crisp', label: 'Scharf'},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="stats-inline">
|
||||||
|
<Stat label="Bitrate (max)" value={screenParams.bitrate} />
|
||||||
|
<Stat label="Resolution" value={screenParams.resolution} />
|
||||||
|
<Stat label="Framerate" value={screenParams.framerate} />
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Geräte">
|
||||||
|
<div className="device-card">
|
||||||
|
<div className="device-card-title">
|
||||||
|
<Icon.Lock /> Dieses Gerät
|
||||||
|
</div>
|
||||||
|
<div className="row-stack" style={{marginTop: 10}}>
|
||||||
|
<Row label="Name" value="Dennis' MacBook Pro" />
|
||||||
|
<Row label="Plattform" value="macOS 14 · Desktop" />
|
||||||
|
<Row label="Device-ID" value="dev_01JC4NKT7XYZ" mono />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="device-backup">
|
||||||
|
<div className="toggle-label">Geräteschlüssel-Backup</div>
|
||||||
|
<div className="toggle-hint" style={{marginTop: 4}}>
|
||||||
|
Sichere deinen privaten Schlüssel passwortgeschützt, damit du auf neuen Geräten alte Nachrichten weiter lesen kannst.
|
||||||
|
</div>
|
||||||
|
<div className="backup-row">
|
||||||
|
<input type="password" placeholder="Passphrase (min 8 Zeichen)" />
|
||||||
|
<button className="btn btn-primary">Export erstellen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Danger Zone">
|
||||||
|
<button className="btn btn-danger">Abmelden</button>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.assign(window, { FriendsPageContent, AdminPageContent, SettingsPageContent });
|
||||||
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 37 KiB |
@@ -0,0 +1,459 @@
|
|||||||
|
/* ================= BASE ================= */
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-body, 'Inter', system-ui, sans-serif);
|
||||||
|
background: var(--bg, #0e0e12);
|
||||||
|
color: var(--fg, #eaeaea);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
transition: background 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button { font-family: inherit; cursor: pointer; border: none; background: none; color: inherit; }
|
||||||
|
input, textarea { font-family: inherit; }
|
||||||
|
|
||||||
|
/* Variant switcher — top fixed */
|
||||||
|
.variant-switcher {
|
||||||
|
position: fixed;
|
||||||
|
top: 12px; left: 50%; transform: translateX(-50%);
|
||||||
|
z-index: 100;
|
||||||
|
display: flex; gap: 6px;
|
||||||
|
padding: 6px;
|
||||||
|
background: rgba(20,20,24,0.85);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid rgba(255,255,255,0.08);
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 10px 40px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
.variant-switcher button {
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(255,255,255,0.6);
|
||||||
|
border-radius: 9px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-family: 'Inter', system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
.variant-switcher button:hover { color: #fff; background: rgba(255,255,255,0.05); }
|
||||||
|
.variant-switcher button.active {
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(255,255,255,0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant-container {
|
||||||
|
padding-top: 72px;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Variant wrapper */
|
||||||
|
.variant {
|
||||||
|
display: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.variant.active { display: block; }
|
||||||
|
|
||||||
|
/* Device frame — desktop window */
|
||||||
|
.app-window {
|
||||||
|
width: min(1320px, 96vw);
|
||||||
|
margin: 0 auto 60px;
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 30px 80px rgba(0,0,0,0.4), 0 0 0 1px rgba(255,255,255,0.05);
|
||||||
|
height: 820px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-bar {
|
||||||
|
height: 38px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 14px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.window-bar .dots { display: flex; gap: 7px; }
|
||||||
|
.window-bar .dot { width: 12px; height: 12px; border-radius: 50%; }
|
||||||
|
.window-bar .title {
|
||||||
|
flex: 1; text-align: center; font-size: 12px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-body {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav {
|
||||||
|
height: 56px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 20px;
|
||||||
|
gap: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-brand {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex: 1;
|
||||||
|
margin-left: 20px;
|
||||||
|
}
|
||||||
|
.nav-tab {
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-user {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 4px 10px 4px 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-area {
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 320px 1fr;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chat list */
|
||||||
|
.chat-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.chat-list-header {
|
||||||
|
padding: 16px 18px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.chat-list-title { font-weight: 600; font-size: 14px; letter-spacing: 0.01em; }
|
||||||
|
.chat-search {
|
||||||
|
margin: 0 14px 10px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.chat-items { flex: 1; overflow-y: auto; padding: 4px 10px; }
|
||||||
|
.chat-item {
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.chat-item-body { flex: 1; min-width: 0; }
|
||||||
|
.chat-item-name { font-weight: 600; font-size: 14px; display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.chat-item-preview {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.6;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.chat-item-time { font-size: 10px; opacity: 0.5; font-weight: 400; }
|
||||||
|
.unread-dot {
|
||||||
|
width: 8px; height: 8px; border-radius: 50%;
|
||||||
|
margin-left: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Avatar */
|
||||||
|
.avatar {
|
||||||
|
width: 38px; height: 38px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.avatar.sm { width: 28px; height: 28px; font-size: 11px; }
|
||||||
|
.avatar.lg { width: 44px; height: 44px; font-size: 16px; }
|
||||||
|
|
||||||
|
.presence {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -1px; right: -1px;
|
||||||
|
width: 12px; height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid var(--bg, #0e0e12);
|
||||||
|
}
|
||||||
|
.presence.online { background: #22c55e; }
|
||||||
|
.presence.idle { background: #f59e0b; }
|
||||||
|
.presence.dnd { background: #ef4444; }
|
||||||
|
.presence.offline { background: #71717a; }
|
||||||
|
|
||||||
|
/* Chat area */
|
||||||
|
.chat-area {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header {
|
||||||
|
height: 64px;
|
||||||
|
padding: 0 22px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.chat-header-info { flex: 1; min-width: 0; }
|
||||||
|
.chat-header-name { font-weight: 600; font-size: 15px; }
|
||||||
|
.chat-header-status { font-size: 11px; opacity: 0.6; margin-top: 2px; }
|
||||||
|
.chat-header-actions { display: flex; gap: 4px; }
|
||||||
|
.icon-btn {
|
||||||
|
width: 34px; height: 34px;
|
||||||
|
border-radius: 9px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px 26px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: flex-end;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.message-row.me { justify-content: flex-end; }
|
||||||
|
.message-row.them { justify-content: flex-start; }
|
||||||
|
.message-avatar-slot {
|
||||||
|
width: 28px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.message-row .message-group {
|
||||||
|
margin-bottom: 0;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: calc(100% - 40px);
|
||||||
|
flex: 1 1 auto;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.message-row.me .message-group { align-items: flex-end; }
|
||||||
|
|
||||||
|
.message-row .bubble { max-width: 100%; width: fit-content; }
|
||||||
|
.message-row.me .bubble { margin-left: auto; }
|
||||||
|
|
||||||
|
.message-group { display: flex; flex-direction: column; gap: 2px; margin-bottom: 14px; }
|
||||||
|
.message-group.me { align-items: flex-end; }
|
||||||
|
.message-group.them { align-items: flex-start; }
|
||||||
|
.message-sender {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
opacity: 0.7;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble {
|
||||||
|
max-width: 70%;
|
||||||
|
min-width: 60px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 18px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.45;
|
||||||
|
position: relative;
|
||||||
|
word-wrap: break-word;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
.bubble .ts {
|
||||||
|
font-size: 10px;
|
||||||
|
opacity: 0.5;
|
||||||
|
margin-top: 2px;
|
||||||
|
display: block;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reactions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.reaction {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: flex; align-items: center; gap: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.reaction .count { font-weight: 600; opacity: 0.8; }
|
||||||
|
|
||||||
|
/* System events (calls etc) */
|
||||||
|
.event-row { display: flex; justify-content: center; margin: 10px 0; }
|
||||||
|
.event-pill {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.event-pill svg { width: 12px; height: 12px; }
|
||||||
|
|
||||||
|
/* Media preview */
|
||||||
|
.media-preview {
|
||||||
|
width: 240px;
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.media-preview .media-image {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 4/3;
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
45deg,
|
||||||
|
rgba(255,255,255,0.04),
|
||||||
|
rgba(255,255,255,0.04) 10px,
|
||||||
|
rgba(255,255,255,0.07) 10px,
|
||||||
|
rgba(255,255,255,0.07) 20px
|
||||||
|
);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 10px; font-family: 'JetBrains Mono', monospace; opacity: 0.5;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
.media-preview .media-meta {
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
display: flex; justify-content: space-between;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Composer */
|
||||||
|
.composer {
|
||||||
|
padding: 14px 20px 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.composer-box {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 4px 4px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
.composer-box input {
|
||||||
|
flex: 1;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
color: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
.composer-btn {
|
||||||
|
width: 36px; height: 36px;
|
||||||
|
border-radius: 9px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Incoming call overlay */
|
||||||
|
.call-overlay {
|
||||||
|
position: absolute;
|
||||||
|
right: 24px;
|
||||||
|
bottom: 100px;
|
||||||
|
width: 300px;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0,0,0,0.4);
|
||||||
|
animation: slideInCall 0.4s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
@keyframes slideInCall {
|
||||||
|
from { transform: translateX(120%); opacity: 0; }
|
||||||
|
to { transform: translateX(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
.call-info { flex: 1; min-width: 0; }
|
||||||
|
.call-name { font-weight: 600; font-size: 14px; }
|
||||||
|
.call-sub { font-size: 11px; opacity: 0.7; margin-top: 2px; }
|
||||||
|
.call-actions { display: flex; gap: 6px; }
|
||||||
|
.call-btn {
|
||||||
|
width: 36px; height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.call-btn.accept { background: #22c55e; }
|
||||||
|
.call-btn.decline { background: #ef4444; }
|
||||||
|
|
||||||
|
/* Profile hover card */
|
||||||
|
.profile-card {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 10;
|
||||||
|
width: 280px;
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 20px 50px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
.profile-card-banner { height: 64px; }
|
||||||
|
.profile-card-body { padding: 14px; margin-top: -20px; position: relative; }
|
||||||
|
.profile-card-avatar {
|
||||||
|
width: 60px; height: 60px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 4px solid var(--bg);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
.profile-card-name { font-weight: 700; font-size: 16px; margin-top: 10px; }
|
||||||
|
.profile-card-handle { font-size: 12px; opacity: 0.6; }
|
||||||
|
.profile-card-meta {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.08);
|
||||||
|
font-size: 11px;
|
||||||
|
display: flex; flex-direction: column; gap: 6px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbars */
|
||||||
|
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 3px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.2); }
|
||||||
@@ -0,0 +1,529 @@
|
|||||||
|
/* ============ CALL UI — Clean Rail matched ============ */
|
||||||
|
|
||||||
|
/* Discord-style call dock: call takes top portion, chat continues below */
|
||||||
|
.call-dock {
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 50%;
|
||||||
|
max-height: 420px;
|
||||||
|
min-height: 280px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-2);
|
||||||
|
}
|
||||||
|
.call-dock > .active-call {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.call-dock .call-topbar { padding: 8px 14px; }
|
||||||
|
.call-dock .call-stage { padding: 10px; }
|
||||||
|
.call-dock .grid { gap: 8px; }
|
||||||
|
.call-dock .participant-tile { border-radius: 10px; }
|
||||||
|
.call-dock .audio-ring .avatar.lg { width: 52px; height: 52px; font-size: 20px; }
|
||||||
|
.call-dock .participant-meta { padding: 4px 8px; font-size: 11px; bottom: 6px; left: 6px; right: 6px; }
|
||||||
|
.call-dock .participant-name { font-size: 11px; }
|
||||||
|
.call-dock .pi { width: 18px; height: 18px; }
|
||||||
|
/* When docked, compact the controls */
|
||||||
|
.call-dock .call-controls { padding: 8px; gap: 8px; }
|
||||||
|
.call-dock .cc-btn { width: 36px; height: 36px; border-radius: 10px; }
|
||||||
|
.call-dock .cc-btn.hangup { width: 54px; }
|
||||||
|
.call-dock .ss-content { padding: 8px; gap: 4px; }
|
||||||
|
.call-dock .ss-line { height: 4px; }
|
||||||
|
.call-dock .ss-block { height: 20px; }
|
||||||
|
.call-dock .ss-badge { font-size: 9px; padding: 3px 7px; }
|
||||||
|
|
||||||
|
/* Screen switcher pill (top-right of window) */
|
||||||
|
.screen-switcher {
|
||||||
|
position: absolute;
|
||||||
|
top: 52px;
|
||||||
|
right: 20px;
|
||||||
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
background: var(--bg-2);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.screen-switcher button {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 7px;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.screen-switcher button.active { background: var(--accent); color: #fff; }
|
||||||
|
|
||||||
|
/* Call stage overlay */
|
||||||
|
.call-screen {
|
||||||
|
position: absolute;
|
||||||
|
inset: 38px 0 0 0; /* below window bar */
|
||||||
|
background: var(--bg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Incoming Call === */
|
||||||
|
.incoming-call {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 30% 20%, rgba(79,70,229,0.15), transparent 50%),
|
||||||
|
radial-gradient(circle at 70% 80%, rgba(109,115,255,0.1), transparent 50%),
|
||||||
|
var(--bg);
|
||||||
|
}
|
||||||
|
.incoming-card {
|
||||||
|
width: 420px;
|
||||||
|
padding: 40px 32px 28px;
|
||||||
|
background: var(--bg-3);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 24px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 20px 60px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.variant-clean.dark .incoming-card { box-shadow: 0 20px 60px rgba(0,0,0,0.5); }
|
||||||
|
.incoming-ringing {
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.incoming-avatar {
|
||||||
|
margin: 22px auto 18px;
|
||||||
|
width: 112px; height: 112px;
|
||||||
|
position: relative;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.incoming-avatar .avatar.lg {
|
||||||
|
width: 96px; height: 96px;
|
||||||
|
font-size: 40px;
|
||||||
|
font-weight: 700;
|
||||||
|
border: 3px solid var(--bg-3);
|
||||||
|
}
|
||||||
|
.pulse-ring {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border: 2px solid var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
opacity: 0;
|
||||||
|
animation: pulseRing 2s ease-out infinite;
|
||||||
|
}
|
||||||
|
.pulse-ring.d2 { animation-delay: 1s; }
|
||||||
|
@keyframes pulseRing {
|
||||||
|
0% { transform: scale(0.85); opacity: 0.6; }
|
||||||
|
100% { transform: scale(1.25); opacity: 0; }
|
||||||
|
}
|
||||||
|
.incoming-name {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: 'Outfit', 'Inter', sans-serif;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.incoming-sub {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 6px;
|
||||||
|
}
|
||||||
|
.incoming-actions {
|
||||||
|
margin-top: 28px;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.incoming-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 14px 20px;
|
||||||
|
border-radius: 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.incoming-btn.decline {
|
||||||
|
background: transparent;
|
||||||
|
color: #dc2626;
|
||||||
|
border: 1px solid rgba(220,38,38,0.3);
|
||||||
|
}
|
||||||
|
.variant-clean.dark .incoming-btn.decline { color: #fb7185; border-color: rgba(251,113,133,0.3); }
|
||||||
|
.incoming-btn.decline:hover { background: rgba(220,38,38,0.08); }
|
||||||
|
.incoming-btn.accept {
|
||||||
|
background: #16a34a;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 4px 14px rgba(22,163,74,0.3);
|
||||||
|
}
|
||||||
|
.incoming-btn.accept:hover { background: #15803d; }
|
||||||
|
|
||||||
|
/* === Active Call === */
|
||||||
|
.active-call {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: linear-gradient(180deg, var(--bg-2), var(--bg));
|
||||||
|
}
|
||||||
|
.call-topbar {
|
||||||
|
padding: 14px 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: var(--bg-3);
|
||||||
|
}
|
||||||
|
.call-topbar-left { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.call-title {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
font-weight: 600; font-size: 14px;
|
||||||
|
}
|
||||||
|
.call-dot { opacity: 0.4; }
|
||||||
|
.call-duration { font-variant-numeric: tabular-nums; color: var(--fg-muted); font-size: 13px; }
|
||||||
|
.call-e2ee {
|
||||||
|
display: flex; align-items: center; gap: 5px;
|
||||||
|
font-size: 11px; color: #16a34a; font-weight: 500;
|
||||||
|
}
|
||||||
|
.variant-clean.dark .call-e2ee { color: #4ade80; }
|
||||||
|
.call-topbar-right { display: flex; gap: 4px; }
|
||||||
|
|
||||||
|
.call-stage {
|
||||||
|
flex: 1;
|
||||||
|
padding: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Participant tiles */
|
||||||
|
.participant-tile {
|
||||||
|
position: relative;
|
||||||
|
background: var(--bg-3);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.participant-tile:hover { border-color: var(--accent); }
|
||||||
|
.participant-tile.speaking { border-color: #16a34a; box-shadow: 0 0 0 2px rgba(22,163,74,0.25); }
|
||||||
|
.participant-tile.focused { border-color: var(--accent); }
|
||||||
|
.participant-tile.small { min-width: 140px; }
|
||||||
|
|
||||||
|
.participant-audio, .participant-video {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.audio-ring {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.audio-ring .avatar.lg {
|
||||||
|
width: 72px; height: 72px;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.participant-tile.small .audio-ring .avatar.lg { width: 44px; height: 44px; font-size: 18px; }
|
||||||
|
.audio-ring.pulse::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid #22c55e;
|
||||||
|
animation: audioPulse 1.3s ease-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes audioPulse {
|
||||||
|
0% { transform: scale(1); opacity: 0.8; }
|
||||||
|
100% { transform: scale(1.3); opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-stub {
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 30% 40%, rgba(109,115,255,0.2), transparent 60%),
|
||||||
|
linear-gradient(135deg, #1a1a24, #0A0A0F);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.variant-clean:not(.dark) .video-stub { background: linear-gradient(135deg, #e0e7ff, #c7d2fe); }
|
||||||
|
|
||||||
|
/* Screenshare stub */
|
||||||
|
.screenshare-stub {
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
position: relative;
|
||||||
|
background: #0A0A0F;
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.variant-clean:not(.dark) .screenshare-stub { background: #111118; }
|
||||||
|
.ss-window {
|
||||||
|
width: 90%; height: 85%;
|
||||||
|
background: #1a1a24;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(255,255,255,0.06);
|
||||||
|
}
|
||||||
|
.ss-chrome {
|
||||||
|
height: 20px;
|
||||||
|
background: #252533;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 8px;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.ss-chrome span { width: 6px; height: 6px; border-radius: 50%; background: #3A3A4D; }
|
||||||
|
.ss-content { padding: 14px; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.ss-line { height: 6px; background: #3A3A4D; border-radius: 3px; }
|
||||||
|
.ss-block { height: 40px; background: rgba(109,115,255,0.25); border-radius: 4px; margin: 4px 0; }
|
||||||
|
.participant-tile.small .ss-content { padding: 6px; gap: 3px; }
|
||||||
|
.participant-tile.small .ss-line { height: 3px; }
|
||||||
|
.participant-tile.small .ss-block { height: 14px; }
|
||||||
|
.ss-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px; left: 12px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: rgba(0,0,0,0.6);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
display: flex; align-items: center; gap: 5px;
|
||||||
|
}
|
||||||
|
.participant-tile.small .ss-badge { display: none; }
|
||||||
|
|
||||||
|
.participant-meta {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 8px; left: 8px; right: 8px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: rgba(0,0,0,0.55);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.participant-tile.small .participant-meta { padding: 4px 8px; font-size: 10px; }
|
||||||
|
.participant-name {
|
||||||
|
display: flex; align-items: center; gap: 5px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.e2ee-dot { opacity: 0.7; display: flex; }
|
||||||
|
.participant-icons { display: flex; gap: 4px; }
|
||||||
|
.pi {
|
||||||
|
width: 20px; height: 20px;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.pi.muted { background: rgba(220,38,38,0.8); }
|
||||||
|
.pi.sharing { background: rgba(34,197,94,0.8); }
|
||||||
|
|
||||||
|
/* Grid layouts */
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
}
|
||||||
|
.grid-1 { grid-template-columns: 1fr; }
|
||||||
|
.grid-2 { grid-template-columns: 1fr 1fr; }
|
||||||
|
.grid-3 { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
.grid-4 { grid-template-columns: repeat(2, 1fr); grid-template-rows: repeat(2, 1fr); }
|
||||||
|
.grid-5, .grid-6 { grid-template-columns: repeat(3, 1fr); grid-template-rows: repeat(2, 1fr); }
|
||||||
|
|
||||||
|
/* Focus layout */
|
||||||
|
.focus-layout {
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.focus-main { flex: 1; min-height: 0; }
|
||||||
|
.focus-main .participant-tile { height: 100%; }
|
||||||
|
.focus-strip {
|
||||||
|
height: 110px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.focus-strip .participant-tile { min-width: 160px; height: 100%; }
|
||||||
|
|
||||||
|
/* Fullscreen layout (hides topbar, pure cinema) */
|
||||||
|
.active-call.mode-fullscreen .call-topbar { display: none; }
|
||||||
|
.active-call.mode-fullscreen .call-stage { padding: 0; position: relative; }
|
||||||
|
.fullscreen-layout {
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.fullscreen-layout > .participant-tile { width: 100%; height: 100%; border-radius: 0; border: none; }
|
||||||
|
.fs-strip {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 90px;
|
||||||
|
right: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
width: 180px;
|
||||||
|
}
|
||||||
|
.fs-strip .participant-tile { height: 100px; backdrop-filter: blur(20px); background: rgba(0,0,0,0.4); }
|
||||||
|
|
||||||
|
/* Call controls bar */
|
||||||
|
.call-controls {
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
background: var(--bg-3);
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.active-call.mode-fullscreen .call-controls {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 16px; left: 50%; transform: translateX(-50%);
|
||||||
|
background: rgba(10,10,15,0.7);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.cc-btn {
|
||||||
|
width: 48px; height: 48px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--bg-2);
|
||||||
|
color: var(--fg);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
transition: all 0.15s;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.cc-btn:hover { background: var(--bg); transform: translateY(-1px); }
|
||||||
|
.cc-btn.sm { width: 32px; height: 32px; border-radius: 8px; }
|
||||||
|
.cc-btn.active-brand { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||||
|
.cc-btn.active-danger { background: #dc2626; color: #fff; border-color: #dc2626; }
|
||||||
|
.cc-btn.hangup { background: #dc2626; color: #fff; border-color: #dc2626; width: 72px; }
|
||||||
|
.cc-btn.hangup:hover { background: #b91c1c; }
|
||||||
|
|
||||||
|
.active-call.mode-fullscreen .cc-btn { background: rgba(255,255,255,0.08); color: #fff; border-color: rgba(255,255,255,0.1); }
|
||||||
|
.active-call.mode-fullscreen .cc-btn.active-brand { background: var(--accent); border-color: var(--accent); }
|
||||||
|
.active-call.mode-fullscreen .cc-btn.active-danger { background: #dc2626; border-color: #dc2626; }
|
||||||
|
.active-call.mode-fullscreen .cc-btn.hangup { background: #dc2626; border-color: #dc2626; }
|
||||||
|
|
||||||
|
/* Exit fullscreen button */
|
||||||
|
.fs-exit {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
z-index: 10;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: rgba(10,10,15,0.7);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid rgba(255,255,255,0.15);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.fs-exit:hover { background: rgba(10,10,15,0.9); transform: translateY(-1px); }
|
||||||
|
|
||||||
|
/* Fullscreen hint — subtle, fades in then out */
|
||||||
|
.fs-hint {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
padding: 6px 14px;
|
||||||
|
background: rgba(10,10,15,0.6);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid rgba(255,255,255,0.08);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: rgba(255,255,255,0.7);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
z-index: 10;
|
||||||
|
animation: fsHintFade 3.5s ease-out forwards;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
@keyframes fsHintFade {
|
||||||
|
0% { opacity: 0; transform: translate(-50%, -6px); }
|
||||||
|
15%, 75% { opacity: 1; transform: translate(-50%, 0); }
|
||||||
|
100% { opacity: 0; transform: translate(-50%, -6px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* PiP in chat view */
|
||||||
|
.pip-call {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 82px; right: 20px;
|
||||||
|
width: 260px;
|
||||||
|
padding: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
background: var(--bg-3);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 12px 40px rgba(0,0,0,0.2), 0 0 0 3px rgba(79,70,229,0.15);
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 30;
|
||||||
|
animation: slideInCall 0.4s cubic-bezier(0.22,1,0.36,1);
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
.pip-call:hover { transform: translateY(-2px); }
|
||||||
|
.pip-preview {
|
||||||
|
width: 44px; height: 44px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--bg-2);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.pip-preview .ss-window.mini {
|
||||||
|
width: 36px; height: 28px;
|
||||||
|
background: #1a1a24;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.pip-info { flex: 1; min-width: 0; }
|
||||||
|
.pip-title { font-weight: 600; font-size: 13px; }
|
||||||
|
.pip-sub {
|
||||||
|
font-size: 11px; color: var(--fg-muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
display: flex; align-items: center; gap: 5px;
|
||||||
|
}
|
||||||
|
.live-dot {
|
||||||
|
width: 6px; height: 6px; border-radius: 50%;
|
||||||
|
background: #ef4444;
|
||||||
|
animation: liveDotPulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes liveDotPulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
.pip-hangup {
|
||||||
|
width: 32px; height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #dc2626;
|
||||||
|
color: #fff;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
@@ -0,0 +1,651 @@
|
|||||||
|
/* ============================================================
|
||||||
|
PAGES — Friends / Admin / Settings (Clean Rail aesthetic)
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.page-scroll {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-inner {
|
||||||
|
max-width: 820px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 28px 32px 64px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
.page-title {
|
||||||
|
font-family: 'Outfit', 'Inter', sans-serif;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--fg);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.page-subtitle {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
margin: 6px 0 0;
|
||||||
|
}
|
||||||
|
.page-header-actions { display: flex; gap: 8px; }
|
||||||
|
|
||||||
|
/* ------------- Section ------------- */
|
||||||
|
.page-section {
|
||||||
|
background: var(--bg-3);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.page-section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.page-section-title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.page-section-body {
|
||||||
|
padding: 16px 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------- Toggle Row ------------- */
|
||||||
|
.toggle-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.toggle-row.disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.toggle-copy { flex: 1; min-width: 0; }
|
||||||
|
.toggle-label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--fg);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.toggle-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
margin-top: 4px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.toggle-switch {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 40px;
|
||||||
|
height: 22px;
|
||||||
|
background: var(--bg-2);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.toggle-switch.on {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.toggle-thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
.toggle-switch.on .toggle-thumb { transform: translateX(18px); }
|
||||||
|
|
||||||
|
/* ------------- Segmented ------------- */
|
||||||
|
.segmented {
|
||||||
|
display: inline-flex;
|
||||||
|
background: var(--bg-2);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.seg-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.seg-btn:hover { color: var(--fg); }
|
||||||
|
.seg-btn.active {
|
||||||
|
background: var(--bg-3);
|
||||||
|
color: var(--fg);
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
.variant-clean.dark .seg-btn.active { box-shadow: 0 1px 3px rgba(0,0,0,0.3); }
|
||||||
|
|
||||||
|
/* ------------- Row KV ------------- */
|
||||||
|
.row-stack { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.row-kv {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.row-kv:last-child { border-bottom: none; }
|
||||||
|
.row-label { font-size: 13px; color: var(--fg-muted); }
|
||||||
|
.row-value { font-size: 13px; color: var(--fg); font-weight: 500; text-align: right; }
|
||||||
|
.row-value.mono {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------- Buttons ------------- */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
font-family: inherit;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn svg { width: 14px; height: 14px; }
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
}
|
||||||
|
.btn-primary:hover { filter: brightness(1.08); }
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--fg);
|
||||||
|
border-color: var(--line);
|
||||||
|
}
|
||||||
|
.btn-ghost:hover { background: var(--bg-2); }
|
||||||
|
.btn-danger {
|
||||||
|
background: #dc2626;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.btn-danger:hover { background: #b91c1c; }
|
||||||
|
.variant-clean.dark .btn-danger { background: #dc2626; }
|
||||||
|
.variant-clean.dark .btn-danger:hover { background: #ef4444; }
|
||||||
|
.btn-danger-ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: #dc2626;
|
||||||
|
border-color: rgba(220,38,38,0.3);
|
||||||
|
}
|
||||||
|
.btn-danger-ghost:hover { background: rgba(220,38,38,0.08); }
|
||||||
|
.variant-clean.dark .btn-danger-ghost {
|
||||||
|
color: #fb7185;
|
||||||
|
border-color: rgba(251,113,133,0.3);
|
||||||
|
}
|
||||||
|
.variant-clean.dark .btn-danger-ghost:hover { background: rgba(251,113,133,0.1); }
|
||||||
|
.btn-xs { padding: 4px 8px; font-size: 11px; }
|
||||||
|
|
||||||
|
/* ------------- Search ------------- */
|
||||||
|
.search-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--bg-3);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
gap: 10px;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.search-wrap:focus-within { border-color: var(--accent); }
|
||||||
|
.search-wrap svg { width: 16px; height: 16px; color: var(--fg-muted); flex-shrink: 0; }
|
||||||
|
.search-wrap input {
|
||||||
|
flex: 1;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--fg);
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.search-wrap input::placeholder { color: var(--fg-muted); }
|
||||||
|
|
||||||
|
/* ------------- Tabs ------------- */
|
||||||
|
.tab-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.tab-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.tab-btn:hover { color: var(--fg); }
|
||||||
|
.tab-btn.active { color: var(--fg); border-bottom-color: var(--accent); font-weight: 600; }
|
||||||
|
.tab-count {
|
||||||
|
background: var(--bg-2);
|
||||||
|
color: var(--fg-muted);
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.tab-btn.active .tab-count { background: var(--accent); color: var(--accent-fg); }
|
||||||
|
.tab-count.highlight:not(.tab-btn.active .tab-count) {
|
||||||
|
background: #dc2626;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------- Friend Row ------------- */
|
||||||
|
.friend-list { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.friend-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.friend-row:hover { background: var(--bg-2); }
|
||||||
|
.friend-body { flex: 1; min-width: 0; }
|
||||||
|
.friend-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--fg);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.friend-handle {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.friend-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.pending-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
font-style: italic;
|
||||||
|
align-self: center;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------- Empty State ------------- */
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48px 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
}
|
||||||
|
.empty-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--bg-2);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
}
|
||||||
|
.empty-icon svg { width: 20px; height: 20px; }
|
||||||
|
.empty-state p { font-size: 13px; margin: 0; max-width: 320px; }
|
||||||
|
|
||||||
|
/* ------------- Banner ------------- */
|
||||||
|
.banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
border: 1px solid;
|
||||||
|
}
|
||||||
|
.banner.error { background: rgba(220,38,38,0.08); border-color: rgba(220,38,38,0.2); color: #dc2626; }
|
||||||
|
.variant-clean.dark .banner.error { color: #fb7185; }
|
||||||
|
.banner.success { background: rgba(22,163,74,0.08); border-color: rgba(22,163,74,0.2); color: #16a34a; }
|
||||||
|
.banner svg { width: 16px; height: 16px; flex-shrink: 0; margin-top: 1px; }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
ADMIN PAGE
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.stat-card {
|
||||||
|
background: var(--bg-3);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
.stat-card-label {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.stat-card-value {
|
||||||
|
font-family: 'Outfit', 'Inter', sans-serif;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--fg);
|
||||||
|
margin-top: 4px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.stat-card-value.danger { color: #dc2626; }
|
||||||
|
.variant-clean.dark .stat-card-value.danger { color: #fb7185; }
|
||||||
|
|
||||||
|
/* Inline stats (inside sections) */
|
||||||
|
.stats-inline {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: var(--bg-2);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.stat-label, .stat .stat-label {
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.stat-value, .stat .stat-value {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--fg);
|
||||||
|
margin-top: 2px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------- Admin Table ------------- */
|
||||||
|
.table-wrap { overflow-x: auto; margin: -4px; padding: 4px; }
|
||||||
|
.admin-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.admin-table thead th {
|
||||||
|
text-align: left;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
padding: 4px 12px 10px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.admin-table tbody td {
|
||||||
|
padding: 12px 12px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
color: var(--fg);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.admin-table tbody tr:last-child td { border-bottom: none; }
|
||||||
|
.admin-table td.mono {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.admin-table td.muted { color: var(--fg-muted); font-size: 12px; }
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 3px 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid;
|
||||||
|
}
|
||||||
|
.status-pill.active { background: rgba(22,163,74,0.08); border-color: rgba(22,163,74,0.25); color: #16a34a; }
|
||||||
|
.variant-clean.dark .status-pill.active { color: #4ade80; border-color: rgba(74,222,128,0.3); }
|
||||||
|
.status-pill.expired { background: rgba(245,158,11,0.08); border-color: rgba(245,158,11,0.25); color: #d97706; }
|
||||||
|
.variant-clean.dark .status-pill.expired { color: #fbbf24; border-color: rgba(251,191,36,0.3); }
|
||||||
|
.status-pill.disabled { background: rgba(220,38,38,0.08); border-color: rgba(220,38,38,0.25); color: #dc2626; }
|
||||||
|
.variant-clean.dark .status-pill.disabled { color: #fb7185; border-color: rgba(251,113,133,0.3); }
|
||||||
|
|
||||||
|
.row-actions { display: flex; gap: 4px; justify-content: flex-end; }
|
||||||
|
.icon-btn.sm { width: 28px; height: 28px; }
|
||||||
|
.icon-btn.sm svg { width: 13px; height: 13px; }
|
||||||
|
.icon-btn.sm.danger { color: #dc2626; border-color: rgba(220,38,38,0.2); }
|
||||||
|
.icon-btn.sm.danger:hover { background: rgba(220,38,38,0.08); }
|
||||||
|
.variant-clean.dark .icon-btn.sm.danger { color: #fb7185; }
|
||||||
|
|
||||||
|
/* ------------- User Admin Row ------------- */
|
||||||
|
.user-admin-list { display: flex; flex-direction: column; }
|
||||||
|
.user-admin-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 4px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.user-admin-row:last-child { border-bottom: none; }
|
||||||
|
.user-admin-body { flex: 1; min-width: 0; }
|
||||||
|
.user-admin-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--fg);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.user-admin-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.admin-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.admin-badge svg { width: 9px; height: 9px; }
|
||||||
|
.user-admin-flags { display: flex; gap: 6px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.flag-chip {
|
||||||
|
background: var(--bg-2);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
color: var(--fg-muted);
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.flag-chip:hover { color: var(--fg); background: var(--bg-3); }
|
||||||
|
.flag-chip.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.flag-chip.active.danger {
|
||||||
|
background: #dc2626;
|
||||||
|
border-color: #dc2626;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.variant-clean.dark .flag-chip.active.danger { background: #ef4444; border-color: #ef4444; }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
SETTINGS PAGE
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.avatar-control {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.avatar-large {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-family: 'Outfit', sans-serif;
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 2px 6px rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
.avatar-copy { flex: 1; min-width: 0; }
|
||||||
|
.avatar-label { font-size: 14px; font-weight: 500; color: var(--fg); }
|
||||||
|
.avatar-hint { font-size: 12px; color: var(--fg-muted); margin-top: 4px; }
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--line);
|
||||||
|
margin: 6px -20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-capture {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-2);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
color: var(--fg);
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 90px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.key-capture:hover { background: var(--bg-3); }
|
||||||
|
.key-capture.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
border-color: var(--accent);
|
||||||
|
animation: keyPulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes keyPulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.6; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-card {
|
||||||
|
background: rgba(22,163,74,0.06);
|
||||||
|
border: 1px solid rgba(22,163,74,0.2);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
.variant-clean.dark .device-card {
|
||||||
|
background: rgba(74,222,128,0.08);
|
||||||
|
border-color: rgba(74,222,128,0.2);
|
||||||
|
}
|
||||||
|
.device-card-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
.variant-clean.dark .device-card-title { color: #4ade80; }
|
||||||
|
.device-card-title svg { width: 14px; height: 14px; }
|
||||||
|
|
||||||
|
.device-backup {
|
||||||
|
background: var(--bg-2);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
.backup-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.backup-row input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--bg-3);
|
||||||
|
color: var(--fg);
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.backup-row input:focus { border-color: var(--accent); }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Layout glue — make pages fill the rail's chat-area slot
|
||||||
|
============================================================ */
|
||||||
|
.rail-layout.page-mode {
|
||||||
|
grid-template-columns: 64px 1fr !important;
|
||||||
|
}
|
||||||
|
.rail-layout .chat-area.has-page {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.rail-layout .chat-area.has-page > .page-scroll {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive tweaks */
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/* Rail layout — Discord-style icon sidebar */
|
||||||
|
.rail-layout {
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 64px 280px 1fr;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.icon-rail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 0;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.rail-brand {
|
||||||
|
width: 44px; height: 44px;
|
||||||
|
border-radius: 14px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
background: var(--accent, #4F46E5);
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.rail-divider { width: 32px; height: 2px; background: rgba(128,128,128,0.2); border-radius: 1px; margin: 4px 0; }
|
||||||
|
.rail-btn {
|
||||||
|
width: 44px; height: 44px;
|
||||||
|
border-radius: 14px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
position: relative;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.rail-btn:hover { background: rgba(128,128,128,0.1); color: var(--fg); border-radius: 12px; }
|
||||||
|
.rail-btn.active { background: var(--accent); color: #fff; border-radius: 12px; }
|
||||||
|
.rail-pill {
|
||||||
|
position: absolute;
|
||||||
|
left: -12px; top: 50%; transform: translateY(-50%);
|
||||||
|
width: 4px; height: 24px;
|
||||||
|
background: var(--fg);
|
||||||
|
border-radius: 0 3px 3px 0;
|
||||||
|
}
|
||||||
|
.rail-chatlist { display: flex; flex-direction: column; }
|
||||||
|
.rail-chatlist .chat-items { flex: 1; }
|
||||||
|
.rail-user {
|
||||||
|
padding: 10px 12px;
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant-clean.layout-rail .icon-rail { background: var(--bg-2); border-right: 1px solid var(--line); }
|
||||||
|
.variant-clean.layout-rail .rail-chatlist { background: var(--bg-3); border-right: 1px solid var(--line); }
|
||||||
|
.variant-clean.layout-rail .rail-user { background: var(--bg-2); border-top: 1px solid var(--line); }
|
||||||
|
.variant-clean.layout-rail .rail-brand { background: var(--accent); color: #fff; }
|
||||||