Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4db65993d5 | |||
| 0ca29952ba | |||
| ff5ea274b9 | |||
| b0f9f1dada | |||
| 961ac2dde5 | |||
| 7efbcf7e39 | |||
| 49c64cc5d9 | |||
| d20c7e210b | |||
| 58fa9487e3 | |||
| d9b08592da | |||
| 4a80bf1c0e | |||
| 05c962d46f | |||
| cf3fef6936 | |||
| 8c878b3718 | |||
| 389f00e85c | |||
| 75618637e2 | |||
| e57f81c9c3 | |||
| 0a5811cc68 | |||
| 0d94b684bf | |||
| 3c2579b3ed |
@@ -21,12 +21,8 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: macos-14 # apple silicon
|
||||
args: "--target aarch64-apple-darwin --bundles app,updater"
|
||||
- platform: macos-13 # intel
|
||||
args: "--target x86_64-apple-darwin --bundles app,updater"
|
||||
- platform: ubuntu-22.04
|
||||
args: ""
|
||||
- platform: macos-14 # universal binary covers both Intel + Apple Silicon
|
||||
args: "--target universal-apple-darwin --bundles app,updater"
|
||||
- platform: windows-latest
|
||||
args: ""
|
||||
|
||||
@@ -47,13 +43,7 @@ jobs:
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.platform == 'macos-14' && 'aarch64-apple-darwin' || matrix.platform == 'macos-13' && 'x86_64-apple-darwin' || '' }}
|
||||
|
||||
- name: Install Linux build deps
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
sudo apt update
|
||||
sudo apt install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libgtk-3-dev
|
||||
targets: ${{ matrix.platform == 'macos-14' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
- name: Install JS deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -4,9 +4,23 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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>
|
||||
<body class="bg-[#0b0b0f] text-white antialiased">
|
||||
<body class="bg-surface text-fg antialiased">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"@livekit/components-react": "^2.9.0",
|
||||
"@supabase/supabase-js": "^2.46.0",
|
||||
"@tauri-apps/api": "^2.1.1",
|
||||
"@tauri-apps/plugin-fs": "^2.5.0",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2.3.1",
|
||||
"@tauri-apps/plugin-notification": "^2.0.1",
|
||||
"@tauri-apps/plugin-sql": "^2.0.1",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<clipPath id="cp02">
|
||||
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19" fill="#2e1065"/>
|
||||
<g clip-path="url(#cp02)">
|
||||
<path d="M-4 32 Q 16 18 32 32 T 68 32 L 68 64 L -4 64 Z" fill="#7c4dff"/>
|
||||
<path d="M-4 32 Q 16 18 32 32 T 68 32" stroke="#a78bfa" stroke-width="2" fill="none"/>
|
||||
</g>
|
||||
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19"
|
||||
fill="none" stroke="#a78bfa" stroke-width="1.5" opacity="0.4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 667 B |
@@ -684,6 +684,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-fs",
|
||||
"tauri-plugin-global-shortcut",
|
||||
"tauri-plugin-notification",
|
||||
"tauri-plugin-sql",
|
||||
@@ -5483,6 +5484,30 @@ dependencies = [
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-fs"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "36e1ec28b79f3d0683f4507e1615c36292c0ea6716668770d4396b9b39871ed8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dunce",
|
||||
"glob",
|
||||
"log",
|
||||
"objc2-foundation",
|
||||
"percent-encoding",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.18",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-global-shortcut"
|
||||
version = "2.3.1"
|
||||
|
||||
@@ -14,10 +14,11 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri = { version = "2", features = ["devtools"] }
|
||||
tauri-plugin-notification = "2"
|
||||
tauri-plugin-sql = { version = "2", features = ["sqlite"] }
|
||||
tauri-plugin-stronghold = "2"
|
||||
tauri-plugin-fs = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
|
||||
@@ -15,6 +15,27 @@
|
||||
"updater:allow-check",
|
||||
"updater:allow-download",
|
||||
"updater:allow-install",
|
||||
"updater:allow-download-and-install"
|
||||
"updater:allow-download-and-install",
|
||||
"stronghold:default",
|
||||
"stronghold:allow-initialize",
|
||||
"stronghold:allow-load-client",
|
||||
"stronghold:allow-create-client",
|
||||
"stronghold:allow-save",
|
||||
"stronghold:allow-get-store-record",
|
||||
"stronghold:allow-save-store-record",
|
||||
"stronghold:allow-remove-store-record",
|
||||
"fs:default",
|
||||
"fs:allow-read-file",
|
||||
"fs:allow-write-file",
|
||||
"fs:allow-mkdir",
|
||||
"fs:allow-exists",
|
||||
"fs:allow-rename",
|
||||
"fs:allow-remove",
|
||||
{
|
||||
"identifier": "fs:scope",
|
||||
"allow": [
|
||||
{ "path": "$APPLOCALDATA/**" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ pub fn run() {
|
||||
let mut builder = tauri::Builder::default()
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_sql::Builder::default().build())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(
|
||||
tauri_plugin_stronghold::Builder::new(|password| {
|
||||
// TODO: derive stronghold key from password using argon2 / blake2b.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "ChatApp",
|
||||
"version": "0.1.1",
|
||||
"version": "0.6.0",
|
||||
"identifier": "com.meinname.chatapp",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm vite:dev",
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
||||
|
||||
import { AppShell } from './components/AppShell';
|
||||
import { UpdateToast } from './components/UpdateToast';
|
||||
import { RequireAdmin, RequireAuth, RequireDevice } from './components/guards';
|
||||
import { AuthProvider } from './context/AuthContext';
|
||||
import { CallProvider } from './context/CallContext';
|
||||
import { ConversationsProvider } from './context/ConversationsContext';
|
||||
import { FriendshipsProvider } from './context/FriendshipsContext';
|
||||
import { ThemeProvider } from './context/ThemeContext';
|
||||
import { AdminPage } from './pages/AdminPage';
|
||||
import { AuthCallbackPage } from './pages/AuthCallbackPage';
|
||||
import { AuthPage } from './pages/AuthPage';
|
||||
@@ -17,6 +19,7 @@ import { SettingsPage } from './pages/SettingsPage';
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<FriendshipsProvider>
|
||||
<ConversationsProvider>
|
||||
@@ -44,10 +47,12 @@ export function App() {
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/chats" replace />} />
|
||||
</Routes>
|
||||
<UpdateToast />
|
||||
</BrowserRouter>
|
||||
</CallProvider>
|
||||
</ConversationsProvider>
|
||||
</FriendshipsProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { startConversationKeySync } from '../lib/conversationKeySync';
|
||||
import { ensureNotificationPermission } from '../lib/osNotify';
|
||||
import { CallUI } from './CallUI';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { UpdateToast } from './UpdateToast';
|
||||
|
||||
export function AppShell() {
|
||||
const { session, device } = useAuth();
|
||||
useEffect(() => {
|
||||
// Prompt once per authenticated shell mount. Module-level guard prevents
|
||||
// re-asking if the user already responded this session.
|
||||
void ensureNotificationPermission();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session?.user.id || !device?.id) return;
|
||||
return startConversationKeySync(session.user.id, device.id);
|
||||
}, [session?.user.id, device?.id]);
|
||||
|
||||
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 />
|
||||
<div className="relative z-10 flex min-h-screen w-full">
|
||||
<Sidebar />
|
||||
@@ -25,16 +32,15 @@ export function AppShell() {
|
||||
</main>
|
||||
</div>
|
||||
<CallUI />
|
||||
<UpdateToast />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Calmer than the auth-screen background — full-bleed grid + 2 large blobs.
|
||||
// No animation here so message lists stay readable.
|
||||
// Subtle ambient background — only renders in dark mode where the app's
|
||||
// visual language expects depth. Light mode stays clean and flat.
|
||||
function ShellBackground() {
|
||||
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="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" />
|
||||
|
||||
@@ -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,170 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
MicIcon,
|
||||
MicOffIcon,
|
||||
MonitorShareIcon,
|
||||
MonitorStopIcon,
|
||||
PhoneOffIcon,
|
||||
UsersIcon,
|
||||
VideoIcon,
|
||||
} from './icons';
|
||||
|
||||
interface Props {
|
||||
muted: boolean;
|
||||
sharing: boolean;
|
||||
video: boolean;
|
||||
onToggleMute: () => void;
|
||||
onToggleShare: () => void;
|
||||
onToggleVideo?: () => void;
|
||||
onHangup: () => void;
|
||||
onOpenParticipants?: () => void;
|
||||
/** Compact variant used inside the docked call (36px buttons). */
|
||||
compact?: boolean;
|
||||
/** Glass variant used when controls float on fullscreen cinema mode. */
|
||||
glass?: boolean;
|
||||
/** Disable transient interactions while the call isn't fully connected. */
|
||||
disabledMedia?: boolean;
|
||||
}
|
||||
|
||||
export function CallControls({
|
||||
muted,
|
||||
sharing,
|
||||
video,
|
||||
onToggleMute,
|
||||
onToggleShare,
|
||||
onToggleVideo,
|
||||
onHangup,
|
||||
onOpenParticipants,
|
||||
compact = false,
|
||||
glass = false,
|
||||
disabledMedia = false,
|
||||
}: Props) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const wrapBase = glass
|
||||
? 'glass-pill flex items-center justify-center gap-2 rounded-[18px] p-2.5'
|
||||
: 'flex items-center justify-center gap-2 border-t border-line bg-surface-3 p-3';
|
||||
const btnSize = compact ? 'h-9 w-9 rounded-[10px]' : 'h-12 w-12 rounded-[14px]';
|
||||
const hangupSize = compact ? 'h-9 w-[54px] rounded-[10px]' : 'h-12 w-[72px] rounded-[14px]';
|
||||
return (
|
||||
<div className={wrapBase}>
|
||||
<CallButton
|
||||
label={muted ? t('app:call.unmute') : t('app:call.mute')}
|
||||
active={muted}
|
||||
activeTone="danger"
|
||||
onClick={onToggleMute}
|
||||
disabled={disabledMedia}
|
||||
glass={glass}
|
||||
className={btnSize}
|
||||
>
|
||||
{muted ? <MicOffIcon className="h-5 w-5" /> : <MicIcon className="h-5 w-5" />}
|
||||
</CallButton>
|
||||
{onToggleVideo && (
|
||||
<CallButton
|
||||
label={t('app:call.start_video', { defaultValue: 'Video' })}
|
||||
active={video}
|
||||
activeTone="accent"
|
||||
onClick={onToggleVideo}
|
||||
disabled={disabledMedia}
|
||||
glass={glass}
|
||||
className={btnSize}
|
||||
>
|
||||
<VideoIcon className="h-5 w-5" />
|
||||
</CallButton>
|
||||
)}
|
||||
<CallButton
|
||||
label={
|
||||
sharing
|
||||
? t('app:call.stop_share_screen', { defaultValue: 'Screen-Share stoppen' })
|
||||
: t('app:call.share_screen', { defaultValue: 'Bildschirm teilen' })
|
||||
}
|
||||
active={sharing}
|
||||
activeTone="accent"
|
||||
onClick={onToggleShare}
|
||||
disabled={disabledMedia}
|
||||
glass={glass}
|
||||
className={btnSize}
|
||||
>
|
||||
{sharing ? (
|
||||
<MonitorStopIcon className="h-5 w-5" />
|
||||
) : (
|
||||
<MonitorShareIcon className="h-5 w-5" />
|
||||
)}
|
||||
</CallButton>
|
||||
{onOpenParticipants && (
|
||||
<CallButton
|
||||
label={t('app:call.participants', { defaultValue: 'Teilnehmer' })}
|
||||
onClick={onOpenParticipants}
|
||||
glass={glass}
|
||||
className={btnSize}
|
||||
>
|
||||
<UsersIcon className="h-5 w-5" />
|
||||
</CallButton>
|
||||
)}
|
||||
<CallButton
|
||||
label={t('app:call.hangup')}
|
||||
onClick={onHangup}
|
||||
tone="danger"
|
||||
glass={glass}
|
||||
className={hangupSize}
|
||||
>
|
||||
<PhoneOffIcon className="h-5 w-5" />
|
||||
</CallButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CallButtonProps {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
active?: boolean;
|
||||
activeTone?: 'accent' | 'danger';
|
||||
tone?: 'default' | 'danger';
|
||||
glass?: boolean;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function CallButton({
|
||||
label,
|
||||
onClick,
|
||||
disabled,
|
||||
active,
|
||||
activeTone = 'accent',
|
||||
tone = 'default',
|
||||
glass = false,
|
||||
className = '',
|
||||
children,
|
||||
}: CallButtonProps) {
|
||||
const base =
|
||||
'inline-flex cursor-pointer items-center justify-center border transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:cursor-not-allowed disabled:opacity-50';
|
||||
|
||||
let toneClass: string;
|
||||
if (tone === 'danger') {
|
||||
toneClass = 'bg-rose-600 text-white border-rose-600 hover:bg-rose-500';
|
||||
} else if (active && activeTone === 'danger') {
|
||||
toneClass = 'bg-rose-600 text-white border-rose-600 hover:bg-rose-500';
|
||||
} else if (active) {
|
||||
toneClass = 'bg-accent text-accent-fg border-accent hover:brightness-110';
|
||||
} else if (glass) {
|
||||
toneClass =
|
||||
'border-white/15 bg-white/10 text-white hover:bg-white/15';
|
||||
} else {
|
||||
toneClass = 'border-line bg-surface-2 text-fg hover:bg-surface';
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
aria-pressed={active}
|
||||
title={label}
|
||||
className={`${base} ${toneClass} ${className}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { CrownIcon, LockIcon, MicOffIcon, MonitorShareIcon } from './icons';
|
||||
|
||||
export type AvatarColorKey = 'violet' | 'amber' | 'rose' | 'teal';
|
||||
|
||||
const AVATAR_COLORS: Record<AvatarColorKey, { bg: string; fg: string }> = {
|
||||
// Tailwind classes tuned per spec: violet/amber/rose/teal swatches in both
|
||||
// light and dark modes. Dark pairs invert to keep contrast readable.
|
||||
violet: {
|
||||
bg: 'bg-violet-200 dark:bg-violet-900/60',
|
||||
fg: 'text-violet-800 dark:text-violet-200',
|
||||
},
|
||||
amber: {
|
||||
bg: 'bg-amber-200 dark:bg-amber-900/60',
|
||||
fg: 'text-amber-900 dark:text-amber-200',
|
||||
},
|
||||
rose: {
|
||||
bg: 'bg-rose-200 dark:bg-rose-900/60',
|
||||
fg: 'text-rose-900 dark:text-rose-200',
|
||||
},
|
||||
teal: {
|
||||
bg: 'bg-teal-200 dark:bg-teal-900/60',
|
||||
fg: 'text-teal-900 dark:text-teal-200',
|
||||
},
|
||||
};
|
||||
|
||||
const COLOR_KEYS: AvatarColorKey[] = ['violet', 'amber', 'rose', 'teal'];
|
||||
|
||||
// Stable per-user color from identity string — keeps avatars visually
|
||||
// consistent across re-mounts without needing a color field on the profile.
|
||||
export function colorKeyFor(id: string): AvatarColorKey {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < id.length; i++) {
|
||||
hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
return COLOR_KEYS[hash % COLOR_KEYS.length]!;
|
||||
}
|
||||
|
||||
export interface ParticipantTileProps {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
avatarUrl: string | null;
|
||||
me: boolean;
|
||||
muted: boolean;
|
||||
speaking: boolean;
|
||||
sharing: boolean;
|
||||
video: boolean;
|
||||
e2ee: boolean;
|
||||
size?: 'default' | 'small';
|
||||
focused?: boolean;
|
||||
onClick?: () => void;
|
||||
onOpenScreenShare?: () => void;
|
||||
}
|
||||
|
||||
export function CallParticipantTile(props: ParticipantTileProps) {
|
||||
const {
|
||||
displayName,
|
||||
me,
|
||||
muted,
|
||||
speaking,
|
||||
sharing,
|
||||
video,
|
||||
e2ee,
|
||||
size = 'default',
|
||||
focused = false,
|
||||
onClick,
|
||||
onOpenScreenShare,
|
||||
} = props;
|
||||
|
||||
const small = size === 'small';
|
||||
const borderClass = speaking
|
||||
? 'border-emerald-500 shadow-[0_0_0_2px_rgba(22,163,74,0.25)] dark:border-emerald-400'
|
||||
: focused
|
||||
? 'border-accent'
|
||||
: 'border-line hover:border-accent';
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={
|
||||
'relative flex flex-col overflow-hidden rounded-[14px] border bg-surface-3 transition ' +
|
||||
(onClick ? 'cursor-pointer ' : '') +
|
||||
borderClass +
|
||||
(small ? ' min-w-[140px]' : '')
|
||||
}
|
||||
>
|
||||
{sharing ? (
|
||||
<ScreenshareStub
|
||||
displayName={displayName}
|
||||
small={small}
|
||||
onOpen={onOpenScreenShare}
|
||||
/>
|
||||
) : video ? (
|
||||
<VideoStub {...props} small={small} />
|
||||
) : (
|
||||
<AudioContent {...props} small={small} />
|
||||
)}
|
||||
|
||||
<div className="pointer-events-none absolute inset-x-2 bottom-2 flex items-center justify-between gap-2 rounded-lg glass-chip px-2.5 py-1.5">
|
||||
<div className="flex min-w-0 items-center gap-1.5 text-xs font-semibold">
|
||||
{me && <CrownIcon className="h-3 w-3 shrink-0 text-amber-300" />}
|
||||
<span className="truncate">
|
||||
{displayName}
|
||||
{me ? ' (du)' : ''}
|
||||
</span>
|
||||
{e2ee && (
|
||||
<span
|
||||
aria-label="E2E verschlüsselt"
|
||||
title="E2E verschlüsselt"
|
||||
className="flex opacity-70"
|
||||
>
|
||||
<LockIcon className="h-3 w-3" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{muted && (
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-md bg-rose-500/80 text-white">
|
||||
<MicOffIcon className="h-3 w-3" />
|
||||
</span>
|
||||
)}
|
||||
{sharing && (
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-md bg-emerald-500/80 text-white">
|
||||
<MonitorShareIcon className="h-3 w-3" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AudioContent({
|
||||
userId,
|
||||
displayName,
|
||||
avatarUrl,
|
||||
speaking,
|
||||
small,
|
||||
}: ParticipantTileProps & { small: boolean }) {
|
||||
const key = colorKeyFor(userId);
|
||||
const colors = AVATAR_COLORS[key];
|
||||
const letter = displayName.trim().charAt(0).toUpperCase() || '?';
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center p-4">
|
||||
<div
|
||||
className={
|
||||
'relative flex items-center justify-center rounded-full ' +
|
||||
(small ? 'h-11 w-11' : 'h-[72px] w-[72px]')
|
||||
}
|
||||
>
|
||||
{speaking && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute -inset-1.5 animate-audio-pulse rounded-full border-2 border-emerald-500 dark:border-emerald-400"
|
||||
/>
|
||||
)}
|
||||
{avatarUrl ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt=""
|
||||
className="relative h-full w-full rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={
|
||||
'relative flex h-full w-full items-center justify-center rounded-full font-bold ' +
|
||||
colors.bg + ' ' + colors.fg + ' ' +
|
||||
(small ? 'text-lg' : 'text-2xl')
|
||||
}
|
||||
>
|
||||
{letter}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VideoStub({
|
||||
userId,
|
||||
displayName,
|
||||
avatarUrl,
|
||||
small,
|
||||
}: ParticipantTileProps & { small: boolean }) {
|
||||
// Video capture playback is out of scope for this UI iteration — show the
|
||||
// audio-avatar gradient background as a placeholder so the layout is stable
|
||||
// when a participant enables video.
|
||||
const key = colorKeyFor(userId);
|
||||
const colors = AVATAR_COLORS[key];
|
||||
const letter = displayName.trim().charAt(0).toUpperCase() || '?';
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center bg-gradient-to-br from-accent/20 to-surface-2 p-4">
|
||||
<div
|
||||
className={
|
||||
'flex items-center justify-center rounded-full font-bold ' +
|
||||
colors.bg + ' ' + colors.fg + ' ' +
|
||||
(small ? 'h-11 w-11 text-lg' : 'h-[72px] w-[72px] text-2xl')
|
||||
}
|
||||
>
|
||||
{avatarUrl ? (
|
||||
<img src={avatarUrl} alt="" className="h-full w-full rounded-full object-cover" />
|
||||
) : (
|
||||
letter
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ScreenshareStubProps {
|
||||
displayName: string;
|
||||
small: boolean;
|
||||
onOpen?: (() => void) | undefined;
|
||||
}
|
||||
|
||||
// Fake browser window placeholder — click to open the real <video> viewer.
|
||||
// Matches the design spec's "screenshare-stub" look.
|
||||
function ScreenshareStub({ displayName, small, onOpen }: ScreenshareStubProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={
|
||||
onOpen
|
||||
? (e) => {
|
||||
e.stopPropagation();
|
||||
onOpen();
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
aria-label={`${displayName} teilt Bildschirm`}
|
||||
className="relative flex min-h-0 flex-1 cursor-pointer items-center justify-center bg-ink-900 p-3 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
<div className="h-[85%] w-[90%] overflow-hidden rounded-lg border border-white/10 bg-ink-700">
|
||||
<div className="flex h-[20px] items-center gap-1 bg-ink-600 px-2">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-ink-500" />
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-ink-500" />
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-ink-500" />
|
||||
</div>
|
||||
<div className={'flex flex-col gap-2 ' + (small ? 'gap-[3px] p-1.5' : 'p-3.5')}>
|
||||
<div className={'w-[60%] rounded ' + (small ? 'h-[3px]' : 'h-1.5') + ' bg-ink-500'} />
|
||||
<div className={'w-[80%] rounded ' + (small ? 'h-[3px]' : 'h-1.5') + ' bg-ink-500'} />
|
||||
<div className={'w-[40%] rounded ' + (small ? 'h-[3px]' : 'h-1.5') + ' bg-ink-500'} />
|
||||
<div className={'my-1 rounded ' + (small ? 'h-[14px]' : 'h-10') + ' bg-accent/30'} />
|
||||
<div className={'w-[70%] rounded ' + (small ? 'h-[3px]' : 'h-1.5') + ' bg-ink-500'} />
|
||||
</div>
|
||||
</div>
|
||||
{!small && (
|
||||
<div className="absolute left-3 top-3 flex items-center gap-1.5 glass-chip rounded-lg px-2.5 py-1 text-[10px] font-medium">
|
||||
<MonitorShareIcon className="h-3 w-3" />
|
||||
<span>{displayName} teilt Bildschirm</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +1,20 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useCall } from '../context/CallContext';
|
||||
import { useConversationsContext } from '../context/ConversationsContext';
|
||||
import { useFriendshipsContext } from '../context/FriendshipsContext';
|
||||
import { ringtone } from '../lib/ringtone';
|
||||
import { useAnyActiveCall } from '../lib/useAnyActiveCall';
|
||||
import { useCallPresence } from '../lib/useCallPresence';
|
||||
import {
|
||||
MicIcon,
|
||||
MicOffIcon,
|
||||
PhoneIcon,
|
||||
PhoneOffIcon,
|
||||
SpinnerIcon,
|
||||
XIcon,
|
||||
} from './icons';
|
||||
import { AvatarColorKey, colorKeyFor } from './CallParticipantTile';
|
||||
import { LockIcon, MonitorShareIcon, PhoneIcon, PhoneOffIcon } from './icons';
|
||||
|
||||
// Shell-level mount. Handles ringtones + the IncomingCallToast.
|
||||
// The persistent CallBar (active-call widget) is rendered inside Sidebar so
|
||||
// users can keep browsing/typing while a call is live.
|
||||
// Shell-level mount. Responsibilities:
|
||||
// - Start/stop ringtones by call-state transition.
|
||||
// - 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() {
|
||||
const { state } = useCall();
|
||||
|
||||
@@ -34,19 +28,37 @@ export function CallUI() {
|
||||
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() {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { state, acceptIncoming, rejectIncoming } = useCall();
|
||||
const { friendships } = useFriendshipsContext();
|
||||
const { conversations } = useConversationsContext();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
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 callerName =
|
||||
conv?.members.find((m) => m.userId === state.fromUserId)?.profile?.displayName ??
|
||||
@@ -54,44 +66,55 @@ function IncomingCallToast() {
|
||||
'?';
|
||||
const isGroup = conv?.type === 'group';
|
||||
const groupName = isGroup ? (conv?.name ?? t('app:chats.new_group')) : null;
|
||||
const letter = (isGroup ? groupName ?? callerName : callerName)
|
||||
.trim()
|
||||
.charAt(0)
|
||||
.toUpperCase() || '?';
|
||||
const title = isGroup ? groupName ?? callerName : callerName;
|
||||
const letter = title.trim().charAt(0).toUpperCase() || '?';
|
||||
const color = colorKeyFor(state.fromUserId);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
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">
|
||||
{t('app:call.incoming_title')}
|
||||
</p>
|
||||
<div className="mt-3 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">
|
||||
{letter}
|
||||
<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')}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
<div
|
||||
className={
|
||||
'flex h-11 w-11 shrink-0 items-center justify-center rounded-full font-semibold ' +
|
||||
TOAST_AVATAR[color]
|
||||
}
|
||||
>
|
||||
{letter}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-display text-base font-semibold text-fg">{title}</p>
|
||||
<p className="flex items-center gap-1.5 truncate text-xs text-fg-muted">
|
||||
<LockIcon className="h-3 w-3" />
|
||||
<span>
|
||||
{isGroup
|
||||
? t('app:call.incoming_group_from', {
|
||||
name: callerName,
|
||||
defaultValue: callerName + ' ruft Gruppe',
|
||||
})
|
||||
: t('app:call.incoming_from', { name: callerName })}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-display text-base font-semibold text-white">
|
||||
{isGroup ? groupName : callerName}
|
||||
</p>
|
||||
<p className="truncate text-xs text-neutral-400">
|
||||
{isGroup
|
||||
? t('app:call.incoming_group_from', {
|
||||
name: callerName,
|
||||
defaultValue: callerName + ' ruft Gruppe',
|
||||
})
|
||||
: t('app:call.incoming_from', { name: callerName })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex gap-2">
|
||||
</button>
|
||||
<div className="flex gap-2 px-5 pb-5">
|
||||
<button
|
||||
type="button"
|
||||
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" />
|
||||
<span>{t('app:call.decline')}</span>
|
||||
@@ -99,7 +122,7 @@ function IncomingCallToast() {
|
||||
<button
|
||||
type="button"
|
||||
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" />
|
||||
<span>{t('app:call.accept')}</span>
|
||||
@@ -110,226 +133,77 @@ function IncomingCallToast() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CallBar — persistent widget inside Sidebar. Lets the user keep browsing
|
||||
// the app while a call is active / connecting / ringing.
|
||||
// PiP widget — shown when the user has an active call but is browsing
|
||||
// somewhere else. Clicking expands back to the call's conversation.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function CallBar() {
|
||||
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() {
|
||||
function PipCall() {
|
||||
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 [, forceTick] = useState(0);
|
||||
useEffect(() => {
|
||||
if (state.kind !== 'connected') return;
|
||||
const id = window.setInterval(() => forceTick((v) => v + 1), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [state.kind]);
|
||||
const active =
|
||||
state.kind === 'connected' ||
|
||||
state.kind === 'connecting' ||
|
||||
state.kind === 'outgoing';
|
||||
if (!active) return null;
|
||||
|
||||
if (state.kind !== 'connected' && state.kind !== 'connecting' && state.kind !== 'outgoing') {
|
||||
return null;
|
||||
}
|
||||
// Only render when the user is NOT currently viewing the call's
|
||||
// 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 title =
|
||||
conv?.type === 'group'
|
||||
? conv.name ?? '—'
|
||||
? conv?.name ?? t('app:chats.new_group')
|
||||
: conv?.peer?.displayName ?? '—';
|
||||
|
||||
const statusLabel =
|
||||
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;
|
||||
const participantCount = 1 + remoteParticipants.length;
|
||||
const someoneSharing = remoteScreenShares.length > 0;
|
||||
|
||||
return (
|
||||
<div className="mb-2 overflow-hidden rounded-xl border border-emerald-500/20 bg-ink-900/80">
|
||||
<Link
|
||||
to={'/chats/' + state.conversationId}
|
||||
className="flex items-center gap-2.5 px-3 pt-2.5 pb-2 transition hover:bg-white/5 focus:outline-none"
|
||||
aria-label={statusLabel}
|
||||
>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-emerald-500/15 text-emerald-300">
|
||||
{state.kind === 'connecting' ? (
|
||||
<SpinnerIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<SignalIcon className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-emerald-300">{statusLabel}</p>
|
||||
<p className="truncate text-xs text-neutral-400">
|
||||
{title}
|
||||
{elapsed && <span className="ml-1.5 font-mono text-neutral-500">{elapsed}</span>}
|
||||
</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={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
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={
|
||||
'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 ' +
|
||||
toneClass
|
||||
}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label={t('app:call.active_in_conv', { defaultValue: 'Aktiver Anruf' })}
|
||||
onClick={() => navigate('/chats/' + state.conversationId)}
|
||||
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"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center overflow-hidden rounded-[10px] bg-surface-2">
|
||||
{someoneSharing ? (
|
||||
<MonitorShareIcon className="h-5 w-5 text-accent" />
|
||||
) : (
|
||||
<PhoneIcon className="h-5 w-5 text-accent" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[13px] font-semibold text-fg">
|
||||
{title} · {participantCount}
|
||||
</p>
|
||||
<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>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void hangup();
|
||||
}}
|
||||
aria-label={t('app:call.hangup')}
|
||||
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"
|
||||
>
|
||||
<PhoneOffIcon className="h-4 w-4" />
|
||||
</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,18 +1,27 @@
|
||||
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||
import type { PresenceState } from '@chat-app/shared/supabase';
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useCall } from '../context/CallContext';
|
||||
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> = {
|
||||
online: 'bg-emerald-400',
|
||||
online: 'bg-emerald-500',
|
||||
idle: 'bg-amber-400',
|
||||
dnd: 'bg-rose-500',
|
||||
invisible: 'bg-neutral-500',
|
||||
offline: 'bg-neutral-600',
|
||||
offline: 'bg-neutral-400 dark:bg-neutral-600',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
@@ -23,9 +32,7 @@ interface Props {
|
||||
|
||||
export function ConversationHeader({ conversation, peerPresence, onInfoClick }: Props) {
|
||||
if (!conversation) {
|
||||
return (
|
||||
<header className="h-[57px] border-b border-white/5 px-6 py-3" aria-busy="true" />
|
||||
);
|
||||
return <header className="h-[57px] border-b border-line px-6 py-3" aria-busy="true" />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -52,35 +59,43 @@ function HeaderBar({ conversation, peerPresence, onInfoClick }: HeaderBarProps)
|
||||
const isDm = conversation.type === 'dm';
|
||||
const title = isDm ? (conversation.peer?.displayName ?? '?') : (conversation.name ?? '?');
|
||||
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.
|
||||
const showPresence = isDm && peerPresence && peerPresence !== 'invisible';
|
||||
const presenceLabel = peerPresence ? t('app:presence.' + peerPresence) : '';
|
||||
|
||||
return (
|
||||
<header className="flex items-center gap-3 border-b border-white/5 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">
|
||||
{isDm ? letter : <UsersIcon className="h-5 w-5" />}
|
||||
<header className="flex items-center gap-3 border-b border-line bg-surface-3 px-6 py-3">
|
||||
<div className="relative">
|
||||
{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 && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
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]
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-display text-base font-semibold text-white">{title}</p>
|
||||
<p className="truncate text-xs text-neutral-500">
|
||||
<p className="truncate font-display text-base font-semibold text-fg">{title}</p>
|
||||
<p className="truncate text-xs text-fg-muted">
|
||||
{isDm ? (
|
||||
<>
|
||||
<span>{handle}</span>
|
||||
{showPresence && (
|
||||
<>
|
||||
<span className="mx-1.5 text-neutral-700">·</span>
|
||||
<span className="mx-1.5 text-fg-muted/50">·</span>
|
||||
<span>{presenceLabel}</span>
|
||||
</>
|
||||
)}
|
||||
@@ -93,75 +108,132 @@ function HeaderBar({ conversation, peerPresence, onInfoClick }: HeaderBarProps)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CallHeaderButton conversationId={conversation.id} />
|
||||
<HeaderActionButton label={t('app:chats.search', { defaultValue: 'Suche' })} icon={SearchIcon} />
|
||||
<CallHeaderButton conversationId={conversation.id} kind="audio" />
|
||||
<CallHeaderButton conversationId={conversation.id} kind="video" />
|
||||
{!isDm && onInfoClick && (
|
||||
<button
|
||||
type="button"
|
||||
<HeaderActionButton
|
||||
label={t('app:group.info_title')}
|
||||
icon={InfoIcon}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function CallHeaderButton({ conversationId }: { conversationId: string }) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { state, startCall } = useCall();
|
||||
const busy = state.kind !== 'idle';
|
||||
interface HeaderActionButtonProps {
|
||||
label: string;
|
||||
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void startCall(conversationId, 'audio')}
|
||||
disabled={busy}
|
||||
aria-label={t('app:call.start_audio')}
|
||||
title={busy ? t('app:call.busy') : t('app:call.start_audio')}
|
||||
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"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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 }) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { session } = useAuth();
|
||||
const { state, joinActiveCall } = useCall();
|
||||
const { state, joinActiveCall, lastCallConversationId, dismissLastCall } = useCall();
|
||||
const active = useCallPresence(conversationId);
|
||||
|
||||
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 =
|
||||
(state.kind === 'connected' ||
|
||||
state.kind === 'connecting' ||
|
||||
state.kind === 'outgoing') &&
|
||||
state.conversationId === conversationId;
|
||||
|
||||
// Only show banner when other people are in it and I'm not.
|
||||
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.
|
||||
useEffect(() => {
|
||||
if (!justLeft) return;
|
||||
if (active.length === 0) return;
|
||||
if (othersIn.length === 0) {
|
||||
// Active reports only us (or something stale) — wait, then dismiss.
|
||||
const id = window.setTimeout(() => dismissLastCall(), 3000);
|
||||
return () => window.clearTimeout(id);
|
||||
}
|
||||
return undefined;
|
||||
}, [justLeft, active.length, 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';
|
||||
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">
|
||||
<PhoneIcon className="h-4 w-4 text-emerald-300" />
|
||||
<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-600 dark:text-emerald-300" />
|
||||
<span className="flex-1">
|
||||
{t('app:call.active_in_conv', {
|
||||
defaultValue: 'Active call · {{count}} in room',
|
||||
count: othersIn.length,
|
||||
count,
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void joinActiveCall(conversationId, 'audio')}
|
||||
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 ? (
|
||||
<SpinnerIcon className="h-3.5 w-3.5" />
|
||||
|
||||
@@ -123,15 +123,25 @@ export function GroupInfoPanel({ open, onClose, conversation }: Props) {
|
||||
{conversation.members.map((m) => {
|
||||
const name = m.profile?.displayName ?? '?';
|
||||
const handle = m.profile?.username ? '@' + m.profile.username : '';
|
||||
const avatarUrl = m.profile?.avatarUrl ?? null;
|
||||
const letter = name.trim().charAt(0).toUpperCase() || '?';
|
||||
return (
|
||||
<li
|
||||
key={m.userId}
|
||||
className="flex items-center gap-3 rounded-lg px-2 py-1.5"
|
||||
>
|
||||
<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}
|
||||
</div>
|
||||
{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">
|
||||
{letter}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm text-white">
|
||||
{name}
|
||||
@@ -187,9 +197,18 @@ export function GroupInfoPanel({ open, onClose, conversation }: Props) {
|
||||
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"
|
||||
>
|
||||
<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}
|
||||
</div>
|
||||
{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">
|
||||
{letter}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm text-white">{f.displayName}</p>
|
||||
<p className="truncate text-xs text-neutral-500">@{f.username}</p>
|
||||
|
||||
@@ -1,46 +1,60 @@
|
||||
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||
import type { RemoteTrack } from 'livekit-client';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { type RemoteScreenShare, useCall } from '../context/CallContext';
|
||||
import { type CallMode, useCall } from '../context/CallContext';
|
||||
import {
|
||||
getPttSettings,
|
||||
type PttSettings,
|
||||
subscribePttSettings,
|
||||
} from '../lib/pttSettings';
|
||||
import {
|
||||
LockIcon,
|
||||
MicIcon,
|
||||
MicOffIcon,
|
||||
MonitorShareIcon,
|
||||
MonitorStopIcon,
|
||||
PhoneOffIcon,
|
||||
SpinnerIcon,
|
||||
} from './icons';
|
||||
import { useActiveSpeakers } from '../lib/useActiveSpeakers';
|
||||
import { CallControls } from './CallControls';
|
||||
import { CallParticipantTile } from './CallParticipantTile';
|
||||
import { FocusIcon, GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } from './icons';
|
||||
import { ScreenShareViewer } from './ScreenShareViewer';
|
||||
|
||||
// Discord-style in-call dock rendered above the message list. Renders three
|
||||
// visual modes driven by CallContext.callMode: grid, focus, fullscreen.
|
||||
// The fullscreen variant absolute-positions itself over the conversation
|
||||
// container so the rail+chat-list remain visible on the left.
|
||||
interface Props {
|
||||
conversation: ConversationSummary;
|
||||
}
|
||||
|
||||
// Discord-style call widget rendered above the message list when the user is
|
||||
// in the current conversation's call. Shows participant avatars + controls.
|
||||
interface Tile {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
avatarUrl: string | null;
|
||||
self: boolean;
|
||||
muted: boolean;
|
||||
video: boolean;
|
||||
sharing: boolean;
|
||||
remoteSharing: boolean;
|
||||
}
|
||||
|
||||
export function InCallPanel({ conversation }: Props) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const {
|
||||
state,
|
||||
room,
|
||||
remoteParticipants,
|
||||
isMuted,
|
||||
isE2EEActive,
|
||||
toggleMute,
|
||||
hangup,
|
||||
isScreenSharing,
|
||||
remoteScreenShares,
|
||||
callMode,
|
||||
focusedId,
|
||||
toggleMute,
|
||||
toggleScreenShare,
|
||||
hangup,
|
||||
setCallMode,
|
||||
setFocusedId,
|
||||
} = useCall();
|
||||
const { session } = useAuth();
|
||||
const myId = session?.user.id ?? null;
|
||||
const activeSpeakers = useActiveSpeakers(room);
|
||||
|
||||
const active =
|
||||
(state.kind === 'connected' ||
|
||||
@@ -49,33 +63,19 @@ export function InCallPanel({ conversation }: Props) {
|
||||
state.conversationId === conversation.id;
|
||||
if (!active) return null;
|
||||
|
||||
const remoteIds = new Set<string>(
|
||||
remoteParticipants.map((p) => p.identity).filter((s): s is string => Boolean(s)),
|
||||
);
|
||||
const tiles: ParticipantTileData[] = [];
|
||||
if (myId) {
|
||||
const me = conversation.members.find((m) => m.userId === myId) ?? null;
|
||||
tiles.push({
|
||||
userId: myId,
|
||||
displayName: me?.profile?.displayName ?? '?',
|
||||
avatarUrl: me?.profile?.avatarUrl ?? null,
|
||||
self: true,
|
||||
speaking: false,
|
||||
muted: isMuted,
|
||||
});
|
||||
}
|
||||
for (const m of conversation.members) {
|
||||
if (m.userId === myId) continue;
|
||||
if (!remoteIds.has(m.userId)) continue;
|
||||
tiles.push({
|
||||
userId: m.userId,
|
||||
displayName: m.profile?.displayName ?? '?',
|
||||
avatarUrl: m.profile?.avatarUrl ?? null,
|
||||
self: false,
|
||||
speaking: false,
|
||||
muted: false,
|
||||
});
|
||||
}
|
||||
const tiles = buildTiles({
|
||||
conversation,
|
||||
myId,
|
||||
remoteIdentities: remoteParticipants.map((p) => p.identity),
|
||||
isMuted,
|
||||
isScreenSharing,
|
||||
remoteSharerIds: new Set(remoteScreenShares.map((s) => s.participantId)),
|
||||
});
|
||||
|
||||
const duration =
|
||||
state.kind === 'connected'
|
||||
? <LiveDuration startedAt={state.startedAt} />
|
||||
: null;
|
||||
|
||||
const statusLabel =
|
||||
state.kind === 'outgoing'
|
||||
@@ -86,268 +86,491 @@ export function InCallPanel({ conversation }: Props) {
|
||||
? t('app:call.waiting_for_peers', { defaultValue: 'Warte auf andere…' })
|
||||
: t('app:call.connected');
|
||||
|
||||
const sharingTile = tiles.find((p) => p.sharing);
|
||||
const effectiveFocusedId = focusedId ?? sharingTile?.userId ?? tiles[0]?.userId ?? null;
|
||||
const speaker = tiles.find((p) => p.userId === effectiveFocusedId) ?? tiles[0];
|
||||
|
||||
const controls = (
|
||||
<CallControls
|
||||
muted={isMuted}
|
||||
sharing={isScreenSharing}
|
||||
video={false}
|
||||
onToggleMute={toggleMute}
|
||||
onToggleShare={() => void toggleScreenShare()}
|
||||
onHangup={() => void hangup()}
|
||||
compact={callMode !== 'fullscreen'}
|
||||
glass={callMode === 'fullscreen'}
|
||||
disabledMedia={state.kind !== 'connected'}
|
||||
/>
|
||||
);
|
||||
|
||||
if (callMode === 'fullscreen') {
|
||||
return (
|
||||
<FullscreenCall
|
||||
tiles={tiles}
|
||||
speaker={speaker}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversation.members}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={isE2EEActive}
|
||||
onExit={() => setCallMode('grid')}
|
||||
onFocusTile={(id) => {
|
||||
setFocusedId(id);
|
||||
}}
|
||||
controls={controls}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const title =
|
||||
conversation.type === 'group'
|
||||
? conversation.name ?? t('app:chats.new_group')
|
||||
: conversation.peer?.displayName ?? '—';
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label={t('app:call.in_call', { defaultValue: 'Im Anruf' })}
|
||||
className="border-b border-white/5 bg-gradient-to-b from-ink-900/80 to-ink-950/40 px-6 py-5"
|
||||
className="flex min-h-[280px] max-h-[420px] shrink-0 flex-col overflow-hidden border-b border-line bg-surface-2"
|
||||
style={{ height: '50%' }}
|
||||
>
|
||||
<div className="mb-4 flex items-center gap-2 text-xs font-medium text-emerald-300">
|
||||
{state.kind === 'connecting' ? (
|
||||
<SpinnerIcon className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400/60" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-400" />
|
||||
</span>
|
||||
)}
|
||||
<span className="uppercase tracking-wide">{statusLabel}</span>
|
||||
{isE2EEActive && (
|
||||
<span
|
||||
className="ml-2 inline-flex items-center gap-1 rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-emerald-200 ring-1 ring-emerald-400/30"
|
||||
title={t('app:call.e2ee_active_hint', {
|
||||
defaultValue: 'Audio + Video sind Ende-zu-Ende-verschlüsselt',
|
||||
})}
|
||||
>
|
||||
<LockIcon className="h-3 w-3" />
|
||||
E2EE
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-4">
|
||||
{tiles.map((p) => (
|
||||
<ParticipantTile key={p.userId} {...p} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{remoteScreenShares.length > 0 && (
|
||||
<div className="mt-4 space-y-3">
|
||||
{remoteScreenShares.map((s) => {
|
||||
const member = conversation.members.find((m) => m.userId === s.participantId);
|
||||
return (
|
||||
<ScreenShareViewer
|
||||
key={s.track.sid ?? s.participantId}
|
||||
share={s}
|
||||
avatarUrl={member?.profile?.avatarUrl ?? null}
|
||||
displayName={member?.profile?.displayName ?? s.participantName}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-5 flex items-center justify-center gap-2">
|
||||
<ControlButton
|
||||
onClick={toggleMute}
|
||||
disabled={state.kind !== 'connected'}
|
||||
active={isMuted}
|
||||
label={isMuted ? t('app:call.unmute') : t('app:call.mute')}
|
||||
tone={isMuted ? 'amber' : 'neutral'}
|
||||
>
|
||||
{isMuted ? <MicOffIcon className="h-5 w-5" /> : <MicIcon className="h-5 w-5" />}
|
||||
</ControlButton>
|
||||
<ControlButton
|
||||
onClick={() => void toggleScreenShare()}
|
||||
disabled={state.kind !== 'connected'}
|
||||
active={isScreenSharing}
|
||||
label={
|
||||
isScreenSharing
|
||||
? t('app:call.stop_share_screen', { defaultValue: 'Screen-Share stoppen' })
|
||||
: t('app:call.share_screen', { defaultValue: 'Bildschirm teilen' })
|
||||
}
|
||||
tone={isScreenSharing ? 'emerald' : 'neutral'}
|
||||
>
|
||||
{isScreenSharing ? (
|
||||
<MonitorStopIcon className="h-5 w-5" />
|
||||
) : (
|
||||
<MonitorShareIcon className="h-5 w-5" />
|
||||
<div className="flex items-center justify-between border-b border-line bg-surface-3 px-4 py-2">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-fg">
|
||||
<UsersIcon className="h-4 w-4 shrink-0 text-fg-muted" />
|
||||
<span className="truncate">{title}</span>
|
||||
<span className="text-fg-muted/50" aria-hidden="true">·</span>
|
||||
<span className="tabular-nums text-fg-muted">
|
||||
{duration ?? statusLabel}
|
||||
{state.kind !== 'connected' && (
|
||||
<SpinnerIcon className="ml-1 inline h-3 w-3" />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{isE2EEActive && (
|
||||
<div className="flex items-center gap-1 text-[11px] font-medium text-emerald-600 dark:text-emerald-400">
|
||||
<LockIcon className="h-3 w-3" />
|
||||
<span>{t('app:call.e2ee_active', { defaultValue: 'E2E verschlüsselt' })}</span>
|
||||
</div>
|
||||
)}
|
||||
</ControlButton>
|
||||
<ControlButton
|
||||
onClick={() => void hangup()}
|
||||
label={t('app:call.hangup')}
|
||||
tone="rose"
|
||||
>
|
||||
<PhoneOffIcon className="h-5 w-5" />
|
||||
</ControlButton>
|
||||
</div>
|
||||
<ModeToggles mode={callMode} onChange={setCallMode} />
|
||||
</div>
|
||||
|
||||
<CallStage
|
||||
tiles={tiles}
|
||||
speaker={speaker}
|
||||
mode={callMode}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={isE2EEActive}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversation.members}
|
||||
onFocusTile={(id) => {
|
||||
setFocusedId(id);
|
||||
if (callMode === 'grid') setCallMode('focus');
|
||||
}}
|
||||
compact
|
||||
/>
|
||||
|
||||
{controls}
|
||||
|
||||
<PttHint />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface ScreenShareViewerProps {
|
||||
share: RemoteScreenShare;
|
||||
avatarUrl: string | null;
|
||||
displayName: string;
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface BuildArgs {
|
||||
conversation: ConversationSummary;
|
||||
myId: string | null;
|
||||
remoteIdentities: string[];
|
||||
isMuted: boolean;
|
||||
isScreenSharing: boolean;
|
||||
remoteSharerIds: Set<string>;
|
||||
}
|
||||
|
||||
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() || '?';
|
||||
function buildTiles({
|
||||
conversation,
|
||||
myId,
|
||||
remoteIdentities,
|
||||
isMuted,
|
||||
isScreenSharing,
|
||||
remoteSharerIds,
|
||||
}: BuildArgs): Tile[] {
|
||||
const remoteSet = new Set(remoteIdentities);
|
||||
const out: Tile[] = [];
|
||||
if (myId) {
|
||||
const me = conversation.members.find((m) => m.userId === myId) ?? null;
|
||||
out.push({
|
||||
userId: myId,
|
||||
displayName: me?.profile?.displayName ?? '?',
|
||||
avatarUrl: me?.profile?.avatarUrl ?? null,
|
||||
self: true,
|
||||
muted: isMuted,
|
||||
video: false,
|
||||
sharing: isScreenSharing,
|
||||
remoteSharing: false,
|
||||
});
|
||||
}
|
||||
for (const m of conversation.members) {
|
||||
if (m.userId === myId) continue;
|
||||
if (!remoteSet.has(m.userId)) continue;
|
||||
const sharing = remoteSharerIds.has(m.userId);
|
||||
out.push({
|
||||
userId: m.userId,
|
||||
displayName: m.profile?.displayName ?? '?',
|
||||
avatarUrl: m.profile?.avatarUrl ?? null,
|
||||
self: false,
|
||||
muted: false,
|
||||
video: false,
|
||||
sharing,
|
||||
remoteSharing: sharing,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function LiveDuration({ startedAt }: { startedAt: string }) {
|
||||
const [, tick] = useState(0);
|
||||
useEffect(() => {
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
const onChange = () => {
|
||||
setIsFullscreen(document.fullscreenElement === containerRef.current);
|
||||
};
|
||||
document.addEventListener('fullscreenchange', onChange);
|
||||
return () => document.removeEventListener('fullscreenchange', onChange);
|
||||
const id = window.setInterval(() => tick((v) => v + 1), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
return <>{formatElapsed(Date.now() - new Date(startedAt).getTime())}</>;
|
||||
}
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
if (document.fullscreenElement === el) {
|
||||
void document.exitFullscreen();
|
||||
} else {
|
||||
void el.requestFullscreen();
|
||||
}
|
||||
};
|
||||
function formatElapsed(ms: number): string {
|
||||
const total = Math.max(0, Math.floor(ms / 1000));
|
||||
const hh = Math.floor(total / 3600);
|
||||
const mm = Math.floor((total % 3600) / 60);
|
||||
const ss = total % 60;
|
||||
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||
return `${pad(hh)}:${pad(mm)}:${pad(ss)}`;
|
||||
}
|
||||
|
||||
function ModeToggles({
|
||||
mode,
|
||||
onChange,
|
||||
}: {
|
||||
mode: CallMode;
|
||||
onChange: (mode: CallMode) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
<div className="flex items-center gap-1">
|
||||
<ModeButton active={mode === 'grid'} onClick={() => onChange('grid')} label="Grid">
|
||||
<GridIcon className="h-4 w-4" />
|
||||
</ModeButton>
|
||||
<ModeButton active={mode === 'focus'} onClick={() => onChange('focus')} label="Fokus">
|
||||
<FocusIcon className="h-4 w-4" />
|
||||
</ModeButton>
|
||||
<ModeButton
|
||||
active={mode === 'fullscreen'}
|
||||
onClick={() => onChange('fullscreen')}
|
||||
label="Vollbild"
|
||||
>
|
||||
<MaximizeIcon className="h-4 w-4" />
|
||||
</ModeButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModeButton({
|
||||
active,
|
||||
onClick,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
aria-pressed={active}
|
||||
title={label}
|
||||
className={
|
||||
'overflow-hidden rounded-xl border border-emerald-500/20 bg-black ' +
|
||||
(isFullscreen ? 'flex h-screen w-screen flex-col rounded-none' : '')
|
||||
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 ' +
|
||||
(active
|
||||
? 'border-accent bg-accent text-accent-fg'
|
||||
: 'border-line bg-surface-2 text-fg-muted hover:bg-surface-3 hover:text-fg')
|
||||
}
|
||||
>
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-emerald-500/10 bg-emerald-500/10 px-3 py-1.5 text-xs text-emerald-200">
|
||||
<MonitorShareIcon className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate flex-1">
|
||||
{t('app:call.is_sharing_screen', {
|
||||
name: displayName,
|
||||
defaultValue: displayName + ' teilt den Bildschirm',
|
||||
})}
|
||||
</span>
|
||||
{watching && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleFullscreen}
|
||||
aria-label={t('app:call.fullscreen', { defaultValue: 'Vollbild' })}
|
||||
title={t('app:call.fullscreen', { defaultValue: 'Vollbild' })}
|
||||
className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-emerald-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40"
|
||||
>
|
||||
<FullscreenIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (document.fullscreenElement === containerRef.current) {
|
||||
void document.exitFullscreen();
|
||||
}
|
||||
setWatching(false);
|
||||
}}
|
||||
aria-label={t('app:call.stop_watching', { defaultValue: 'Nicht mehr anschauen' })}
|
||||
title={t('app:call.stop_watching', { defaultValue: 'Nicht mehr anschauen' })}
|
||||
className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-emerald-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40"
|
||||
>
|
||||
<span aria-hidden="true" className="text-[14px] leading-none">×</span>
|
||||
</button>
|
||||
</>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface StageProps {
|
||||
tiles: Tile[];
|
||||
speaker: Tile | undefined;
|
||||
mode: CallMode;
|
||||
activeSpeakers: Set<string>;
|
||||
e2ee: boolean;
|
||||
remoteScreenShares: {
|
||||
track: import('livekit-client').RemoteTrack;
|
||||
participantId: string;
|
||||
participantName: string;
|
||||
}[];
|
||||
conversationMembers: ConversationSummary['members'];
|
||||
onFocusTile: (id: string) => void;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
function CallStage({
|
||||
tiles,
|
||||
speaker,
|
||||
mode,
|
||||
activeSpeakers,
|
||||
e2ee,
|
||||
remoteScreenShares,
|
||||
conversationMembers,
|
||||
onFocusTile,
|
||||
compact = false,
|
||||
}: StageProps) {
|
||||
if (mode === 'focus' && speaker) {
|
||||
const others = tiles.filter((p) => p.userId !== speaker.userId);
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2.5 p-3">
|
||||
<div className="min-h-0 flex-1">
|
||||
<FocusedTile
|
||||
tile={speaker}
|
||||
e2ee={e2ee}
|
||||
speaking={activeSpeakers.has(speaker.userId)}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
/>
|
||||
</div>
|
||||
{others.length > 0 && (
|
||||
<div className="flex h-[110px] gap-2.5 overflow-x-auto">
|
||||
{others.map((p) => (
|
||||
<div key={p.userId} className="h-full min-w-[160px]">
|
||||
<CallParticipantTile
|
||||
userId={p.userId}
|
||||
displayName={p.displayName}
|
||||
avatarUrl={p.avatarUrl}
|
||||
me={p.self}
|
||||
muted={p.muted}
|
||||
speaking={activeSpeakers.has(p.userId)}
|
||||
sharing={p.sharing}
|
||||
video={p.video}
|
||||
e2ee={e2ee}
|
||||
size="small"
|
||||
onClick={() => onFocusTile(p.userId)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{watching ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
onDoubleClick={toggleFullscreen}
|
||||
className={
|
||||
'block cursor-zoom-in bg-black ' +
|
||||
(isFullscreen ? 'h-full w-full flex-1 object-contain' : 'w-full')
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWatching(true)}
|
||||
aria-label={t('app:call.watch_screen', { defaultValue: 'Bildschirm anschauen' })}
|
||||
className="group relative block w-full cursor-pointer overflow-hidden bg-ink-900 focus:outline-none"
|
||||
style={{ aspectRatio: '16 / 9' }}
|
||||
>
|
||||
<BlurredTile avatarUrl={avatarUrl} letter={letter} />
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/30 transition group-hover:bg-black/40">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span className="flex h-14 w-14 items-center justify-center rounded-full bg-emerald-500/90 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-brand-500/20 to-emerald-500/20" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Grid
|
||||
const gridClass = gridColsFor(tiles.length);
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-brand-500/40 via-fuchsia-500/20 to-emerald-500/30">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="text-[120px] font-display font-bold text-white/20 blur-[2px]"
|
||||
>
|
||||
{letter}
|
||||
</span>
|
||||
<div className={'min-h-0 flex-1 p-3 ' + (compact ? '' : 'p-4')}>
|
||||
<div className={'grid h-full gap-2 ' + gridClass}>
|
||||
{tiles.map((p) => (
|
||||
<CallParticipantTile
|
||||
key={p.userId}
|
||||
userId={p.userId}
|
||||
displayName={p.displayName}
|
||||
avatarUrl={p.avatarUrl}
|
||||
me={p.self}
|
||||
muted={p.muted}
|
||||
speaking={activeSpeakers.has(p.userId)}
|
||||
sharing={p.sharing}
|
||||
video={p.video}
|
||||
e2ee={e2ee}
|
||||
onClick={() => onFocusTile(p.userId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
function FocusedTile({
|
||||
tile,
|
||||
e2ee,
|
||||
speaking,
|
||||
remoteScreenShares,
|
||||
conversationMembers,
|
||||
}: {
|
||||
tile: Tile;
|
||||
e2ee: boolean;
|
||||
speaking: boolean;
|
||||
remoteScreenShares: StageProps['remoteScreenShares'];
|
||||
conversationMembers: StageProps['conversationMembers'];
|
||||
}) {
|
||||
// When the focused participant is remotely sharing their screen, embed the
|
||||
// real video stream rather than the fake-window placeholder.
|
||||
if (tile.remoteSharing) {
|
||||
const share = remoteScreenShares.find((s) => s.participantId === tile.userId);
|
||||
const member = conversationMembers.find((m) => m.userId === tile.userId);
|
||||
if (share) {
|
||||
return (
|
||||
<div className="h-full">
|
||||
<ScreenShareViewer
|
||||
share={share}
|
||||
avatarUrl={member?.profile?.avatarUrl ?? tile.avatarUrl}
|
||||
displayName={member?.profile?.displayName ?? tile.displayName}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
<div className="h-full">
|
||||
<CallParticipantTile
|
||||
userId={tile.userId}
|
||||
displayName={tile.displayName}
|
||||
avatarUrl={tile.avatarUrl}
|
||||
me={tile.self}
|
||||
muted={tile.muted}
|
||||
speaking={speaking}
|
||||
sharing={tile.sharing}
|
||||
video={tile.video}
|
||||
e2ee={e2ee}
|
||||
focused
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FullscreenIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
function gridColsFor(n: number): string {
|
||||
if (n <= 1) return 'grid-cols-1';
|
||||
if (n === 2) return 'grid-cols-2';
|
||||
if (n === 3) return 'grid-cols-3';
|
||||
if (n === 4) return 'grid-cols-2 grid-rows-2';
|
||||
return 'grid-cols-3 grid-rows-2';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fullscreen cinema mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface FullscreenProps {
|
||||
tiles: Tile[];
|
||||
speaker: Tile | undefined;
|
||||
remoteScreenShares: StageProps['remoteScreenShares'];
|
||||
conversationMembers: StageProps['conversationMembers'];
|
||||
activeSpeakers: Set<string>;
|
||||
e2ee: boolean;
|
||||
onExit: () => void;
|
||||
onFocusTile: (id: string) => void;
|
||||
controls: React.ReactNode;
|
||||
}
|
||||
|
||||
function FullscreenCall({
|
||||
tiles,
|
||||
speaker,
|
||||
remoteScreenShares,
|
||||
conversationMembers,
|
||||
activeSpeakers,
|
||||
e2ee,
|
||||
onExit: _onExit,
|
||||
onFocusTile,
|
||||
controls,
|
||||
}: FullscreenProps) {
|
||||
const others = speaker ? tiles.filter((p) => p.userId !== speaker.userId) : tiles;
|
||||
const [hintGone, setHintGone] = useState(false);
|
||||
useEffect(() => {
|
||||
const id = window.setTimeout(() => setHintGone(true), 3500);
|
||||
return () => window.clearTimeout(id);
|
||||
}, []);
|
||||
|
||||
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>
|
||||
<div className="absolute inset-0 z-40 flex flex-col overflow-hidden bg-surface">
|
||||
<div className="relative min-h-0 flex-1">
|
||||
{speaker && (
|
||||
<div className="absolute inset-0">
|
||||
{speaker.remoteSharing ? (
|
||||
<FullscreenShare speaker={speaker} remoteScreenShares={remoteScreenShares} conversationMembers={conversationMembers} />
|
||||
) : (
|
||||
<div className="h-full w-full [&>div]:rounded-none [&>div]:border-0">
|
||||
<CallParticipantTile
|
||||
userId={speaker.userId}
|
||||
displayName={speaker.displayName}
|
||||
avatarUrl={speaker.avatarUrl}
|
||||
me={speaker.self}
|
||||
muted={speaker.muted}
|
||||
speaking={activeSpeakers.has(speaker.userId)}
|
||||
sharing={speaker.sharing}
|
||||
video={speaker.video}
|
||||
e2ee={e2ee}
|
||||
focused
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{others.length > 0 && (
|
||||
<div className="absolute bottom-24 right-4 flex w-[180px] flex-col gap-2">
|
||||
{others.slice(0, 4).map((p) => (
|
||||
<div key={p.userId} className="h-[100px] backdrop-blur-xl">
|
||||
<CallParticipantTile
|
||||
userId={p.userId}
|
||||
displayName={p.displayName}
|
||||
avatarUrl={p.avatarUrl}
|
||||
me={p.self}
|
||||
muted={p.muted}
|
||||
speaking={activeSpeakers.has(p.userId)}
|
||||
sharing={p.sharing}
|
||||
video={p.video}
|
||||
e2ee={e2ee}
|
||||
size="small"
|
||||
onClick={() => onFocusTile(p.userId)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hintGone && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-1/2 top-5 z-10 animate-fs-hint rounded-lg border border-white/10 bg-black/60 px-3.5 py-1.5 text-[11px] font-medium tracking-wide text-white/70 backdrop-blur-md"
|
||||
>
|
||||
Esc zum Verlassen
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pointer-events-auto absolute bottom-4 left-1/2 -translate-x-1/2">
|
||||
{controls}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FullscreenShare({
|
||||
speaker,
|
||||
remoteScreenShares,
|
||||
conversationMembers,
|
||||
}: {
|
||||
speaker: Tile;
|
||||
remoteScreenShares: StageProps['remoteScreenShares'];
|
||||
conversationMembers: StageProps['conversationMembers'];
|
||||
}) {
|
||||
const share = remoteScreenShares.find((s) => s.participantId === speaker.userId);
|
||||
const member = conversationMembers.find((m) => m.userId === speaker.userId);
|
||||
if (!share) return null;
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<ScreenShareViewer
|
||||
share={share}
|
||||
avatarUrl={member?.profile?.avatarUrl ?? speaker.avatarUrl}
|
||||
displayName={member?.profile?.displayName ?? speaker.displayName}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -356,95 +579,11 @@ function PttHint() {
|
||||
useEffect(() => subscribePttSettings(setPtt), []);
|
||||
if (!ptt.enabled) return null;
|
||||
return (
|
||||
<p className="mt-3 text-center text-[11px] text-neutral-500">
|
||||
<p className="border-t border-line bg-surface-3 py-2 text-center text-[11px] text-fg-muted">
|
||||
Push-to-Talk:
|
||||
<kbd className="rounded border border-white/10 bg-white/5 px-1.5 py-0.5 font-mono text-[10px] text-neutral-300">
|
||||
<kbd className="rounded border border-line bg-surface-2 px-1.5 py-0.5 font-mono text-[10px] text-fg">
|
||||
{ptt.keyLabel}
|
||||
</kbd>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
interface ParticipantTileData {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
avatarUrl: string | null;
|
||||
self: boolean;
|
||||
speaking: boolean;
|
||||
muted: boolean;
|
||||
}
|
||||
|
||||
function ParticipantTile(p: ParticipantTileData) {
|
||||
const letter = p.displayName.trim().charAt(0).toUpperCase() || '?';
|
||||
return (
|
||||
<div className="flex w-28 flex-col items-center gap-2">
|
||||
<div
|
||||
className={
|
||||
'relative flex h-20 w-20 items-center justify-center rounded-2xl bg-gradient-to-br from-brand-500/40 to-brand-700/40 text-2xl font-semibold text-white ring-2 transition ' +
|
||||
(p.speaking
|
||||
? 'ring-emerald-400 shadow-[0_0_24px_rgba(52,211,153,0.35)]'
|
||||
: 'ring-white/10')
|
||||
}
|
||||
>
|
||||
{p.avatarUrl ? (
|
||||
<img
|
||||
src={p.avatarUrl}
|
||||
alt=""
|
||||
className="h-full w-full rounded-2xl object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span aria-hidden="true">{letter}</span>
|
||||
)}
|
||||
{p.muted && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute -bottom-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full bg-amber-500 ring-2 ring-ink-950"
|
||||
>
|
||||
<MicOffIcon className="h-3 w-3 text-ink-950" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="max-w-full truncate text-xs font-medium text-neutral-200">
|
||||
{p.displayName}
|
||||
{p.self && (
|
||||
<span className="ml-1 text-neutral-500">· Du</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ControlButtonProps {
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
tone: 'neutral' | 'amber' | 'rose' | 'emerald';
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function ControlButton({ onClick, label, tone, disabled, children }: ControlButtonProps) {
|
||||
const toneClass =
|
||||
tone === 'rose'
|
||||
? 'bg-rose-500 text-white hover:bg-rose-400 focus-visible:ring-rose-400/50'
|
||||
: tone === 'amber'
|
||||
? 'bg-amber-500/90 text-ink-950 hover:bg-amber-400 focus-visible:ring-amber-400/50'
|
||||
: tone === 'emerald'
|
||||
? 'bg-emerald-500/85 text-white hover:bg-emerald-400 focus-visible:ring-emerald-400/50'
|
||||
: 'bg-white/10 text-neutral-100 hover:bg-white/20 focus-visible:ring-brand-400/40';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={
|
||||
'inline-flex h-11 w-11 cursor-pointer items-center justify-center rounded-full transition focus:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-50 ' +
|
||||
toneClass
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useCall } from '../context/CallContext';
|
||||
import { useFriendshipsContext } from '../context/FriendshipsContext';
|
||||
import { AvatarColorKey, colorKeyFor } from './CallParticipantTile';
|
||||
import { LockIcon, PhoneIcon, PhoneOffIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
conversation: ConversationSummary;
|
||||
}
|
||||
|
||||
const PULSE_BG: Record<AvatarColorKey, string> = {
|
||||
violet: 'bg-violet-200 text-violet-800 dark:bg-violet-900/60 dark:text-violet-200',
|
||||
amber: 'bg-amber-200 text-amber-900 dark:bg-amber-900/60 dark:text-amber-200',
|
||||
rose: 'bg-rose-200 text-rose-900 dark:bg-rose-900/60 dark:text-rose-200',
|
||||
teal: 'bg-teal-200 text-teal-900 dark:bg-teal-900/60 dark:text-teal-200',
|
||||
};
|
||||
|
||||
// Docked incoming-call panel: replaces the chat header, chat + composer stay
|
||||
// visible and usable below (per user decision). Full-bleed overlay lives in
|
||||
// the shell-level toast for cross-conversation ring notifications.
|
||||
export function IncomingCallPanel({ conversation }: Props) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { state, acceptIncoming, rejectIncoming } = useCall();
|
||||
const { friendships } = useFriendshipsContext();
|
||||
|
||||
if (state.kind !== 'incoming' || state.conversationId !== conversation.id) return null;
|
||||
|
||||
const isGroup = conversation.type === 'group';
|
||||
const callerProfile = conversation.members.find((m) => m.userId === state.fromUserId)?.profile ?? null;
|
||||
const callerName =
|
||||
callerProfile?.displayName ??
|
||||
friendships.find((f) => f.peer.userId === state.fromUserId)?.peer.displayName ??
|
||||
'?';
|
||||
const groupName = isGroup ? (conversation.name ?? t('app:chats.new_group')) : null;
|
||||
const title = isGroup ? groupName ?? callerName : callerName;
|
||||
const letter = title.trim().charAt(0).toUpperCase() || '?';
|
||||
const avatarUrl = isGroup
|
||||
? (conversation.avatarUrl ?? callerProfile?.avatarUrl ?? null)
|
||||
: callerProfile?.avatarUrl ?? null;
|
||||
const colorKey = colorKeyFor(state.fromUserId);
|
||||
const avatarTone = PULSE_BG[colorKey];
|
||||
const mediaLabel =
|
||||
state.mediaKind === 'video' ? 'Video Call' : 'Voice Call';
|
||||
|
||||
return (
|
||||
<section
|
||||
role="region"
|
||||
aria-label={t('app:call.incoming_title')}
|
||||
className="relative overflow-hidden border-b border-line bg-surface-3"
|
||||
>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_30%_20%,rgba(79,70,229,0.10),transparent_60%),radial-gradient(circle_at_70%_80%,rgba(109,115,255,0.08),transparent_55%)]" />
|
||||
<div className="relative mx-auto flex max-w-[480px] flex-col items-center px-6 py-8 text-center">
|
||||
<p className="text-[12px] font-semibold uppercase tracking-[0.12em] text-fg-muted">
|
||||
{t('app:call.incoming_title')}
|
||||
</p>
|
||||
<div className="relative my-4 flex h-[112px] w-[112px] items-center justify-center">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 rounded-full border-2 border-accent animate-pulse-ring"
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 rounded-full border-2 border-accent animate-pulse-ring"
|
||||
style={{ animationDelay: '1s' }}
|
||||
/>
|
||||
{avatarUrl ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={title}
|
||||
className="h-24 w-24 rounded-full border-[3px] border-surface-3 object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={
|
||||
'flex h-24 w-24 items-center justify-center rounded-full border-[3px] border-surface-3 font-display text-4xl font-bold ' +
|
||||
avatarTone
|
||||
}
|
||||
>
|
||||
{letter}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="font-display text-2xl font-bold tracking-tight text-fg">{title}</p>
|
||||
<p className="mt-1.5 flex items-center gap-1.5 text-xs text-fg-muted">
|
||||
<LockIcon className="h-3.5 w-3.5" />
|
||||
<span>E2E verschlüsselt · {mediaLabel}</span>
|
||||
{isGroup && (
|
||||
<>
|
||||
<span className="text-fg-muted/50" aria-hidden="true">·</span>
|
||||
<span>
|
||||
{t('app:call.incoming_group_from', {
|
||||
name: callerName,
|
||||
defaultValue: callerName + ' ruft an',
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<div className="mt-5 flex w-full max-w-[360px] gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={rejectIncoming}
|
||||
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-[14px] border border-rose-500/40 bg-transparent px-5 py-3.5 text-sm font-semibold text-rose-600 transition hover:bg-rose-500/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-500/40 dark:text-rose-400"
|
||||
>
|
||||
<PhoneOffIcon className="h-4 w-4" />
|
||||
<span>{t('app:call.decline')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void acceptIncoming()}
|
||||
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-[14px] bg-emerald-600 px-5 py-3.5 text-sm font-semibold text-white shadow-accept-btn transition hover:bg-emerald-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50"
|
||||
>
|
||||
<PhoneIcon className="h-4 w-4" />
|
||||
<span>{t('app:call.accept')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { devLocalSecretStore } from '../lib/secretStore';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import type { AggregatedReaction } from '../lib/useMessageReactions';
|
||||
import { AttachmentImage } from './AttachmentImage';
|
||||
import { Avatar } from './Avatar';
|
||||
import { PencilIcon, PhoneIcon, PhoneOffIcon, SmileIcon, SpinnerIcon, TrashIcon, XIcon } from './icons';
|
||||
|
||||
const EMOJI_CHOICES = ['👍', '❤️', '😂', '🎉', '🔥', '😮', '😢', '🙏'];
|
||||
@@ -23,6 +24,10 @@ interface Props {
|
||||
message: DecryptedMessage;
|
||||
mine: 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;
|
||||
reactions: AggregatedReaction[];
|
||||
onToggleReaction: (emoji: string) => Promise<void>;
|
||||
@@ -33,6 +38,9 @@ export function MessageBubble({
|
||||
message,
|
||||
mine,
|
||||
groupedWithPrev,
|
||||
isLastOfRun = false,
|
||||
senderDisplayName,
|
||||
senderAvatarUrl,
|
||||
conversationId,
|
||||
reactions,
|
||||
onToggleReaction,
|
||||
@@ -98,6 +106,8 @@ export function MessageBubble({
|
||||
messageId: message.id,
|
||||
conversationId,
|
||||
newPlaintext: trimmed,
|
||||
senderUserId: session.user.id,
|
||||
senderDeviceId: device.id,
|
||||
senderPrivateKey: priv,
|
||||
});
|
||||
setEditing(false);
|
||||
@@ -141,8 +151,13 @@ export function MessageBubble({
|
||||
|
||||
if (message.deletedAt) {
|
||||
return (
|
||||
<div className={'flex ' + (mine ? 'justify-end' : 'justify-start')}>
|
||||
<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">
|
||||
<div className={'flex items-end gap-2 ' + (mine ? 'flex-row-reverse' : '')}>
|
||||
<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')}
|
||||
</div>
|
||||
</div>
|
||||
@@ -154,14 +169,19 @@ export function MessageBubble({
|
||||
}
|
||||
|
||||
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
|
||||
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 ? (
|
||||
<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
|
||||
value={editText}
|
||||
onChange={(e) => setEditText(e.target.value)}
|
||||
@@ -177,10 +197,10 @@ export function MessageBubble({
|
||||
}}
|
||||
rows={2}
|
||||
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 && (
|
||||
<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}
|
||||
</p>
|
||||
)}
|
||||
@@ -191,7 +211,7 @@ export function MessageBubble({
|
||||
setEditing(false);
|
||||
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')}
|
||||
</button>
|
||||
@@ -199,7 +219,7 @@ export function MessageBubble({
|
||||
type="button"
|
||||
disabled={busy}
|
||||
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" />}
|
||||
<span>{t('common:save', { defaultValue: 'Save' })}</span>
|
||||
@@ -209,14 +229,14 @@ export function MessageBubble({
|
||||
) : (
|
||||
<div
|
||||
className={
|
||||
'break-words rounded-2xl px-3.5 py-2 text-sm ' +
|
||||
'break-words px-3.5 py-2 text-sm ' +
|
||||
(mine
|
||||
? 'bg-brand-500/85 text-white'
|
||||
: 'border border-white/5 bg-ink-900/70 text-neutral-100')
|
||||
? 'rounded-[18px_18px_4px_18px] bg-accent text-accent-fg'
|
||||
: 'rounded-[18px_18px_18px_4px] border border-line text-fg')
|
||||
}
|
||||
>
|
||||
{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>}
|
||||
@@ -228,7 +248,7 @@ export function MessageBubble({
|
||||
<div
|
||||
className={
|
||||
'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>
|
||||
@@ -240,7 +260,7 @@ export function MessageBubble({
|
||||
)}
|
||||
|
||||
{showSeen && mine && !editing && !message.deletedAt && (
|
||||
<p className="mt-0.5 text-right text-[10px] text-neutral-500">
|
||||
<p className="mt-0.5 text-right text-[10px] text-fg-muted">
|
||||
{t('app:chats.seen')}
|
||||
</p>
|
||||
)}
|
||||
@@ -253,10 +273,10 @@ export function MessageBubble({
|
||||
type="button"
|
||||
onClick={() => void onToggleReaction(r.emoji)}
|
||||
className={
|
||||
'inline-flex cursor-pointer items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' +
|
||||
'inline-flex cursor-pointer items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
(r.mine
|
||||
? 'border-brand-400/40 bg-brand-500/20 text-brand-100'
|
||||
: 'border-white/10 bg-white/5 text-neutral-200 hover:bg-white/10')
|
||||
? 'border-accent/40 bg-accent/20 text-accent'
|
||||
: 'border-line bg-surface-2 text-fg hover:bg-surface-3')
|
||||
}
|
||||
>
|
||||
<span>{r.emoji}</span>
|
||||
@@ -273,7 +293,7 @@ export function MessageBubble({
|
||||
(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
|
||||
label={t('app:friends.action_accept', { defaultValue: 'React' })}
|
||||
onClick={() => setPickerOpen((v) => !v)}
|
||||
@@ -304,7 +324,7 @@ export function MessageBubble({
|
||||
ref={pickerRef}
|
||||
role="menu"
|
||||
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')
|
||||
}
|
||||
>
|
||||
@@ -313,7 +333,7 @@ export function MessageBubble({
|
||||
key={e}
|
||||
type="button"
|
||||
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}
|
||||
</button>
|
||||
@@ -321,7 +341,7 @@ export function MessageBubble({
|
||||
<button
|
||||
type="button"
|
||||
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" />
|
||||
</button>
|
||||
@@ -334,6 +354,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({
|
||||
parsed,
|
||||
mine,
|
||||
@@ -349,8 +390,8 @@ function CallEventRow({
|
||||
|
||||
const Icon = isMissed ? PhoneOffIcon : PhoneIcon;
|
||||
const tone = isMissed
|
||||
? 'border-rose-500/20 bg-rose-500/10 text-rose-200'
|
||||
: 'border-emerald-500/20 bg-emerald-500/10 text-emerald-200';
|
||||
? 'border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-200'
|
||||
: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-200';
|
||||
|
||||
const label =
|
||||
status === 'ended'
|
||||
@@ -406,10 +447,10 @@ function ActionButton({
|
||||
title={label}
|
||||
onClick={onClick}
|
||||
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'
|
||||
? 'text-neutral-400 hover:bg-rose-500/20 hover:text-rose-200'
|
||||
: 'text-neutral-400 hover:bg-white/10 hover:text-neutral-100')
|
||||
? 'text-fg-muted hover:bg-rose-500/20 hover:text-rose-500 dark:hover:text-rose-200'
|
||||
: 'text-fg-muted hover:bg-surface-2 hover:text-fg')
|
||||
}
|
||||
>
|
||||
{icon}
|
||||
|
||||
@@ -35,22 +35,22 @@ export function Modal({ open, title, onClose, children, size = 'md' }: Props) {
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
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
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
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
|
||||
}
|
||||
>
|
||||
<header className="flex items-center justify-between border-b border-white/5 px-6 py-4">
|
||||
<h2 className="font-display text-lg font-semibold text-white">{title}</h2>
|
||||
<header className="flex items-center justify-between border-b border-line px-6 py-4">
|
||||
<h2 className="font-display text-lg font-semibold text-fg">{title}</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
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" />
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
const onChange = () => {
|
||||
setIsFullscreen(document.fullscreenElement === containerRef.current);
|
||||
};
|
||||
document.addEventListener('fullscreenchange', onChange);
|
||||
return () => document.removeEventListener('fullscreenchange', onChange);
|
||||
}, []);
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
if (document.fullscreenElement === el) {
|
||||
void document.exitFullscreen();
|
||||
} else {
|
||||
void el.requestFullscreen();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={
|
||||
'overflow-hidden rounded-xl border border-emerald-500/30 bg-black ' +
|
||||
(isFullscreen ? 'flex h-screen w-screen flex-col rounded-none' : '')
|
||||
}
|
||||
>
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-emerald-500/20 bg-emerald-500/15 px-3 py-1.5 text-xs text-emerald-700 dark:text-emerald-200">
|
||||
<MonitorShareIcon className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="flex-1 truncate">
|
||||
{t('app:call.is_sharing_screen', {
|
||||
name: displayName,
|
||||
defaultValue: displayName + ' teilt den Bildschirm',
|
||||
})}
|
||||
</span>
|
||||
{watching && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleFullscreen}
|
||||
aria-label={t('app:call.fullscreen', { defaultValue: 'Vollbild' })}
|
||||
title={t('app:call.fullscreen', { defaultValue: 'Vollbild' })}
|
||||
className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-emerald-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40"
|
||||
>
|
||||
<FullscreenIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (document.fullscreenElement === containerRef.current) {
|
||||
void document.exitFullscreen();
|
||||
}
|
||||
setWatching(false);
|
||||
}}
|
||||
aria-label={t('app:call.stop_watching', { defaultValue: 'Nicht mehr anschauen' })}
|
||||
title={t('app:call.stop_watching', { defaultValue: 'Nicht mehr anschauen' })}
|
||||
className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-emerald-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40"
|
||||
>
|
||||
<span aria-hidden="true" className="text-[14px] leading-none">×</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{watching ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
onDoubleClick={toggleFullscreen}
|
||||
className={
|
||||
'block cursor-zoom-in bg-black ' +
|
||||
(isFullscreen ? 'h-full w-full flex-1 object-contain' : 'w-full')
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWatching(true)}
|
||||
aria-label={t('app:call.watch_screen', { defaultValue: 'Bildschirm anschauen' })}
|
||||
className="group relative block w-full cursor-pointer overflow-hidden bg-ink-900 focus:outline-none"
|
||||
style={{ aspectRatio: '16 / 9' }}
|
||||
>
|
||||
<BlurredTile avatarUrl={avatarUrl} letter={letter} />
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/30 transition group-hover:bg-black/40">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span className="flex h-14 w-14 items-center justify-center rounded-full bg-emerald-500 text-white shadow-lg transition group-hover:scale-105">
|
||||
<PlayIcon className="ml-0.5 h-6 w-6" />
|
||||
</span>
|
||||
<span className="text-xs font-medium text-white/90">
|
||||
{t('app:call.watch_screen', { defaultValue: 'Bildschirm anschauen' })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BlurredTile({ avatarUrl, letter }: { avatarUrl: string | null; letter: string }) {
|
||||
if (avatarUrl) {
|
||||
return (
|
||||
<>
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt=""
|
||||
className="absolute inset-0 h-full w-full scale-110 object-cover blur-2xl"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-accent/20 to-emerald-500/20" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-accent/40 via-fuchsia-500/20 to-emerald-500/30">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="text-[120px] font-display font-bold text-white/20 blur-[2px]"
|
||||
>
|
||||
{letter}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function FullscreenIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
{...props}
|
||||
>
|
||||
<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -4,110 +4,176 @@ import { NavLink } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useConversationsContext } from '../context/ConversationsContext';
|
||||
import { useFriendshipsContext } from '../context/FriendshipsContext';
|
||||
import { CallBar } from './CallUI';
|
||||
import { useTheme } from '../context/ThemeContext';
|
||||
import {
|
||||
ChatBubbleIcon,
|
||||
GearIcon,
|
||||
LogoMark,
|
||||
MoonIcon,
|
||||
ShieldIcon,
|
||||
SignOutIcon,
|
||||
SunIcon,
|
||||
UsersIcon,
|
||||
} from './icons';
|
||||
import { UserBar } from './UserBar';
|
||||
|
||||
interface NavItem {
|
||||
to: string;
|
||||
labelKey: string;
|
||||
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
|
||||
badge?: 'friends' | 'chats';
|
||||
}
|
||||
|
||||
const BASE_NAV_ITEMS: NavItem[] = [
|
||||
{ to: '/chats', labelKey: 'app:nav.chats', icon: ChatBubbleIcon },
|
||||
{ to: '/friends', labelKey: 'app:nav.friends', icon: UsersIcon },
|
||||
{ to: '/settings', labelKey: 'app:nav.settings', icon: GearIcon },
|
||||
const PRIMARY_NAV: NavItem[] = [
|
||||
{ to: '/chats', labelKey: 'app:nav.chats', icon: ChatBubbleIcon, badge: 'chats' },
|
||||
{ to: '/friends', labelKey: 'app:nav.friends', icon: UsersIcon, badge: 'friends' },
|
||||
];
|
||||
const ADMIN_NAV_ITEM: NavItem = {
|
||||
const ADMIN_NAV: NavItem = {
|
||||
to: '/admin',
|
||||
labelKey: 'app:nav.admin',
|
||||
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() {
|
||||
const { t } = useTranslation(['app', 'common']);
|
||||
const { t } = useTranslation(['app']);
|
||||
const { signOut, profile } = useAuth();
|
||||
const { incomingCount } = useFriendshipsContext();
|
||||
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 (
|
||||
<aside
|
||||
aria-label="Primary navigation"
|
||||
className="flex h-screen w-72 shrink-0 flex-col border-r border-white/5 bg-ink-900/70 backdrop-blur-xl"
|
||||
className="flex h-screen w-[72px] shrink-0 flex-col items-center border-r border-line bg-surface-2/80 py-4 backdrop-blur-xl"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 px-5 pb-3 pt-5">
|
||||
<LogoMark className="h-7 w-7" />
|
||||
<span className="font-display text-base font-semibold tracking-tight text-white">
|
||||
{t('common:app_name')}
|
||||
</span>
|
||||
<div className="flex h-10 w-10 items-center justify-center" title="Netralax">
|
||||
<LogoMark className="h-8 w-8" />
|
||||
</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) => {
|
||||
const badge =
|
||||
item.to === '/friends'
|
||||
const badgeCount =
|
||||
item.badge === 'friends'
|
||||
? incomingCount
|
||||
: item.to === '/chats'
|
||||
: item.badge === 'chats'
|
||||
? totalUnread
|
||||
: 0;
|
||||
const ariaLabel = badge > 0 ? `${t(item.labelKey)} (${badge})` : undefined;
|
||||
return (
|
||||
<NavLink
|
||||
<RailNavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
aria-label={ariaLabel}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
'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>
|
||||
label={t(item.labelKey)}
|
||||
icon={item.icon}
|
||||
badge={badgeCount}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="border-t border-white/5 p-3">
|
||||
<CallBar />
|
||||
<UserBar />
|
||||
<button
|
||||
type="button"
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<RailNavLink
|
||||
to="/settings"
|
||||
label={t('app:nav.settings')}
|
||||
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()}
|
||||
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"
|
||||
>
|
||||
<SignOutIcon className="h-4 w-4" />
|
||||
<span>{t('app:sidebar.sign_out')}</span>
|
||||
</button>
|
||||
icon={SignOutIcon}
|
||||
tone="danger"
|
||||
/>
|
||||
</div>
|
||||
</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);
|
||||
return (
|
||||
<span
|
||||
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}
|
||||
</span>
|
||||
|
||||
@@ -20,7 +20,7 @@ export function TypingIndicator({ typingUserIds, members }: Props) {
|
||||
: t('app:chats.typing_many', { count: typingUserIds.length });
|
||||
|
||||
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">
|
||||
<TypingDots />
|
||||
<span>{text}</span>
|
||||
@@ -32,9 +32,9 @@ export function TypingIndicator({ typingUserIds, members }: Props) {
|
||||
function TypingDots() {
|
||||
return (
|
||||
<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-neutral-500 [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]" />
|
||||
<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-fg-muted [animation:pulse_1.2s_ease-in-out_infinite] [animation-delay:0.3s]" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,22 +57,22 @@ export function UpdateToast() {
|
||||
<div
|
||||
role="status"
|
||||
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 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" />
|
||||
</div>
|
||||
<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' })}
|
||||
{state.version ? ' · v' + state.version : ''}
|
||||
</p>
|
||||
{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 && (
|
||||
<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>
|
||||
{!installing && (
|
||||
@@ -80,7 +80,7 @@ export function UpdateToast() {
|
||||
type="button"
|
||||
onClick={() => setDismissed(true)}
|
||||
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" />
|
||||
</button>
|
||||
@@ -90,7 +90,7 @@ export function UpdateToast() {
|
||||
type="button"
|
||||
onClick={() => void handleInstall()}
|
||||
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 ? (
|
||||
<>
|
||||
|
||||
@@ -5,22 +5,19 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { Avatar } from './Avatar';
|
||||
import { ChevronDownIcon } from './icons';
|
||||
|
||||
const PRESENCE_OPTIONS: PresenceState[] = ['online', 'idle', 'dnd', 'invisible', 'offline'];
|
||||
|
||||
const PRESENCE_DOT: Record<PresenceState, string> = {
|
||||
online: 'bg-emerald-400',
|
||||
online: 'bg-emerald-500',
|
||||
idle: 'bg-amber-400',
|
||||
dnd: 'bg-rose-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() {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { profile, refreshProfile } = useAuth();
|
||||
@@ -53,34 +50,38 @@ export function UserBar() {
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-haspopup="menu"
|
||||
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">
|
||||
{avatarLetter(profile?.displayName ?? profile?.username)}
|
||||
<div className="relative">
|
||||
<Avatar
|
||||
url={profile?.avatarUrl ?? null}
|
||||
displayName={profile?.displayName ?? profile?.username}
|
||||
className="h-9 w-9 text-sm"
|
||||
/>
|
||||
<span
|
||||
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]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<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 ?? '—'}
|
||||
</p>
|
||||
<p className="truncate text-xs text-neutral-400">
|
||||
{profile?.username ? '@' + profile.username : '—'}
|
||||
<p className="truncate text-xs text-fg-muted">
|
||||
{t('app:presence.' + presence)}
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
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) => (
|
||||
<button
|
||||
@@ -90,12 +91,12 @@ export function UserBar() {
|
||||
aria-checked={opt === presence}
|
||||
onClick={() => void changePresence(opt)}
|
||||
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="flex-1">{t('app:presence.' + opt)}</span>
|
||||
{opt === presence && (
|
||||
<span className="text-xs text-brand-300">●</span>
|
||||
<span className="text-xs text-accent">●</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -336,6 +336,89 @@ 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 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) {
|
||||
return (
|
||||
<svg
|
||||
@@ -376,3 +459,58 @@ export function LogoMark(props: IconProps) {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Full lockup: hex icon + "Netralax" wordmark. `tone` decides text colour:
|
||||
// "dark" = white text (use on dark background), "light" = black text.
|
||||
export function LogoLockup({
|
||||
tone = 'dark',
|
||||
...props
|
||||
}: IconProps & { tone?: 'dark' | 'light' }) {
|
||||
const textFill = tone === 'dark' ? '#ffffff' : '#0F172A';
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 260 64"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<defs>
|
||||
<clipPath id="logo-lockup-hex-clip">
|
||||
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19" fill="#2e1065" />
|
||||
<g clipPath="url(#logo-lockup-hex-clip)">
|
||||
<path
|
||||
d="M-4 32 Q 16 18 32 32 T 68 32 L 68 64 L -4 64 Z"
|
||||
fill="#7c4dff"
|
||||
/>
|
||||
<path
|
||||
d="M-4 32 Q 16 18 32 32 T 68 32"
|
||||
stroke="#a78bfa"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
/>
|
||||
</g>
|
||||
<polygon
|
||||
points="32,5 57,19 57,45 32,59 7,45 7,19"
|
||||
fill="none"
|
||||
stroke="#a78bfa"
|
||||
strokeWidth="1.5"
|
||||
opacity="0.4"
|
||||
/>
|
||||
<text
|
||||
x="78"
|
||||
y="42"
|
||||
fontFamily="'Space Grotesk', system-ui, sans-serif"
|
||||
fontSize="30"
|
||||
fontWeight="600"
|
||||
letterSpacing="-0.6"
|
||||
fill={textFill}
|
||||
>
|
||||
Netralax
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { findExistingDevice } from '../lib/device';
|
||||
import { setSecretStoreUser } from '../lib/secretStore';
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
interface AuthContextValue {
|
||||
@@ -60,7 +61,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const status = (error as { status?: number }).status;
|
||||
if (status === 401 || status === 403) {
|
||||
// Token genuinely invalid — wipe.
|
||||
await supabase.auth.signOut().catch(() => {
|
||||
await supabase.auth.signOut({ scope: 'local' }).catch(() => {
|
||||
/* ignore */
|
||||
});
|
||||
setSession(null);
|
||||
@@ -81,6 +82,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const { data: sub } = supabase.auth.onAuthStateChange((_event, s) => {
|
||||
setSession(s);
|
||||
setReady(true);
|
||||
void setSecretStoreUser(s?.user.id ?? null);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
||||
@@ -90,6 +90,10 @@ export interface RemoteScreenShare {
|
||||
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 {
|
||||
state: CallState;
|
||||
room: Room | null;
|
||||
@@ -102,6 +106,9 @@ interface CallContextValue {
|
||||
// Remembers the conversation of the last call we left so a sidebar widget
|
||||
// can show "still live — rejoin" while peers stay in the room.
|
||||
lastCallConversationId: string | null;
|
||||
// Clean-Rail UI state — display mode + focused participant id.
|
||||
callMode: CallMode;
|
||||
focusedId: string | null;
|
||||
// Actions:
|
||||
startCall: (conversationId: string, mediaKind?: CallKind) => Promise<void>;
|
||||
joinActiveCall: (conversationId: string, mediaKind?: CallKind) => Promise<void>;
|
||||
@@ -111,6 +118,8 @@ interface CallContextValue {
|
||||
toggleMute: () => void;
|
||||
toggleScreenShare: () => Promise<void>;
|
||||
dismissLastCall: () => void;
|
||||
setCallMode: (mode: CallMode) => void;
|
||||
setFocusedId: (id: string | null) => void;
|
||||
}
|
||||
|
||||
const CallContext = createContext<CallContextValue | null>(null);
|
||||
@@ -135,6 +144,8 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
const [isScreenSharing, setIsScreenSharing] = useState(false);
|
||||
const [remoteScreenShares, setRemoteScreenShares] = useState<RemoteScreenShare[]>([]);
|
||||
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 presenceChannelRef = useRef<RealtimeChannel | null>(null);
|
||||
@@ -954,6 +965,36 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}, [myId, clearRingTimer, clearSoloTimer, disconnectRoom, sendSignal, emitCallEvent]);
|
||||
|
||||
const setCallMode = useCallback((mode: CallMode) => {
|
||||
setCallModeState(mode);
|
||||
}, []);
|
||||
|
||||
const setFocusedId = useCallback((id: string | null) => {
|
||||
setFocusedIdState(id);
|
||||
}, []);
|
||||
|
||||
// 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>(
|
||||
() => ({
|
||||
state,
|
||||
@@ -964,6 +1005,8 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
isScreenSharing,
|
||||
remoteScreenShares,
|
||||
lastCallConversationId,
|
||||
callMode,
|
||||
focusedId,
|
||||
startCall,
|
||||
joinActiveCall,
|
||||
acceptIncoming,
|
||||
@@ -972,6 +1015,8 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
toggleMute,
|
||||
toggleScreenShare,
|
||||
dismissLastCall,
|
||||
setCallMode,
|
||||
setFocusedId,
|
||||
}),
|
||||
[
|
||||
state,
|
||||
@@ -982,6 +1027,8 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
isScreenSharing,
|
||||
remoteScreenShares,
|
||||
lastCallConversationId,
|
||||
callMode,
|
||||
focusedId,
|
||||
startCall,
|
||||
joinActiveCall,
|
||||
acceptIncoming,
|
||||
@@ -990,6 +1037,8 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
toggleMute,
|
||||
toggleScreenShare,
|
||||
dismissLastCall,
|
||||
setCallMode,
|
||||
setFocusedId,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { supabase } from './supabase';
|
||||
|
||||
const BUCKET = 'profile-avatars';
|
||||
const MAX_DIM = 512;
|
||||
const QUALITY = 0.85;
|
||||
|
||||
// Resizes the source image to a centred-cropped square ≤ MAX_DIM and
|
||||
// re-encodes as WebP. Falls back to JPEG if WebP isn't supported (rare).
|
||||
async function resizeToSquare(file: File): Promise<Blob> {
|
||||
const url = URL.createObjectURL(file);
|
||||
try {
|
||||
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const i = new Image();
|
||||
i.onload = () => resolve(i);
|
||||
i.onerror = () => reject(new Error('image load failed'));
|
||||
i.src = url;
|
||||
});
|
||||
|
||||
const minSide = Math.min(img.naturalWidth, img.naturalHeight);
|
||||
const sx = (img.naturalWidth - minSide) / 2;
|
||||
const sy = (img.naturalHeight - minSide) / 2;
|
||||
const target = Math.min(MAX_DIM, minSide);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = target;
|
||||
canvas.height = target;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('canvas context unavailable');
|
||||
ctx.drawImage(img, sx, sy, minSide, minSide, 0, 0, target, target);
|
||||
|
||||
const blob = await new Promise<Blob | null>((resolve) =>
|
||||
canvas.toBlob(resolve, 'image/webp', QUALITY),
|
||||
);
|
||||
if (blob) return blob;
|
||||
|
||||
const jpeg = await new Promise<Blob | null>((resolve) =>
|
||||
canvas.toBlob(resolve, 'image/jpeg', QUALITY),
|
||||
);
|
||||
if (!jpeg) throw new Error('canvas toBlob returned null');
|
||||
return jpeg;
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadAvatar(userId: string, file: File): Promise<string> {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
throw new Error('only image files are accepted');
|
||||
}
|
||||
const blob = await resizeToSquare(file);
|
||||
const ext = blob.type === 'image/webp' ? 'webp' : 'jpg';
|
||||
// Random filename so old uploads don't get overwritten before we update
|
||||
// the profile row — Supabase Storage CDN caches by URL, so a fresh path
|
||||
// also forces clients to fetch the new image.
|
||||
const name =
|
||||
userId + '/' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8) + '.' + ext;
|
||||
const { error: upErr } = await supabase.storage.from(BUCKET).upload(name, blob, {
|
||||
contentType: blob.type,
|
||||
cacheControl: '604800',
|
||||
upsert: false,
|
||||
});
|
||||
if (upErr) throw upErr;
|
||||
const { data: pub } = supabase.storage.from(BUCKET).getPublicUrl(name);
|
||||
return pub.publicUrl;
|
||||
}
|
||||
|
||||
export async function deleteAvatarObject(publicUrl: string): Promise<void> {
|
||||
// Public URLs look like
|
||||
// https://<host>/storage/v1/object/public/profile-avatars/<path>
|
||||
// Extract <path> and remove.
|
||||
const marker = '/object/public/' + BUCKET + '/';
|
||||
const idx = publicUrl.indexOf(marker);
|
||||
if (idx === -1) return;
|
||||
const path = publicUrl.slice(idx + marker.length);
|
||||
const { error } = await supabase.storage.from(BUCKET).remove([path]);
|
||||
if (error) throw error;
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
|
||||
import { type OwnDeviceCtx, shareConvKeyToDevice } from '@chat-app/shared/chat';
|
||||
import { pgHexToBytes } from '@chat-app/shared/supabase';
|
||||
|
||||
import { devLocalSecretStore } from './secretStore';
|
||||
import { supabase } from './supabase';
|
||||
|
||||
// Watches the `devices` table for new entries AND, on mount, scans every
|
||||
// conversation we participate in for missing key bundles. Fills gaps by
|
||||
// re-wrapping our active conv-key for the missing recipient devices.
|
||||
//
|
||||
// This fixes the "cannot decrypt" cliff for devices that registered while
|
||||
// no other participant device was online to share the key with them.
|
||||
|
||||
interface SyncCtx {
|
||||
myUserId: string;
|
||||
myDeviceId: string;
|
||||
priv: Uint8Array;
|
||||
}
|
||||
|
||||
// db-types is stale for `conversation_keys`/`active_key_version`; bypass.
|
||||
function rawFrom(table: string) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (supabase as unknown as { from: (t: string) => any }).from(table);
|
||||
}
|
||||
|
||||
// Module-level flag — gap-fill runs once per (user, device) combo per
|
||||
// process lifetime. Page reloads / route changes don't re-trigger it.
|
||||
const backfilledKey = new Set<string>();
|
||||
|
||||
export function startConversationKeySync(
|
||||
ownUserId: string,
|
||||
ownDeviceId: string,
|
||||
): () => void {
|
||||
let cancelled = false;
|
||||
let priv: Uint8Array | null = null;
|
||||
const dedupeKey = ownUserId + ':' + ownDeviceId;
|
||||
|
||||
void loadDevicePrivateKey(devLocalSecretStore, ownUserId, ownDeviceId).then(async (pk) => {
|
||||
if (cancelled) return;
|
||||
priv = pk;
|
||||
if (!priv) return;
|
||||
if (backfilledKey.has(dedupeKey)) return;
|
||||
backfilledKey.add(dedupeKey);
|
||||
await syncAllExistingGaps({ myUserId: ownUserId, myDeviceId: ownDeviceId, priv });
|
||||
});
|
||||
|
||||
const channel = supabase
|
||||
.channel('device-key-sync:' + ownDeviceId)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{ event: 'INSERT', schema: 'public', table: 'devices' },
|
||||
(payload: { new: { id?: string; user_id?: string; public_key?: string } }) => {
|
||||
if (cancelled) return;
|
||||
const row = payload.new;
|
||||
if (!row?.id || !row.user_id || !row.public_key) return;
|
||||
if (row.user_id === ownUserId && row.id === ownDeviceId) return;
|
||||
if (!priv) return; // backfill on mount will catch it later
|
||||
void wrapForOneDevice(
|
||||
{ myUserId: ownUserId, myDeviceId: ownDeviceId, priv },
|
||||
row.id,
|
||||
row.user_id,
|
||||
row.public_key,
|
||||
);
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
void supabase.removeChannel(channel);
|
||||
};
|
||||
}
|
||||
|
||||
async function listMyConversationIds(myUserId: string): Promise<string[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('conversation_members')
|
||||
.select('conversation_id')
|
||||
.eq('user_id', myUserId)
|
||||
.eq('accepted', true);
|
||||
if (error) {
|
||||
console.warn('keySync: own-member lookup failed', error);
|
||||
return [];
|
||||
}
|
||||
return (data ?? []).map((r) => r.conversation_id as string);
|
||||
}
|
||||
|
||||
async function listConversationDevices(
|
||||
conversationId: string,
|
||||
): Promise<{ id: string; user_id: string; public_key: string }[]> {
|
||||
const { data: members, error: mErr } = await supabase
|
||||
.from('conversation_members')
|
||||
.select('user_id')
|
||||
.eq('conversation_id', conversationId)
|
||||
.eq('accepted', true);
|
||||
if (mErr) {
|
||||
console.warn('keySync: members lookup failed', mErr);
|
||||
return [];
|
||||
}
|
||||
const userIds = (members ?? []).map((m) => m.user_id as string);
|
||||
if (userIds.length === 0) return [];
|
||||
|
||||
const { data: devices, error: dErr } = await supabase
|
||||
.from('devices')
|
||||
.select('id, user_id, public_key')
|
||||
.in('user_id', userIds);
|
||||
if (dErr) {
|
||||
console.warn('keySync: devices lookup failed', dErr);
|
||||
return [];
|
||||
}
|
||||
return (devices ?? []) as { id: string; user_id: string; public_key: string }[];
|
||||
}
|
||||
|
||||
async function listExistingKeyRecipients(
|
||||
conversationId: string,
|
||||
keyVersion: number,
|
||||
): Promise<Set<string>> {
|
||||
const { data, error } = await rawFrom('conversation_keys')
|
||||
.select('recipient_device_id')
|
||||
.eq('conversation_id', conversationId)
|
||||
.eq('key_version', keyVersion);
|
||||
if (error) {
|
||||
console.warn('keySync: existing keys lookup failed', error);
|
||||
return new Set();
|
||||
}
|
||||
return new Set((data ?? []).map((r: { recipient_device_id: string }) => r.recipient_device_id));
|
||||
}
|
||||
|
||||
async function getActiveKeyVersion(conversationId: string): Promise<number> {
|
||||
const { data, error } = await rawFrom('conversations')
|
||||
.select('active_key_version')
|
||||
.eq('id', conversationId)
|
||||
.single();
|
||||
if (error) {
|
||||
console.warn('keySync: active key version lookup failed', error);
|
||||
return 1;
|
||||
}
|
||||
return (data as { active_key_version: number }).active_key_version;
|
||||
}
|
||||
|
||||
async function syncAllExistingGaps(ctx: SyncCtx): Promise<void> {
|
||||
const convs = await listMyConversationIds(ctx.myUserId);
|
||||
for (const convId of convs) {
|
||||
try {
|
||||
await syncOneConversationGaps(ctx, convId);
|
||||
} catch (err: unknown) {
|
||||
console.warn('keySync: conv gap sync failed', { convId, err });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function syncOneConversationGaps(ctx: SyncCtx, convId: string): Promise<void> {
|
||||
const version = await getActiveKeyVersion(convId);
|
||||
const devices = await listConversationDevices(convId);
|
||||
if (devices.length === 0) return;
|
||||
|
||||
const recipients = await listExistingKeyRecipients(convId, version);
|
||||
const ownCtx: OwnDeviceCtx = {
|
||||
userId: ctx.myUserId,
|
||||
deviceId: ctx.myDeviceId,
|
||||
privateKey: ctx.priv,
|
||||
};
|
||||
|
||||
for (const dev of devices) {
|
||||
if (recipients.has(dev.id)) continue;
|
||||
// Skip our own device — we already have the bundle if we're capable of
|
||||
// sharing (or don't need it if we ourselves haven't been wrapped yet).
|
||||
if (dev.id === ctx.myDeviceId) continue;
|
||||
try {
|
||||
await shareConvKeyToDevice(supabase, convId, dev.id, pgHexToBytes(dev.public_key), ownCtx);
|
||||
} catch (err: unknown) {
|
||||
// Backfill is best-effort. Most common silent failures:
|
||||
// - tryGetConvKey couldn't unwrap (another peer will fill the gap).
|
||||
// - RLS rejects because the recipient's owner is a pending (not-yet
|
||||
// accepted) DM member, or was removed from the conv.
|
||||
// Both are recoverable / expected, swallow without spam.
|
||||
if (isExpectedShareFailure(err)) continue;
|
||||
console.warn('keySync: shareConvKeyToDevice gap-fill failed', {
|
||||
convId,
|
||||
recipient: dev.id,
|
||||
err,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isExpectedShareFailure(err: unknown): boolean {
|
||||
if (!err || typeof err !== 'object') return false;
|
||||
const e = err as { message?: string; code?: string; details?: string; status?: number };
|
||||
const code = (e.code ?? '').toString();
|
||||
const status = e.status;
|
||||
const haystack = (e.message ?? '') + ' ' + (e.details ?? '');
|
||||
return (
|
||||
status === 403 ||
|
||||
code === '42501' || // postgres: insufficient_privilege (RLS)
|
||||
code === '23505' || // unique_violation
|
||||
haystack.includes('row-level security') ||
|
||||
haystack.includes('does not have it yet') ||
|
||||
haystack.includes('Forbidden')
|
||||
);
|
||||
}
|
||||
|
||||
async function wrapForOneDevice(
|
||||
ctx: SyncCtx,
|
||||
newDeviceId: string,
|
||||
newDeviceUserId: string,
|
||||
newDevicePubHex: string,
|
||||
): Promise<void> {
|
||||
const myConvs = new Set(await listMyConversationIds(ctx.myUserId));
|
||||
const { data: peerMember, error: pErr } = await supabase
|
||||
.from('conversation_members')
|
||||
.select('conversation_id')
|
||||
.eq('user_id', newDeviceUserId);
|
||||
if (pErr) {
|
||||
console.warn('keySync: peer-member lookup failed', pErr);
|
||||
return;
|
||||
}
|
||||
const sharedConvs = (peerMember ?? [])
|
||||
.map((r) => r.conversation_id as string)
|
||||
.filter((id) => myConvs.has(id));
|
||||
if (sharedConvs.length === 0) return;
|
||||
|
||||
const newPub = pgHexToBytes(newDevicePubHex);
|
||||
const ownCtx: OwnDeviceCtx = {
|
||||
userId: ctx.myUserId,
|
||||
deviceId: ctx.myDeviceId,
|
||||
privateKey: ctx.priv,
|
||||
};
|
||||
for (const convId of sharedConvs) {
|
||||
try {
|
||||
await shareConvKeyToDevice(supabase, convId, newDeviceId, newPub, ownCtx);
|
||||
} catch (err: unknown) {
|
||||
console.warn('keySync: shareConvKeyToDevice failed', { convId, err });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { getCryptoBackend } from '@chat-app/shared/crypto';
|
||||
import sodium from 'libsodium-wrappers';
|
||||
|
||||
// Encrypts/decrypts the device private key with a user-provided passphrase
|
||||
// so the backup string can be safely written down or stored in a password
|
||||
// manager. Uses Argon2id (libsodium crypto_pwhash) for the KDF and
|
||||
// XSalsa20-Poly1305 (crypto_secretbox) for the AEAD.
|
||||
//
|
||||
// Backup format (base64url-encoded blob, prefixed with a magic string so we
|
||||
// can version it):
|
||||
//
|
||||
// chatapp-backup-v1.<base64url(salt(16) | nonce(24) | ciphertext)>
|
||||
|
||||
const MAGIC = 'chatapp-backup-v1.';
|
||||
const SALT_LEN = 16; // crypto_pwhash_SALTBYTES
|
||||
const NONCE_LEN = 24; // crypto_secretbox_NONCEBYTES
|
||||
const KEY_LEN = 32; // crypto_secretbox_KEYBYTES
|
||||
|
||||
async function ensureSodium(): Promise<typeof sodium> {
|
||||
await sodium.ready;
|
||||
return sodium;
|
||||
}
|
||||
|
||||
function b64url(bytes: Uint8Array): string {
|
||||
let s = '';
|
||||
for (const b of bytes) s += String.fromCharCode(b);
|
||||
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
function unb64url(s: string): Uint8Array {
|
||||
let str = s.replace(/-/g, '+').replace(/_/g, '/');
|
||||
while (str.length % 4) str += '=';
|
||||
const bin = atob(str);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function deriveKey(passphrase: string, salt: Uint8Array, sodiumLib: typeof sodium): Promise<Uint8Array> {
|
||||
return sodiumLib.crypto_pwhash(
|
||||
KEY_LEN,
|
||||
passphrase,
|
||||
salt,
|
||||
sodiumLib.crypto_pwhash_OPSLIMIT_MODERATE,
|
||||
sodiumLib.crypto_pwhash_MEMLIMIT_MODERATE,
|
||||
sodiumLib.crypto_pwhash_ALG_ARGON2ID13,
|
||||
);
|
||||
}
|
||||
|
||||
export async function exportDeviceKey(
|
||||
privateKey: Uint8Array,
|
||||
passphrase: string,
|
||||
): Promise<string> {
|
||||
if (passphrase.length < 8) throw new Error('Passphrase must be at least 8 characters.');
|
||||
const s = await ensureSodium();
|
||||
const salt = s.randombytes_buf(SALT_LEN);
|
||||
const nonce = s.randombytes_buf(NONCE_LEN);
|
||||
const key = await deriveKey(passphrase, salt, s);
|
||||
const backend = getCryptoBackend();
|
||||
const ciphertext = backend.secretbox(privateKey, nonce, key);
|
||||
s.memzero(key);
|
||||
const blob = new Uint8Array(SALT_LEN + NONCE_LEN + ciphertext.length);
|
||||
blob.set(salt, 0);
|
||||
blob.set(nonce, SALT_LEN);
|
||||
blob.set(ciphertext, SALT_LEN + NONCE_LEN);
|
||||
return MAGIC + b64url(blob);
|
||||
}
|
||||
|
||||
export async function importDeviceKey(
|
||||
backup: string,
|
||||
passphrase: string,
|
||||
): Promise<Uint8Array> {
|
||||
if (!backup.startsWith(MAGIC)) {
|
||||
throw new Error('Invalid backup format');
|
||||
}
|
||||
const blob = unb64url(backup.slice(MAGIC.length));
|
||||
if (blob.length < SALT_LEN + NONCE_LEN + 1) {
|
||||
throw new Error('Backup too short');
|
||||
}
|
||||
const salt = blob.slice(0, SALT_LEN);
|
||||
const nonce = blob.slice(SALT_LEN, SALT_LEN + NONCE_LEN);
|
||||
const ciphertext = blob.slice(SALT_LEN + NONCE_LEN);
|
||||
const s = await ensureSodium();
|
||||
const key = await deriveKey(passphrase, salt, s);
|
||||
const backend = getCryptoBackend();
|
||||
try {
|
||||
return backend.secretboxOpen(ciphertext, nonce, key);
|
||||
} catch {
|
||||
throw new Error('Wrong passphrase or corrupt backup');
|
||||
} finally {
|
||||
s.memzero(key);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
sendNotification,
|
||||
} from '@tauri-apps/plugin-notification';
|
||||
|
||||
import { isTauriRuntime } from './globalShortcut';
|
||||
|
||||
// Tracks whether permission has already been requested this session so we
|
||||
// don't spam the OS prompt. Actual permission state lives in the OS.
|
||||
let permissionChecked = false;
|
||||
@@ -12,6 +14,11 @@ let permissionGranted = false;
|
||||
export async function ensureNotificationPermission(): Promise<boolean> {
|
||||
if (permissionChecked) return permissionGranted;
|
||||
permissionChecked = true;
|
||||
if (!isTauriRuntime()) {
|
||||
// Web preview / Chrome — Tauri notification plugin not available.
|
||||
permissionGranted = false;
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
let granted = await isPermissionGranted();
|
||||
if (!granted) {
|
||||
@@ -20,7 +27,6 @@ export async function ensureNotificationPermission(): Promise<boolean> {
|
||||
}
|
||||
permissionGranted = granted;
|
||||
} catch (err: unknown) {
|
||||
// Not running under Tauri (e.g. web preview) — fall back silently.
|
||||
permissionGranted = false;
|
||||
console.warn('notification permission check failed', err);
|
||||
}
|
||||
@@ -40,6 +46,7 @@ interface NotifyOpts {
|
||||
|
||||
export async function notify({ title, body, force = false }: NotifyOpts): Promise<void> {
|
||||
if (!force && isAppFocused()) return;
|
||||
if (!isTauriRuntime()) return;
|
||||
const granted = await ensureNotificationPermission();
|
||||
if (!granted) return;
|
||||
try {
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import { base64FromBytes, bytesFromBase64, type SecretStore } from '@chat-app/shared/auth';
|
||||
|
||||
// M1 dev-only impl: persists secrets as base64 in localStorage.
|
||||
// Swap this out for a tauri-plugin-stronghold implementation before release.
|
||||
// The SecretStore interface stays identical so callers won't notice.
|
||||
import { isTauriRuntime } from './globalShortcut';
|
||||
import { makeSecureFileStore, migrateLocalStorageToVault } from './secureFileStore';
|
||||
|
||||
// Two-tier SecretStore:
|
||||
// - Tauri runtime: encrypted single-file vault in `appLocalDataDir`
|
||||
// (`secureFileStore` — XSalsa20-Poly1305 + Argon2id KDF). Survives app
|
||||
// reinstalls when the OS preserves the data dir.
|
||||
// - Web / pre-auth: plain localStorage (legacy fallback).
|
||||
//
|
||||
// Callers import `devLocalSecretStore` and call `setSecretStoreUser(userId)`
|
||||
// once the session is known. The singleton object's identity is stable so
|
||||
// existing imports keep working.
|
||||
|
||||
const PREFIX = 'chatapp.secret:';
|
||||
|
||||
export const devLocalSecretStore: SecretStore = {
|
||||
const localStore: SecretStore = {
|
||||
async getSecret(key: string): Promise<Uint8Array | null> {
|
||||
const raw = window.localStorage.getItem(PREFIX + key);
|
||||
if (!raw) return null;
|
||||
@@ -20,3 +29,48 @@ export const devLocalSecretStore: SecretStore = {
|
||||
window.localStorage.removeItem(PREFIX + key);
|
||||
},
|
||||
};
|
||||
|
||||
let activeBackend: SecretStore = localStore;
|
||||
let activeUserId: string | null = null;
|
||||
|
||||
export async function setSecretStoreUser(userId: string | null): Promise<void> {
|
||||
if (userId === activeUserId) return;
|
||||
activeUserId = userId;
|
||||
|
||||
if (userId && isTauriRuntime()) {
|
||||
const fileStore = makeSecureFileStore(userId);
|
||||
try {
|
||||
// Probe write/read to confirm the vault is usable on this machine.
|
||||
// If anything throws (perm denied, disk full, KDF error), fall back to
|
||||
// localStorage so the rest of the app keeps working.
|
||||
await fileStore.getSecret('__probe');
|
||||
activeBackend = fileStore;
|
||||
try {
|
||||
await migrateLocalStorageToVault(userId, PREFIX);
|
||||
} catch (err: unknown) {
|
||||
console.warn('vault migration failed', err);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.warn('secure file vault init failed — falling back to localStorage', err);
|
||||
activeBackend = localStore;
|
||||
}
|
||||
} else {
|
||||
activeBackend = localStore;
|
||||
}
|
||||
}
|
||||
|
||||
export const devLocalSecretStore: SecretStore = {
|
||||
async getSecret(key) {
|
||||
return activeBackend.getSecret(key);
|
||||
},
|
||||
async setSecret(key, value) {
|
||||
return activeBackend.setSecret(key, value);
|
||||
},
|
||||
async removeSecret(key) {
|
||||
return activeBackend.removeSecret(key);
|
||||
},
|
||||
};
|
||||
|
||||
export function isEncryptedVaultActive(): boolean {
|
||||
return activeBackend !== localStore;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import type { SecretStore } from '@chat-app/shared/auth';
|
||||
import { exists, mkdir, readFile, rename, writeFile } from '@tauri-apps/plugin-fs';
|
||||
import { appLocalDataDir } from '@tauri-apps/api/path';
|
||||
import sodium from 'libsodium-wrappers';
|
||||
|
||||
// Encrypted single-file SecretStore for Tauri. Replaces the flaky
|
||||
// tauri-plugin-stronghold implementation.
|
||||
//
|
||||
// File layout (binary, little-endian):
|
||||
// bytes 0..7 magic: ASCII "CHATVLT1"
|
||||
// bytes 8..23 salt for KDF (16 bytes)
|
||||
// bytes 24..47 XSalsa20-Poly1305 nonce (24 bytes)
|
||||
// bytes 48.. secretbox(plaintext_json, key, nonce)
|
||||
//
|
||||
// `plaintext_json` is a UTF-8 JSON object { [key: string]: base64url(value) }.
|
||||
//
|
||||
// Key derivation: Argon2id (libsodium MODERATE ops/mem) over a passphrase
|
||||
// derived from the authenticated user-id + a constant. Same userId on the
|
||||
// same machine after re-install ⇒ same key ⇒ vault recovers automatically.
|
||||
//
|
||||
// Atomic writes: serialised vault is first written to `<file>.tmp` then
|
||||
// renamed onto `<file>` so an interrupted write never corrupts the existing
|
||||
// vault.
|
||||
|
||||
// Per-user vault filename so multiple accounts on the same machine each get
|
||||
// their own file (and Argon2 derives a different key per user, so cross-user
|
||||
// decrypt is also blocked even if filenames collided).
|
||||
async function vaultFileName(userId: string): Promise<string> {
|
||||
const enc = new TextEncoder();
|
||||
const buf = await crypto.subtle.digest('SHA-256', enc.encode('chatapp-vault-name:' + userId));
|
||||
const hex = Array.from(new Uint8Array(buf))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
return 'chatapp-vault-' + hex.slice(0, 16) + '.bin';
|
||||
}
|
||||
const MAGIC = new TextEncoder().encode('CHATVLT1'); // 8 bytes
|
||||
const SALT_LEN = 16;
|
||||
const NONCE_LEN = 24;
|
||||
const KEY_LEN = 32;
|
||||
|
||||
interface VaultState {
|
||||
path: string;
|
||||
tmpPath: string;
|
||||
key: Uint8Array; // derived encryption key
|
||||
data: Map<string, Uint8Array>;
|
||||
}
|
||||
|
||||
let initPromise: Promise<VaultState> | null = null;
|
||||
let vault: VaultState | null = null;
|
||||
let initializedFor: string | null = null;
|
||||
|
||||
async function ensureSodium(): Promise<typeof sodium> {
|
||||
await sodium.ready;
|
||||
return sodium;
|
||||
}
|
||||
|
||||
function joinPath(dir: string, name: string): string {
|
||||
const sep = dir.endsWith('/') || dir.endsWith('\\') ? '' : '/';
|
||||
return dir + sep + name;
|
||||
}
|
||||
|
||||
function b64url(bytes: Uint8Array): string {
|
||||
let s = '';
|
||||
for (const b of bytes) s += String.fromCharCode(b);
|
||||
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
function unb64url(s: string): Uint8Array {
|
||||
let str = s.replace(/-/g, '+').replace(/_/g, '/');
|
||||
while (str.length % 4) str += '=';
|
||||
const bin = atob(str);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function deriveKey(userId: string, salt: Uint8Array, s: typeof sodium): Promise<Uint8Array> {
|
||||
const passphrase = 'chatapp-vault-v1:' + userId;
|
||||
return s.crypto_pwhash(
|
||||
KEY_LEN,
|
||||
passphrase,
|
||||
salt,
|
||||
s.crypto_pwhash_OPSLIMIT_MODERATE,
|
||||
s.crypto_pwhash_MEMLIMIT_MODERATE,
|
||||
s.crypto_pwhash_ALG_ARGON2ID13,
|
||||
);
|
||||
}
|
||||
|
||||
async function loadOrCreateVault(userId: string): Promise<VaultState> {
|
||||
const s = await ensureSodium();
|
||||
const dir = await appLocalDataDir();
|
||||
const fileName = await vaultFileName(userId);
|
||||
const path = joinPath(dir, fileName);
|
||||
const tmpPath = path + '.tmp';
|
||||
|
||||
try {
|
||||
await mkdir(dir, { recursive: true });
|
||||
} catch {
|
||||
/* parent likely already exists */
|
||||
}
|
||||
|
||||
const fileExists = await exists(path).catch(() => false);
|
||||
if (!fileExists) {
|
||||
const salt = s.randombytes_buf(SALT_LEN);
|
||||
const key = await deriveKey(userId, salt, s);
|
||||
const state: VaultState = { path, tmpPath, key, data: new Map() };
|
||||
await persist(state, salt, s);
|
||||
return state;
|
||||
}
|
||||
|
||||
const raw = await readFile(path);
|
||||
if (raw.length < MAGIC.length + SALT_LEN + NONCE_LEN + 1) {
|
||||
throw new Error('vault file too short');
|
||||
}
|
||||
for (let i = 0; i < MAGIC.length; i++) {
|
||||
if (raw[i] !== MAGIC[i]) throw new Error('vault magic mismatch');
|
||||
}
|
||||
const salt = raw.slice(MAGIC.length, MAGIC.length + SALT_LEN);
|
||||
const nonce = raw.slice(MAGIC.length + SALT_LEN, MAGIC.length + SALT_LEN + NONCE_LEN);
|
||||
const ciphertext = raw.slice(MAGIC.length + SALT_LEN + NONCE_LEN);
|
||||
|
||||
const key = await deriveKey(userId, salt, s);
|
||||
let plain: Uint8Array;
|
||||
try {
|
||||
plain = s.crypto_secretbox_open_easy(ciphertext, nonce, key);
|
||||
} catch (err: unknown) {
|
||||
throw new Error(
|
||||
'vault decrypt failed (wrong user / corrupted file): ' +
|
||||
(err instanceof Error ? err.message : String(err)),
|
||||
);
|
||||
}
|
||||
const json = new TextDecoder().decode(plain) || '{}';
|
||||
const obj = JSON.parse(json) as Record<string, string>;
|
||||
const data = new Map<string, Uint8Array>();
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
try {
|
||||
data.set(k, unb64url(v));
|
||||
} catch {
|
||||
/* skip malformed entries */
|
||||
}
|
||||
}
|
||||
return { path, tmpPath, key, data };
|
||||
}
|
||||
|
||||
async function persist(state: VaultState, salt: Uint8Array, s: typeof sodium): Promise<void> {
|
||||
const obj: Record<string, string> = {};
|
||||
for (const [k, v] of state.data) obj[k] = b64url(v);
|
||||
const plain = new TextEncoder().encode(JSON.stringify(obj));
|
||||
const nonce = s.randombytes_buf(NONCE_LEN);
|
||||
const ciphertext = s.crypto_secretbox_easy(plain, nonce, state.key);
|
||||
|
||||
const out = new Uint8Array(MAGIC.length + SALT_LEN + NONCE_LEN + ciphertext.length);
|
||||
out.set(MAGIC, 0);
|
||||
out.set(salt, MAGIC.length);
|
||||
out.set(nonce, MAGIC.length + SALT_LEN);
|
||||
out.set(ciphertext, MAGIC.length + SALT_LEN + NONCE_LEN);
|
||||
|
||||
// Atomic write: tmp → rename. `rename` on the same filesystem is atomic
|
||||
// on macOS, Linux, and Windows (NTFS).
|
||||
await writeFile(state.tmpPath, out);
|
||||
await rename(state.tmpPath, state.path);
|
||||
}
|
||||
|
||||
// Re-derives the salt by reading the existing file header so persist() can
|
||||
// keep using the same KDF salt across writes (we don't rotate KDF on every
|
||||
// save — only on initial vault creation).
|
||||
async function readSalt(state: VaultState): Promise<Uint8Array> {
|
||||
const raw = await readFile(state.path);
|
||||
return raw.slice(MAGIC.length, MAGIC.length + SALT_LEN);
|
||||
}
|
||||
|
||||
async function ensureInit(userId: string): Promise<VaultState> {
|
||||
if (initializedFor === userId && vault) return vault;
|
||||
if (initPromise) return initPromise;
|
||||
initPromise = loadOrCreateVault(userId)
|
||||
.then((v) => {
|
||||
vault = v;
|
||||
initializedFor = userId;
|
||||
return v;
|
||||
})
|
||||
.finally(() => {
|
||||
initPromise = null;
|
||||
});
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
export function makeSecureFileStore(userId: string): SecretStore {
|
||||
return {
|
||||
async getSecret(key: string): Promise<Uint8Array | null> {
|
||||
const v = await ensureInit(userId);
|
||||
const found = v.data.get(key);
|
||||
return found ? new Uint8Array(found) : null;
|
||||
},
|
||||
async setSecret(key: string, value: Uint8Array): Promise<void> {
|
||||
const v = await ensureInit(userId);
|
||||
v.data.set(key, new Uint8Array(value));
|
||||
const s = await ensureSodium();
|
||||
const salt = await readSalt(v);
|
||||
await persist(v, salt, s);
|
||||
},
|
||||
async removeSecret(key: string): Promise<void> {
|
||||
const v = await ensureInit(userId);
|
||||
v.data.delete(key);
|
||||
const s = await ensureSodium();
|
||||
const salt = await readSalt(v);
|
||||
await persist(v, salt, s);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Migrates legacy localStorage entries (chatapp.secret:*) into the encrypted
|
||||
// vault on first init. Idempotent — checks for marker key.
|
||||
export async function migrateLocalStorageToVault(
|
||||
userId: string,
|
||||
prefix: string,
|
||||
): Promise<void> {
|
||||
const v = await ensureInit(userId);
|
||||
if (v.data.has('__migrated_from_localstorage')) return;
|
||||
|
||||
for (let i = 0; i < window.localStorage.length; i++) {
|
||||
const fullKey = window.localStorage.key(i);
|
||||
if (!fullKey || !fullKey.startsWith(prefix)) continue;
|
||||
const raw = window.localStorage.getItem(fullKey);
|
||||
if (!raw) continue;
|
||||
try {
|
||||
const decoded = Uint8Array.from(atob(raw), (c) => c.charCodeAt(0));
|
||||
const shortKey = fullKey.slice(prefix.length);
|
||||
v.data.set(shortKey, decoded);
|
||||
} catch {
|
||||
/* skip malformed */
|
||||
}
|
||||
}
|
||||
v.data.set('__migrated_from_localstorage', new Uint8Array([1]));
|
||||
const s = await ensureSodium();
|
||||
const salt = await readSalt(v);
|
||||
await persist(v, salt, s);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { SecretStore } from '@chat-app/shared/auth';
|
||||
import { appLocalDataDir } from '@tauri-apps/api/path';
|
||||
import { type Client, type Store, Stronghold } from '@tauri-apps/plugin-stronghold';
|
||||
|
||||
// Stronghold-backed SecretStore. Vault file lives in Tauri's
|
||||
// `appLocalDataDir/chatapp.stronghold` and survives app re-installs (the
|
||||
// directory is preserved by the OS on macOS/Windows/Linux unless the user
|
||||
// manually removes it). Vault password is derived from the Supabase user-id
|
||||
// so the same user re-installing the app on the same machine recovers their
|
||||
// device key automatically.
|
||||
|
||||
const VAULT_NAME = 'chatapp.stronghold';
|
||||
const CLIENT_NAME = 'chatapp';
|
||||
|
||||
let strongholdRef: Stronghold | null = null;
|
||||
let storeRef: Store | null = null;
|
||||
let initPromise: Promise<void> | null = null;
|
||||
let initializedFor: string | null = null;
|
||||
|
||||
async function derivePassword(userId: string): Promise<string> {
|
||||
const enc = new TextEncoder();
|
||||
const buf = await crypto.subtle.digest(
|
||||
'SHA-256',
|
||||
enc.encode('chatapp-stronghold-v1:' + userId),
|
||||
);
|
||||
return Array.from(new Uint8Array(buf))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
async function ensureInit(userId: string): Promise<void> {
|
||||
if (initializedFor === userId && storeRef) return;
|
||||
if (initPromise) return initPromise;
|
||||
|
||||
initPromise = (async () => {
|
||||
const dir = await appLocalDataDir();
|
||||
const sep = dir.endsWith('/') || dir.endsWith('\\') ? '' : '/';
|
||||
const vaultPath = dir + sep + VAULT_NAME;
|
||||
const password = await derivePassword(userId);
|
||||
|
||||
strongholdRef = await Stronghold.load(vaultPath, password);
|
||||
let client: Client;
|
||||
try {
|
||||
client = await strongholdRef.loadClient(CLIENT_NAME);
|
||||
} catch {
|
||||
client = await strongholdRef.createClient(CLIENT_NAME);
|
||||
}
|
||||
storeRef = client.getStore();
|
||||
initializedFor = userId;
|
||||
})().finally(() => {
|
||||
initPromise = null;
|
||||
});
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
export function makeStrongholdStore(userId: string): SecretStore {
|
||||
return {
|
||||
async getSecret(key: string): Promise<Uint8Array | null> {
|
||||
await ensureInit(userId);
|
||||
const val = await storeRef!.get(key);
|
||||
if (!val) return null;
|
||||
return val instanceof Uint8Array ? val : new Uint8Array(val);
|
||||
},
|
||||
async setSecret(key: string, value: Uint8Array): Promise<void> {
|
||||
await ensureInit(userId);
|
||||
await storeRef!.insert(key, Array.from(value));
|
||||
await strongholdRef!.save();
|
||||
},
|
||||
async removeSecret(key: string): Promise<void> {
|
||||
await ensureInit(userId);
|
||||
await storeRef!.remove(key);
|
||||
await strongholdRef!.save();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// One-time migration: copies any keys we find in localStorage (the legacy
|
||||
// dev store) into Stronghold so a user who upgrades from a localStorage-only
|
||||
// build doesn't lose their device key. Safe to call multiple times — no-op
|
||||
// once the marker key is present.
|
||||
export async function migrateLocalStorageToStronghold(
|
||||
userId: string,
|
||||
prefix: string,
|
||||
): Promise<void> {
|
||||
await ensureInit(userId);
|
||||
if (!storeRef) return;
|
||||
const markerKey = '__migrated_from_localstorage';
|
||||
const already = await storeRef.get(markerKey);
|
||||
if (already) return;
|
||||
|
||||
for (let i = 0; i < window.localStorage.length; i++) {
|
||||
const fullKey = window.localStorage.key(i);
|
||||
if (!fullKey || !fullKey.startsWith(prefix)) continue;
|
||||
const raw = window.localStorage.getItem(fullKey);
|
||||
if (!raw) continue;
|
||||
try {
|
||||
const decoded = Uint8Array.from(atob(raw), (c) => c.charCodeAt(0));
|
||||
const shortKey = fullKey.slice(prefix.length);
|
||||
await storeRef.insert(shortKey, Array.from(decoded));
|
||||
} catch {
|
||||
// Skip malformed entries.
|
||||
}
|
||||
}
|
||||
await storeRef.insert(markerKey, [1]);
|
||||
if (strongholdRef) await strongholdRef.save();
|
||||
}
|
||||
@@ -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,35 @@
|
||||
import type { Participant, Room } from 'livekit-client';
|
||||
import { RoomEvent } from 'livekit-client';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
// Subscribe to LiveKit ActiveSpeakersChanged to drive the speaking-ring pulse
|
||||
// on participant tiles. Returns the set of currently speaking identities
|
||||
// (includes local participant when they're active).
|
||||
export function useActiveSpeakers(room: Room | null): Set<string> {
|
||||
const [ids, setIds] = useState<Set<string>>(() => new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (!room) {
|
||||
setIds(new Set());
|
||||
return;
|
||||
}
|
||||
|
||||
const update = (speakers: Participant[]) => {
|
||||
const next = new Set<string>();
|
||||
for (const s of speakers) {
|
||||
if (s.identity) next.add(s.identity);
|
||||
}
|
||||
setIds(next);
|
||||
};
|
||||
|
||||
// Seed from current state.
|
||||
update(room.activeSpeakers ?? []);
|
||||
|
||||
room.on(RoomEvent.ActiveSpeakersChanged, update);
|
||||
return () => {
|
||||
room.off(RoomEvent.ActiveSpeakersChanged, update);
|
||||
};
|
||||
}, [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
|
||||
// viewer is in the room — used to show "Active call · Join" affordances.
|
||||
//
|
||||
// Supabase realtime 2.103+ dedupes channels by topic: calling
|
||||
// `supabase.channel(topic)` returns an existing channel if one already exists.
|
||||
// That means an observer can't safely register `.on('presence', ...)` because
|
||||
// the tracker (CallContext) may have already subscribed it. We side-step this
|
||||
// by polling `presenceState()` on the (shared or fresh) channel.
|
||||
// Why presence-callbacks + no-removeChannel:
|
||||
// - Supabase realtime 2.103+ dedupes channels by topic. CallContext owns
|
||||
// the same channel during a live call to track its own presence.
|
||||
// - If this hook removed the channel on unmount (e.g. when ConversationHeader
|
||||
// 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[] {
|
||||
const [activeUserIds, setActiveUserIds] = useState<string[]>([]);
|
||||
|
||||
@@ -24,23 +28,13 @@ export function useCallPresence(conversationId: string | undefined): string[] {
|
||||
config: {
|
||||
presence: {
|
||||
key: 'observer-' + Math.random().toString(36).slice(2, 8),
|
||||
// Supabase realtime only sends presence_state/diff events to a
|
||||
// channel that has presence enabled. Without `enabled: true` (and
|
||||
// no `.on('presence', ...)` bindings) the server treats the channel
|
||||
// as non-presence and `presenceState()` never populates.
|
||||
// Server only routes presence to channels that opt-in. Without this
|
||||
// flag we'd receive zero presence_state/presence_diff messages.
|
||||
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 state = channel.presenceState() as Record<string, Array<Record<string, unknown>>>;
|
||||
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();
|
||||
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 () => {
|
||||
window.clearInterval(pollId);
|
||||
if (ownsChannel) {
|
||||
void supabase.removeChannel(channel);
|
||||
}
|
||||
// Don't removeChannel — the channel is shared with CallContext (and any
|
||||
// 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]);
|
||||
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
|
||||
import {
|
||||
type AttachmentHandle,
|
||||
type ChatMessage,
|
||||
type DecryptedMessage,
|
||||
decryptMessages,
|
||||
encryptAndUploadAttachment,
|
||||
fetchConversationMessages,
|
||||
fetchOwnEnvelopes,
|
||||
fetchSenderDeviceKeys,
|
||||
insertAttachmentRow,
|
||||
MAX_ATTACHMENT_BYTES,
|
||||
type MessageWithCipher,
|
||||
sendEncryptedMessage,
|
||||
} from '@chat-app/shared/chat';
|
||||
import { bytesToPgHex } from '@chat-app/shared/supabase';
|
||||
import { bytesToPgHex, pgBytesToBytes } from '@chat-app/shared/supabase';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { devLocalSecretStore } from './secretStore';
|
||||
@@ -36,7 +34,7 @@ type MessageChangePayload = {
|
||||
old: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function rowToMessage(row: Record<string, unknown>): ChatMessage {
|
||||
function rowToMessage(row: Record<string, unknown>): MessageWithCipher {
|
||||
return {
|
||||
id: String(row.id),
|
||||
conversationId: String(row.conversation_id),
|
||||
@@ -46,6 +44,9 @@ function rowToMessage(row: Record<string, unknown>): ChatMessage {
|
||||
editedAt: row.edited_at ? String(row.edited_at) : null,
|
||||
deletedAt: row.deleted_at ? String(row.deleted_at) : null,
|
||||
createdAt: String(row.created_at),
|
||||
ciphertext: pgBytesToBytes(String(row.ciphertext ?? '\\x')),
|
||||
nonce: pgBytesToBytes(String(row.nonce ?? '\\x')),
|
||||
keyVersion: typeof row.key_version === 'number' ? row.key_version : 1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -66,23 +67,15 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
}, [userId, deviceId]);
|
||||
|
||||
const decryptBatch = useCallback(
|
||||
async (messages: ChatMessage[]): Promise<DecryptedMessage[]> => {
|
||||
async (messages: MessageWithCipher[]): Promise<DecryptedMessage[]> => {
|
||||
const priv = privateKeyRef.current;
|
||||
if (!priv || !deviceId || messages.length === 0) {
|
||||
return messages.map((m) => ({ ...m, plaintext: null }));
|
||||
}
|
||||
const ids = messages.map((m) => m.id);
|
||||
const senderDeviceIds = messages
|
||||
.map((m) => m.senderDeviceId)
|
||||
.filter((v): v is string => v != null);
|
||||
const [envelopes, senderKeys] = await Promise.all([
|
||||
fetchOwnEnvelopes(supabase, ids, deviceId),
|
||||
fetchSenderDeviceKeys(supabase, senderDeviceIds),
|
||||
]);
|
||||
return decryptMessages({
|
||||
client: supabase,
|
||||
messages,
|
||||
envelopes,
|
||||
senderKeys,
|
||||
ownDeviceId: deviceId,
|
||||
ownPrivateKey: priv,
|
||||
});
|
||||
},
|
||||
@@ -105,26 +98,81 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
}
|
||||
}, [conversationId, decryptBatch]);
|
||||
|
||||
// Realtime INSERT handler — decrypt + append (with retry for envelope race).
|
||||
// Realtime INSERT handler — refetches the row via REST so we get the
|
||||
// canonical bytea encoding (postgres_changes payloads serialize bytea
|
||||
// differently and decoding them inline is brittle). Then decrypt + append.
|
||||
// Skips if the message is already in state (e.g. optimistic insert from our
|
||||
// own send), so the sender's cached copy isn't overwritten with a flicker.
|
||||
const handleInsert = useCallback(
|
||||
async (row: Record<string, unknown>) => {
|
||||
if (!deviceId) return;
|
||||
const msg = rowToMessage(row);
|
||||
let decrypted: DecryptedMessage = { ...msg, plaintext: null };
|
||||
if (!conversationId || !deviceId) return;
|
||||
const id = String(row.id);
|
||||
|
||||
let alreadyHave = false;
|
||||
setState((prev) => {
|
||||
if (prev.messages.some((m) => m.id === id)) alreadyHave = true;
|
||||
return prev;
|
||||
});
|
||||
if (alreadyHave) 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', id)
|
||||
.maybeSingle();
|
||||
if (error) {
|
||||
console.warn('handleInsert refetch failed', error);
|
||||
return;
|
||||
}
|
||||
if (!data) {
|
||||
await new Promise((r) => window.setTimeout(r, 120 * (attempt + 1)));
|
||||
continue;
|
||||
}
|
||||
// db-types snapshot predates the sender-key columns; cast to bypass.
|
||||
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, 120 * (attempt + 1)));
|
||||
await new Promise((r) => window.setTimeout(r, 200 * (attempt + 1)));
|
||||
}
|
||||
if (!decrypted) return;
|
||||
setState((prev) => {
|
||||
if (prev.messages.some((m) => m.id === decrypted.id)) return prev;
|
||||
return { ...prev, messages: [...prev.messages, decrypted] };
|
||||
if (prev.messages.some((m) => m.id === decrypted!.id)) return prev;
|
||||
return { ...prev, messages: [...prev.messages, decrypted!] };
|
||||
});
|
||||
},
|
||||
[deviceId, decryptBatch],
|
||||
[conversationId, deviceId, decryptBatch],
|
||||
);
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
@@ -190,6 +238,22 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
}
|
||||
},
|
||||
)
|
||||
// When a peer device wraps the conversation-key for us (e.g. we just
|
||||
// registered a fresh device), re-decrypt the visible messages.
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'INSERT',
|
||||
schema: 'public',
|
||||
table: 'conversation_keys',
|
||||
filter: 'conversation_id=eq.' + conversationId,
|
||||
},
|
||||
(payload: { new: { recipient_device_id?: string } }) => {
|
||||
if (payload.new?.recipient_device_id === deviceId) {
|
||||
void refresh();
|
||||
}
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
@@ -226,7 +290,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
blobNonceHexByHandleId.set(res.handle.id, bytesToPgHex(res.nonce));
|
||||
}
|
||||
|
||||
// 2. Send message (inserts messages + envelopes in one helper).
|
||||
// 2. Send message (inserts messages + per-conversation key bundles).
|
||||
const msg = await sendEncryptedMessage({
|
||||
client: supabase,
|
||||
conversationId,
|
||||
@@ -237,7 +301,28 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
...(handles.length > 0 ? { attachmentHandles: handles } : {}),
|
||||
});
|
||||
|
||||
// 3. Insert public attachment metadata rows pointing at the new message.
|
||||
// 3. Optimistic insert — we already have the plaintext in hand and the
|
||||
// server returned the row id, so add the message to local state
|
||||
// immediately. Realtime will then no-op (handleInsert dedupes by id).
|
||||
const attachmentsPayload =
|
||||
handles.length === 0
|
||||
? trimmed
|
||||
: JSON.stringify({ v: 1, text: trimmed, attachments: handles });
|
||||
setState((prev) => {
|
||||
if (prev.messages.some((m) => m.id === msg.id)) return prev;
|
||||
return {
|
||||
...prev,
|
||||
messages: [
|
||||
...prev.messages,
|
||||
{
|
||||
...msg,
|
||||
plaintext: attachmentsPayload,
|
||||
} as DecryptedMessage,
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
// 4. Insert public attachment metadata rows pointing at the new message.
|
||||
for (const h of handles) {
|
||||
const blobNonce = blobNonceHexByHandleId.get(h.id) ?? '\\x';
|
||||
await insertAttachmentRow(supabase, msg.id, h, blobNonce);
|
||||
|
||||
@@ -16,6 +16,7 @@ import { extractErrorCode } from '@chat-app/shared/i18n';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Avatar } from '../components/Avatar';
|
||||
import {
|
||||
AlertIcon,
|
||||
CopyIcon,
|
||||
@@ -64,9 +65,10 @@ export function AdminPage() {
|
||||
}, [refresh]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-4xl flex-col gap-6 px-6 py-8">
|
||||
<div className="h-full bg-surface-3 text-fg">
|
||||
<div className="mx-auto flex max-w-4xl flex-col gap-6 px-6 py-8">
|
||||
<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')}
|
||||
</h1>
|
||||
</header>
|
||||
@@ -74,16 +76,16 @@ export function AdminPage() {
|
||||
{error && (
|
||||
<div
|
||||
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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-neutral-500">
|
||||
<SpinnerIcon className="h-4 w-4 text-brand-400" />
|
||||
<div className="flex items-center gap-2 text-sm text-fg-muted">
|
||||
<SpinnerIcon className="h-4 w-4 text-accent" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -92,15 +94,16 @@ export function AdminPage() {
|
||||
<UsersSection users={users} onRefresh={refresh} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) {
|
||||
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">
|
||||
<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}
|
||||
</header>
|
||||
{children}
|
||||
@@ -199,7 +202,7 @@ function InvitesSection({ invites, onRefresh }: { invites: InviteRecord[]; onRef
|
||||
type="button"
|
||||
onClick={() => void handleCreate()}
|
||||
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" />}
|
||||
<span>{t('app:admin.invites_create')}</span>
|
||||
@@ -207,11 +210,11 @@ function InvitesSection({ invites, onRefresh }: { invites: InviteRecord[]; onRef
|
||||
}
|
||||
>
|
||||
{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">
|
||||
<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>
|
||||
<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>
|
||||
@@ -220,7 +223,7 @@ function InvitesSection({ invites, onRefresh }: { invites: InviteRecord[]; onRef
|
||||
<th className="py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
<tbody className="divide-y divide-line">
|
||||
{invites.map((inv) => {
|
||||
const expired = inv.expiresAt ? new Date(inv.expiresAt) < new Date() : false;
|
||||
const status = inv.disabled
|
||||
@@ -229,10 +232,10 @@ function InvitesSection({ invites, onRefresh }: { invites: InviteRecord[]; onRef
|
||||
? t('app:admin.invite_status_expired')
|
||||
: t('app:admin.invite_status_active');
|
||||
const statusColor = inv.disabled
|
||||
? 'text-rose-300'
|
||||
? 'text-rose-600 dark:text-rose-300'
|
||||
: expired
|
||||
? 'text-amber-300'
|
||||
: 'text-emerald-300';
|
||||
? 'text-amber-600 dark:text-amber-300'
|
||||
: 'text-emerald-600 dark:text-emerald-300';
|
||||
const usesText = inv.usesLimit
|
||||
? inv.usesCount + '/' + inv.usesLimit
|
||||
: inv.usesCount + '/∞';
|
||||
@@ -241,9 +244,9 @@ function InvitesSection({ invites, onRefresh }: { invites: InviteRecord[]; onRef
|
||||
: t('app:admin.invite_expires_never');
|
||||
return (
|
||||
<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 text-xs text-neutral-400">{usesText}</td>
|
||||
<td className="py-2 pr-3 text-xs text-neutral-400">{expiresText}</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-fg-muted">{usesText}</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 text-right">
|
||||
<div className="inline-flex gap-1">
|
||||
@@ -260,7 +263,7 @@ function InvitesSection({ invites, onRefresh }: { invites: InviteRecord[]; onRef
|
||||
<button
|
||||
type="button"
|
||||
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')}
|
||||
</button>
|
||||
@@ -301,20 +304,19 @@ function UsersSection({ users, onRefresh }: { users: AdminProfileRow[]; onRefres
|
||||
return (
|
||||
<Section title={t('app:admin.users_title')}>
|
||||
{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) => {
|
||||
const letter =
|
||||
(u.displayName ?? u.username ?? '?').trim().charAt(0).toUpperCase() || '?';
|
||||
return (
|
||||
<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">
|
||||
{letter}
|
||||
</div>
|
||||
<Avatar
|
||||
displayName={u.displayName ?? u.username}
|
||||
className="h-9 w-9 text-sm"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-white">{u.displayName}</p>
|
||||
<p className="truncate text-xs text-neutral-500">@{u.username}</p>
|
||||
<p className="truncate text-sm font-semibold text-fg">{u.displayName}</p>
|
||||
<p className="truncate text-xs text-fg-muted">@{u.username}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<FlagChip
|
||||
@@ -361,8 +363,8 @@ function Toggle({
|
||||
return (
|
||||
<label className="flex cursor-pointer items-start justify-between gap-4">
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm text-neutral-200">{label}</span>
|
||||
{hint && <span className="mt-1 block text-xs text-neutral-500">{hint}</span>}
|
||||
<span className="block text-sm text-fg">{label}</span>
|
||||
{hint && <span className="mt-1 block text-xs text-fg-muted">{hint}</span>}
|
||||
</span>
|
||||
<span className="relative mt-0.5 inline-flex h-6 w-11 shrink-0">
|
||||
<input
|
||||
@@ -372,8 +374,8 @@ function Toggle({
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
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="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition peer-checked:translate-x-5" />
|
||||
<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 shadow transition peer-checked:translate-x-5" />
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
@@ -399,8 +401,8 @@ function IconButton({
|
||||
className={
|
||||
'flex h-7 w-7 cursor-pointer items-center justify-center rounded-md border transition focus:outline-none focus-visible:ring-2 ' +
|
||||
(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-white/10 bg-white/5 text-neutral-300 hover:bg-white/10 focus-visible:ring-brand-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-line bg-surface-3 text-fg hover:brightness-95 focus-visible:ring-accent/40')
|
||||
}
|
||||
>
|
||||
{children}
|
||||
@@ -421,15 +423,15 @@ function FlagChip({
|
||||
}) {
|
||||
const activeClass =
|
||||
tone === 'danger'
|
||||
? 'border-rose-400/40 bg-rose-500/20 text-rose-100'
|
||||
: 'border-brand-400/40 bg-brand-500/20 text-brand-100';
|
||||
? 'border-rose-500/40 bg-rose-500/20 text-rose-700 dark:text-rose-100'
|
||||
: 'border-accent/40 bg-accent/20 text-accent';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(!active)}
|
||||
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 ' +
|
||||
(active ? activeClass : 'border-white/10 bg-white/5 text-neutral-400 hover:bg-white/10')
|
||||
'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-line bg-surface-3 text-fg-muted hover:brightness-95')
|
||||
}
|
||||
>
|
||||
{label}
|
||||
|
||||
@@ -5,7 +5,14 @@ import { useTranslation } from 'react-i18next';
|
||||
import { NavLink, Outlet, useParams } from 'react-router-dom';
|
||||
|
||||
import { CreateGroupDialog } from '../components/CreateGroupDialog';
|
||||
import { ChatBubbleIcon, PlusIcon, SpinnerIcon, UsersIcon } from '../components/icons';
|
||||
import {
|
||||
AddUserIcon,
|
||||
ChatBubbleIcon,
|
||||
SearchIcon,
|
||||
SpinnerIcon,
|
||||
UsersIcon,
|
||||
} from '../components/icons';
|
||||
import { UserBar } from '../components/UserBar';
|
||||
import { useConversationsContext } from '../context/ConversationsContext';
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
@@ -13,6 +20,7 @@ export function ChatsPage() {
|
||||
const { conversations, loading, error, refresh, unread } = useConversationsContext();
|
||||
const { id: activeId } = useParams<{ id: string }>();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
return [...conversations].sort((a, b) => {
|
||||
@@ -22,14 +30,26 @@ export function ChatsPage() {
|
||||
});
|
||||
}, [conversations]);
|
||||
|
||||
const filtered = 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]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
<ConversationList
|
||||
items={sorted}
|
||||
items={filtered}
|
||||
loading={loading}
|
||||
error={error}
|
||||
activeId={activeId}
|
||||
unread={unread}
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
onAccept={async (id) => {
|
||||
try {
|
||||
await acceptDm(supabase, id);
|
||||
@@ -41,7 +61,7 @@ export function ChatsPage() {
|
||||
}}
|
||||
onNewGroup={() => setCreateOpen(true)}
|
||||
/>
|
||||
<div className="flex-1 border-l border-white/5">
|
||||
<div className="flex-1 border-l border-line bg-surface-3">
|
||||
<Outlet />
|
||||
</div>
|
||||
<CreateGroupDialog open={createOpen} onClose={() => setCreateOpen(false)} />
|
||||
@@ -49,32 +69,38 @@ 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;
|
||||
}
|
||||
|
||||
function ConversationList({
|
||||
items,
|
||||
loading,
|
||||
error,
|
||||
activeId,
|
||||
unread,
|
||||
query,
|
||||
onQueryChange,
|
||||
onAccept,
|
||||
onNewGroup,
|
||||
}: {
|
||||
items: ConversationSummary[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
activeId: string | undefined;
|
||||
unread: Record<string, number>;
|
||||
onAccept: (id: string) => void;
|
||||
onNewGroup: () => void;
|
||||
}) {
|
||||
}: ConversationListProps) {
|
||||
const { t } = useTranslation(['app']);
|
||||
|
||||
return (
|
||||
<aside
|
||||
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">
|
||||
<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')}
|
||||
</h2>
|
||||
<button
|
||||
@@ -82,29 +108,45 @@ function ConversationList({
|
||||
onClick={onNewGroup}
|
||||
aria-label={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" />
|
||||
<PlusIcon className="h-3 w-3" />
|
||||
<AddUserIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</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 ? (
|
||||
<div className="flex items-center gap-2 px-4 py-2 text-xs text-neutral-500">
|
||||
<SpinnerIcon className="h-3.5 w-3.5 text-brand-400" />
|
||||
<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-accent" />
|
||||
</div>
|
||||
) : 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 ? (
|
||||
<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">
|
||||
<ChatBubbleIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<p className="text-sm text-neutral-400">{t('app:chats.empty_title')}</p>
|
||||
<p className="text-xs text-neutral-500">{t('app:chats.empty_subtitle')}</p>
|
||||
<p className="text-sm text-fg-muted">{t('app:chats.empty_title')}</p>
|
||||
<p className="text-xs text-fg-muted/80">{t('app:chats.empty_subtitle')}</p>
|
||||
</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) => (
|
||||
<li key={c.id}>
|
||||
<ConversationRow
|
||||
@@ -117,6 +159,10 @@ function ConversationList({
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="shrink-0 border-t border-line bg-surface-2 p-2">
|
||||
<UserBar />
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -136,16 +182,22 @@ function ConversationRow({
|
||||
const title =
|
||||
item.type === 'dm' ? (item.peer?.displayName ?? '?') : (item.name ?? '?');
|
||||
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) {
|
||||
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">
|
||||
<Avatar letter={letter} />
|
||||
<ConvAvatar url={avatarUrl} title={title} isGroup={item.type === 'group'} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-white">{title}</p>
|
||||
<p className="truncate text-xs text-amber-200/80">
|
||||
<p className="truncate text-sm font-semibold text-fg">{title}</p>
|
||||
<p className="truncate text-xs text-amber-600 dark:text-amber-200/80">
|
||||
{t('app:friends.incoming_request')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -153,7 +205,7 @@ function ConversationRow({
|
||||
<button
|
||||
type="button"
|
||||
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')}
|
||||
</button>
|
||||
@@ -165,26 +217,28 @@ function ConversationRow({
|
||||
<NavLink
|
||||
to={'/chats/' + item.id}
|
||||
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 ' +
|
||||
(active ? 'bg-brand-500/15 ring-1 ring-brand-400/30' : 'hover:bg-white/5')
|
||||
'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-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">
|
||||
<p
|
||||
className={
|
||||
'truncate text-sm ' +
|
||||
(unreadCount > 0 ? 'font-bold text-white' : 'font-semibold text-white')
|
||||
(unreadCount > 0 ? 'font-bold' : 'font-semibold')
|
||||
}
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
<p className="truncate text-xs text-neutral-500">{handle}</p>
|
||||
<p className="truncate text-xs text-fg-muted">{preview}</p>
|
||||
</div>
|
||||
{unreadCount > 0 && (
|
||||
<span
|
||||
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 bg-accent px-1.5 text-[10px] font-bold leading-tight text-accent-fg"
|
||||
>
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
@@ -193,9 +247,35 @@ function ConversationRow({
|
||||
);
|
||||
}
|
||||
|
||||
function Avatar({ letter }: { letter: string }) {
|
||||
function ConvAvatar({
|
||||
url,
|
||||
title,
|
||||
isGroup,
|
||||
}: {
|
||||
url: string | null;
|
||||
title: string;
|
||||
isGroup: boolean;
|
||||
}) {
|
||||
if (url) {
|
||||
return (
|
||||
<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-brand-500/30 text-sm font-semibold text-white 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-sm font-semibold text-accent">
|
||||
{letter}
|
||||
</div>
|
||||
);
|
||||
@@ -206,13 +286,13 @@ export function ChatsEmptyState() {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-10">
|
||||
<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" />
|
||||
</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')}
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -7,9 +7,11 @@ import { ConversationHeader } from '../components/ConversationHeader';
|
||||
import { GroupInfoPanel } from '../components/GroupInfoPanel';
|
||||
import { AlertIcon, ArrowRightIcon, PlusIcon, SpinnerIcon, XIcon } from '../components/icons';
|
||||
import { InCallPanel } from '../components/InCallPanel';
|
||||
import { IncomingCallPanel } from '../components/IncomingCallPanel';
|
||||
import { MessageBubble } from '../components/MessageBubble';
|
||||
import { TypingIndicator } from '../components/TypingIndicator';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useCall } from '../context/CallContext';
|
||||
import { useConversationsContext } from '../context/ConversationsContext';
|
||||
import { useConversationMessages } from '../lib/useConversationMessages';
|
||||
import { useMessageReactions } from '../lib/useMessageReactions';
|
||||
@@ -157,14 +159,27 @@ export function ConversationPage() {
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="relative flex h-full flex-col">
|
||||
<ConversationHeader
|
||||
conversation={conversation}
|
||||
peerPresence={peerPresence}
|
||||
{...(isGroup ? { onInfoClick: () => setInfoPanelOpen(true) } : {})}
|
||||
/>
|
||||
{!callHereActive && (
|
||||
<ConversationHeader
|
||||
conversation={conversation}
|
||||
peerPresence={peerPresence}
|
||||
{...(isGroup ? { onInfoClick: () => setInfoPanelOpen(true) } : {})}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isGroup && conversation && (
|
||||
<GroupInfoPanel
|
||||
@@ -174,28 +189,36 @@ export function ConversationPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{incomingHere && conversation && <IncomingCallPanel 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 ? (
|
||||
<div className="flex items-center gap-2 text-xs text-neutral-500">
|
||||
<SpinnerIcon className="h-3.5 w-3.5 text-brand-400" />
|
||||
<div className="flex items-center gap-2 text-xs text-fg-muted">
|
||||
<SpinnerIcon className="h-3.5 w-3.5 text-accent" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<Banner>{error}</Banner>
|
||||
) : 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">
|
||||
{messages.map((m, idx) => {
|
||||
const prev = messages[idx - 1];
|
||||
const next = messages[idx + 1];
|
||||
const grouped = idx > 0 && prev?.senderId === m.senderId;
|
||||
const isLastOfRun = !next || next.senderId !== m.senderId;
|
||||
const senderProfile =
|
||||
conversation?.members.find((mm) => mm.userId === m.senderId)?.profile ?? null;
|
||||
return (
|
||||
<li key={m.id}>
|
||||
<MessageBubble
|
||||
message={m}
|
||||
mine={m.senderId === myId}
|
||||
groupedWithPrev={grouped}
|
||||
isLastOfRun={isLastOfRun}
|
||||
senderDisplayName={senderProfile?.displayName}
|
||||
senderAvatarUrl={senderProfile?.avatarUrl}
|
||||
conversationId={id ?? ''}
|
||||
reactions={reactionsByMessage.get(m.id) ?? []}
|
||||
onToggleReaction={(emoji) => toggleReaction(m.id, emoji)}
|
||||
@@ -213,7 +236,7 @@ export function ConversationPage() {
|
||||
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 && (
|
||||
<div className="mb-2">
|
||||
<Banner>{sendError}</Banner>
|
||||
@@ -248,7 +271,7 @@ export function ConversationPage() {
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
aria-label="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" />
|
||||
</button>
|
||||
@@ -265,14 +288,14 @@ export function ConversationPage() {
|
||||
}
|
||||
}}
|
||||
rows={1}
|
||||
placeholder="…"
|
||||
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"
|
||||
placeholder="Nachricht schreiben…"
|
||||
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
|
||||
type="submit"
|
||||
disabled={sending || (text.trim().length === 0 && attachments.length === 0)}
|
||||
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" />}
|
||||
</button>
|
||||
@@ -286,9 +309,9 @@ function Banner({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
@@ -302,7 +325,7 @@ function AttachmentPreview({ file, onRemove }: { file: File; onRemove: () => voi
|
||||
return () => URL.revokeObjectURL(u);
|
||||
}, [file]);
|
||||
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 ? (
|
||||
<img src={url} alt={file.name} className="block h-20 w-20 object-cover" />
|
||||
) : (
|
||||
@@ -312,7 +335,7 @@ function AttachmentPreview({ file, onRemove }: { file: File; onRemove: () => voi
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
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" />
|
||||
</button>
|
||||
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
import { extractErrorCode } from '@chat-app/shared/i18n';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Avatar } from '../components/Avatar';
|
||||
import {
|
||||
AlertIcon,
|
||||
ChatBubbleIcon,
|
||||
@@ -28,6 +30,7 @@ type Tab = 'friends' | 'pending' | 'requests';
|
||||
export function FriendsPage() {
|
||||
const { t } = useTranslation(['app', 'errors']);
|
||||
const { friendships, loading, error, refresh } = useFriendshipsContext();
|
||||
const navigate = useNavigate();
|
||||
const [tab, setTab] = useState<Tab>('friends');
|
||||
const [query, setQuery] = useState('');
|
||||
const [debounced, setDebounced] = useState('');
|
||||
@@ -104,16 +107,17 @@ export function FriendsPage() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-full max-w-3xl flex-col gap-5 px-6 py-8">
|
||||
<div className="h-full bg-surface-3 text-fg">
|
||||
<div className="mx-auto flex h-full max-w-3xl flex-col gap-5 px-6 py-8">
|
||||
<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')}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div className="space-y-3">
|
||||
<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
|
||||
type="search"
|
||||
value={query}
|
||||
@@ -122,7 +126,7 @@ export function FriendsPage() {
|
||||
autoComplete="off"
|
||||
autoCapitalize="none"
|
||||
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>
|
||||
{query.length > 0 && (
|
||||
@@ -138,7 +142,7 @@ export function FriendsPage() {
|
||||
)}
|
||||
</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')}>
|
||||
{t('app:friends.tab_friends')} · {accepted.length}
|
||||
</TabButton>
|
||||
@@ -165,7 +169,7 @@ export function FriendsPage() {
|
||||
onMessage={() =>
|
||||
performAction('msg-' + f.peer.userId, async () => {
|
||||
const id = await createDm(supabase, f.peer.userId);
|
||||
navigateToChat(id);
|
||||
navigate('/chats/' + id);
|
||||
})
|
||||
}
|
||||
onUnfriend={() =>
|
||||
@@ -206,16 +210,11 @@ export function FriendsPage() {
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</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 {
|
||||
const code = extractErrorCode(err);
|
||||
if (code) return t('errors:' + code, { defaultValue: t('errors:generic') });
|
||||
@@ -241,8 +240,8 @@ function TabButton({
|
||||
className={
|
||||
'-mb-px cursor-pointer border-b-2 px-3 pb-2.5 text-sm font-medium transition focus:outline-none ' +
|
||||
(active
|
||||
? 'border-brand-400 text-white'
|
||||
: 'border-transparent text-neutral-400 hover:text-neutral-200')
|
||||
? 'border-accent text-fg'
|
||||
: 'border-transparent text-fg-muted hover:text-fg')
|
||||
}
|
||||
>
|
||||
{children}
|
||||
@@ -262,10 +261,11 @@ function FriendList({
|
||||
const { t } = useTranslation(['app']);
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center text-center text-sm text-neutral-500">
|
||||
<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="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-line bg-surface-2 text-fg-muted">
|
||||
<UsersIcon className="h-5 w-5" />
|
||||
</div>
|
||||
|
||||
<p>{t(emptyKey)}</p>
|
||||
</div>
|
||||
);
|
||||
@@ -283,26 +283,21 @@ function FriendList({
|
||||
|
||||
function FriendRow({ profile, children }: { profile: ProfileBrief; children: React.ReactNode }) {
|
||||
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">
|
||||
<Avatar profile={profile} />
|
||||
<div className="flex items-center gap-3 rounded-xl border border-line bg-surface-2 px-4 py-3">
|
||||
<Avatar
|
||||
url={profile.avatarUrl}
|
||||
displayName={profile.displayName ?? profile.username}
|
||||
className="h-9 w-9 text-sm"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-white">{profile.displayName}</p>
|
||||
<p className="truncate text-xs text-neutral-500">@{profile.username}</p>
|
||||
<p className="truncate text-sm font-semibold text-fg">{profile.displayName}</p>
|
||||
<p className="truncate text-xs text-fg-muted">@{profile.username}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">{children}</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({
|
||||
busy,
|
||||
onMessage,
|
||||
@@ -371,7 +366,7 @@ function PrimaryButton({
|
||||
type="button"
|
||||
disabled={busy}
|
||||
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}
|
||||
<span>{children}</span>
|
||||
@@ -393,7 +388,7 @@ function SecondaryButton({
|
||||
type="button"
|
||||
disabled={busy}
|
||||
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" />}
|
||||
<span>{children}</span>
|
||||
@@ -415,7 +410,7 @@ function DangerButton({
|
||||
type="button"
|
||||
disabled={busy}
|
||||
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" />}
|
||||
<span>{children}</span>
|
||||
@@ -425,8 +420,8 @@ function DangerButton({
|
||||
|
||||
function LoadingRow() {
|
||||
return (
|
||||
<div className="flex items-center gap-3 text-sm text-neutral-500">
|
||||
<SpinnerIcon className="h-4 w-4 text-brand-400" />
|
||||
<div className="flex items-center gap-3 text-sm text-fg-muted">
|
||||
<SpinnerIcon className="h-4 w-4 text-accent" />
|
||||
<span>…</span>
|
||||
</div>
|
||||
);
|
||||
@@ -440,14 +435,14 @@ function Banner({ kind, children }: { kind: 'error' | 'info'; children: React.Re
|
||||
className={
|
||||
'flex items-start gap-3 rounded-lg border p-3 text-sm ' +
|
||||
(isError
|
||||
? 'border-rose-500/20 bg-rose-500/10 text-rose-100'
|
||||
: 'border-emerald-500/20 bg-emerald-500/10 text-emerald-100')
|
||||
? 'border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-100'
|
||||
: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-100')
|
||||
}
|
||||
>
|
||||
{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>
|
||||
</div>
|
||||
@@ -474,24 +469,24 @@ function SearchResults({
|
||||
const { t } = useTranslation(['app']);
|
||||
|
||||
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) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs text-neutral-500">
|
||||
<SpinnerIcon className="h-3.5 w-3.5 text-brand-400" />
|
||||
<div className="flex items-center gap-2 text-xs text-fg-muted">
|
||||
<SpinnerIcon className="h-3.5 w-3.5 text-accent" />
|
||||
<span>…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) return <Banner kind="error">{error}</Banner>;
|
||||
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 (
|
||||
<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')}
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
@@ -530,7 +525,7 @@ function SearchActionButton({
|
||||
|
||||
if (existing?.status === 'accepted') {
|
||||
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" />
|
||||
{t('app:friends.already_friends')}
|
||||
</span>
|
||||
@@ -539,7 +534,7 @@ function SearchActionButton({
|
||||
|
||||
if (existing?.status === 'pending' && existing.direction === 'outgoing') {
|
||||
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')}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -4,11 +4,16 @@ import {
|
||||
SUPPORTED_LOCALES,
|
||||
type SupportedLocale,
|
||||
} from '@chat-app/shared/i18n';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Avatar } from '../components/Avatar';
|
||||
import { LockIcon } from '../components/icons';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { loadDevicePrivateKey, saveDevicePrivateKey } from '@chat-app/shared/auth';
|
||||
import { deleteAvatarObject, uploadAvatar } from '../lib/avatarUpload';
|
||||
import { exportDeviceKey, importDeviceKey } from '../lib/deviceBackup';
|
||||
import { devLocalSecretStore } from '../lib/secretStore';
|
||||
import {
|
||||
getPttSettings,
|
||||
keyCodeToLabel,
|
||||
@@ -71,15 +76,20 @@ export function SettingsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
|
||||
<div className="h-full bg-surface-3 text-fg">
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
|
||||
<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')}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
{/* 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.display_name')} value={profile?.displayName ?? '—'} />
|
||||
<Row label={t('auth:signed_in.email')} value={profile?.userId ?? '—'} mono />
|
||||
@@ -88,7 +98,7 @@ export function SettingsPage() {
|
||||
{/* Appearance */}
|
||||
<Section title={t('app:settings.section_appearance')}>
|
||||
<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) => {
|
||||
const active = (i18n.resolvedLanguage ?? i18n.language) === locale;
|
||||
return (
|
||||
@@ -98,10 +108,10 @@ export function SettingsPage() {
|
||||
disabled={busy}
|
||||
onClick={() => void handleLocaleChange(locale)}
|
||||
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
|
||||
? 'bg-brand-500/25 text-white ring-1 ring-brand-400/40'
|
||||
: 'text-neutral-400 hover:text-neutral-200')
|
||||
? 'bg-accent text-accent-fg'
|
||||
: 'text-fg-muted hover:text-fg')
|
||||
}
|
||||
>
|
||||
{LOCALE_LABELS[locale]}
|
||||
@@ -133,10 +143,10 @@ export function SettingsPage() {
|
||||
{/* Voice / Push-to-Talk + Audio Quality + E2EE */}
|
||||
<Section title={t('app:settings.section_voice', { defaultValue: 'Sprache' })}>
|
||||
<AudioQualityControls />
|
||||
<div className="mt-3 border-t border-white/5 pt-3">
|
||||
<div className="mt-3 border-t border-line pt-3">
|
||||
<PttControls />
|
||||
</div>
|
||||
<div className="mt-3 border-t border-white/5 pt-3">
|
||||
<div className="mt-3 border-t border-line pt-3">
|
||||
<CallE2EEControls />
|
||||
</div>
|
||||
</Section>
|
||||
@@ -149,8 +159,8 @@ export function SettingsPage() {
|
||||
{/* Devices */}
|
||||
<Section title={t('app:settings.section_devices')}>
|
||||
{device && (
|
||||
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/5 p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-emerald-200">
|
||||
<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-700 dark:text-emerald-200">
|
||||
<LockIcon className="h-4 w-4" />
|
||||
{t('app:settings.this_device')}
|
||||
</div>
|
||||
@@ -161,6 +171,7 @@ export function SettingsPage() {
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
<DeviceKeyBackupControls />
|
||||
</Section>
|
||||
|
||||
{/* Danger zone */}
|
||||
@@ -168,11 +179,12 @@ export function SettingsPage() {
|
||||
<button
|
||||
type="button"
|
||||
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')}
|
||||
</button>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -219,10 +231,10 @@ function PttControls() {
|
||||
type="button"
|
||||
onClick={() => setCapturing((v) => !v)}
|
||||
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
|
||||
? 'border-brand-400 bg-brand-500/20 text-white animate-pulse'
|
||||
: 'border-white/10 bg-ink-800 text-neutral-200 hover:bg-ink-700')
|
||||
? 'border-accent bg-accent/20 text-fg animate-pulse'
|
||||
: 'border-line bg-surface-3 text-fg hover:brightness-95')
|
||||
}
|
||||
>
|
||||
{capturing
|
||||
@@ -279,7 +291,7 @@ function AudioQualityControls() {
|
||||
return (
|
||||
<>
|
||||
<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) => {
|
||||
const active = cfg.quality === q;
|
||||
return (
|
||||
@@ -288,10 +300,10 @@ function AudioQualityControls() {
|
||||
type="button"
|
||||
onClick={() => updateAudioSettings({ quality: q })}
|
||||
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
|
||||
? 'bg-brand-500/25 text-white ring-1 ring-brand-400/40'
|
||||
: 'text-neutral-400 hover:text-neutral-200')
|
||||
? 'bg-accent text-accent-fg'
|
||||
: 'text-fg-muted hover:text-fg')
|
||||
}
|
||||
>
|
||||
{labels[q]}
|
||||
@@ -300,7 +312,7 @@ function AudioQualityControls() {
|
||||
})}
|
||||
</div>
|
||||
</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">
|
||||
<Stat label="Bitrate" value={params.bitrateKbps + ' kbps'} />
|
||||
<Stat label="Channels" value={params.stereo ? 'Stereo' : 'Mono'} />
|
||||
@@ -308,7 +320,7 @@ function AudioQualityControls() {
|
||||
<Stat label="DSP" value={params.echoCancellation ? 'On' : 'Off'} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500">
|
||||
<p className="text-xs text-fg-muted">
|
||||
{cfg.quality === 'hifi'
|
||||
? t('app:settings.audio_hifi_hint', {
|
||||
defaultValue:
|
||||
@@ -323,6 +335,271 @@ function AudioQualityControls() {
|
||||
);
|
||||
}
|
||||
|
||||
interface AvatarControlsProps {
|
||||
patchProfile: (patch: Parameters<typeof updateOwnProfile>[1]) => Promise<void>;
|
||||
busy: boolean;
|
||||
}
|
||||
|
||||
function AvatarControls({ patchProfile, busy }: AvatarControlsProps) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { profile } = useAuth();
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const userId = profile?.userId;
|
||||
const url = profile?.avatarUrl ?? null;
|
||||
|
||||
async function handleFile(file: File) {
|
||||
if (!userId) return;
|
||||
setError(null);
|
||||
setUploading(true);
|
||||
try {
|
||||
const newUrl = await uploadAvatar(userId, file);
|
||||
const oldUrl = url;
|
||||
await patchProfile({ avatarUrl: newUrl });
|
||||
if (oldUrl) {
|
||||
// Best-effort cleanup of the previous file (don't block on it).
|
||||
void deleteAvatarObject(oldUrl).catch(() => {
|
||||
/* ignore */
|
||||
});
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'upload failed');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (inputRef.current) inputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove() {
|
||||
if (!userId || !url) return;
|
||||
setError(null);
|
||||
setUploading(true);
|
||||
try {
|
||||
await patchProfile({ avatarUrl: null });
|
||||
void deleteAvatarObject(url).catch(() => undefined);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'remove failed');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar
|
||||
url={url}
|
||||
displayName={profile?.displayName ?? profile?.username}
|
||||
className="h-16 w-16 text-2xl"
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<div className="text-sm font-medium text-fg">
|
||||
{t('app:settings.avatar', { defaultValue: 'Avatar' })}
|
||||
</div>
|
||||
<div className="text-xs text-fg-muted">
|
||||
{t('app:settings.avatar_hint', {
|
||||
defaultValue: 'Quadratisch, max 512×512, WebP. Sichtbar für deine Freunde.',
|
||||
})}
|
||||
</div>
|
||||
{error && (
|
||||
<div className="text-xs text-rose-500 dark:text-rose-300">{error}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) void handleFile(f);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={busy || uploading}
|
||||
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"
|
||||
>
|
||||
{uploading
|
||||
? t('app:settings.avatar_uploading', { defaultValue: 'Lade…' })
|
||||
: url
|
||||
? t('app:settings.avatar_change', { defaultValue: 'Ändern' })
|
||||
: t('app:settings.avatar_upload', { defaultValue: 'Hochladen' })}
|
||||
</button>
|
||||
{url && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRemove()}
|
||||
disabled={busy || uploading}
|
||||
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"
|
||||
>
|
||||
{t('app:settings.avatar_remove', { defaultValue: 'Entfernen' })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceKeyBackupControls() {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { profile, device } = useAuth();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [backupOut, setBackupOut] = useState<string | null>(null);
|
||||
const [exportPass, setExportPass] = useState('');
|
||||
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;
|
||||
|
||||
async function handleExport() {
|
||||
if (!canRun) return;
|
||||
setMsg(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const priv = await loadDevicePrivateKey(devLocalSecretStore, profile.userId, device.id);
|
||||
if (!priv) throw new Error('No device key on this install');
|
||||
const out = await exportDeviceKey(priv, exportPass);
|
||||
setBackupOut(out);
|
||||
setExportPass('');
|
||||
setMsg({
|
||||
kind: 'ok',
|
||||
text: t('app:settings.backup_export_ok', {
|
||||
defaultValue: 'Backup erstellt — kopiere und bewahre es sicher auf.',
|
||||
}),
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
setMsg({
|
||||
kind: 'err',
|
||||
text: err instanceof Error ? err.message : 'export failed',
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (!canRun) return;
|
||||
setMsg(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const priv = await importDeviceKey(importBlob.trim(), importPass);
|
||||
await saveDevicePrivateKey(devLocalSecretStore, profile.userId, device.id, priv);
|
||||
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) {
|
||||
setMsg({
|
||||
kind: 'err',
|
||||
text: err instanceof Error ? err.message : 'import failed',
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
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', {
|
||||
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="text-xs font-semibold uppercase tracking-[0.1em] text-fg-muted">
|
||||
{t('app:settings.backup_export', { defaultValue: 'Export' })}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="password"
|
||||
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-line bg-surface-3 px-3 py-2 text-sm text-fg placeholder-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || exportPass.length < 8 || !canRun}
|
||||
onClick={() => void handleExport()}
|
||||
className="cursor-pointer rounded-lg bg-accent px-4 text-sm font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('app:settings.backup_create', { defaultValue: 'Erstellen' })}
|
||||
</button>
|
||||
</div>
|
||||
{backupOut && (
|
||||
<textarea
|
||||
readOnly
|
||||
value={backupOut}
|
||||
onClick={(e) => (e.target as HTMLTextAreaElement).select()}
|
||||
rows={3}
|
||||
className="w-full rounded-lg border border-emerald-500/30 bg-emerald-500/5 p-2 font-mono text-[10px] text-emerald-700 dark:text-emerald-200"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2 border-t border-line pt-4">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.1em] text-fg-muted">
|
||||
{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-line bg-surface-3 p-2 font-mono text-[11px] text-fg placeholder-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="password"
|
||||
value={importPass}
|
||||
onChange={(e) => setImportPass(e.target.value)}
|
||||
placeholder={t('app:settings.backup_passphrase', { defaultValue: 'Passphrase' })}
|
||||
className="flex-1 rounded-lg border border-line bg-surface-3 px-3 py-2 text-sm text-fg placeholder-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || !importBlob || !importPass || !canRun}
|
||||
onClick={() => void handleImport()}
|
||||
className="cursor-pointer rounded-lg bg-emerald-600 px-4 text-sm font-semibold text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('app:settings.backup_restore', { defaultValue: 'Wiederherstellen' })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{msg && (
|
||||
<p
|
||||
className={
|
||||
'mt-3 text-xs ' +
|
||||
(msg.kind === 'ok'
|
||||
? 'text-emerald-600 dark:text-emerald-300'
|
||||
: 'text-rose-600 dark:text-rose-300')
|
||||
}
|
||||
>
|
||||
{msg.text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScreenShareControls() {
|
||||
const { t } = useTranslation(['app']);
|
||||
const [cfg, setCfg] = useState<ScreenShareSettings>(() => getScreenShareSettings());
|
||||
@@ -339,7 +616,7 @@ function ScreenShareControls() {
|
||||
onChange={(e) =>
|
||||
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) => (
|
||||
<option key={p} value={p}>
|
||||
@@ -348,7 +625,7 @@ function ScreenShareControls() {
|
||||
))}
|
||||
</select>
|
||||
</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">
|
||||
<Stat label="Bitrate (max)" value={formatBitrate(params.bitrateKbps)} />
|
||||
<Stat
|
||||
@@ -358,7 +635,7 @@ function ScreenShareControls() {
|
||||
<Stat label="Framerate" value={params.framerate + ' fps'} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500">
|
||||
<p className="text-xs text-fg-muted">
|
||||
{t('app:settings.screen_share_hint', {
|
||||
defaultValue:
|
||||
'WebRTC passt Bitrate + Auflösung dynamisch an die Netzwerkqualität an (SVC/VP9). Die Werte sind Obergrenzen. Änderungen greifen beim nächsten Call.',
|
||||
@@ -371,8 +648,8 @@ function ScreenShareControls() {
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wide text-neutral-500">{label}</div>
|
||||
<div className="mt-0.5 font-mono text-neutral-200">{value}</div>
|
||||
<div className="text-[10px] uppercase tracking-[0.1em] text-fg-muted">{label}</div>
|
||||
<div className="mt-0.5 font-mono text-fg">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -386,8 +663,8 @@ function formatBitrate(kbps: number): string {
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="rounded-2xl border border-white/10 bg-ink-900/60 p-5 backdrop-blur-xl">
|
||||
<h2 className="mb-4 text-xs font-semibold uppercase tracking-wide text-neutral-400">
|
||||
<section className="rounded-2xl border border-line bg-surface-2 p-5">
|
||||
<h2 className="mb-4 text-xs font-semibold uppercase tracking-[0.1em] text-fg-muted">
|
||||
{title}
|
||||
</h2>
|
||||
<div className="space-y-3">{children}</div>
|
||||
@@ -398,10 +675,10 @@ function Section({ title, children }: { title: string; children: React.ReactNode
|
||||
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||
return (
|
||||
<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
|
||||
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' : '')
|
||||
}
|
||||
title={value}
|
||||
@@ -415,7 +692,7 @@ function Row({ label, value, mono }: { label: string; value: string; mono?: bool
|
||||
function SettingRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<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}
|
||||
</div>
|
||||
);
|
||||
@@ -437,8 +714,8 @@ function Toggle({
|
||||
return (
|
||||
<label className="flex cursor-pointer items-start justify-between gap-4">
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm text-neutral-200">{label}</span>
|
||||
{hint && <span className="mt-1 block text-xs text-neutral-500">{hint}</span>}
|
||||
<span className="block text-sm text-fg">{label}</span>
|
||||
{hint && <span className="mt-1 block text-xs text-fg-muted">{hint}</span>}
|
||||
</span>
|
||||
<span className="relative mt-0.5 inline-flex h-6 w-11 shrink-0">
|
||||
<input
|
||||
@@ -448,8 +725,8 @@ function Toggle({
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
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="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition peer-checked:translate-x-5" />
|
||||
<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 shadow transition peer-checked:translate-x-5" />
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
|
||||
@@ -4,11 +4,42 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Clean-Rail design tokens.
|
||||
*
|
||||
* `--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,
|
||||
body,
|
||||
#root {
|
||||
@@ -16,12 +47,12 @@
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-ink-950 text-neutral-100 font-sans antialiased;
|
||||
@apply bg-surface text-fg font-sans antialiased;
|
||||
font-feature-settings: 'cv11', 'ss01';
|
||||
}
|
||||
|
||||
::selection {
|
||||
@apply bg-brand-500/40 text-white;
|
||||
@apply bg-accent/40 text-white;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@@ -46,4 +77,15 @@
|
||||
background-size: 32px 32px;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: 'class',
|
||||
content: [
|
||||
'./index.html',
|
||||
'./src/**/*.{ts,tsx}',
|
||||
@@ -51,12 +52,28 @@ export default {
|
||||
800: '#312E81',
|
||||
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: {
|
||||
'blob-a': 'blob 22s ease-in-out infinite',
|
||||
'blob-b': 'blob 28s ease-in-out infinite reverse',
|
||||
'fade-in': 'fadeIn 400ms 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',
|
||||
},
|
||||
keyframes: {
|
||||
blob: {
|
||||
@@ -72,9 +89,34 @@ export default {
|
||||
from: { opacity: '0', transform: 'translateY(8px)' },
|
||||
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)' },
|
||||
},
|
||||
},
|
||||
boxShadow: {
|
||||
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)',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
<!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">
|
||||
|
||||
<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/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,314 @@
|
||||
# Handoff: ChatApp — Clean Rail Redesign + Discord-Style Call UI
|
||||
|
||||
## Overview
|
||||
|
||||
This package contains the full design spec for redesigning the existing ChatApp (Netralax) interface around a **Clean Rail** layout aesthetic, plus a complete **Discord-style call system** (incoming call, in-call dock, picture-in-picture, fullscreen cinema mode).
|
||||
|
||||
The redesign covers:
|
||||
1. The main chat interface (icon-rail sidebar + chat list + conversation area) in both **light** and **dark** mode.
|
||||
2. The call UX: incoming call overlay → active call docked above the chat (so users can keep typing while on a call) → fullscreen cinema mode → minimizable PiP bubble.
|
||||
|
||||
## 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.
|
||||
|
||||
The design files are organized as:
|
||||
- `ChatApp Redesign.html` — entry HTML that boots the prototype
|
||||
- `components/app.jsx` — main layout (rail + chat list + chat area, plus call state machine)
|
||||
- `components/call.jsx` — all call UI components (IncomingCallScreen, ActiveCallScreen, ParticipantTile, CallControls, PipCall)
|
||||
- `components/icons.jsx` — SVG icon set used
|
||||
- `components/data.jsx` — dummy chat/message data
|
||||
- `styles/base.css` — shared base tokens + layout primitives
|
||||
- `styles/variants.css` — `variant-clean` light + dark token definitions (this is the look you're targeting)
|
||||
- `styles/rail.css` — rail-layout specifics
|
||||
- `styles/call.css` — **all call UI styles** (the most important file for the call feature)
|
||||
- `screenshots/` — reference images of each state
|
||||
|
||||
## 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 (app background)
|
||||
--bg-2: #f4f4f2 (secondary surfaces, hover, search input)
|
||||
--bg-3: #ffffff (elevated surfaces — chat list, chat area, cards)
|
||||
--fg: #0a0a0f (primary text)
|
||||
--fg-muted: #737380 (secondary text, icons)
|
||||
--line: rgba(10,10,15,0.08) (dividers, borders)
|
||||
--accent: #4F46E5 (indigo — primary actions, active states, my-bubble)
|
||||
--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 (slightly lighter indigo for contrast)
|
||||
--accent-fg: #ffffff
|
||||
```
|
||||
|
||||
### Status / Semantic Colors (both modes)
|
||||
```
|
||||
Speaking/Online/Success: #16a34a light · #22c55e / #4ade80 dark
|
||||
Danger/Decline/Hangup: #dc2626 (hover #b91c1c) · #fb7185 dark accent
|
||||
Warning/Idle: #f59e0b
|
||||
Live/Recording: #ef4444
|
||||
E2EE indicator (light): #16a34a (dark: #4ade80)
|
||||
```
|
||||
|
||||
### Avatar color swatches (assigned per chat)
|
||||
```
|
||||
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` — Inter is the base. Display/headline use `'Outfit'` (participant name in IncomingCall).
|
||||
- Scale:
|
||||
- Chat title / participant name large: 24px / 700
|
||||
- Primary labels, chat name: 14–15px / 600
|
||||
- Body (bubbles, messages): 14px / 400–500
|
||||
- Meta, timestamps, presence: 11–12px / 400–500
|
||||
- Rail tooltips, badges: 10–11px / 500–600
|
||||
- Uppercase eyebrow (e.g. "EINGEHENDER ANRUF"): 12px / 600 / letter-spacing 0.12em
|
||||
|
||||
### Spacing
|
||||
- Container padding: 16px (call-stage), 14–20px (topbars, composer)
|
||||
- Tile gap in grid: 8–10px
|
||||
- Control-button row gap: 8–10px
|
||||
- Border radius scale: 4 (small), 8 (tiles/badges), 10 (buttons), 14 (cards, large buttons), 18 (bubbles, bottom-fs controls), 24 (modal/card)
|
||||
|
||||
### 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(22,163,74,0.3)` (green accept) / same pattern for accent
|
||||
- PiP glow: `0 12px 40px rgba(0,0,0,0.2), 0 0 0 3px rgba(79,70,229,0.15)`
|
||||
|
||||
### Motion
|
||||
- Standard UI transition: `all 0.15s`
|
||||
- Pulse ring (incoming call): `2s ease-out infinite`, scale 0.85→1.25, opacity 0.6→0
|
||||
- Audio pulse (speaking ring): `1.3s ease-out infinite`, scale 1→1.3
|
||||
- Live dot: `1.5s ease-in-out infinite`, opacity 1↔0.4
|
||||
- PiP entrance: `slideInCall 0.4s cubic-bezier(0.22,1,0.36,1)`
|
||||
- Fullscreen hint fade: 3.5s total (0–15% fade in, 75–100% fade out)
|
||||
|
||||
---
|
||||
|
||||
## Screens / Views
|
||||
|
||||
### 1. Main Chat — Clean Rail Layout
|
||||
|
||||
**Structure (left → right):**
|
||||
- **Icon Rail** (72px wide): brand logo at top, 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 wide): 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, fills rest): chat header (counterpart avatar + name + presence/handle + search/voice/video buttons), messages scroll area, composer at bottom.
|
||||
|
||||
**Chat header actions (right side):** search icon, phone icon, video icon. Phone triggers voice call (`callScreen → 'active'`, `callState.video = false`), Video triggers video call (`callState.video = true`).
|
||||
|
||||
**Message bubbles:**
|
||||
- Mine (outgoing): filled accent bg, white text, radius `18px 18px 4px 18px`.
|
||||
- Theirs (incoming): transparent bg, 1px line border, fg text, radius `18px 18px 18px 4px`.
|
||||
- Avatars appear only on the **last message of a run** from that sender.
|
||||
- Reactions: pill row below bubble, `.mine` = filled accent.
|
||||
- System events ("Kaiwandi hat einen Anruf gestartet — Anruf beitreten"): centered `event-pill`, muted bg, muted fg, 11px.
|
||||
|
||||
**Composer:** plus button (attach), rounded input bar with inline emoji + mic icons, send button.
|
||||
|
||||
---
|
||||
|
||||
### 2. Incoming Call Overlay
|
||||
|
||||
Full-bleed overlay replacing the chat area (takes over `.chat-area` space), radial-gradient background tinted with accent.
|
||||
|
||||
Centered card (420px wide):
|
||||
- Eyebrow "EINGEHENDER ANRUF" (uppercase, 12px, tracked)
|
||||
- Large avatar (96px) with **two pulsing rings** (staggered 1s)
|
||||
- Caller name (24px / 700 / Outfit)
|
||||
- Subtitle row: lock icon + "E2E verschlüsselt · Voice Call"
|
||||
- Two side-by-side action buttons:
|
||||
- **Ablehnen**: transparent, red border/text, hover = 8% red bg
|
||||
- **Annehmen**: green #16a34a, white, shadow
|
||||
|
||||
### 3. Active Call — Docked (Default)
|
||||
|
||||
**CRITICAL layout decision:** The active call does NOT take over the chat area. Instead it renders as a **dock above the messages**, taking roughly 50% of the chat-area height (min 280px, max 420px). The chat header is **hidden** while the dock is active (redundant — call topbar shows the channel name). Messages + composer remain fully visible and usable below, so users can keep chatting during the call. This is the Discord desktop pattern.
|
||||
|
||||
**Dock content:**
|
||||
- **Call topbar**: left side shows group icon + "Die Squad" + duration (tabular-nums), with E2E verschlüsselt below in success-green. Right side has 3 mode toggles: Grid / Focus / Fullscreen.
|
||||
- **Call stage**: fills the remaining space, renders either:
|
||||
- **Grid**: CSS grid, 2×2 for 4 people, 3×2 for 5–6, 1fr 1fr for 2.
|
||||
- **Focus**: main speaker tile takes most height, bottom strip with 110px-tall smaller tiles for others.
|
||||
- **Fullscreen**: switches to `mode === 'fullscreen'` — see screen 4.
|
||||
- **Call controls bar** at the bottom of the dock: Mic, Video, Screen-share, Participants, **Hangup** (wider, red). Compact 36px buttons when docked.
|
||||
|
||||
### 4. Active Call — Fullscreen Cinema Mode
|
||||
|
||||
When mode = `fullscreen`:
|
||||
- Entire call-dock container becomes absolute-positioned and takes over everything below the window bar (chat list still visible on the left).
|
||||
- The **call topbar is hidden** entirely.
|
||||
- Main speaker fills the whole frame, no border/radius.
|
||||
- Participants strip moves to a **floating column in the bottom-right** (`.fs-strip`): 180px wide, 100px tall tiles, glass-blurred bg.
|
||||
- **Call controls bar floats** at bottom center — glass pill: `rgba(10,10,15,0.7)` + `backdrop-filter: blur(20px)` + subtle border + 18px radius.
|
||||
- **"Esc zum Verlassen" hint** fades in top-center on entry and auto-fades out over 3.5s. Pressing Esc or clicking Grid/Focus buttons exits.
|
||||
- **No visible X button** (intentional — keyboard-only exit, confirmed design decision).
|
||||
|
||||
### 5. Participant Tile — Shared Component
|
||||
|
||||
A participant is either in **audio-only**, **video**, or **sharing screen** mode.
|
||||
- Tile border: 1px `var(--line)`, radius 14 (10 in dock). Hover → border becomes accent. `.speaking` → 2px green border + green glow shadow. `.focused` → accent border.
|
||||
- **Audio-only**: centered avatar (72px large, 44px small). When `speaking`, a green ring pulses around the avatar (`::before` pseudo).
|
||||
- **Video**: placeholder stub (gradient background + centered avatar, since real video not in scope here).
|
||||
- **Screenshare**: renders a "fake window" — chrome row with 3 dots, content row with grey skeleton lines + one accent-tinted block. A glass badge top-left: monitor icon + "Max teilt Bildschirm" (badge hidden on small tiles).
|
||||
- **Meta row** (absolute, bottom 8px, glass): left shows crown (if me) + name + lock (if E2EE), right shows muted mic icon (red chip) and/or sharing icon (green chip).
|
||||
|
||||
### 6. Picture-in-Picture (PiP)
|
||||
|
||||
A compact 260px pill docked bottom-right of the chat area:
|
||||
- 44px preview square (avatar of speaker, or mini shared-window placeholder if someone's sharing)
|
||||
- Title "Die Squad · {participantCount}"
|
||||
- Subtitle row: red pulsing dot + "Live · tippe zum Öffnen"
|
||||
- Circular red hangup button on the right
|
||||
- Whole thing is clickable → expands back to active call
|
||||
- Entrance animation: slide from bottom + fade in (`slideInCall`)
|
||||
- Shadow: standard + 3px accent glow ring
|
||||
|
||||
---
|
||||
|
||||
## Interactions & Behavior
|
||||
|
||||
### Call State Machine (lives in `ChatApp`)
|
||||
|
||||
```js
|
||||
// Primary state
|
||||
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 & Transitions
|
||||
- Chat-header **phone icon** click → `setCallScreen('active')`, `callState.video = false`
|
||||
- Chat-header **video icon** click → `setCallScreen('active')`, `callState.video = true`
|
||||
- Grid tile click → `setFocusedId(id); setCallMode('focus')`
|
||||
- Focus/Grid/Fullscreen buttons in call topbar → set mode directly
|
||||
- Incoming "Annehmen" → `setCallScreen('active')`
|
||||
- Incoming "Ablehnen" → `setCallScreen('none')`
|
||||
- Hangup → `setCallScreen('none')`
|
||||
- PiP clicked → `setCallScreen('active')`
|
||||
- PiP hangup (circle red btn) → `stopPropagation()` + `setCallScreen('none')`
|
||||
- **Esc key** (global listener while `callScreen === 'active' && callMode === 'fullscreen'`) → `setCallMode('grid')`
|
||||
|
||||
### Chat Header Visibility Rule
|
||||
```
|
||||
if (callScreen === 'active' && callMode !== 'fullscreen') → hide chat header
|
||||
else → show chat header
|
||||
```
|
||||
|
||||
### Keyboard
|
||||
- `Esc` exits fullscreen (only). No other global keybindings.
|
||||
|
||||
---
|
||||
|
||||
## State Management
|
||||
|
||||
Data shapes to wire up (see `components/call.jsx`):
|
||||
|
||||
```ts
|
||||
type Participant = {
|
||||
id: string;
|
||||
name: string;
|
||||
initial: string;
|
||||
color: 'violet' | 'amber' | 'rose' | 'teal';
|
||||
speaking: boolean;
|
||||
muted: boolean;
|
||||
video: boolean;
|
||||
sharing?: boolean;
|
||||
me?: boolean;
|
||||
e2ee: boolean;
|
||||
};
|
||||
|
||||
type CallState = {
|
||||
muted: boolean;
|
||||
sharing: boolean;
|
||||
video: boolean;
|
||||
duration: string; // "HH:MM:SS"
|
||||
};
|
||||
```
|
||||
|
||||
In production these all come from your real-time signaling layer (WebRTC, LiveKit, etc.). The prototype uses static data for layout purposes only — wire them to your call store.
|
||||
|
||||
---
|
||||
|
||||
## Assets
|
||||
|
||||
No custom imagery. Everything is SVG icons (inlined in `components/icons.jsx`) and CSS. Fonts from Google Fonts: `Inter`, `Outfit`.
|
||||
|
||||
Icons used in this scope: Chat, Friends, Shield, Settings, Logo, AddUser, Search, Phone, Video, Plus, Smile, Mic, MicOff, Send, Sun, Moon, Monitor, Lock, Crown, Grid, Focus, Maximize, PhoneOff.
|
||||
|
||||
Replace with your codebase's icon library if one exists (e.g. lucide-react, heroicons) — names match lucide conventions.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
1. **Tokens**: add the Clean-variant light + dark CSS variables to your theme layer. If you already have a token system, map them to your existing names; otherwise create `--bg`, `--bg-2`, `--bg-3`, `--fg`, `--fg-muted`, `--line`, `--accent`, `--accent-fg`.
|
||||
2. **Icon rail + chat list + chat area**: refactor the current top-nav layout into the 3-column rail layout. Use your existing `Avatar`, `ChatItem`, `Message` components — only restyle.
|
||||
3. **Message bubbles**: outlined `them` / filled `me`, asymmetric radius, avatars only on last-of-run.
|
||||
4. **Call state machine**: add the four `useState` hooks to whatever component owns the chat screen. Hook the Esc key effect.
|
||||
5. **IncomingCallScreen**: new component, full-bleed overlay of `.chat-area`.
|
||||
6. **Call dock**: new component, renders above `.messages`. Hide chat-header while docked.
|
||||
7. **ParticipantTile**: audio / video / sharing modes, speaking ring, meta overlay.
|
||||
8. **Fullscreen mode**: switch call container to position absolute, floating strip + floating controls + Esc hint.
|
||||
9. **PiP**: minimizable widget bottom-right of chat area.
|
||||
10. **Wire real data**: replace `CALL_PARTICIPANTS` dummy with your live call-session participants.
|
||||
|
||||
---
|
||||
|
||||
## Files in This Handoff
|
||||
|
||||
```
|
||||
design_handoff_chatapp/
|
||||
├── README.md (this file)
|
||||
├── ChatApp Redesign.html (entry — open in browser to see the prototype)
|
||||
├── components/
|
||||
│ ├── app.jsx (chat app + rail layout + call state machine)
|
||||
│ ├── call.jsx (Incoming / Active / Pip / ParticipantTile / CallControls)
|
||||
│ ├── icons.jsx (all SVG icons)
|
||||
│ └── data.jsx (dummy chats, messages)
|
||||
├── styles/
|
||||
│ ├── base.css
|
||||
│ ├── variants.css (Clean light + dark tokens — this is your theme)
|
||||
│ ├── rail.css (rail layout)
|
||||
│ └── call.css (all call UI — highest priority file)
|
||||
└── screenshots/
|
||||
├── 01-chat-light.png
|
||||
├── 03-chat-dark.png
|
||||
├── 04-incoming-dark.png
|
||||
├── 05-incall-grid-dark.png
|
||||
├── 06-incall-focus-dark.png
|
||||
├── 07-incall-fullscreen-dark.png
|
||||
├── 09-pip-dark.png
|
||||
└── 11-incall-light.png
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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), they were design exploration and not the chosen direction.
|
||||
- Prefer composition over copying raw CSS. If the codebase has a `Card`, `Button`, `Tooltip`, use them.
|
||||
- Duration ("00:12:47") should tick live from the call start timestamp, not be static.
|
||||
- For WebRTC/media handling: this package specifies **UI only**. The media/signaling layer is out of scope and already exists in the codebase (see `CallUI.tsx`, `InCallPanel.tsx`).
|
||||
@@ -0,0 +1,439 @@
|
||||
// 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">
|
||||
{/* 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 */}
|
||||
<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 (unchanged below) */}
|
||||
<div className="chat-area">
|
||||
{!(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">
|
||||
<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 */}
|
||||
{variant.layout === 'rail' && (
|
||||
<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,87 @@
|
||||
// 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>
|
||||
),
|
||||
};
|
||||
|
||||
Object.assign(window, { Icon });
|
||||
|
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 |
@@ -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,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; }
|
||||
@@ -0,0 +1,461 @@
|
||||
/* ================= VARIANT 1: CLEAN MINIMAL (Light) ================= */
|
||||
.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;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
.variant-clean .nav-brand { font-family: 'Outfit', 'Inter', sans-serif; font-weight: 700; letter-spacing: -0.02em; }
|
||||
|
||||
/* Dark mode for Clean */
|
||||
.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;
|
||||
}
|
||||
.variant-clean.dark .app-window { background: var(--bg-3); box-shadow: 0 30px 80px rgba(0,0,0,0.5), 0 0 0 1px var(--line); }
|
||||
.variant-clean.dark .avatar { background: #1A1A24; color: #DDE0FF; }
|
||||
.variant-clean.dark .avatar[data-color="violet"] { background: #312E81; color: #C4B5FD; }
|
||||
.variant-clean.dark .avatar[data-color="amber"] { background: #422e05; color: #fde68a; }
|
||||
.variant-clean.dark .avatar[data-color="rose"] { background: #4a1d29; color: #fecdd3; }
|
||||
.variant-clean.dark .avatar[data-color="teal"] { background: #134e4a; color: #99f6e4; }
|
||||
.variant-clean.dark .bubble.them { border-color: rgba(255,255,255,0.1); }
|
||||
.variant-clean.dark .bubble.me { background: var(--accent); color: #fff; }
|
||||
.variant-clean.dark .nav-tab.active { background: var(--accent); }
|
||||
.variant-clean.dark .reaction.mine { background: var(--accent); }
|
||||
.variant-clean.dark .composer-btn { background: var(--accent); }
|
||||
.variant-clean .app-window { background: var(--bg-3); box-shadow: 0 30px 80px rgba(0,0,0,0.08), 0 0 0 1px var(--line); }
|
||||
.variant-clean .window-bar { background: var(--bg-2); border-bottom: 1px solid var(--line); }
|
||||
.variant-clean .window-bar .title { color: var(--fg-muted); font-weight: 500; }
|
||||
.variant-clean .top-nav { border-bottom: 1px solid var(--line); background: var(--bg-3); }
|
||||
.variant-clean .nav-brand svg { color: #0a0a0a; }
|
||||
.variant-clean .nav-tab { color: var(--fg-muted); }
|
||||
.variant-clean .nav-tab:hover { background: var(--bg-2); color: var(--fg); }
|
||||
.variant-clean .nav-tab.active { background: var(--accent); color: var(--accent-fg); }
|
||||
.variant-clean .nav-user { border: 1px solid var(--line); }
|
||||
.variant-clean .chat-list { border-right: 1px solid var(--line); background: var(--bg-3); }
|
||||
.variant-clean .chat-list-header { border-bottom: 1px solid var(--line); }
|
||||
.variant-clean .chat-search { background: var(--bg-2); color: var(--fg); }
|
||||
.variant-clean .chat-item:hover { background: var(--bg-2); }
|
||||
.variant-clean .chat-item.active { background: var(--bg-2); }
|
||||
.variant-clean .avatar { background: #e5e5e5; color: #0a0a0a; }
|
||||
.variant-clean .avatar[data-color="violet"] { background: #ddd6fe; color: #5b21b6; }
|
||||
.variant-clean .avatar[data-color="amber"] { background: #fde68a; color: #78350f; }
|
||||
.variant-clean .avatar[data-color="rose"] { background: #fecdd3; color: #881337; }
|
||||
.variant-clean .avatar[data-color="teal"] { background: #99f6e4; color: #134e4a; }
|
||||
.variant-clean .chat-header { border-bottom: 1px solid var(--line); }
|
||||
.variant-clean .icon-btn { color: var(--fg-muted); }
|
||||
.variant-clean .icon-btn:hover { background: var(--bg-2); color: var(--fg); }
|
||||
.variant-clean .bubble.me { background: var(--accent); color: var(--accent-fg); border-radius: 18px 18px 4px 18px; }
|
||||
.variant-clean .bubble.them { background: transparent; color: var(--fg); border: 1px solid var(--line); border-radius: 18px 18px 18px 4px; }
|
||||
.variant-clean .reaction { background: var(--bg-2); border: 1px solid var(--line); }
|
||||
.variant-clean .reaction.mine { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
|
||||
.variant-clean .event-pill { background: var(--bg-2); color: var(--fg-muted); }
|
||||
.variant-clean .composer { border-top: 1px solid var(--line); }
|
||||
.variant-clean .composer-box { background: var(--bg-2); }
|
||||
.variant-clean .composer-btn { background: var(--accent); color: var(--accent-fg); }
|
||||
.variant-clean .presence { border-color: var(--bg-3); }
|
||||
|
||||
|
||||
/* ================= VARIANT 2: PLAYFUL DISCORD-VIBES ================= */
|
||||
.variant-playful {
|
||||
--bg: #1a1625;
|
||||
--bg-2: #2a2338;
|
||||
--bg-3: #221b30;
|
||||
--fg: #f5f3ff;
|
||||
--fg-muted: #9691b3;
|
||||
--accent: #a78bfa;
|
||||
--accent-2: #f472b6;
|
||||
--accent-3: #60a5fa;
|
||||
background: radial-gradient(ellipse at top left, #3d1a54 0%, #1a1625 50%);
|
||||
color: var(--fg);
|
||||
font-family: 'Plus Jakarta Sans', 'Inter', sans-serif;
|
||||
}
|
||||
.variant-playful .app-window { background: var(--bg-3); }
|
||||
.variant-playful .window-bar { background: rgba(0,0,0,0.25); }
|
||||
.variant-playful .window-bar .title { color: #fff; font-weight: 700; font-size: 13px; }
|
||||
.variant-playful .top-nav { background: linear-gradient(90deg, #2a1a48 0%, #1a1625 100%); border-bottom: 1px solid rgba(255,255,255,0.05); }
|
||||
.variant-playful .nav-brand { font-size: 18px; background: linear-gradient(135deg, #f472b6, #a78bfa); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.variant-playful .nav-tab { color: var(--fg-muted); font-weight: 600; }
|
||||
.variant-playful .nav-tab:hover { background: rgba(255,255,255,0.06); color: #fff; }
|
||||
.variant-playful .nav-tab.active { background: linear-gradient(135deg, #a78bfa, #f472b6); color: #fff; box-shadow: 0 4px 14px rgba(167,139,250,0.4); }
|
||||
.variant-playful .nav-user { background: rgba(255,255,255,0.05); }
|
||||
.variant-playful .chat-list { background: rgba(0,0,0,0.15); border-right: 1px solid rgba(255,255,255,0.04); }
|
||||
.variant-playful .chat-search { background: rgba(0,0,0,0.3); color: #fff; }
|
||||
.variant-playful .chat-item:hover { background: rgba(255,255,255,0.04); }
|
||||
.variant-playful .chat-item.active { background: linear-gradient(90deg, rgba(167,139,250,0.2), rgba(244,114,182,0.1)); }
|
||||
.variant-playful .avatar { background: linear-gradient(135deg, #a78bfa, #f472b6); color: #fff; font-weight: 800; }
|
||||
.variant-playful .avatar[data-color="teal"] { background: linear-gradient(135deg, #34d399, #60a5fa); }
|
||||
.variant-playful .avatar[data-color="amber"] { background: linear-gradient(135deg, #fbbf24, #f472b6); }
|
||||
.variant-playful .avatar[data-color="rose"] { background: linear-gradient(135deg, #fb7185, #a78bfa); }
|
||||
.variant-playful .avatar[data-color="violet"] { background: linear-gradient(135deg, #a78bfa, #60a5fa); }
|
||||
.variant-playful .chat-header { background: rgba(0,0,0,0.2); border-bottom: 1px solid rgba(255,255,255,0.05); }
|
||||
.variant-playful .icon-btn:hover { background: rgba(255,255,255,0.08); }
|
||||
.variant-playful .messages { background: radial-gradient(ellipse at bottom right, rgba(244,114,182,0.06), transparent 50%); }
|
||||
.variant-playful .bubble.me { background: linear-gradient(135deg, #a78bfa, #8b5cf6); color: #fff; border-radius: 20px 20px 4px 20px; box-shadow: 0 4px 14px rgba(167,139,250,0.25); }
|
||||
.variant-playful .bubble.them { background: rgba(255,255,255,0.06); color: #fff; border-radius: 20px 20px 20px 4px; backdrop-filter: blur(10px); }
|
||||
.variant-playful .reaction { background: rgba(255,255,255,0.08); }
|
||||
.variant-playful .reaction.mine { background: rgba(167,139,250,0.3); border: 1px solid #a78bfa; }
|
||||
.variant-playful .event-pill { background: rgba(52,211,153,0.15); color: #34d399; }
|
||||
.variant-playful .event-pill.rejected { background: rgba(251,113,133,0.15); color: #fb7185; }
|
||||
.variant-playful .composer { background: rgba(0,0,0,0.2); }
|
||||
.variant-playful .composer-box { background: rgba(255,255,255,0.06); }
|
||||
.variant-playful .composer-btn { background: linear-gradient(135deg, #a78bfa, #f472b6); color: #fff; box-shadow: 0 4px 14px rgba(167,139,250,0.4); }
|
||||
.variant-playful .presence { border-color: var(--bg-3); }
|
||||
|
||||
|
||||
/* ================= VARIANT 3: Y2K GLASSMORPHISM ================= */
|
||||
.variant-y2k {
|
||||
--bg: #0a0a1f;
|
||||
--fg: #f8fafc;
|
||||
--fg-muted: rgba(248,250,252,0.6);
|
||||
--accent: #c7d2fe;
|
||||
color: var(--fg);
|
||||
font-family: 'Space Grotesk', 'Inter', sans-serif;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 50% at 20% 20%, rgba(168,85,247,0.4), transparent),
|
||||
radial-gradient(ellipse 60% 40% at 80% 80%, rgba(59,130,246,0.35), transparent),
|
||||
radial-gradient(ellipse 70% 60% at 50% 50%, rgba(236,72,153,0.2), transparent),
|
||||
#0a0a1f;
|
||||
}
|
||||
.variant-y2k .app-window {
|
||||
background: rgba(255,255,255,0.04);
|
||||
backdrop-filter: blur(30px);
|
||||
-webkit-backdrop-filter: blur(30px);
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
box-shadow: 0 30px 80px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.15);
|
||||
}
|
||||
.variant-y2k .window-bar { background: rgba(255,255,255,0.04); border-bottom: 1px solid rgba(255,255,255,0.08); }
|
||||
.variant-y2k .window-bar .title { color: rgba(255,255,255,0.7); font-weight: 500; letter-spacing: 0.08em; font-size: 11px; text-transform: uppercase; }
|
||||
.variant-y2k .top-nav { background: rgba(255,255,255,0.02); border-bottom: 1px solid rgba(255,255,255,0.08); }
|
||||
.variant-y2k .nav-brand { font-weight: 700; letter-spacing: -0.03em; font-size: 18px; }
|
||||
.variant-y2k .nav-tab {
|
||||
color: var(--fg-muted);
|
||||
font-weight: 500;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.variant-y2k .nav-tab:hover { background: rgba(255,255,255,0.06); color: #fff; border-color: rgba(255,255,255,0.1); }
|
||||
.variant-y2k .nav-tab.active {
|
||||
background: rgba(255,255,255,0.15);
|
||||
color: #fff;
|
||||
border-color: rgba(255,255,255,0.25);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.2);
|
||||
}
|
||||
.variant-y2k .nav-user { background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1); }
|
||||
.variant-y2k .chat-list { background: rgba(255,255,255,0.02); border-right: 1px solid rgba(255,255,255,0.08); }
|
||||
.variant-y2k .chat-search { background: rgba(255,255,255,0.06); color: #fff; border: 1px solid rgba(255,255,255,0.08); }
|
||||
.variant-y2k .chat-item:hover { background: rgba(255,255,255,0.05); }
|
||||
.variant-y2k .chat-item.active {
|
||||
background: rgba(255,255,255,0.1);
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.1);
|
||||
}
|
||||
.variant-y2k .avatar {
|
||||
background: linear-gradient(135deg, #e0e7ff, #c7d2fe);
|
||||
color: #312e81;
|
||||
border: 1px solid rgba(255,255,255,0.3);
|
||||
}
|
||||
.variant-y2k .avatar[data-color="violet"] { background: linear-gradient(135deg, #e9d5ff, #c4b5fd); color: #4c1d95; }
|
||||
.variant-y2k .avatar[data-color="rose"] { background: linear-gradient(135deg, #fecdd3, #f9a8d4); color: #831843; }
|
||||
.variant-y2k .avatar[data-color="teal"] { background: linear-gradient(135deg, #a7f3d0, #7dd3fc); color: #064e3b; }
|
||||
.variant-y2k .avatar[data-color="amber"] { background: linear-gradient(135deg, #fde68a, #fdba74); color: #7c2d12; }
|
||||
.variant-y2k .chat-header { background: rgba(255,255,255,0.02); border-bottom: 1px solid rgba(255,255,255,0.08); }
|
||||
.variant-y2k .icon-btn:hover { background: rgba(255,255,255,0.08); }
|
||||
.variant-y2k .bubble.me {
|
||||
background: linear-gradient(135deg, rgba(199,210,254,0.9), rgba(196,181,253,0.8));
|
||||
color: #1e1b4b;
|
||||
border-radius: 20px 20px 6px 20px;
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255,255,255,0.3);
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.4), 0 4px 14px rgba(124,58,237,0.2);
|
||||
}
|
||||
.variant-y2k .bubble.them {
|
||||
background: rgba(255,255,255,0.08);
|
||||
color: #fff;
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
border-radius: 20px 20px 20px 6px;
|
||||
backdrop-filter: blur(20px);
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.1);
|
||||
}
|
||||
.variant-y2k .reaction { background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.1); backdrop-filter: blur(10px); }
|
||||
.variant-y2k .reaction.mine { background: rgba(199,210,254,0.25); border-color: rgba(199,210,254,0.5); }
|
||||
.variant-y2k .event-pill { background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.8); border: 1px solid rgba(255,255,255,0.1); backdrop-filter: blur(10px); }
|
||||
.variant-y2k .event-pill.rejected { background: rgba(251,113,133,0.15); color: #fda4af; border-color: rgba(251,113,133,0.3); }
|
||||
.variant-y2k .composer { background: rgba(255,255,255,0.02); border-top: 1px solid rgba(255,255,255,0.08); }
|
||||
.variant-y2k .composer-box { background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1); backdrop-filter: blur(10px); }
|
||||
.variant-y2k .composer-btn { background: linear-gradient(135deg, #e0e7ff, #c4b5fd); color: #312e81; border: 1px solid rgba(255,255,255,0.3); box-shadow: inset 0 1px 0 rgba(255,255,255,0.4); }
|
||||
.variant-y2k .presence { border-color: #0a0a1f; }
|
||||
|
||||
|
||||
/* ================= VARIANT 4: CYBERPUNK NEON ================= */
|
||||
.variant-cyber {
|
||||
--bg: #05050a;
|
||||
--fg: #e0e0e8;
|
||||
--fg-muted: #6b7280;
|
||||
--neon-cyan: #00f0ff;
|
||||
--neon-magenta: #ff00aa;
|
||||
--neon-lime: #b6ff3d;
|
||||
color: var(--fg);
|
||||
font-family: 'JetBrains Mono', 'Courier New', monospace;
|
||||
background:
|
||||
repeating-linear-gradient(0deg, transparent 0, transparent 2px, rgba(0,240,255,0.015) 2px, rgba(0,240,255,0.015) 4px),
|
||||
radial-gradient(ellipse at center, #0c0c18 0%, #05050a 70%);
|
||||
}
|
||||
.variant-cyber .app-window {
|
||||
background: #08080f;
|
||||
border: 1px solid rgba(0,240,255,0.2);
|
||||
box-shadow: 0 0 40px rgba(255,0,170,0.15), 0 0 80px rgba(0,240,255,0.08);
|
||||
}
|
||||
.variant-cyber .window-bar { background: #05050a; border-bottom: 1px solid rgba(0,240,255,0.15); }
|
||||
.variant-cyber .window-bar .title { color: var(--neon-cyan); font-size: 11px; letter-spacing: 0.15em; text-transform: uppercase; }
|
||||
.variant-cyber .window-bar .title::before { content: "// "; opacity: 0.5; }
|
||||
.variant-cyber .top-nav { background: #05050a; border-bottom: 1px solid rgba(0,240,255,0.15); }
|
||||
.variant-cyber .nav-brand { color: var(--neon-cyan); text-transform: uppercase; letter-spacing: 0.1em; font-size: 13px; text-shadow: 0 0 10px rgba(0,240,255,0.5); }
|
||||
.variant-cyber .nav-tab { color: var(--fg-muted); text-transform: uppercase; letter-spacing: 0.1em; font-size: 11px; font-weight: 500; border: 1px solid transparent; }
|
||||
.variant-cyber .nav-tab:hover { color: var(--neon-cyan); border-color: rgba(0,240,255,0.2); }
|
||||
.variant-cyber .nav-tab.active { color: var(--neon-lime); border-color: var(--neon-lime); background: rgba(182,255,61,0.05); box-shadow: 0 0 10px rgba(182,255,61,0.2); }
|
||||
.variant-cyber .nav-tab.active::before { content: "> "; }
|
||||
.variant-cyber .nav-user { border: 1px solid rgba(0,240,255,0.15); }
|
||||
.variant-cyber .chat-list { background: #05050a; border-right: 1px solid rgba(0,240,255,0.15); }
|
||||
.variant-cyber .chat-list-title { color: var(--neon-cyan); text-transform: uppercase; letter-spacing: 0.15em; font-size: 11px; }
|
||||
.variant-cyber .chat-list-title::before { content: "[ "; opacity: 0.5; }
|
||||
.variant-cyber .chat-list-title::after { content: " ]"; opacity: 0.5; }
|
||||
.variant-cyber .chat-search { background: #0c0c18; border: 1px solid rgba(0,240,255,0.15); color: var(--fg); }
|
||||
.variant-cyber .chat-item:hover { background: rgba(0,240,255,0.04); }
|
||||
.variant-cyber .chat-item.active { background: rgba(0,240,255,0.06); border-left: 2px solid var(--neon-cyan); padding-left: 10px; }
|
||||
.variant-cyber .chat-item-name { color: var(--fg); }
|
||||
.variant-cyber .avatar {
|
||||
background: #0c0c18;
|
||||
border: 1px solid var(--neon-cyan);
|
||||
color: var(--neon-cyan);
|
||||
border-radius: 4px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
text-shadow: 0 0 8px rgba(0,240,255,0.5);
|
||||
}
|
||||
.variant-cyber .avatar[data-color="violet"] { border-color: var(--neon-magenta); color: var(--neon-magenta); text-shadow: 0 0 8px rgba(255,0,170,0.5); }
|
||||
.variant-cyber .avatar[data-color="amber"] { border-color: var(--neon-lime); color: var(--neon-lime); text-shadow: 0 0 8px rgba(182,255,61,0.5); }
|
||||
.variant-cyber .avatar[data-color="rose"] { border-color: #ff5500; color: #ff5500; text-shadow: 0 0 8px rgba(255,85,0,0.5); }
|
||||
.variant-cyber .avatar[data-color="teal"] { border-color: var(--neon-cyan); color: var(--neon-cyan); }
|
||||
.variant-cyber .chat-header { background: #05050a; border-bottom: 1px solid rgba(0,240,255,0.15); }
|
||||
.variant-cyber .chat-header-name { color: var(--neon-cyan); font-family: 'JetBrains Mono', monospace; }
|
||||
.variant-cyber .chat-header-name::before { content: "@"; opacity: 0.5; }
|
||||
.variant-cyber .icon-btn { color: var(--neon-cyan); }
|
||||
.variant-cyber .icon-btn:hover { background: rgba(0,240,255,0.1); }
|
||||
.variant-cyber .bubble.me {
|
||||
background: transparent;
|
||||
color: var(--neon-magenta);
|
||||
border: 1px solid var(--neon-magenta);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0 15px rgba(255,0,170,0.15), inset 0 0 15px rgba(255,0,170,0.05);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
.variant-cyber .bubble.them {
|
||||
background: transparent;
|
||||
color: var(--neon-cyan);
|
||||
border: 1px solid var(--neon-cyan);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0 15px rgba(0,240,255,0.15), inset 0 0 15px rgba(0,240,255,0.05);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
.variant-cyber .bubble .ts { color: rgba(255,255,255,0.4); font-size: 9px; letter-spacing: 0.1em; }
|
||||
.variant-cyber .reaction { background: #0c0c18; border: 1px solid rgba(0,240,255,0.2); border-radius: 2px; color: var(--neon-lime); }
|
||||
.variant-cyber .reaction.mine { border-color: var(--neon-lime); box-shadow: 0 0 10px rgba(182,255,61,0.2); }
|
||||
.variant-cyber .event-pill { background: transparent; border: 1px dashed var(--neon-lime); color: var(--neon-lime); border-radius: 2px; text-transform: uppercase; letter-spacing: 0.1em; font-size: 10px; }
|
||||
.variant-cyber .event-pill.rejected { border-color: var(--neon-magenta); color: var(--neon-magenta); }
|
||||
.variant-cyber .composer { background: #05050a; border-top: 1px solid rgba(0,240,255,0.15); }
|
||||
.variant-cyber .composer-box { background: #0c0c18; border: 1px solid rgba(0,240,255,0.2); border-radius: 4px; }
|
||||
.variant-cyber .composer-box::before { content: ">"; color: var(--neon-lime); margin-right: 6px; font-weight: bold; }
|
||||
.variant-cyber .composer-btn { background: var(--neon-lime); color: #05050a; border-radius: 4px; box-shadow: 0 0 15px rgba(182,255,61,0.3); }
|
||||
.variant-cyber .presence { border-color: #08080f; }
|
||||
|
||||
|
||||
/* ================= VARIANT 5: WARM SOFT ================= */
|
||||
.variant-warm {
|
||||
--bg: #f7f2e9;
|
||||
--bg-2: #efe6d4;
|
||||
--bg-3: #fdfaf3;
|
||||
--fg: #3d2e1f;
|
||||
--fg-muted: #8a7560;
|
||||
--accent: #c75d3a;
|
||||
--accent-2: #e8a87c;
|
||||
--line: rgba(61,46,31,0.1);
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
.variant-warm .nav-brand { font-family: 'Fraunces', 'Playfair Display', serif; font-weight: 700; font-size: 20px; letter-spacing: -0.02em; }
|
||||
.variant-warm .chat-header-name { font-family: 'Fraunces', 'Playfair Display', serif; font-size: 17px; }
|
||||
.variant-warm .chat-list-title { font-family: 'Fraunces', serif; font-size: 16px; font-weight: 600; }
|
||||
.variant-warm .app-window { background: var(--bg-3); box-shadow: 0 20px 60px rgba(61,46,31,0.12); border: 1px solid var(--line); }
|
||||
.variant-warm .window-bar { background: var(--bg-2); border-bottom: 1px solid var(--line); }
|
||||
.variant-warm .window-bar .title { color: var(--fg-muted); font-weight: 500; }
|
||||
.variant-warm .top-nav { background: var(--bg-3); border-bottom: 1px solid var(--line); }
|
||||
.variant-warm .nav-tab { color: var(--fg-muted); font-weight: 500; border-radius: 999px; }
|
||||
.variant-warm .nav-tab:hover { background: var(--bg-2); color: var(--fg); }
|
||||
.variant-warm .nav-tab.active { background: var(--accent); color: #fff; }
|
||||
.variant-warm .nav-user { background: var(--bg-2); border-radius: 999px; }
|
||||
.variant-warm .chat-list { background: var(--bg-2); }
|
||||
.variant-warm .chat-search { background: var(--bg-3); border: 1px solid var(--line); color: var(--fg); border-radius: 999px; }
|
||||
.variant-warm .chat-item { border-radius: 14px; }
|
||||
.variant-warm .chat-item:hover { background: rgba(255,255,255,0.5); }
|
||||
.variant-warm .chat-item.active { background: var(--bg-3); box-shadow: 0 2px 8px rgba(61,46,31,0.06); }
|
||||
.variant-warm .avatar { background: #e8a87c; color: #7c3a1e; font-family: 'Fraunces', serif; font-weight: 700; }
|
||||
.variant-warm .avatar[data-color="violet"] { background: #bfa4c9; color: #4a2a5c; }
|
||||
.variant-warm .avatar[data-color="amber"] { background: #e8c87c; color: #5c3e1a; }
|
||||
.variant-warm .avatar[data-color="rose"] { background: #e8a4a4; color: #5c1e1e; }
|
||||
.variant-warm .avatar[data-color="teal"] { background: #a4c9bf; color: #1e4a3e; }
|
||||
.variant-warm .chat-header { background: var(--bg-3); border-bottom: 1px solid var(--line); }
|
||||
.variant-warm .icon-btn:hover { background: var(--bg-2); }
|
||||
.variant-warm .messages { background: var(--bg-3); }
|
||||
.variant-warm .bubble.me { background: var(--accent); color: #fff; border-radius: 20px 20px 6px 20px; }
|
||||
.variant-warm .bubble.them { background: var(--bg-2); color: var(--fg); border-radius: 20px 20px 20px 6px; }
|
||||
.variant-warm .reaction { background: var(--bg-2); border: 1px solid var(--line); }
|
||||
.variant-warm .reaction.mine { background: var(--accent); color: #fff; }
|
||||
.variant-warm .event-pill { background: var(--bg-2); color: var(--fg-muted); border: 1px solid var(--line); }
|
||||
.variant-warm .event-pill.rejected { background: rgba(199,93,58,0.1); color: var(--accent); }
|
||||
.variant-warm .composer { background: var(--bg-3); border-top: 1px solid var(--line); }
|
||||
.variant-warm .composer-box { background: var(--bg-2); border-radius: 999px; padding-left: 16px; }
|
||||
.variant-warm .composer-btn { background: var(--accent); color: #fff; border-radius: 50%; }
|
||||
.variant-warm .presence { border-color: var(--bg-3); }
|
||||
|
||||
|
||||
/* ================= VARIANT 6: BRUTAL GAMING ================= */
|
||||
.variant-brutal {
|
||||
--bg: #0d0d0d;
|
||||
--bg-2: #161616;
|
||||
--bg-3: #1a1a1a;
|
||||
--fg: #ffffff;
|
||||
--fg-muted: #808080;
|
||||
--lime: #d4ff3d;
|
||||
--line: #2a2a2a;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
.variant-brutal .nav-brand {
|
||||
font-family: 'Archivo Black', 'Inter', sans-serif;
|
||||
font-weight: 900;
|
||||
font-size: 22px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.variant-brutal .chat-header-name { font-weight: 800; font-size: 16px; text-transform: uppercase; letter-spacing: -0.01em; }
|
||||
.variant-brutal .chat-list-title { font-weight: 900; font-size: 18px; text-transform: uppercase; letter-spacing: -0.02em; }
|
||||
.variant-brutal .app-window { background: var(--bg); border: 2px solid var(--lime); border-radius: 0; box-shadow: 12px 12px 0 var(--lime); }
|
||||
.variant-brutal .window-bar { background: var(--bg); border-bottom: 2px solid var(--lime); }
|
||||
.variant-brutal .window-bar .title { color: var(--lime); font-weight: 800; text-transform: uppercase; letter-spacing: 0.1em; font-size: 11px; }
|
||||
.variant-brutal .top-nav { background: var(--bg); border-bottom: 2px solid var(--line); }
|
||||
.variant-brutal .nav-tab { color: var(--fg-muted); font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; font-size: 12px; border-radius: 0; border: 2px solid transparent; }
|
||||
.variant-brutal .nav-tab:hover { color: var(--fg); background: var(--bg-2); }
|
||||
.variant-brutal .nav-tab.active { background: var(--lime); color: #000; }
|
||||
.variant-brutal .nav-user { background: var(--bg-2); border: 2px solid var(--line); border-radius: 0; }
|
||||
.variant-brutal .chat-list { background: var(--bg); border-right: 2px solid var(--line); }
|
||||
.variant-brutal .chat-search { background: var(--bg-2); border: 2px solid var(--line); color: var(--fg); border-radius: 0; font-weight: 500; }
|
||||
.variant-brutal .chat-item { border-radius: 0; border-left: 4px solid transparent; }
|
||||
.variant-brutal .chat-item:hover { background: var(--bg-2); }
|
||||
.variant-brutal .chat-item.active { background: var(--bg-2); border-left-color: var(--lime); }
|
||||
.variant-brutal .chat-item-name { font-weight: 800; text-transform: uppercase; font-size: 13px; letter-spacing: -0.01em; }
|
||||
.variant-brutal .avatar { background: var(--lime); color: #000; border-radius: 0; font-weight: 900; font-family: 'Archivo Black', sans-serif; }
|
||||
.variant-brutal .avatar[data-color="violet"] { background: #a78bfa; }
|
||||
.variant-brutal .avatar[data-color="amber"] { background: #fbbf24; }
|
||||
.variant-brutal .avatar[data-color="rose"] { background: #fb7185; }
|
||||
.variant-brutal .avatar[data-color="teal"] { background: #2dd4bf; }
|
||||
.variant-brutal .chat-header { background: var(--bg); border-bottom: 2px solid var(--line); }
|
||||
.variant-brutal .icon-btn { border-radius: 0; border: 2px solid var(--line); }
|
||||
.variant-brutal .icon-btn:hover { background: var(--lime); color: #000; border-color: var(--lime); }
|
||||
.variant-brutal .bubble.me { background: var(--lime); color: #000; border-radius: 0; font-weight: 600; border: 2px solid #000; box-shadow: 4px 4px 0 #fff; }
|
||||
.variant-brutal .bubble.them { background: var(--fg); color: #000; border-radius: 0; font-weight: 600; border: 2px solid var(--fg); box-shadow: 4px 4px 0 var(--lime); }
|
||||
.variant-brutal .bubble .ts { color: rgba(0,0,0,0.6); font-weight: 700; font-size: 10px; text-transform: uppercase; }
|
||||
.variant-brutal .reaction { background: var(--bg-2); border: 2px solid var(--line); border-radius: 0; font-weight: 700; }
|
||||
.variant-brutal .reaction.mine { background: var(--lime); color: #000; border-color: var(--lime); }
|
||||
.variant-brutal .event-pill { background: var(--bg-2); border: 2px solid var(--line); border-radius: 0; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 800; font-size: 11px; }
|
||||
.variant-brutal .event-pill.rejected { border-color: #fb7185; color: #fb7185; }
|
||||
.variant-brutal .composer { background: var(--bg); border-top: 2px solid var(--line); }
|
||||
.variant-brutal .composer-box { background: var(--bg-2); border: 2px solid var(--line); border-radius: 0; }
|
||||
.variant-brutal .composer-btn { background: var(--lime); color: #000; border-radius: 0; font-weight: 900; }
|
||||
.variant-brutal .presence { border-color: var(--bg); border-width: 3px; }
|
||||
|
||||
/* Profile card & call overlay — inherit from variant */
|
||||
.variant-clean .profile-card { background: #fff; border: 1px solid var(--line); }
|
||||
.variant-clean .profile-card-banner { background: linear-gradient(135deg, #e5e5e5, #d4d4d4); }
|
||||
.variant-clean .profile-card-avatar { border-color: #fff; }
|
||||
.variant-clean .profile-card-meta { border-top-color: var(--line); color: var(--fg-muted); }
|
||||
.variant-clean .call-overlay { background: #fff; border: 1px solid var(--line); color: var(--fg); }
|
||||
|
||||
.variant-playful .profile-card { background: linear-gradient(180deg, #2a1a48, #1a1625); border: 1px solid rgba(255,255,255,0.08); }
|
||||
.variant-playful .profile-card-banner { background: linear-gradient(135deg, #a78bfa, #f472b6); }
|
||||
.variant-playful .profile-card-avatar { border-color: #2a1a48; }
|
||||
.variant-playful .call-overlay { background: linear-gradient(135deg, #2a1a48, #1a1625); border: 1px solid rgba(167,139,250,0.3); }
|
||||
|
||||
.variant-y2k .profile-card { background: rgba(255,255,255,0.08); backdrop-filter: blur(30px); border: 1px solid rgba(255,255,255,0.15); }
|
||||
.variant-y2k .profile-card-banner { background: linear-gradient(135deg, #c4b5fd, #f9a8d4); }
|
||||
.variant-y2k .profile-card-avatar { border-color: #1a1a2e; }
|
||||
.variant-y2k .call-overlay { background: rgba(255,255,255,0.08); backdrop-filter: blur(30px); border: 1px solid rgba(255,255,255,0.15); }
|
||||
|
||||
.variant-cyber .profile-card { background: #0c0c18; border: 1px solid var(--neon-cyan); box-shadow: 0 0 30px rgba(0,240,255,0.2); }
|
||||
.variant-cyber .profile-card-banner { background: repeating-linear-gradient(90deg, #00f0ff, #00f0ff 2px, transparent 2px, transparent 8px), linear-gradient(135deg, #ff00aa 0%, #00f0ff 100%); }
|
||||
.variant-cyber .profile-card-avatar { border-color: #0c0c18; border-radius: 4px; }
|
||||
.variant-cyber .call-overlay { background: #0c0c18; border: 1px solid var(--neon-lime); box-shadow: 0 0 30px rgba(182,255,61,0.2); }
|
||||
|
||||
.variant-warm .profile-card { background: var(--bg-3); border: 1px solid var(--line); color: var(--fg); }
|
||||
.variant-warm .profile-card-banner { background: linear-gradient(135deg, #e8a87c, #c75d3a); }
|
||||
.variant-warm .profile-card-avatar { border-color: var(--bg-3); }
|
||||
.variant-warm .profile-card-meta { border-top-color: var(--line); }
|
||||
.variant-warm .call-overlay { background: var(--bg-3); border: 1px solid var(--line); color: var(--fg); }
|
||||
|
||||
.variant-brutal .profile-card { background: var(--bg); border: 2px solid var(--lime); border-radius: 0; box-shadow: 8px 8px 0 var(--lime); }
|
||||
.variant-brutal .profile-card-banner { background: var(--lime); }
|
||||
.variant-brutal .profile-card-avatar { border-color: var(--bg); border-radius: 0; border-width: 3px; }
|
||||
.variant-brutal .profile-card-meta { border-top-color: var(--line); }
|
||||
.variant-brutal .call-overlay { background: var(--bg); border: 2px solid var(--lime); border-radius: 0; box-shadow: 6px 6px 0 var(--lime); }
|
||||
|
||||
|
||||
/* Typing indicator */
|
||||
.typing { display: inline-flex; gap: 3px; padding: 4px 2px; }
|
||||
.typing span { width: 6px; height: 6px; border-radius: 50%; background: currentColor; opacity: 0.4; animation: typingDot 1.3s infinite; }
|
||||
.typing span:nth-child(2) { animation-delay: 0.15s; }
|
||||
.typing span:nth-child(3) { animation-delay: 0.3s; }
|
||||
@keyframes typingDot {
|
||||
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
|
||||
30% { transform: translateY(-4px); opacity: 1; }
|
||||
}
|
||||
|
||||
/* Tweaks panel */
|
||||
.tweaks-panel {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
width: 280px;
|
||||
background: rgba(20,20,24,0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
z-index: 200;
|
||||
color: #fff;
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
box-shadow: 0 20px 50px rgba(0,0,0,0.5);
|
||||
display: none;
|
||||
}
|
||||
.tweaks-panel.open { display: block; }
|
||||
.tweaks-panel h3 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.1em; opacity: 0.7; margin-bottom: 14px; font-weight: 600; }
|
||||
.tweaks-row { margin-bottom: 12px; }
|
||||
.tweaks-row label { display: block; font-size: 11px; opacity: 0.6; margin-bottom: 6px; }
|
||||
.tweaks-row input[type="color"] { width: 100%; height: 34px; border-radius: 8px; background: transparent; border: 1px solid rgba(255,255,255,0.1); cursor: pointer; }
|
||||
.tweaks-row input[type="range"] { width: 100%; }
|
||||
.tweaks-row select { width: 100%; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); color: #fff; padding: 8px; border-radius: 8px; font-size: 12px; }
|
||||
@@ -141,6 +141,17 @@ export async function forgetDevicePrivateKey(
|
||||
await secretStore.removeSecret(privateKeySecretName(userId, deviceId));
|
||||
}
|
||||
|
||||
// Writes a device private key into the secret store. Used by the
|
||||
// backup-restore flow to re-import a key generated on another machine.
|
||||
export async function saveDevicePrivateKey(
|
||||
secretStore: SecretStore,
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
privateKey: Uint8Array,
|
||||
): Promise<void> {
|
||||
await secretStore.setSecret(privateKeySecretName(userId, deviceId), privateKey);
|
||||
}
|
||||
|
||||
// Lightweight helpers for platforms that want to cache their current device id
|
||||
// in JSON storage (separate from the secret store, which only holds raw bytes).
|
||||
export const DEVICE_ID_STORAGE_KEY_PREFIX = 'chatapp.device_id';
|
||||
|
||||
@@ -86,7 +86,11 @@ export async function completeSessionFromUrl(
|
||||
}
|
||||
|
||||
export async function signOut(client: AppSupabaseClient): Promise<void> {
|
||||
const { error } = await client.auth.signOut();
|
||||
// `scope: 'local'` only ends the session in THIS client. Without it Supabase
|
||||
// defaults to 'global', which invalidates the user's refresh tokens
|
||||
// everywhere — meaning a logout in the browser would also kick the desktop
|
||||
// app (and vice versa) the next time it tries to refresh its token.
|
||||
const { error } = await client.auth.signOut({ scope: 'local' });
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
import {
|
||||
decryptWithConvKey,
|
||||
encryptWithConvKey,
|
||||
generateConvKey,
|
||||
unwrapConvKey,
|
||||
wrapConvKeyForRecipient,
|
||||
} from '../crypto/sessionKeys.js';
|
||||
import { bytesToPgHex, pgHexToBytes } from '../supabase/bytea.js';
|
||||
import type { AppSupabaseClient } from '../supabase/client.js';
|
||||
|
||||
// db-types in this monorepo is a static snapshot generated against the older
|
||||
// schema. The new `conversation_keys` table + `active_key_version` column on
|
||||
// `conversations` aren't in there yet. Until the codegen catches up we bypass
|
||||
// the typed builder for those calls.
|
||||
function rawFrom(client: AppSupabaseClient, table: string) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (client as unknown as { from: (t: string) => any }).from(table);
|
||||
}
|
||||
|
||||
// Per-conversation symmetric key management. Replaces per-device envelopes
|
||||
// with a single conv-key (32-byte XSalsa20-Poly1305) wrapped to each device's
|
||||
// X25519 pubkey via crypto_box.
|
||||
|
||||
interface DeviceKey {
|
||||
deviceId: string;
|
||||
userId: string;
|
||||
publicKey: Uint8Array;
|
||||
}
|
||||
|
||||
export interface OwnDeviceCtx {
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
privateKey: Uint8Array;
|
||||
}
|
||||
|
||||
export interface ConvKeyHandle {
|
||||
conversationId: string;
|
||||
keyVersion: number;
|
||||
key: Uint8Array;
|
||||
}
|
||||
|
||||
// In-process cache to avoid re-fetching + re-unwrapping every send/decrypt.
|
||||
const cache = new Map<string, ConvKeyHandle>();
|
||||
const cacheKey = (convId: string, version: number) => convId + '@' + version;
|
||||
|
||||
export function clearConvKeyCache(): void {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
async function listDeviceKeys(
|
||||
client: AppSupabaseClient,
|
||||
conversationId: string,
|
||||
): Promise<DeviceKey[]> {
|
||||
const { data: members, error: mErr } = await client
|
||||
.from('conversation_members')
|
||||
.select('user_id, accepted')
|
||||
.eq('conversation_id', conversationId);
|
||||
if (mErr) throw mErr;
|
||||
const memberIds = (members ?? []).filter((m) => m.accepted).map((m) => m.user_id);
|
||||
if (memberIds.length === 0) return [];
|
||||
|
||||
const { data: devices, error: dErr } = await client
|
||||
.from('devices')
|
||||
.select('id, user_id, public_key')
|
||||
.in('user_id', memberIds);
|
||||
if (dErr) throw dErr;
|
||||
|
||||
return (devices ?? []).map((d) => ({
|
||||
deviceId: d.id,
|
||||
userId: d.user_id,
|
||||
publicKey: pgHexToBytes(d.public_key),
|
||||
}));
|
||||
}
|
||||
|
||||
async function fetchActiveKeyVersion(
|
||||
client: AppSupabaseClient,
|
||||
conversationId: string,
|
||||
): Promise<number> {
|
||||
const { data, error } = await rawFrom(client, 'conversations')
|
||||
.select('active_key_version')
|
||||
.eq('id', conversationId)
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return (data as { active_key_version: number }).active_key_version;
|
||||
}
|
||||
|
||||
interface SenderInfo {
|
||||
senderDeviceId: string;
|
||||
senderPublicKey: Uint8Array;
|
||||
}
|
||||
|
||||
async function fetchKeyBundle(
|
||||
client: AppSupabaseClient,
|
||||
conversationId: string,
|
||||
ownDeviceId: string,
|
||||
keyVersion: number,
|
||||
): Promise<{ encryptedKey: Uint8Array; nonce: Uint8Array; sender: SenderInfo } | null> {
|
||||
const { data, error } = await rawFrom(client, 'conversation_keys')
|
||||
.select('encrypted_key, nonce, sender_device_id')
|
||||
.eq('conversation_id', conversationId)
|
||||
.eq('recipient_device_id', ownDeviceId)
|
||||
.eq('key_version', keyVersion)
|
||||
.maybeSingle();
|
||||
if (error) throw error;
|
||||
if (!data) return null;
|
||||
|
||||
const row = data as {
|
||||
encrypted_key: string;
|
||||
nonce: string;
|
||||
sender_device_id: string;
|
||||
};
|
||||
const { data: dev, error: dErr } = await client
|
||||
.from('devices')
|
||||
.select('id, public_key')
|
||||
.eq('id', row.sender_device_id)
|
||||
.single();
|
||||
if (dErr) throw dErr;
|
||||
|
||||
return {
|
||||
encryptedKey: pgHexToBytes(row.encrypted_key),
|
||||
nonce: pgHexToBytes(row.nonce),
|
||||
sender: {
|
||||
senderDeviceId: dev.id,
|
||||
senderPublicKey: pgHexToBytes(dev.public_key),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Strips the leading `\x` postgres bytea hex prefix so the RPC's
|
||||
// `decode(text, 'hex')` accepts it.
|
||||
function hexNoPrefix(bytes: Uint8Array): string {
|
||||
return bytesToPgHex(bytes).slice(2);
|
||||
}
|
||||
|
||||
// Bootstraps a brand-new conv-key, wrapping it for every member device that
|
||||
// currently exists (including the caller's own devices). Used the first time
|
||||
// a conversation needs a key, or when rotation is requested. All inserts go
|
||||
// through `share_conv_keys` (SECURITY DEFINER) — silently skips invalid
|
||||
// recipients, no per-row 403 console spam.
|
||||
export async function bootstrapConvKey(
|
||||
client: AppSupabaseClient,
|
||||
conversationId: string,
|
||||
own: OwnDeviceCtx,
|
||||
keyVersion: number,
|
||||
): Promise<ConvKeyHandle> {
|
||||
const convKey = generateConvKey();
|
||||
const recipients = await listDeviceKeys(client, conversationId);
|
||||
if (recipients.length === 0) {
|
||||
throw new Error('cannot bootstrap conv key — no recipient devices');
|
||||
}
|
||||
|
||||
const bundles: Array<{ recipient_device_id: string; encrypted_key: string; nonce: string }> = [];
|
||||
for (const r of recipients) {
|
||||
const wrapped = await wrapConvKeyForRecipient(convKey, r.publicKey, own.privateKey);
|
||||
bundles.push({
|
||||
recipient_device_id: r.deviceId,
|
||||
encrypted_key: hexNoPrefix(wrapped.ciphertext),
|
||||
nonce: hexNoPrefix(wrapped.nonce),
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const rpc = (client as unknown as { rpc: (n: string, p: object) => Promise<{ error: any }> }).rpc;
|
||||
const { error } = await rpc.call(client, 'share_conv_keys', {
|
||||
p_conv_id: conversationId,
|
||||
p_sender_device_id: own.deviceId,
|
||||
p_key_version: keyVersion,
|
||||
p_bundles: bundles,
|
||||
});
|
||||
if (error) throw error;
|
||||
|
||||
const handle = { conversationId, keyVersion, key: convKey };
|
||||
cache.set(cacheKey(conversationId, keyVersion), handle);
|
||||
return handle;
|
||||
}
|
||||
|
||||
// Resolves the current conv-key for `conversationId`. Order:
|
||||
// 1) cache hit
|
||||
// 2) DB row for own device → unwrap
|
||||
// 3) bootstrap a brand-new key (only valid path if NO existing keys exist
|
||||
// for any device — i.e. this is the conversation's very first message)
|
||||
export async function getOrCreateConvKey(
|
||||
client: AppSupabaseClient,
|
||||
conversationId: string,
|
||||
own: OwnDeviceCtx,
|
||||
): Promise<ConvKeyHandle> {
|
||||
const version = await fetchActiveKeyVersion(client, conversationId);
|
||||
const cached = cache.get(cacheKey(conversationId, version));
|
||||
if (cached) return cached;
|
||||
|
||||
const bundle = await fetchKeyBundle(client, conversationId, own.deviceId, version);
|
||||
if (bundle) {
|
||||
const key = await unwrapConvKey(
|
||||
bundle.encryptedKey,
|
||||
bundle.nonce,
|
||||
bundle.sender.senderPublicKey,
|
||||
own.privateKey,
|
||||
);
|
||||
const handle = { conversationId, keyVersion: version, key };
|
||||
cache.set(cacheKey(conversationId, version), handle);
|
||||
return handle;
|
||||
}
|
||||
|
||||
// No bundle yet for THIS device. Two cases:
|
||||
// - I'm the first ever sender → bootstrap.
|
||||
// - Conversation already has keys but my device wasn't included yet → I
|
||||
// have to wait until an existing device wraps the key for me.
|
||||
const { count, error: cntErr } = await rawFrom(client, 'conversation_keys')
|
||||
.select('recipient_device_id', { count: 'exact', head: true })
|
||||
.eq('conversation_id', conversationId)
|
||||
.eq('key_version', version);
|
||||
if (cntErr) throw cntErr;
|
||||
|
||||
if ((count ?? 0) > 0) {
|
||||
throw new Error(
|
||||
'Awaiting conversation key — another device must share it with this device.',
|
||||
);
|
||||
}
|
||||
return bootstrapConvKey(client, conversationId, own, version);
|
||||
}
|
||||
|
||||
// Read-only variant: never bootstraps. Returns null if no key bundle exists
|
||||
// for this device yet.
|
||||
export async function tryGetConvKey(
|
||||
client: AppSupabaseClient,
|
||||
conversationId: string,
|
||||
ownDeviceId: string,
|
||||
ownPrivateKey: Uint8Array,
|
||||
keyVersion: number,
|
||||
): Promise<ConvKeyHandle | null> {
|
||||
const cached = cache.get(cacheKey(conversationId, keyVersion));
|
||||
if (cached) return cached;
|
||||
|
||||
const bundle = await fetchKeyBundle(client, conversationId, ownDeviceId, keyVersion);
|
||||
if (!bundle) return null;
|
||||
const key = await unwrapConvKey(
|
||||
bundle.encryptedKey,
|
||||
bundle.nonce,
|
||||
bundle.sender.senderPublicKey,
|
||||
ownPrivateKey,
|
||||
);
|
||||
const handle = { conversationId, keyVersion, key };
|
||||
cache.set(cacheKey(conversationId, keyVersion), handle);
|
||||
return handle;
|
||||
}
|
||||
|
||||
// Wraps the active conv-key for a single new device (e.g. when a peer
|
||||
// registers a new device). The caller's device must have an unwrapped copy
|
||||
// of the conv-key in cache (or be able to fetch it).
|
||||
export async function shareConvKeyToDevice(
|
||||
client: AppSupabaseClient,
|
||||
conversationId: string,
|
||||
recipientDeviceId: string,
|
||||
recipientPublicKey: Uint8Array,
|
||||
own: OwnDeviceCtx,
|
||||
): Promise<void> {
|
||||
const version = await fetchActiveKeyVersion(client, conversationId);
|
||||
const handle =
|
||||
cache.get(cacheKey(conversationId, version)) ??
|
||||
(await tryGetConvKey(client, conversationId, own.deviceId, own.privateKey, version));
|
||||
if (!handle) {
|
||||
throw new Error('cannot share conv key — own device does not have it yet');
|
||||
}
|
||||
|
||||
const wrapped = await wrapConvKeyForRecipient(
|
||||
handle.key,
|
||||
recipientPublicKey,
|
||||
own.privateKey,
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const rpc = (client as unknown as { rpc: (n: string, p: object) => Promise<{ error: any }> }).rpc;
|
||||
const { error } = await rpc.call(client, 'share_conv_keys', {
|
||||
p_conv_id: conversationId,
|
||||
p_sender_device_id: own.deviceId,
|
||||
p_key_version: version,
|
||||
p_bundles: [
|
||||
{
|
||||
recipient_device_id: recipientDeviceId,
|
||||
encrypted_key: hexNoPrefix(wrapped.ciphertext),
|
||||
nonce: hexNoPrefix(wrapped.nonce),
|
||||
},
|
||||
],
|
||||
});
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
// Re-exports for convenience.
|
||||
export { decryptWithConvKey, encryptWithConvKey };
|
||||
@@ -2,6 +2,7 @@ import type { AppSupabaseClient } from '../supabase/client.js';
|
||||
|
||||
export * from './attachments.js';
|
||||
export * from './conversations.js';
|
||||
export * from './convKeys.js';
|
||||
export * from './groups.js';
|
||||
export * from './messages.js';
|
||||
export * from './types.js';
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import {
|
||||
bytesToUtf8,
|
||||
decryptFrom,
|
||||
encryptFor,
|
||||
utf8ToBytes,
|
||||
} from '../crypto/index.js';
|
||||
import { bytesToUtf8, utf8ToBytes } from '../crypto/index.js';
|
||||
import { bytesToPgHex, pgHexToBytes } from '../supabase/bytea.js';
|
||||
import type { AppSupabaseClient } from '../supabase/client.js';
|
||||
import {
|
||||
decryptWithConvKey,
|
||||
encryptWithConvKey,
|
||||
getOrCreateConvKey,
|
||||
type OwnDeviceCtx,
|
||||
tryGetConvKey,
|
||||
} from './convKeys.js';
|
||||
import type { ChatMessage, DecryptedMessage } from './types.js';
|
||||
|
||||
const MESSAGE_COLS =
|
||||
'id, conversation_id, sender_id, sender_device_id, reply_to_id, edited_at, deleted_at, created_at';
|
||||
'id, conversation_id, sender_id, sender_device_id, reply_to_id, edited_at, deleted_at, created_at, ciphertext, nonce, key_version';
|
||||
|
||||
interface MessageRow {
|
||||
id: string;
|
||||
@@ -20,9 +22,18 @@ interface MessageRow {
|
||||
edited_at: string | null;
|
||||
deleted_at: string | null;
|
||||
created_at: string;
|
||||
ciphertext: string;
|
||||
nonce: string;
|
||||
key_version: number;
|
||||
}
|
||||
|
||||
function mapMessage(row: MessageRow): ChatMessage {
|
||||
interface MessageWithCipher extends ChatMessage {
|
||||
ciphertext: Uint8Array;
|
||||
nonce: Uint8Array;
|
||||
keyVersion: number;
|
||||
}
|
||||
|
||||
function mapMessage(row: MessageRow): MessageWithCipher {
|
||||
return {
|
||||
id: row.id,
|
||||
conversationId: row.conversation_id,
|
||||
@@ -32,6 +43,9 @@ function mapMessage(row: MessageRow): ChatMessage {
|
||||
editedAt: row.edited_at,
|
||||
deletedAt: row.deleted_at,
|
||||
createdAt: row.created_at,
|
||||
ciphertext: pgHexToBytes(row.ciphertext),
|
||||
nonce: pgHexToBytes(row.nonce),
|
||||
keyVersion: row.key_version,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -85,14 +99,17 @@ export interface SendMessageParams {
|
||||
attachmentHandles?: import('./attachments.js').AttachmentHandle[];
|
||||
}
|
||||
|
||||
// Encrypts and inserts a message + per-device envelopes (one per recipient
|
||||
// device, including the sender's own devices so multi-device sender devices
|
||||
// can decrypt their own outbox).
|
||||
// Encrypts and inserts a message using the shared per-conversation key
|
||||
// (Sender-Key / Signal-style). The conv-key is generated lazily on first
|
||||
// send and shared with every existing recipient device. New devices that
|
||||
// register later receive their key bundle through `shareConvKeyToDevice`.
|
||||
export async function sendEncryptedMessage(params: SendMessageParams): Promise<ChatMessage> {
|
||||
const deviceKeys = await listConversationDeviceKeys(params.client, params.conversationId);
|
||||
if (deviceKeys.length === 0) {
|
||||
throw new Error('no recipient devices found');
|
||||
}
|
||||
const ownCtx: OwnDeviceCtx = {
|
||||
userId: params.senderUserId,
|
||||
deviceId: params.senderDeviceId,
|
||||
privateKey: params.senderPrivateKey,
|
||||
};
|
||||
const handle = await getOrCreateConvKey(params.client, params.conversationId, ownCtx);
|
||||
|
||||
const attachments = params.attachmentHandles ?? [];
|
||||
const payloadString =
|
||||
@@ -101,11 +118,15 @@ export async function sendEncryptedMessage(params: SendMessageParams): Promise<C
|
||||
: JSON.stringify({ v: 1, text: params.plaintext, attachments });
|
||||
const plainBytes = utf8ToBytes(payloadString);
|
||||
|
||||
// Insert the message metadata first.
|
||||
const cipher = encryptWithConvKey(plainBytes, handle.key);
|
||||
|
||||
const insertPayload: Record<string, unknown> = {
|
||||
conversation_id: params.conversationId,
|
||||
sender_id: params.senderUserId,
|
||||
sender_device_id: params.senderDeviceId,
|
||||
ciphertext: bytesToPgHex(cipher.ciphertext),
|
||||
nonce: bytesToPgHex(cipher.nonce),
|
||||
key_version: handle.keyVersion,
|
||||
};
|
||||
if (params.replyToId) insertPayload.reply_to_id = params.replyToId;
|
||||
|
||||
@@ -115,30 +136,7 @@ export async function sendEncryptedMessage(params: SendMessageParams): Promise<C
|
||||
.select(MESSAGE_COLS)
|
||||
.single();
|
||||
if (insertErr) throw insertErr;
|
||||
const msg = mapMessage(messageRow as unknown as MessageRow);
|
||||
|
||||
// Encrypt one envelope per recipient device (including own devices).
|
||||
const envelopes: { message_id: string; recipient_device_id: string; ciphertext: string; nonce: string }[] = [];
|
||||
for (const dk of deviceKeys) {
|
||||
const { ciphertext, nonce } = await encryptFor(plainBytes, dk.publicKey, params.senderPrivateKey);
|
||||
envelopes.push({
|
||||
message_id: msg.id,
|
||||
recipient_device_id: dk.deviceId,
|
||||
ciphertext: bytesToPgHex(ciphertext),
|
||||
nonce: bytesToPgHex(nonce),
|
||||
});
|
||||
}
|
||||
|
||||
const { error: envErr } = await params.client
|
||||
.from('message_envelopes')
|
||||
.insert(envelopes as never);
|
||||
if (envErr) {
|
||||
// Best-effort cleanup if envelope insert failed.
|
||||
await params.client.from('messages').delete().eq('id', msg.id);
|
||||
throw envErr;
|
||||
}
|
||||
|
||||
return msg;
|
||||
return mapMessage(messageRow as unknown as MessageRow);
|
||||
}
|
||||
|
||||
// Fetch the last `limit` messages of a conversation in ascending order.
|
||||
@@ -146,7 +144,7 @@ export async function fetchConversationMessages(
|
||||
client: AppSupabaseClient,
|
||||
conversationId: string,
|
||||
limit = 100,
|
||||
): Promise<ChatMessage[]> {
|
||||
): Promise<MessageWithCipher[]> {
|
||||
const { data, error } = await client
|
||||
.from('messages')
|
||||
.select(MESSAGE_COLS)
|
||||
@@ -158,47 +156,7 @@ export async function fetchConversationMessages(
|
||||
return rows.map(mapMessage).reverse();
|
||||
}
|
||||
|
||||
// Pull envelopes targeted at our own device for a batch of message ids.
|
||||
export async function fetchOwnEnvelopes(
|
||||
client: AppSupabaseClient,
|
||||
messageIds: string[],
|
||||
ownDeviceId: string,
|
||||
): Promise<Map<string, { ciphertext: Uint8Array; nonce: Uint8Array }>> {
|
||||
if (messageIds.length === 0) return new Map();
|
||||
const { data, error } = await client
|
||||
.from('message_envelopes')
|
||||
.select('message_id, ciphertext, nonce')
|
||||
.in('message_id', messageIds)
|
||||
.eq('recipient_device_id', ownDeviceId);
|
||||
if (error) throw error;
|
||||
const out = new Map<string, { ciphertext: Uint8Array; nonce: Uint8Array }>();
|
||||
for (const row of data ?? []) {
|
||||
out.set(row.message_id, {
|
||||
ciphertext: pgHexToBytes(row.ciphertext),
|
||||
nonce: pgHexToBytes(row.nonce),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Map sender device id -> public key (for verifying envelope authenticity).
|
||||
export async function fetchSenderDeviceKeys(
|
||||
client: AppSupabaseClient,
|
||||
deviceIds: string[],
|
||||
): Promise<Map<string, Uint8Array>> {
|
||||
if (deviceIds.length === 0) return new Map();
|
||||
const unique = Array.from(new Set(deviceIds));
|
||||
const { data, error } = await client
|
||||
.from('devices')
|
||||
.select('id, public_key')
|
||||
.in('id', unique);
|
||||
if (error) throw error;
|
||||
const out = new Map<string, Uint8Array>();
|
||||
for (const row of data ?? []) {
|
||||
out.set(row.id, pgHexToBytes(row.public_key));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
export type { MessageWithCipher };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edit + delete
|
||||
@@ -212,42 +170,30 @@ export interface EditMessageParams {
|
||||
senderPrivateKey: Uint8Array;
|
||||
}
|
||||
|
||||
// Re-encrypts the message for every currently-registered device in the
|
||||
// conversation and rewrites the envelope rows. The server-side trigger
|
||||
// enforces the 24h window + sender-only rule.
|
||||
export async function editEncryptedMessage(params: EditMessageParams): Promise<void> {
|
||||
const deviceKeys = await listConversationDeviceKeys(params.client, params.conversationId);
|
||||
if (deviceKeys.length === 0) throw new Error('no recipient devices found');
|
||||
// Re-encrypts the message body with the conv-key and updates the row.
|
||||
// Server-side trigger enforces 24h window + sender-only rule.
|
||||
export async function editEncryptedMessage(
|
||||
params: EditMessageParams & { senderUserId: string; senderDeviceId: string },
|
||||
): Promise<void> {
|
||||
const ownCtx: OwnDeviceCtx = {
|
||||
userId: params.senderUserId,
|
||||
deviceId: params.senderDeviceId,
|
||||
privateKey: params.senderPrivateKey,
|
||||
};
|
||||
const handle = await getOrCreateConvKey(params.client, params.conversationId, ownCtx);
|
||||
|
||||
const plainBytes = utf8ToBytes(params.newPlaintext);
|
||||
const rows: {
|
||||
message_id: string;
|
||||
recipient_device_id: string;
|
||||
ciphertext: string;
|
||||
nonce: string;
|
||||
}[] = [];
|
||||
for (const dk of deviceKeys) {
|
||||
const { ciphertext, nonce } = await encryptFor(plainBytes, dk.publicKey, params.senderPrivateKey);
|
||||
rows.push({
|
||||
message_id: params.messageId,
|
||||
recipient_device_id: dk.deviceId,
|
||||
ciphertext: bytesToPgHex(ciphertext),
|
||||
nonce: bytesToPgHex(nonce),
|
||||
});
|
||||
}
|
||||
const cipher = encryptWithConvKey(utf8ToBytes(params.newPlaintext), handle.key);
|
||||
|
||||
// UPDATE the message row — trigger rechecks 24h window + sets edited_at.
|
||||
const { error: mErr } = await params.client
|
||||
const { error } = await params.client
|
||||
.from('messages')
|
||||
.update({ edited_at: new Date().toISOString() } as never)
|
||||
.update({
|
||||
ciphertext: bytesToPgHex(cipher.ciphertext),
|
||||
nonce: bytesToPgHex(cipher.nonce),
|
||||
key_version: handle.keyVersion,
|
||||
edited_at: new Date().toISOString(),
|
||||
} as never)
|
||||
.eq('id', params.messageId);
|
||||
if (mErr) throw mErr;
|
||||
|
||||
// Upsert envelopes (INSERT on conflict UPDATE).
|
||||
const { error: eErr } = await params.client
|
||||
.from('message_envelopes')
|
||||
.upsert(rows as never, { onConflict: 'message_id,recipient_device_id' });
|
||||
if (eErr) throw eErr;
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
export async function softDeleteMessage(
|
||||
@@ -365,24 +311,46 @@ export async function removeReaction(
|
||||
// Decrypt helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DecryptOptions {
|
||||
export interface DecryptParams {
|
||||
client: AppSupabaseClient;
|
||||
messages: MessageWithCipher[];
|
||||
ownDeviceId: string;
|
||||
ownPrivateKey: Uint8Array;
|
||||
}
|
||||
|
||||
export async function decryptMessages(opts: {
|
||||
messages: ChatMessage[];
|
||||
envelopes: Map<string, { ciphertext: Uint8Array; nonce: Uint8Array }>;
|
||||
senderKeys: Map<string, Uint8Array>;
|
||||
ownPrivateKey: Uint8Array;
|
||||
}): Promise<DecryptedMessage[]> {
|
||||
// Decrypts messages using their conv-key (looked up + cached per
|
||||
// keyVersion). Returns null `plaintext` when this device has no key bundle
|
||||
// for that version yet (e.g. brand-new device waiting for share).
|
||||
export async function decryptMessages(opts: DecryptParams): Promise<DecryptedMessage[]> {
|
||||
const out: DecryptedMessage[] = [];
|
||||
// Group versions to avoid redundant lookups.
|
||||
const versions = new Map<string, Map<number, Uint8Array | null>>(); // convId -> version -> key | null
|
||||
|
||||
for (const m of opts.messages) {
|
||||
const env = opts.envelopes.get(m.id);
|
||||
const senderKey = m.senderDeviceId ? opts.senderKeys.get(m.senderDeviceId) : undefined;
|
||||
let convCache = versions.get(m.conversationId);
|
||||
if (!convCache) {
|
||||
convCache = new Map();
|
||||
versions.set(m.conversationId, convCache);
|
||||
}
|
||||
let key: Uint8Array | null;
|
||||
if (convCache.has(m.keyVersion)) {
|
||||
key = convCache.get(m.keyVersion) ?? null;
|
||||
} else {
|
||||
const handle = await tryGetConvKey(
|
||||
opts.client,
|
||||
m.conversationId,
|
||||
opts.ownDeviceId,
|
||||
opts.ownPrivateKey,
|
||||
m.keyVersion,
|
||||
);
|
||||
key = handle?.key ?? null;
|
||||
convCache.set(m.keyVersion, key);
|
||||
}
|
||||
|
||||
let plaintext: string | null = null;
|
||||
if (env && senderKey) {
|
||||
if (key) {
|
||||
try {
|
||||
const decoded = await decryptFrom(env.ciphertext, env.nonce, senderKey, opts.ownPrivateKey);
|
||||
const decoded = decryptWithConvKey(m.ciphertext, m.nonce, key);
|
||||
plaintext = bytesToUtf8(decoded);
|
||||
} catch {
|
||||
plaintext = null;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './backend.js';
|
||||
export * from './box.js';
|
||||
export * from './keys.js';
|
||||
export * from './sessionKeys.js';
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { getCryptoBackend } from './backend.js';
|
||||
import { decryptFrom, encryptFor, type EncryptedEnvelope } from './box.js';
|
||||
|
||||
// Sender-Key (Signal-style) helpers — one symmetric XSalsa20-Poly1305 key per
|
||||
// conversation, wrapped with `crypto_box` for each recipient device's pubkey.
|
||||
//
|
||||
// Flow:
|
||||
// - generateConvKey() produces 32 random bytes
|
||||
// - wrapConvKeyForRecipient() encrypts the conv-key with sender's private key
|
||||
// and recipient's pubkey -> stored in `conversation_keys` table
|
||||
// - unwrapConvKey() reverses it on the receiving side
|
||||
// - encryptWithConvKey()/decryptWithConvKey() do the message-payload work
|
||||
|
||||
export function generateConvKey(): Uint8Array {
|
||||
const backend = getCryptoBackend();
|
||||
return backend.randomBytes(backend.secretboxKeyLength);
|
||||
}
|
||||
|
||||
export async function wrapConvKeyForRecipient(
|
||||
convKey: Uint8Array,
|
||||
recipientPublicKey: Uint8Array,
|
||||
senderPrivateKey: Uint8Array,
|
||||
): Promise<EncryptedEnvelope> {
|
||||
return encryptFor(convKey, recipientPublicKey, senderPrivateKey);
|
||||
}
|
||||
|
||||
export async function unwrapConvKey(
|
||||
encryptedKey: Uint8Array,
|
||||
nonce: Uint8Array,
|
||||
senderPublicKey: Uint8Array,
|
||||
recipientPrivateKey: Uint8Array,
|
||||
): Promise<Uint8Array> {
|
||||
return decryptFrom(encryptedKey, nonce, senderPublicKey, recipientPrivateKey);
|
||||
}
|
||||
|
||||
export interface ConvCipher {
|
||||
ciphertext: Uint8Array;
|
||||
nonce: Uint8Array;
|
||||
}
|
||||
|
||||
export function encryptWithConvKey(
|
||||
plaintext: Uint8Array,
|
||||
convKey: Uint8Array,
|
||||
): ConvCipher {
|
||||
const backend = getCryptoBackend();
|
||||
const nonce = backend.randomBytes(backend.secretboxNonceLength);
|
||||
const ciphertext = backend.secretbox(plaintext, nonce, convKey);
|
||||
return { ciphertext, nonce };
|
||||
}
|
||||
|
||||
export function decryptWithConvKey(
|
||||
ciphertext: Uint8Array,
|
||||
nonce: Uint8Array,
|
||||
convKey: Uint8Array,
|
||||
): Uint8Array {
|
||||
const backend = getCryptoBackend();
|
||||
return backend.secretboxOpen(ciphertext, nonce, convKey);
|
||||
}
|
||||
@@ -23,3 +23,20 @@ export function pgHexToBytes(hex: string): Uint8Array {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Accepts either:
|
||||
// - PostgREST/REST `\x<hex>` strings (what the .from('table').select() path
|
||||
// returns for bytea), or
|
||||
// - Realtime `postgres_changes` payloads, which encode bytea as plain
|
||||
// base64 (no `\x` prefix).
|
||||
// Useful when the same row can arrive through both paths in the same UI.
|
||||
export function pgBytesToBytes(value: string): Uint8Array {
|
||||
if (value.startsWith('\\x')) {
|
||||
return pgHexToBytes(value);
|
||||
}
|
||||
// Assume base64 (the realtime serializer's default for bytea).
|
||||
const bin = atob(value);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,20 @@ import type { Database, SupabaseConfig } from './types.js';
|
||||
// Typed client alias used throughout the app.
|
||||
export type AppSupabaseClient = SupabaseClient<Database>;
|
||||
|
||||
// Inline serial lock — replaces Supabase's default `navigator.locks` based
|
||||
// lock that occasionally throws "Lock was stolen by another request" when
|
||||
// the same origin opens multiple tabs / Tauri windows / HMR-reloaded
|
||||
// modules. We only have one client instance per process so a simple promise
|
||||
// chain serialises token-refresh fine without cross-tab coordination.
|
||||
const acquireLock = (() => {
|
||||
let chain: Promise<unknown> = Promise.resolve();
|
||||
return async <R>(_name: string, _acquireTimeout: number, fn: () => Promise<R>): Promise<R> => {
|
||||
const next = chain.then(() => fn(), () => fn());
|
||||
chain = next.catch(() => undefined);
|
||||
return next;
|
||||
};
|
||||
})();
|
||||
|
||||
export function createClient(config: SupabaseConfig): AppSupabaseClient {
|
||||
return createSupabaseClient<Database>(config.url, config.anonKey, {
|
||||
auth: {
|
||||
@@ -12,6 +26,7 @@ export function createClient(config: SupabaseConfig): AppSupabaseClient {
|
||||
autoRefreshToken: true,
|
||||
persistSession: true,
|
||||
detectSessionInUrl: config.detectSessionInUrl ?? false,
|
||||
lock: acquireLock,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -65,6 +65,9 @@ importers:
|
||||
'@tauri-apps/api':
|
||||
specifier: ^2.1.1
|
||||
version: 2.10.1
|
||||
'@tauri-apps/plugin-fs':
|
||||
specifier: ^2.5.0
|
||||
version: 2.5.0
|
||||
'@tauri-apps/plugin-global-shortcut':
|
||||
specifier: ^2.3.1
|
||||
version: 2.3.1
|
||||
@@ -1766,6 +1769,9 @@ packages:
|
||||
engines: {node: '>= 10'}
|
||||
hasBin: true
|
||||
|
||||
'@tauri-apps/plugin-fs@2.5.0':
|
||||
resolution: {integrity: sha512-c83kbz61AK+rKjhS+je9+stIO27nXj7p9cqeg36TwkIUtxpCFTttlHHtqon6h6FN54cXjyAjlMPOJcW3mwE5XQ==}
|
||||
|
||||
'@tauri-apps/plugin-global-shortcut@2.3.1':
|
||||
resolution: {integrity: sha512-vr40W2N6G63dmBPaha1TsBQLLURXG538RQbH5vAm0G/ovVZyXJrmZR1HF1W+WneNloQvwn4dm8xzwpEXRW560g==}
|
||||
|
||||
@@ -7401,6 +7407,10 @@ snapshots:
|
||||
'@tauri-apps/cli-win32-ia32-msvc': 2.10.1
|
||||
'@tauri-apps/cli-win32-x64-msvc': 2.10.1
|
||||
|
||||
'@tauri-apps/plugin-fs@2.5.0':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.10.1
|
||||
|
||||
'@tauri-apps/plugin-global-shortcut@2.3.1':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.10.1
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
-- Sender-Key (Signal-style) multi-device crypto.
|
||||
--
|
||||
-- Replaces per-device envelopes with a per-conversation symmetric key.
|
||||
-- Each device that should be able to read a conversation gets one row in
|
||||
-- `conversation_keys` containing the conv-key wrapped to its X25519 pubkey.
|
||||
--
|
||||
-- Senders encrypt the message body once with the conv-key (XSalsa20-Poly1305
|
||||
-- secretbox) instead of N times to N device pubkeys. This makes new-device
|
||||
-- onboarding for existing conversations possible: any existing device can
|
||||
-- wrap the conv-key for a freshly-registered device, and that device can then
|
||||
-- decrypt the entire history without back-population.
|
||||
--
|
||||
-- Pre-launch wipe: existing messages + envelopes are dropped (the old
|
||||
-- per-device envelopes can't be re-encoded into the new format without the
|
||||
-- sender's private key, which lives only on each user's device).
|
||||
|
||||
-- 1) Wipe legacy message data --------------------------------------------------
|
||||
|
||||
drop table if exists public.message_envelopes cascade;
|
||||
truncate table
|
||||
public.message_reactions,
|
||||
public.message_reads,
|
||||
public.message_attachments,
|
||||
public.messages
|
||||
restart identity cascade;
|
||||
|
||||
-- 2) Add ciphertext + nonce + key_version columns to messages -----------------
|
||||
|
||||
alter table public.messages
|
||||
add column if not exists ciphertext bytea,
|
||||
add column if not exists nonce bytea,
|
||||
add column if not exists key_version int;
|
||||
|
||||
-- Backfill not needed because we just truncated. Make NOT NULL going forward.
|
||||
alter table public.messages
|
||||
alter column ciphertext set not null,
|
||||
alter column nonce set not null,
|
||||
alter column key_version set not null,
|
||||
alter column key_version set default 1;
|
||||
|
||||
-- 3) Track currently-active key version per conversation ----------------------
|
||||
|
||||
alter table public.conversations
|
||||
add column if not exists active_key_version int not null default 1;
|
||||
|
||||
-- 4) conversation_keys table ---------------------------------------------------
|
||||
--
|
||||
-- One row per (conversation, recipient_device, key_version). The wrapped key
|
||||
-- is encrypted with `crypto_box`/`box`-style asymmetric crypto (X25519 +
|
||||
-- XSalsa20-Poly1305) — sender_device's private key + recipient_device's
|
||||
-- public key derive a shared secret to decrypt the embedded conv-key.
|
||||
|
||||
create table if not exists public.conversation_keys (
|
||||
conversation_id uuid not null references public.conversations(id) on delete cascade,
|
||||
recipient_device_id uuid not null references public.devices(id) on delete cascade,
|
||||
key_version int not null,
|
||||
sender_device_id uuid not null references public.devices(id) on delete restrict,
|
||||
encrypted_key bytea not null,
|
||||
nonce bytea not null,
|
||||
created_at timestamptz not null default now(),
|
||||
primary key (conversation_id, recipient_device_id, key_version)
|
||||
);
|
||||
|
||||
create index if not exists conversation_keys_recipient_idx
|
||||
on public.conversation_keys(recipient_device_id);
|
||||
|
||||
create index if not exists conversation_keys_conv_version_idx
|
||||
on public.conversation_keys(conversation_id, key_version);
|
||||
|
||||
-- 5) RLS policies for conversation_keys ---------------------------------------
|
||||
|
||||
alter table public.conversation_keys enable row level security;
|
||||
|
||||
-- A user can SELECT a row only if the recipient device belongs to them.
|
||||
drop policy if exists conversation_keys_select_owner on public.conversation_keys;
|
||||
create policy conversation_keys_select_owner
|
||||
on public.conversation_keys
|
||||
for select
|
||||
to authenticated
|
||||
using (
|
||||
exists (
|
||||
select 1
|
||||
from public.devices d
|
||||
where d.id = recipient_device_id
|
||||
and d.user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- INSERT allowed only by an authenticated user who:
|
||||
-- (a) is a member of the conversation,
|
||||
-- (b) owns the sender_device_id (so a 3rd party can't impersonate),
|
||||
-- (c) the recipient_device belongs to a member of the same conversation.
|
||||
drop policy if exists conversation_keys_insert_member on public.conversation_keys;
|
||||
create policy conversation_keys_insert_member
|
||||
on public.conversation_keys
|
||||
for insert
|
||||
to authenticated
|
||||
with check (
|
||||
exists (
|
||||
select 1
|
||||
from public.conversation_members m
|
||||
where m.conversation_id = conversation_keys.conversation_id
|
||||
and m.user_id = auth.uid()
|
||||
and m.accepted = true
|
||||
)
|
||||
and exists (
|
||||
select 1
|
||||
from public.devices d
|
||||
where d.id = sender_device_id
|
||||
and d.user_id = auth.uid()
|
||||
)
|
||||
and exists (
|
||||
select 1
|
||||
from public.devices d
|
||||
join public.conversation_members m
|
||||
on m.user_id = d.user_id
|
||||
and m.conversation_id = conversation_keys.conversation_id
|
||||
where d.id = recipient_device_id
|
||||
and m.accepted = true
|
||||
)
|
||||
);
|
||||
|
||||
-- DELETE: only sender (cleanup, e.g. when a device is removed). Rare.
|
||||
drop policy if exists conversation_keys_delete_sender on public.conversation_keys;
|
||||
create policy conversation_keys_delete_sender
|
||||
on public.conversation_keys
|
||||
for delete
|
||||
to authenticated
|
||||
using (
|
||||
exists (
|
||||
select 1
|
||||
from public.devices d
|
||||
where d.id = sender_device_id
|
||||
and d.user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- 6) Realtime publication ------------------------------------------------------
|
||||
--
|
||||
-- Devices subscribe to INSERTs on `conversation_keys` so a freshly-registered
|
||||
-- device sees its key bundles arrive. They also already subscribe to `devices`
|
||||
-- INSERTs to know when peers join a conversation; that's set up in earlier
|
||||
-- migrations.
|
||||
|
||||
alter publication supabase_realtime add table public.conversation_keys;
|
||||
@@ -0,0 +1,109 @@
|
||||
-- 1) Test-Daten-Wipe: alle conversation/message Daten weg, User+Devices+
|
||||
-- Friendships+Invites bleiben erhalten so dass bestehende Logins weiter
|
||||
-- funktionieren. Cascade räumt mitlaufende rows (envelopes, attachments,
|
||||
-- reactions, reads, conversation_keys, conversation_members).
|
||||
|
||||
truncate
|
||||
public.message_reactions,
|
||||
public.message_reads,
|
||||
public.message_attachments,
|
||||
public.messages,
|
||||
public.conversation_keys,
|
||||
public.conversation_members,
|
||||
public.conversations
|
||||
restart identity cascade;
|
||||
|
||||
-- 2) RPC: share conv-key bundles to multiple recipients atomically.
|
||||
-- Runs as SECURITY DEFINER so RLS doesn't reject individual rows. The
|
||||
-- function itself enforces the same invariants as the policy. This avoids
|
||||
-- the per-row 403 console spam we got with direct inserts.
|
||||
|
||||
create or replace function public.share_conv_keys(
|
||||
p_conv_id uuid,
|
||||
p_sender_device_id uuid,
|
||||
p_key_version int,
|
||||
p_bundles jsonb
|
||||
) returns int
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public
|
||||
as $$
|
||||
declare
|
||||
caller uuid := auth.uid();
|
||||
bundle jsonb;
|
||||
recipient_user_id uuid;
|
||||
inserted int := 0;
|
||||
recipient_did uuid;
|
||||
enc_key_hex text;
|
||||
nonce_hex text;
|
||||
begin
|
||||
if caller is null then
|
||||
raise exception 'not authenticated';
|
||||
end if;
|
||||
|
||||
-- Caller must be an accepted member of the conversation.
|
||||
if not exists (
|
||||
select 1
|
||||
from public.conversation_members
|
||||
where conversation_id = p_conv_id
|
||||
and user_id = caller
|
||||
and accepted = true
|
||||
) then
|
||||
raise exception 'caller is not an accepted member of %', p_conv_id;
|
||||
end if;
|
||||
|
||||
-- Sender device must belong to the caller.
|
||||
if not exists (
|
||||
select 1
|
||||
from public.devices
|
||||
where id = p_sender_device_id
|
||||
and user_id = caller
|
||||
) then
|
||||
raise exception 'sender_device % not owned by caller', p_sender_device_id;
|
||||
end if;
|
||||
|
||||
-- Iterate bundles and only insert ones whose recipient is a current
|
||||
-- accepted member. Silently skip otherwise (no error → no 403 noise).
|
||||
for bundle in
|
||||
select * from jsonb_array_elements(p_bundles)
|
||||
loop
|
||||
recipient_did := (bundle->>'recipient_device_id')::uuid;
|
||||
enc_key_hex := bundle->>'encrypted_key';
|
||||
nonce_hex := bundle->>'nonce';
|
||||
|
||||
select user_id
|
||||
into recipient_user_id
|
||||
from public.devices
|
||||
where id = recipient_did;
|
||||
if recipient_user_id is null then continue; end if;
|
||||
|
||||
if not exists (
|
||||
select 1
|
||||
from public.conversation_members
|
||||
where conversation_id = p_conv_id
|
||||
and user_id = recipient_user_id
|
||||
and accepted = true
|
||||
) then continue; end if;
|
||||
|
||||
insert into public.conversation_keys
|
||||
(conversation_id, recipient_device_id, key_version,
|
||||
sender_device_id, encrypted_key, nonce)
|
||||
values
|
||||
(p_conv_id, recipient_did, p_key_version,
|
||||
p_sender_device_id,
|
||||
decode(enc_key_hex, 'hex'),
|
||||
decode(nonce_hex, 'hex'))
|
||||
on conflict (conversation_id, recipient_device_id, key_version)
|
||||
do nothing;
|
||||
|
||||
if found then
|
||||
inserted := inserted + 1;
|
||||
end if;
|
||||
end loop;
|
||||
|
||||
return inserted;
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke execute on function public.share_conv_keys(uuid, uuid, int, jsonb) from public, anon;
|
||||
grant execute on function public.share_conv_keys(uuid, uuid, int, jsonb) to authenticated;
|
||||
@@ -0,0 +1,54 @@
|
||||
-- Profile-Avatar storage bucket.
|
||||
--
|
||||
-- Layout: <user_id>/<filename>.webp
|
||||
-- Public read so peers can see each other's avatar without auth roundtrip
|
||||
-- (avatars are non-sensitive). Write/update/delete restricted to the owning
|
||||
-- user via path prefix matching their auth.uid().
|
||||
|
||||
insert into storage.buckets (id, name, public)
|
||||
values ('profile-avatars', 'profile-avatars', true)
|
||||
on conflict (id) do nothing;
|
||||
|
||||
-- Read: anyone authenticated can view any avatar (public bucket also lets
|
||||
-- unauth fetch by URL but our RLS keeps the table-level policy explicit).
|
||||
drop policy if exists profile_avatars_select on storage.objects;
|
||||
create policy profile_avatars_select
|
||||
on storage.objects
|
||||
for select
|
||||
to authenticated, anon
|
||||
using (bucket_id = 'profile-avatars');
|
||||
|
||||
-- Write only into your own folder (first path segment must equal auth.uid()).
|
||||
drop policy if exists profile_avatars_insert_own on storage.objects;
|
||||
create policy profile_avatars_insert_own
|
||||
on storage.objects
|
||||
for insert
|
||||
to authenticated
|
||||
with check (
|
||||
bucket_id = 'profile-avatars'
|
||||
and (storage.foldername(name))[1] = auth.uid()::text
|
||||
);
|
||||
|
||||
drop policy if exists profile_avatars_update_own on storage.objects;
|
||||
create policy profile_avatars_update_own
|
||||
on storage.objects
|
||||
for update
|
||||
to authenticated
|
||||
using (
|
||||
bucket_id = 'profile-avatars'
|
||||
and (storage.foldername(name))[1] = auth.uid()::text
|
||||
)
|
||||
with check (
|
||||
bucket_id = 'profile-avatars'
|
||||
and (storage.foldername(name))[1] = auth.uid()::text
|
||||
);
|
||||
|
||||
drop policy if exists profile_avatars_delete_own on storage.objects;
|
||||
create policy profile_avatars_delete_own
|
||||
on storage.objects
|
||||
for delete
|
||||
to authenticated
|
||||
using (
|
||||
bucket_id = 'profile-avatars'
|
||||
and (storage.foldername(name))[1] = auth.uid()::text
|
||||
);
|
||||