Compare commits

..

21 Commits

Author SHA1 Message Date
byGalax 4db65993d5 feat: redesign + avatars + theme + call presence fixes (v0.6.0)
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
Visual:
- Light/dark theme via ThemeContext + CSS vars
- New design tokens (surface/fg/accent/line/etc.) across all pages
- Reusable Avatar component (img + letter fallback) wired into UserBar,
  ConversationHeader, ChatsPage, FriendsPage, GroupInfoPanel, IncomingCallPanel

Calls:
- Split call UI: IncomingCallPanel, InCallPanel, CallControls,
  CallParticipantTile, ScreenShareViewer
- Active speaker hook (useActiveSpeakers)
- Fix: ActiveCallBanner stayed hidden after hangup while peers in room.
  - useCallPresence: bind presence callbacks only when we own subscribe
    (Supabase forbids .on() after .subscribe() on shared dedup'd channels)
  - useCallPresence: never removeChannel — channel is shared with CallContext
    so tearing it down on ConversationHeader unmount killed live tracking
  - ActiveCallBanner: lastCallConversationId fallback so banner shows
    instantly after hangup, auto-dismiss when room confirmed empty
- Drop unused useAnyActiveCall

Bump tauri version 0.5.0 -> 0.6.0
2026-04-20 00:26:19 +02:00
byGalax 0ca29952ba feat: profile avatar upload + share_conv_keys rpc + favicon + smtp tweaks
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 23:04:03 +02:00
byGalax ff5ea274b9 fix(keysync): swallow expected supabase errors by code/status, not just message
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 22:08:54 +02:00
byGalax b0f9f1dada fix(keysync): silence expected RLS rejections during best-effort backfill
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 22:03:49 +02:00
byGalax 961ac2dde5 fix: idempotent conv-key upsert + guard tauri-only notification calls
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 22:00:21 +02:00
byGalax 7efbcf7e39 fix(vault): per-user filename so multi-account on same machine works
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 21:29:03 +02:00
byGalax 49c64cc5d9 feat(crypto): self-rolled encrypted file vault, replaces flaky stronghold
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 21:21:40 +02:00
byGalax d20c7e210b fix(senderkey): backfill missing key bundles on mount + refresh on incoming bundle
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 21:07:47 +02:00
byGalax 58fa9487e3 fix(auth): replace navigator.locks with in-process serial lock to avoid 'lock stolen' aborts
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 20:38:42 +02:00
byGalax d9b08592da fix(messages): refetch row via REST on realtime insert + optimistic sender update
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 20:35:00 +02:00
byGalax 4a80bf1c0e fix(realtime): handle base64 bytea payloads from postgres_changes
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 20:19:40 +02:00
byGalax 05c962d46f fix(auth): scope signOut to local session only
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 20:11:08 +02:00
byGalax cf3fef6936 fix(secretStore): fall back to localStorage when stronghold init fails
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 20:05:37 +02:00
byGalax 8c878b3718 fix(capabilities): allow stronghold operations
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 20:02:44 +02:00
byGalax 389f00e85c build: enable devtools in release for easier debugging
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 20:00:33 +02:00
byGalax 75618637e2 feat(crypto): sender-key per-conversation multi-device E2EE
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 19:27:24 +02:00
byGalax e57f81c9c3 ci: trim matrix to macos-14 universal + windows
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
2026-04-19 18:55:06 +02:00
byGalax 0a5811cc68 feat(ui): netralax wordmark lockup in sidebar 2026-04-19 18:46:14 +02:00
byGalax 0d94b684bf feat(crypto): stronghold persistence + passphrase device-key backup/restore
Release desktop app / build (, ubuntu-22.04) (push) Has been cancelled
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target aarch64-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
Release desktop app / build (--target x86_64-apple-darwin --bundles app,updater, macos-13) (push) Has been cancelled
2026-04-19 18:41:35 +02:00
byGalax 3c2579b3ed fix(updater): mount UpdateToast outside auth gates
Release desktop app / build (, ubuntu-22.04) (push) Has been cancelled
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target aarch64-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
Release desktop app / build (--target x86_64-apple-darwin --bundles app,updater, macos-13) (push) Has been cancelled
2026-04-19 14:55:06 +02:00
byGalax c413ee1cf6 feat: netralax split-hex logo + app icons
Release desktop app / build (, ubuntu-22.04) (push) Has been cancelled
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target aarch64-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
Release desktop app / build (--target x86_64-apple-darwin --bundles app,updater, macos-13) (push) Has been cancelled
2026-04-19 14:40:14 +02:00
137 changed files with 7345 additions and 1258 deletions
+3 -13
View File
@@ -21,12 +21,8 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
include: include:
- platform: macos-14 # apple silicon - platform: macos-14 # universal binary covers both Intel + Apple Silicon
args: "--target aarch64-apple-darwin --bundles app,updater" args: "--target universal-apple-darwin --bundles app,updater"
- platform: macos-13 # intel
args: "--target x86_64-apple-darwin --bundles app,updater"
- platform: ubuntu-22.04
args: ""
- platform: windows-latest - platform: windows-latest
args: "" args: ""
@@ -47,13 +43,7 @@ jobs:
- name: Setup Rust - name: Setup Rust
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
with: with:
targets: ${{ matrix.platform == 'macos-14' && 'aarch64-apple-darwin' || matrix.platform == 'macos-13' && 'x86_64-apple-darwin' || '' }} targets: ${{ matrix.platform == 'macos-14' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: Install Linux build deps
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt update
sudo apt install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libgtk-3-dev
- name: Install JS deps - name: Install JS deps
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
+1 -1
View File
@@ -1 +1 @@
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDg0Q0Y0N0I1Q0U2MjBEMzcKUldRM0RXTE90VWZQaERVWnBGNTVKUVZ2MWZyRktaaDFJaXVWc3NGNUZVb08yVHNxaVp2c2dOL2oK dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDQ5N0U0RDcxOTU2OEQ0QUUKUldTdTFHaVZjVTErU1ZuMk1lWXBUbEcyS1RHYzJQN3k4VDdiUGRvRnVJYVJKR3BxWG1xcENpdlYK
+16 -2
View File
@@ -4,9 +4,23 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>ChatApp</title> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Netralax</title>
<script>
// Apply persisted / system theme before paint to avoid FOUC.
(function () {
try {
var stored = localStorage.getItem('netralax.theme');
var dark =
stored === 'dark' ||
(stored !== 'light' &&
window.matchMedia('(prefers-color-scheme: dark)').matches);
if (dark) document.documentElement.classList.add('dark');
} catch (_) {}
})();
</script>
</head> </head>
<body class="bg-[#0b0b0f] text-white antialiased"> <body class="bg-surface text-fg antialiased">
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
+1
View File
@@ -23,6 +23,7 @@
"@livekit/components-react": "^2.9.0", "@livekit/components-react": "^2.9.0",
"@supabase/supabase-js": "^2.46.0", "@supabase/supabase-js": "^2.46.0",
"@tauri-apps/api": "^2.1.1", "@tauri-apps/api": "^2.1.1",
"@tauri-apps/plugin-fs": "^2.5.0",
"@tauri-apps/plugin-global-shortcut": "^2.3.1", "@tauri-apps/plugin-global-shortcut": "^2.3.1",
"@tauri-apps/plugin-notification": "^2.0.1", "@tauri-apps/plugin-notification": "^2.0.1",
"@tauri-apps/plugin-sql": "^2.0.1", "@tauri-apps/plugin-sql": "^2.0.1",
+14
View File
@@ -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

+25
View File
@@ -684,6 +684,7 @@ dependencies = [
"serde_json", "serde_json",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-fs",
"tauri-plugin-global-shortcut", "tauri-plugin-global-shortcut",
"tauri-plugin-notification", "tauri-plugin-notification",
"tauri-plugin-sql", "tauri-plugin-sql",
@@ -5483,6 +5484,30 @@ dependencies = [
"walkdir", "walkdir",
] ]
[[package]]
name = "tauri-plugin-fs"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36e1ec28b79f3d0683f4507e1615c36292c0ea6716668770d4396b9b39871ed8"
dependencies = [
"anyhow",
"dunce",
"glob",
"log",
"objc2-foundation",
"percent-encoding",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"toml 0.9.12+spec-1.1.0",
"url",
]
[[package]] [[package]]
name = "tauri-plugin-global-shortcut" name = "tauri-plugin-global-shortcut"
version = "2.3.1" version = "2.3.1"
+2 -1
View File
@@ -14,10 +14,11 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
tauri = { version = "2", features = [] } tauri = { version = "2", features = ["devtools"] }
tauri-plugin-notification = "2" tauri-plugin-notification = "2"
tauri-plugin-sql = { version = "2", features = ["sqlite"] } tauri-plugin-sql = { version = "2", features = ["sqlite"] }
tauri-plugin-stronghold = "2" tauri-plugin-stronghold = "2"
tauri-plugin-fs = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
@@ -15,6 +15,27 @@
"updater:allow-check", "updater:allow-check",
"updater:allow-download", "updater:allow-download",
"updater:allow-install", "updater:allow-install",
"updater:allow-download-and-install" "updater:allow-download-and-install",
"stronghold:default",
"stronghold:allow-initialize",
"stronghold:allow-load-client",
"stronghold:allow-create-client",
"stronghold:allow-save",
"stronghold:allow-get-store-record",
"stronghold:allow-save-store-record",
"stronghold:allow-remove-store-record",
"fs:default",
"fs:allow-read-file",
"fs:allow-write-file",
"fs:allow-mkdir",
"fs:allow-exists",
"fs:allow-rename",
"fs:allow-remove",
{
"identifier": "fs:scope",
"allow": [
{ "path": "$APPLOCALDATA/**" }
]
}
] ]
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 948 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 928 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 611 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 858 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

+14
View File
@@ -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

+1
View File
@@ -3,6 +3,7 @@ pub fn run() {
let mut builder = tauri::Builder::default() let mut builder = tauri::Builder::default()
.plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_sql::Builder::default().build()) .plugin(tauri_plugin_sql::Builder::default().build())
.plugin(tauri_plugin_fs::init())
.plugin( .plugin(
tauri_plugin_stronghold::Builder::new(|password| { tauri_plugin_stronghold::Builder::new(|password| {
// TODO: derive stronghold key from password using argon2 / blake2b. // TODO: derive stronghold key from password using argon2 / blake2b.
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "ChatApp", "productName": "ChatApp",
"version": "0.1.0", "version": "0.6.0",
"identifier": "com.meinname.chatapp", "identifier": "com.meinname.chatapp",
"build": { "build": {
"beforeDevCommand": "pnpm vite:dev", "beforeDevCommand": "pnpm vite:dev",
+5
View File
@@ -1,11 +1,13 @@
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import { AppShell } from './components/AppShell'; import { AppShell } from './components/AppShell';
import { UpdateToast } from './components/UpdateToast';
import { RequireAdmin, RequireAuth, RequireDevice } from './components/guards'; import { RequireAdmin, RequireAuth, RequireDevice } from './components/guards';
import { AuthProvider } from './context/AuthContext'; import { AuthProvider } from './context/AuthContext';
import { CallProvider } from './context/CallContext'; import { CallProvider } from './context/CallContext';
import { ConversationsProvider } from './context/ConversationsContext'; import { ConversationsProvider } from './context/ConversationsContext';
import { FriendshipsProvider } from './context/FriendshipsContext'; import { FriendshipsProvider } from './context/FriendshipsContext';
import { ThemeProvider } from './context/ThemeContext';
import { AdminPage } from './pages/AdminPage'; import { AdminPage } from './pages/AdminPage';
import { AuthCallbackPage } from './pages/AuthCallbackPage'; import { AuthCallbackPage } from './pages/AuthCallbackPage';
import { AuthPage } from './pages/AuthPage'; import { AuthPage } from './pages/AuthPage';
@@ -17,6 +19,7 @@ import { SettingsPage } from './pages/SettingsPage';
export function App() { export function App() {
return ( return (
<ThemeProvider>
<AuthProvider> <AuthProvider>
<FriendshipsProvider> <FriendshipsProvider>
<ConversationsProvider> <ConversationsProvider>
@@ -44,10 +47,12 @@ export function App() {
</Route> </Route>
<Route path="*" element={<Navigate to="/chats" replace />} /> <Route path="*" element={<Navigate to="/chats" replace />} />
</Routes> </Routes>
<UpdateToast />
</BrowserRouter> </BrowserRouter>
</CallProvider> </CallProvider>
</ConversationsProvider> </ConversationsProvider>
</FriendshipsProvider> </FriendshipsProvider>
</AuthProvider> </AuthProvider>
</ThemeProvider>
); );
} }
+12 -6
View File
@@ -1,20 +1,27 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Outlet } from 'react-router-dom'; import { Outlet } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { startConversationKeySync } from '../lib/conversationKeySync';
import { ensureNotificationPermission } from '../lib/osNotify'; import { ensureNotificationPermission } from '../lib/osNotify';
import { CallUI } from './CallUI'; import { CallUI } from './CallUI';
import { Sidebar } from './Sidebar'; import { Sidebar } from './Sidebar';
import { UpdateToast } from './UpdateToast';
export function AppShell() { export function AppShell() {
const { session, device } = useAuth();
useEffect(() => { useEffect(() => {
// Prompt once per authenticated shell mount. Module-level guard prevents // Prompt once per authenticated shell mount. Module-level guard prevents
// re-asking if the user already responded this session. // re-asking if the user already responded this session.
void ensureNotificationPermission(); void ensureNotificationPermission();
}, []); }, []);
useEffect(() => {
if (!session?.user.id || !device?.id) return;
return startConversationKeySync(session.user.id, device.id);
}, [session?.user.id, device?.id]);
return ( return (
<div className="relative flex min-h-screen overflow-hidden bg-ink-950 text-neutral-100"> <div className="relative flex min-h-screen overflow-hidden bg-surface text-fg">
<ShellBackground /> <ShellBackground />
<div className="relative z-10 flex min-h-screen w-full"> <div className="relative z-10 flex min-h-screen w-full">
<Sidebar /> <Sidebar />
@@ -25,16 +32,15 @@ export function AppShell() {
</main> </main>
</div> </div>
<CallUI /> <CallUI />
<UpdateToast />
</div> </div>
); );
} }
// Calmer than the auth-screen background — full-bleed grid + 2 large blobs. // Subtle ambient background — only renders in dark mode where the app's
// No animation here so message lists stay readable. // visual language expects depth. Light mode stays clean and flat.
function ShellBackground() { function ShellBackground() {
return ( return (
<div aria-hidden="true" className="pointer-events-none absolute inset-0"> <div aria-hidden="true" className="pointer-events-none absolute inset-0 hidden dark:block">
<div className="bg-grid absolute inset-0 opacity-[0.18]" /> <div className="bg-grid absolute inset-0 opacity-[0.18]" />
<div className="absolute -left-32 top-1/4 h-[420px] w-[420px] rounded-full bg-brand-500/20 blur-3xl" /> <div className="absolute -left-32 top-1/4 h-[420px] w-[420px] rounded-full bg-brand-500/20 blur-3xl" />
<div className="absolute -right-32 bottom-0 h-[420px] w-[420px] rounded-full bg-fuchsia-500/10 blur-3xl" /> <div className="absolute -right-32 bottom-0 h-[420px] w-[420px] rounded-full bg-fuchsia-500/10 blur-3xl" />
+45
View File
@@ -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>
);
}
+126 -252
View File
@@ -1,26 +1,20 @@
import { useEffect, useState } from 'react'; import { useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { useCall } from '../context/CallContext'; import { useCall } from '../context/CallContext';
import { useConversationsContext } from '../context/ConversationsContext'; import { useConversationsContext } from '../context/ConversationsContext';
import { useFriendshipsContext } from '../context/FriendshipsContext'; import { useFriendshipsContext } from '../context/FriendshipsContext';
import { ringtone } from '../lib/ringtone'; import { ringtone } from '../lib/ringtone';
import { useAnyActiveCall } from '../lib/useAnyActiveCall'; import { AvatarColorKey, colorKeyFor } from './CallParticipantTile';
import { useCallPresence } from '../lib/useCallPresence'; import { LockIcon, MonitorShareIcon, PhoneIcon, PhoneOffIcon } from './icons';
import {
MicIcon,
MicOffIcon,
PhoneIcon,
PhoneOffIcon,
SpinnerIcon,
XIcon,
} from './icons';
// Shell-level mount. Handles ringtones + the IncomingCallToast. // Shell-level mount. Responsibilities:
// The persistent CallBar (active-call widget) is rendered inside Sidebar so // - Start/stop ringtones by call-state transition.
// users can keep browsing/typing while a call is live. // - Toast-style incoming-call banner for conversations the user isn't in
// (clicking the toast routes to the docked IncomingCallPanel).
// - PiP widget when an active call is live but the user is looking at a
// different route.
export function CallUI() { export function CallUI() {
const { state } = useCall(); const { state } = useCall();
@@ -34,19 +28,37 @@ export function CallUI() {
return () => ringtone.stop(); return () => ringtone.stop();
}, []); }, []);
return <IncomingCallToast />; return (
<>
<IncomingCallToast />
<PipCall />
</>
);
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const TOAST_AVATAR: Record<AvatarColorKey, string> = {
violet: 'bg-violet-500/90 text-white',
amber: 'bg-amber-500/90 text-white',
rose: 'bg-rose-500/90 text-white',
teal: 'bg-teal-500/90 text-white',
};
function IncomingCallToast() { function IncomingCallToast() {
const { t } = useTranslation(['app']); const { t } = useTranslation(['app']);
const { state, acceptIncoming, rejectIncoming } = useCall(); const { state, acceptIncoming, rejectIncoming } = useCall();
const { friendships } = useFriendshipsContext(); const { friendships } = useFriendshipsContext();
const { conversations } = useConversationsContext(); const { conversations } = useConversationsContext();
const navigate = useNavigate();
const location = useLocation();
if (state.kind !== 'incoming') return null; if (state.kind !== 'incoming') return null;
// When the user is already looking at the target conversation, the docked
// IncomingCallPanel handles the UI; hide the toast to avoid double chrome.
if (location.pathname === '/chats/' + state.conversationId) return null;
const conv = conversations.find((c) => c.id === state.conversationId) ?? null; const conv = conversations.find((c) => c.id === state.conversationId) ?? null;
const callerName = const callerName =
conv?.members.find((m) => m.userId === state.fromUserId)?.profile?.displayName ?? conv?.members.find((m) => m.userId === state.fromUserId)?.profile?.displayName ??
@@ -54,44 +66,55 @@ function IncomingCallToast() {
'?'; '?';
const isGroup = conv?.type === 'group'; const isGroup = conv?.type === 'group';
const groupName = isGroup ? (conv?.name ?? t('app:chats.new_group')) : null; const groupName = isGroup ? (conv?.name ?? t('app:chats.new_group')) : null;
const letter = (isGroup ? groupName ?? callerName : callerName) const title = isGroup ? groupName ?? callerName : callerName;
.trim() const letter = title.trim().charAt(0).toUpperCase() || '?';
.charAt(0) const color = colorKeyFor(state.fromUserId);
.toUpperCase() || '?';
return ( return (
<div <div
role="dialog" role="dialog"
aria-modal="false" aria-modal="false"
aria-label={t('app:call.incoming_title')} aria-label={t('app:call.incoming_title')}
className="fixed bottom-6 right-6 z-50 w-80 animate-slide-up rounded-2xl border border-white/10 bg-ink-900/95 p-5 shadow-2xl backdrop-blur-xl" className="fixed bottom-6 right-6 z-50 w-80 animate-slide-up overflow-hidden rounded-2xl border border-line bg-surface-3 shadow-call-card-dark"
> >
<p className="text-xs font-medium uppercase tracking-wide text-neutral-500"> <button
{t('app:call.incoming_title')} type="button"
</p> onClick={() => navigate('/chats/' + state.conversationId)}
<div className="mt-3 flex items-center gap-3"> 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"
<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} <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>
<div className="min-w-0 flex-1"> </button>
<p className="truncate font-display text-base font-semibold text-white"> <div className="flex gap-2 px-5 pb-5">
{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 <button
type="button" type="button"
onClick={rejectIncoming} onClick={rejectIncoming}
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm font-semibold text-rose-200 transition hover:bg-rose-500/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/40" className="inline-flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-lg border border-rose-500/40 bg-transparent px-3 py-2 text-sm font-semibold text-rose-600 transition hover:bg-rose-500/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-500/40 dark:text-rose-400"
> >
<PhoneOffIcon className="h-4 w-4" /> <PhoneOffIcon className="h-4 w-4" />
<span>{t('app:call.decline')}</span> <span>{t('app:call.decline')}</span>
@@ -99,7 +122,7 @@ function IncomingCallToast() {
<button <button
type="button" type="button"
onClick={() => void acceptIncoming()} onClick={() => void acceptIncoming()}
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-lg bg-emerald-500/90 px-3 py-2 text-sm font-semibold text-white transition hover:bg-emerald-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40" className="inline-flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-lg bg-emerald-600 px-3 py-2 text-sm font-semibold text-white transition hover:bg-emerald-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50"
> >
<PhoneIcon className="h-4 w-4" /> <PhoneIcon className="h-4 w-4" />
<span>{t('app:call.accept')}</span> <span>{t('app:call.accept')}</span>
@@ -110,226 +133,77 @@ function IncomingCallToast() {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// CallBar — persistent widget inside Sidebar. Lets the user keep browsing // PiP widget — shown when the user has an active call but is browsing
// the app while a call is active / connecting / ringing. // somewhere else. Clicking expands back to the call's conversation.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function CallBar() { function PipCall() {
const { state, lastCallConversationId } = useCall();
const { conversations } = useConversationsContext();
const { session } = useAuth();
const myId = session?.user.id ?? null;
// Aggregate observer across every conversation the user is in so a
// "call is live, rejoin" affordance appears in the sidebar whenever ANY
// peer is in a call — not just calls I previously joined.
const convIds = conversations.map((c) => c.id);
const anyActive = useAnyActiveCall(convIds, myId);
if (state.kind === 'connected' || state.kind === 'connecting' || state.kind === 'outgoing') {
return <ActiveCallBar />;
}
// Prefer the conversation I just left, fall back to any other live call.
const rejoinId = lastCallConversationId ?? anyActive?.conversationId ?? null;
if (rejoinId) {
return <RejoinCallBar conversationId={rejoinId} />;
}
return null;
}
function ActiveCallBar() {
const { t } = useTranslation(['app']); const { t } = useTranslation(['app']);
const { state, isMuted, hangup, toggleMute } = useCall(); const {
state,
remoteParticipants,
remoteScreenShares,
hangup,
} = useCall();
const navigate = useNavigate();
const location = useLocation();
const { conversations } = useConversationsContext(); const { conversations } = useConversationsContext();
const [, forceTick] = useState(0); const active =
useEffect(() => { state.kind === 'connected' ||
if (state.kind !== 'connected') return; state.kind === 'connecting' ||
const id = window.setInterval(() => forceTick((v) => v + 1), 1000); state.kind === 'outgoing';
return () => window.clearInterval(id); if (!active) return null;
}, [state.kind]);
if (state.kind !== 'connected' && state.kind !== 'connecting' && state.kind !== 'outgoing') { // Only render when the user is NOT currently viewing the call's
return null; // conversation. Inside that conversation the full dock is visible already.
} if (location.pathname === '/chats/' + state.conversationId) return null;
const conv = conversations.find((c) => c.id === state.conversationId) ?? null; const conv = conversations.find((c) => c.id === state.conversationId) ?? null;
const title = const title =
conv?.type === 'group' conv?.type === 'group'
? conv.name ?? '—' ? conv?.name ?? t('app:chats.new_group')
: conv?.peer?.displayName ?? '—'; : conv?.peer?.displayName ?? '—';
const participantCount = 1 + remoteParticipants.length;
const statusLabel = const someoneSharing = remoteScreenShares.length > 0;
state.kind === 'outgoing'
? t('app:call.outgoing_ringing')
: state.kind === 'connecting'
? t('app:call.connecting')
: t('app:call.voice_connected', { defaultValue: 'Sprachchat verbunden' });
const elapsed =
state.kind === 'connected'
? formatElapsed(Date.now() - new Date(state.startedAt).getTime())
: null;
return ( return (
<div className="mb-2 overflow-hidden rounded-xl border border-emerald-500/20 bg-ink-900/80"> <div
<Link role="dialog"
to={'/chats/' + state.conversationId} aria-label={t('app:call.active_in_conv', { defaultValue: 'Aktiver Anruf' })}
className="flex items-center gap-2.5 px-3 pt-2.5 pb-2 transition hover:bg-white/5 focus:outline-none" onClick={() => navigate('/chats/' + state.conversationId)}
aria-label={statusLabel} className="fixed bottom-5 right-5 z-40 flex w-[260px] animate-slide-in-call cursor-pointer items-center gap-2.5 rounded-[14px] border border-accent bg-surface-3 p-2.5 shadow-pip-call transition hover:-translate-y-0.5"
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-emerald-500/15 text-emerald-300">
{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
}
> >
{children} <div className="flex h-11 w-11 shrink-0 items-center justify-center overflow-hidden rounded-[10px] bg-surface-2">
</button> {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 { ConversationSummary } from '@chat-app/shared/chat';
import type { PresenceState } from '@chat-app/shared/supabase'; import type { PresenceState } from '@chat-app/shared/supabase';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useCall } from '../context/CallContext'; import { useCall } from '../context/CallContext';
import { useCallPresence } from '../lib/useCallPresence'; import { useCallPresence } from '../lib/useCallPresence';
import { InfoIcon, PhoneIcon, SpinnerIcon, UsersIcon } from './icons'; import { Avatar } from './Avatar';
import {
InfoIcon,
PhoneIcon,
SearchIcon,
SpinnerIcon,
UsersIcon,
VideoIcon,
} from './icons';
const PRESENCE_DOT: Record<PresenceState, string> = { const PRESENCE_DOT: Record<PresenceState, string> = {
online: 'bg-emerald-400', online: 'bg-emerald-500',
idle: 'bg-amber-400', idle: 'bg-amber-400',
dnd: 'bg-rose-500', dnd: 'bg-rose-500',
invisible: 'bg-neutral-500', invisible: 'bg-neutral-500',
offline: 'bg-neutral-600', offline: 'bg-neutral-400 dark:bg-neutral-600',
}; };
interface Props { interface Props {
@@ -23,9 +32,7 @@ interface Props {
export function ConversationHeader({ conversation, peerPresence, onInfoClick }: Props) { export function ConversationHeader({ conversation, peerPresence, onInfoClick }: Props) {
if (!conversation) { if (!conversation) {
return ( return <header className="h-[57px] border-b border-line px-6 py-3" aria-busy="true" />;
<header className="h-[57px] border-b border-white/5 px-6 py-3" aria-busy="true" />
);
} }
return ( return (
@@ -52,35 +59,43 @@ function HeaderBar({ conversation, peerPresence, onInfoClick }: HeaderBarProps)
const isDm = conversation.type === 'dm'; const isDm = conversation.type === 'dm';
const title = isDm ? (conversation.peer?.displayName ?? '?') : (conversation.name ?? '?'); const title = isDm ? (conversation.peer?.displayName ?? '?') : (conversation.name ?? '?');
const handle = isDm ? '@' + (conversation.peer?.username ?? '?') : ''; const handle = isDm ? '@' + (conversation.peer?.username ?? '?') : '';
const letter = title.trim().charAt(0).toUpperCase() || '?'; const peerAvatar = isDm ? (conversation.peer?.avatarUrl ?? null) : (conversation.avatarUrl ?? null);
// Hide presence when peer chose invisible — reciprocal privacy. // Hide presence when peer chose invisible — reciprocal privacy.
const showPresence = isDm && peerPresence && peerPresence !== 'invisible'; const showPresence = isDm && peerPresence && peerPresence !== 'invisible';
const presenceLabel = peerPresence ? t('app:presence.' + peerPresence) : ''; const presenceLabel = peerPresence ? t('app:presence.' + peerPresence) : '';
return ( return (
<header className="flex items-center gap-3 border-b border-white/5 px-6 py-3"> <header className="flex items-center gap-3 border-b border-line bg-surface-3 px-6 py-3">
<div className="relative flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-base font-semibold text-white ring-1 ring-brand-400/30"> <div className="relative">
{isDm ? letter : <UsersIcon className="h-5 w-5" />} {isDm ? (
<Avatar url={peerAvatar} displayName={title} className="h-10 w-10 text-base" />
) : peerAvatar ? (
<Avatar url={peerAvatar} displayName={title} className="h-10 w-10 text-base" />
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-accent/20 text-accent">
<UsersIcon className="h-5 w-5" />
</div>
)}
{showPresence && peerPresence && ( {showPresence && peerPresence && (
<span <span
aria-hidden="true" aria-hidden="true"
className={ className={
'absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full ring-2 ring-ink-950 ' + 'absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full ring-2 ring-surface-3 ' +
PRESENCE_DOT[peerPresence] PRESENCE_DOT[peerPresence]
} }
/> />
)} )}
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate font-display text-base font-semibold text-white">{title}</p> <p className="truncate font-display text-base font-semibold text-fg">{title}</p>
<p className="truncate text-xs text-neutral-500"> <p className="truncate text-xs text-fg-muted">
{isDm ? ( {isDm ? (
<> <>
<span>{handle}</span> <span>{handle}</span>
{showPresence && ( {showPresence && (
<> <>
<span className="mx-1.5 text-neutral-700">·</span> <span className="mx-1.5 text-fg-muted/50">·</span>
<span>{presenceLabel}</span> <span>{presenceLabel}</span>
</> </>
)} )}
@@ -93,75 +108,132 @@ function HeaderBar({ conversation, peerPresence, onInfoClick }: HeaderBarProps)
</p> </p>
</div> </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 && ( {!isDm && onInfoClick && (
<button <HeaderActionButton
type="button" label={t('app:group.info_title')}
icon={InfoIcon}
onClick={onInfoClick} onClick={onInfoClick}
aria-label={t('app:group.info_title')} />
title={t('app:group.info_title')}
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg text-neutral-400 transition hover:bg-white/10 hover:text-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
>
<InfoIcon className="h-4 w-4" />
</button>
)} )}
</header> </header>
); );
} }
function CallHeaderButton({ conversationId }: { conversationId: string }) { interface HeaderActionButtonProps {
const { t } = useTranslation(['app']); label: string;
const { state, startCall } = useCall(); icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
const busy = state.kind !== 'idle'; onClick?: () => void;
disabled?: boolean;
tone?: 'default' | 'accent';
}
function HeaderActionButton({
label,
icon: Icon,
onClick,
disabled,
tone = 'default',
}: HeaderActionButtonProps) {
const toneClass =
tone === 'accent'
? 'text-accent hover:bg-accent/10'
: 'text-fg-muted hover:bg-surface-2 hover:text-fg';
return ( return (
<button <button
type="button" type="button"
onClick={() => void startCall(conversationId, 'audio')} onClick={onClick}
disabled={busy} disabled={disabled}
aria-label={t('app:call.start_audio')} aria-label={label}
title={busy ? t('app:call.busy') : t('app:call.start_audio')} title={label}
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg text-neutral-400 transition hover:bg-white/10 hover:text-brand-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 disabled:cursor-not-allowed disabled:opacity-50" className={
'flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50 ' +
toneClass
}
> >
<PhoneIcon className="h-4 w-4" /> <Icon className="h-4 w-4" />
</button> </button>
); );
} }
function CallHeaderButton({
conversationId,
kind,
}: {
conversationId: string;
kind: 'audio' | 'video';
}) {
const { t } = useTranslation(['app']);
const { state, startCall } = useCall();
const busy = state.kind !== 'idle';
const label =
kind === 'video'
? t('app:call.start_video', { defaultValue: 'Video-Anruf' })
: t('app:call.start_audio');
const Icon = kind === 'video' ? VideoIcon : PhoneIcon;
return (
<HeaderActionButton
label={busy ? t('app:call.busy') : label}
icon={Icon}
onClick={() => void startCall(conversationId, kind)}
disabled={busy}
/>
);
}
function ActiveCallBanner({ conversationId }: { conversationId: string }) { function ActiveCallBanner({ conversationId }: { conversationId: string }) {
const { t } = useTranslation(['app']); const { t } = useTranslation(['app']);
const { session } = useAuth(); const { session } = useAuth();
const { state, joinActiveCall } = useCall(); const { state, joinActiveCall, lastCallConversationId, dismissLastCall } = useCall();
const active = useCallPresence(conversationId); const active = useCallPresence(conversationId);
const myId = session?.user.id; const myId = session?.user.id;
// Source of truth for "am I in this call" is the CallContext state, not
// presence — my own presence entry can lag behind the room join, which
// would otherwise make the banner flash while I'm already connected.
const iAmIn = const iAmIn =
(state.kind === 'connected' || (state.kind === 'connected' ||
state.kind === 'connecting' || state.kind === 'connecting' ||
state.kind === 'outgoing') && state.kind === 'outgoing') &&
state.conversationId === conversationId; state.conversationId === conversationId;
// Only show banner when other people are in it and I'm not.
const othersIn = active.filter((u) => u !== myId); const othersIn = active.filter((u) => u !== myId);
if (iAmIn || othersIn.length === 0) return null; // Fallback signal: I just left this conv with peers still inside. Covers the
// brief window after hangup where presence may not have re-synced yet (the
// realtime channel can churn while ConversationHeader remounts).
const justLeft = state.kind === 'idle' && lastCallConversationId === conversationId;
// Once presence confirms the room is empty, drop the "just left" hint so the
// banner hides cleanly instead of sticking forever.
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'; const busy = state.kind !== 'idle';
return ( return (
<div className="flex items-center gap-3 border-b border-emerald-500/20 bg-emerald-500/10 px-6 py-2.5 text-sm text-emerald-100"> <div className="flex items-center gap-3 border-b border-emerald-500/30 bg-emerald-500/10 px-6 py-2.5 text-sm text-emerald-700 dark:text-emerald-100">
<PhoneIcon className="h-4 w-4 text-emerald-300" /> <PhoneIcon className="h-4 w-4 text-emerald-600 dark:text-emerald-300" />
<span className="flex-1"> <span className="flex-1">
{t('app:call.active_in_conv', { {t('app:call.active_in_conv', {
defaultValue: 'Active call · {{count}} in room', defaultValue: 'Active call · {{count}} in room',
count: othersIn.length, count,
})} })}
</span> </span>
<button <button
type="button" type="button"
onClick={() => void joinActiveCall(conversationId, 'audio')} onClick={() => void joinActiveCall(conversationId, 'audio')}
disabled={busy} disabled={busy}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-emerald-500/90 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-emerald-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40 disabled:cursor-not-allowed disabled:opacity-60" className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-emerald-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40 disabled:cursor-not-allowed disabled:opacity-60"
> >
{busy ? ( {busy ? (
<SpinnerIcon className="h-3.5 w-3.5" /> <SpinnerIcon className="h-3.5 w-3.5" />
+25 -6
View File
@@ -123,15 +123,25 @@ export function GroupInfoPanel({ open, onClose, conversation }: Props) {
{conversation.members.map((m) => { {conversation.members.map((m) => {
const name = m.profile?.displayName ?? '?'; const name = m.profile?.displayName ?? '?';
const handle = m.profile?.username ? '@' + m.profile.username : ''; const handle = m.profile?.username ? '@' + m.profile.username : '';
const avatarUrl = m.profile?.avatarUrl ?? null;
const letter = name.trim().charAt(0).toUpperCase() || '?'; const letter = name.trim().charAt(0).toUpperCase() || '?';
return ( return (
<li <li
key={m.userId} key={m.userId}
className="flex items-center gap-3 rounded-lg px-2 py-1.5" className="flex items-center gap-3 rounded-lg px-2 py-1.5"
> >
<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"> {avatarUrl ? (
{letter} <img
</div> 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"> <div className="min-w-0 flex-1">
<p className="truncate text-sm text-white"> <p className="truncate text-sm text-white">
{name} {name}
@@ -187,9 +197,18 @@ export function GroupInfoPanel({ open, onClose, conversation }: Props) {
onClick={() => void handleAdd(f.userId)} onClick={() => void handleAdd(f.userId)}
className="flex w-full cursor-pointer items-center gap-3 rounded-lg px-2 py-1.5 text-left transition hover:bg-white/5 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 disabled:opacity-60" className="flex w-full cursor-pointer items-center gap-3 rounded-lg px-2 py-1.5 text-left transition hover:bg-white/5 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 disabled:opacity-60"
> >
<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"> {f.avatarUrl ? (
{letter} <img
</div> 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"> <div className="min-w-0 flex-1">
<p className="truncate text-sm text-white">{f.displayName}</p> <p className="truncate text-sm text-white">{f.displayName}</p>
<p className="truncate text-xs text-neutral-500">@{f.username}</p> <p className="truncate text-xs text-neutral-500">@{f.username}</p>
+493 -354
View File
@@ -1,46 +1,60 @@
import type { ConversationSummary } from '@chat-app/shared/chat'; import type { ConversationSummary } from '@chat-app/shared/chat';
import type { RemoteTrack } from 'livekit-client'; import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { type RemoteScreenShare, useCall } from '../context/CallContext'; import { type CallMode, useCall } from '../context/CallContext';
import { import {
getPttSettings, getPttSettings,
type PttSettings, type PttSettings,
subscribePttSettings, subscribePttSettings,
} from '../lib/pttSettings'; } from '../lib/pttSettings';
import { import { useActiveSpeakers } from '../lib/useActiveSpeakers';
LockIcon, import { CallControls } from './CallControls';
MicIcon, import { CallParticipantTile } from './CallParticipantTile';
MicOffIcon, import { FocusIcon, GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } from './icons';
MonitorShareIcon, import { ScreenShareViewer } from './ScreenShareViewer';
MonitorStopIcon,
PhoneOffIcon,
SpinnerIcon,
} from './icons';
// Discord-style in-call dock rendered above the message list. Renders three
// visual modes driven by CallContext.callMode: grid, focus, fullscreen.
// The fullscreen variant absolute-positions itself over the conversation
// container so the rail+chat-list remain visible on the left.
interface Props { interface Props {
conversation: ConversationSummary; conversation: ConversationSummary;
} }
// Discord-style call widget rendered above the message list when the user is interface Tile {
// in the current conversation's call. Shows participant avatars + controls. userId: string;
displayName: string;
avatarUrl: string | null;
self: boolean;
muted: boolean;
video: boolean;
sharing: boolean;
remoteSharing: boolean;
}
export function InCallPanel({ conversation }: Props) { export function InCallPanel({ conversation }: Props) {
const { t } = useTranslation(['app']); const { t } = useTranslation(['app']);
const { const {
state, state,
room,
remoteParticipants, remoteParticipants,
isMuted, isMuted,
isE2EEActive, isE2EEActive,
toggleMute,
hangup,
isScreenSharing, isScreenSharing,
remoteScreenShares, remoteScreenShares,
callMode,
focusedId,
toggleMute,
toggleScreenShare, toggleScreenShare,
hangup,
setCallMode,
setFocusedId,
} = useCall(); } = useCall();
const { session } = useAuth(); const { session } = useAuth();
const myId = session?.user.id ?? null; const myId = session?.user.id ?? null;
const activeSpeakers = useActiveSpeakers(room);
const active = const active =
(state.kind === 'connected' || (state.kind === 'connected' ||
@@ -49,33 +63,19 @@ export function InCallPanel({ conversation }: Props) {
state.conversationId === conversation.id; state.conversationId === conversation.id;
if (!active) return null; if (!active) return null;
const remoteIds = new Set<string>( const tiles = buildTiles({
remoteParticipants.map((p) => p.identity).filter((s): s is string => Boolean(s)), conversation,
); myId,
const tiles: ParticipantTileData[] = []; remoteIdentities: remoteParticipants.map((p) => p.identity),
if (myId) { isMuted,
const me = conversation.members.find((m) => m.userId === myId) ?? null; isScreenSharing,
tiles.push({ remoteSharerIds: new Set(remoteScreenShares.map((s) => s.participantId)),
userId: myId, });
displayName: me?.profile?.displayName ?? '?',
avatarUrl: me?.profile?.avatarUrl ?? null, const duration =
self: true, state.kind === 'connected'
speaking: false, ? <LiveDuration startedAt={state.startedAt} />
muted: isMuted, : null;
});
}
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 statusLabel = const statusLabel =
state.kind === 'outgoing' state.kind === 'outgoing'
@@ -86,268 +86,491 @@ export function InCallPanel({ conversation }: Props) {
? t('app:call.waiting_for_peers', { defaultValue: 'Warte auf andere…' }) ? t('app:call.waiting_for_peers', { defaultValue: 'Warte auf andere…' })
: t('app:call.connected'); : t('app:call.connected');
const sharingTile = tiles.find((p) => p.sharing);
const effectiveFocusedId = focusedId ?? sharingTile?.userId ?? tiles[0]?.userId ?? null;
const speaker = tiles.find((p) => p.userId === effectiveFocusedId) ?? tiles[0];
const controls = (
<CallControls
muted={isMuted}
sharing={isScreenSharing}
video={false}
onToggleMute={toggleMute}
onToggleShare={() => void toggleScreenShare()}
onHangup={() => void hangup()}
compact={callMode !== 'fullscreen'}
glass={callMode === 'fullscreen'}
disabledMedia={state.kind !== 'connected'}
/>
);
if (callMode === 'fullscreen') {
return (
<FullscreenCall
tiles={tiles}
speaker={speaker}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversation.members}
activeSpeakers={activeSpeakers}
e2ee={isE2EEActive}
onExit={() => setCallMode('grid')}
onFocusTile={(id) => {
setFocusedId(id);
}}
controls={controls}
/>
);
}
const title =
conversation.type === 'group'
? conversation.name ?? t('app:chats.new_group')
: conversation.peer?.displayName ?? '—';
return ( return (
<section <section
aria-label={t('app:call.in_call', { defaultValue: 'Im Anruf' })} aria-label={t('app:call.in_call', { defaultValue: 'Im Anruf' })}
className="border-b border-white/5 bg-gradient-to-b from-ink-900/80 to-ink-950/40 px-6 py-5" className="flex min-h-[280px] max-h-[420px] shrink-0 flex-col overflow-hidden border-b border-line bg-surface-2"
style={{ height: '50%' }}
> >
<div className="mb-4 flex items-center gap-2 text-xs font-medium text-emerald-300"> <div className="flex items-center justify-between border-b border-line bg-surface-3 px-4 py-2">
{state.kind === 'connecting' ? ( <div className="flex min-w-0 flex-col gap-0.5">
<SpinnerIcon className="h-3.5 w-3.5" /> <div className="flex items-center gap-2 text-sm font-semibold text-fg">
) : ( <UsersIcon className="h-4 w-4 shrink-0 text-fg-muted" />
<span className="relative flex h-2 w-2"> <span className="truncate">{title}</span>
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400/60" /> <span className="text-fg-muted/50" aria-hidden="true">·</span>
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-400" /> <span className="tabular-nums text-fg-muted">
</span> {duration ?? statusLabel}
)} {state.kind !== 'connected' && (
<span className="uppercase tracking-wide">{statusLabel}</span> <SpinnerIcon className="ml-1 inline h-3 w-3" />
{isE2EEActive && ( )}
<span </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" </div>
title={t('app:call.e2ee_active_hint', { {isE2EEActive && (
defaultValue: 'Audio + Video sind Ende-zu-Ende-verschlüsselt', <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>
<LockIcon className="h-3 w-3" /> </div>
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" />
)} )}
</ControlButton> </div>
<ControlButton <ModeToggles mode={callMode} onChange={setCallMode} />
onClick={() => void hangup()}
label={t('app:call.hangup')}
tone="rose"
>
<PhoneOffIcon className="h-5 w-5" />
</ControlButton>
</div> </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 /> <PttHint />
</section> </section>
); );
} }
interface ScreenShareViewerProps { // ---------------------------------------------------------------------------
share: RemoteScreenShare; // Helpers
avatarUrl: string | null; // ---------------------------------------------------------------------------
displayName: string;
interface BuildArgs {
conversation: ConversationSummary;
myId: string | null;
remoteIdentities: string[];
isMuted: boolean;
isScreenSharing: boolean;
remoteSharerIds: Set<string>;
} }
function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShareViewerProps) { function buildTiles({
const { t } = useTranslation(['app']); conversation,
const videoRef = useRef<HTMLVideoElement | null>(null); myId,
const containerRef = useRef<HTMLDivElement | null>(null); remoteIdentities,
const [watching, setWatching] = useState(false); isMuted,
const [isFullscreen, setIsFullscreen] = useState(false); isScreenSharing,
const letter = displayName.trim().charAt(0).toUpperCase() || '?'; remoteSharerIds,
}: BuildArgs): Tile[] {
const remoteSet = new Set(remoteIdentities);
const out: Tile[] = [];
if (myId) {
const me = conversation.members.find((m) => m.userId === myId) ?? null;
out.push({
userId: myId,
displayName: me?.profile?.displayName ?? '?',
avatarUrl: me?.profile?.avatarUrl ?? null,
self: true,
muted: isMuted,
video: false,
sharing: isScreenSharing,
remoteSharing: false,
});
}
for (const m of conversation.members) {
if (m.userId === myId) continue;
if (!remoteSet.has(m.userId)) continue;
const sharing = remoteSharerIds.has(m.userId);
out.push({
userId: m.userId,
displayName: m.profile?.displayName ?? '?',
avatarUrl: m.profile?.avatarUrl ?? null,
self: false,
muted: false,
video: false,
sharing,
remoteSharing: sharing,
});
}
return out;
}
function LiveDuration({ startedAt }: { startedAt: string }) {
const [, tick] = useState(0);
useEffect(() => { useEffect(() => {
if (!watching) return; const id = window.setInterval(() => tick((v) => v + 1), 1000);
const el = videoRef.current; return () => window.clearInterval(id);
if (!el) return;
const track: RemoteTrack = share.track;
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);
}, []); }, []);
return <>{formatElapsed(Date.now() - new Date(startedAt).getTime())}</>;
}
const toggleFullscreen = () => { function formatElapsed(ms: number): string {
const el = containerRef.current; const total = Math.max(0, Math.floor(ms / 1000));
if (!el) return; const hh = Math.floor(total / 3600);
if (document.fullscreenElement === el) { const mm = Math.floor((total % 3600) / 60);
void document.exitFullscreen(); const ss = total % 60;
} else { const pad = (n: number) => n.toString().padStart(2, '0');
void el.requestFullscreen(); return `${pad(hh)}:${pad(mm)}:${pad(ss)}`;
} }
};
function ModeToggles({
mode,
onChange,
}: {
mode: CallMode;
onChange: (mode: CallMode) => void;
}) {
return ( return (
<div <div className="flex items-center gap-1">
ref={containerRef} <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={ className={
'overflow-hidden rounded-xl border border-emerald-500/20 bg-black ' + '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 ' +
(isFullscreen ? 'flex h-screen w-screen flex-col rounded-none' : '') (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"> {children}
<MonitorShareIcon className="h-3.5 w-3.5 shrink-0" /> </button>
<span className="truncate flex-1"> );
{t('app:call.is_sharing_screen', { }
name: displayName,
defaultValue: displayName + ' teilt den Bildschirm', interface StageProps {
})} tiles: Tile[];
</span> speaker: Tile | undefined;
{watching && ( mode: CallMode;
<> activeSpeakers: Set<string>;
<button e2ee: boolean;
type="button" remoteScreenShares: {
onClick={toggleFullscreen} track: import('livekit-client').RemoteTrack;
aria-label={t('app:call.fullscreen', { defaultValue: 'Vollbild' })} participantId: string;
title={t('app:call.fullscreen', { defaultValue: 'Vollbild' })} participantName: string;
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" }[];
> conversationMembers: ConversationSummary['members'];
<FullscreenIcon className="h-3.5 w-3.5" /> onFocusTile: (id: string) => void;
</button> compact?: boolean;
<button }
type="button"
onClick={() => { function CallStage({
if (document.fullscreenElement === containerRef.current) { tiles,
void document.exitFullscreen(); speaker,
} mode,
setWatching(false); activeSpeakers,
}} e2ee,
aria-label={t('app:call.stop_watching', { defaultValue: 'Nicht mehr anschauen' })} remoteScreenShares,
title={t('app:call.stop_watching', { defaultValue: 'Nicht mehr anschauen' })} conversationMembers,
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" onFocusTile,
> compact = false,
<span aria-hidden="true" className="text-[14px] leading-none">×</span> }: StageProps) {
</button> 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> </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 ( 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"> <div className={'min-h-0 flex-1 p-3 ' + (compact ? '' : 'p-4')}>
<span <div className={'grid h-full gap-2 ' + gridClass}>
aria-hidden="true" {tiles.map((p) => (
className="text-[120px] font-display font-bold text-white/20 blur-[2px]" <CallParticipantTile
> key={p.userId}
{letter} userId={p.userId}
</span> 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> </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 ( return (
<svg viewBox="0 0 24 24" fill="currentColor" {...props}> <div className="h-full">
<path d="M8 5v14l11-7z" /> <CallParticipantTile
</svg> 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 ( return (
<svg <div className="absolute inset-0 z-40 flex flex-col overflow-hidden bg-surface">
viewBox="0 0 24 24" <div className="relative min-h-0 flex-1">
fill="none" {speaker && (
stroke="currentColor" <div className="absolute inset-0">
strokeWidth={2} {speaker.remoteSharing ? (
strokeLinecap="round" <FullscreenShare speaker={speaker} remoteScreenShares={remoteScreenShares} conversationMembers={conversationMembers} />
strokeLinejoin="round" ) : (
{...props} <div className="h-full w-full [&>div]:rounded-none [&>div]:border-0">
> <CallParticipantTile
<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5" /> userId={speaker.userId}
</svg> 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), []); useEffect(() => subscribePttSettings(setPtt), []);
if (!ptt.enabled) return null; if (!ptt.enabled) return null;
return ( return (
<p className="mt-3 text-center text-[11px] text-neutral-500"> <p className="border-t border-line bg-surface-3 py-2 text-center text-[11px] text-fg-muted">
Push-to-Talk:&nbsp; Push-to-Talk:&nbsp;
<kbd className="rounded border border-white/10 bg-white/5 px-1.5 py-0.5 font-mono text-[10px] text-neutral-300"> <kbd className="rounded border border-line bg-surface-2 px-1.5 py-0.5 font-mono text-[10px] text-fg">
{ptt.keyLabel} {ptt.keyLabel}
</kbd> </kbd>
</p> </p>
); );
} }
interface ParticipantTileData {
userId: string;
displayName: string;
avatarUrl: string | null;
self: boolean;
speaking: boolean;
muted: boolean;
}
function ParticipantTile(p: ParticipantTileData) {
const letter = p.displayName.trim().charAt(0).toUpperCase() || '?';
return (
<div className="flex w-28 flex-col items-center gap-2">
<div
className={
'relative flex h-20 w-20 items-center justify-center rounded-2xl bg-gradient-to-br from-brand-500/40 to-brand-700/40 text-2xl font-semibold text-white ring-2 transition ' +
(p.speaking
? 'ring-emerald-400 shadow-[0_0_24px_rgba(52,211,153,0.35)]'
: 'ring-white/10')
}
>
{p.avatarUrl ? (
<img
src={p.avatarUrl}
alt=""
className="h-full w-full rounded-2xl object-cover"
/>
) : (
<span aria-hidden="true">{letter}</span>
)}
{p.muted && (
<span
aria-hidden="true"
className="absolute -bottom-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full bg-amber-500 ring-2 ring-ink-950"
>
<MicOffIcon className="h-3 w-3 text-ink-950" />
</span>
)}
</div>
<p className="max-w-full truncate text-xs font-medium text-neutral-200">
{p.displayName}
{p.self && (
<span className="ml-1 text-neutral-500">·&nbsp;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>
);
}
+68 -27
View File
@@ -14,6 +14,7 @@ import { devLocalSecretStore } from '../lib/secretStore';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
import type { AggregatedReaction } from '../lib/useMessageReactions'; import type { AggregatedReaction } from '../lib/useMessageReactions';
import { AttachmentImage } from './AttachmentImage'; import { AttachmentImage } from './AttachmentImage';
import { Avatar } from './Avatar';
import { PencilIcon, PhoneIcon, PhoneOffIcon, SmileIcon, SpinnerIcon, TrashIcon, XIcon } from './icons'; import { PencilIcon, PhoneIcon, PhoneOffIcon, SmileIcon, SpinnerIcon, TrashIcon, XIcon } from './icons';
const EMOJI_CHOICES = ['👍', '❤️', '😂', '🎉', '🔥', '😮', '😢', '🙏']; const EMOJI_CHOICES = ['👍', '❤️', '😂', '🎉', '🔥', '😮', '😢', '🙏'];
@@ -23,6 +24,10 @@ interface Props {
message: DecryptedMessage; message: DecryptedMessage;
mine: boolean; mine: boolean;
groupedWithPrev: boolean; groupedWithPrev: boolean;
/** Last message of a run from this sender — anchor avatar on this row. */
isLastOfRun?: boolean;
senderDisplayName?: string | null | undefined;
senderAvatarUrl?: string | null | undefined;
conversationId: string; conversationId: string;
reactions: AggregatedReaction[]; reactions: AggregatedReaction[];
onToggleReaction: (emoji: string) => Promise<void>; onToggleReaction: (emoji: string) => Promise<void>;
@@ -33,6 +38,9 @@ export function MessageBubble({
message, message,
mine, mine,
groupedWithPrev, groupedWithPrev,
isLastOfRun = false,
senderDisplayName,
senderAvatarUrl,
conversationId, conversationId,
reactions, reactions,
onToggleReaction, onToggleReaction,
@@ -98,6 +106,8 @@ export function MessageBubble({
messageId: message.id, messageId: message.id,
conversationId, conversationId,
newPlaintext: trimmed, newPlaintext: trimmed,
senderUserId: session.user.id,
senderDeviceId: device.id,
senderPrivateKey: priv, senderPrivateKey: priv,
}); });
setEditing(false); setEditing(false);
@@ -141,8 +151,13 @@ export function MessageBubble({
if (message.deletedAt) { if (message.deletedAt) {
return ( return (
<div className={'flex ' + (mine ? 'justify-end' : 'justify-start')}> <div className={'flex items-end gap-2 ' + (mine ? 'flex-row-reverse' : '')}>
<div className="max-w-[70%] rounded-2xl border border-white/5 bg-white/5 px-3.5 py-1.5 text-xs italic text-neutral-500"> <AvatarSlot
show={isLastOfRun}
url={senderAvatarUrl ?? null}
displayName={senderDisplayName ?? null}
/>
<div className="max-w-[calc(70%-2.5rem)] rounded-2xl border border-line bg-surface-2 px-3.5 py-1.5 text-xs italic text-fg-muted">
{t('app:chats.deleted')} {t('app:chats.deleted')}
</div> </div>
</div> </div>
@@ -154,14 +169,19 @@ export function MessageBubble({
} }
return ( return (
<div className={'flex ' + (mine ? 'justify-end' : 'justify-start')}> <div className={'flex items-end gap-2 ' + (mine ? 'flex-row-reverse' : '')}>
<AvatarSlot
show={isLastOfRun}
url={senderAvatarUrl ?? null}
displayName={senderDisplayName ?? null}
/>
<div <div
className={ className={
'group relative max-w-[70%] ' + (groupedWithPrev ? 'mt-0.5' : 'mt-2') 'group relative max-w-[calc(70%-2.5rem)] ' + (groupedWithPrev ? 'mt-0.5' : 'mt-2')
} }
> >
{editing ? ( {editing ? (
<div className="rounded-2xl border border-brand-400/40 bg-ink-900/80 p-2 backdrop-blur-xl"> <div className="rounded-2xl border border-accent/40 bg-surface-2 p-2 shadow-sm">
<textarea <textarea
value={editText} value={editText}
onChange={(e) => setEditText(e.target.value)} onChange={(e) => setEditText(e.target.value)}
@@ -177,10 +197,10 @@ export function MessageBubble({
}} }}
rows={2} rows={2}
autoFocus autoFocus
className="w-full resize-none rounded-md bg-ink-800 px-3 py-2 text-sm text-white outline-none focus:ring-2 focus:ring-brand-400/60" className="w-full resize-none rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg outline-none focus:ring-2 focus:ring-accent/50"
/> />
{editError && ( {editError && (
<p className="mt-1.5 break-words rounded-md border border-rose-500/20 bg-rose-500/10 px-2 py-1 text-[11px] text-rose-200"> <p className="mt-1.5 break-words rounded-md border border-rose-500/30 bg-rose-500/10 px-2 py-1 text-[11px] text-rose-600 dark:text-rose-200">
{editError} {editError}
</p> </p>
)} )}
@@ -191,7 +211,7 @@ export function MessageBubble({
setEditing(false); setEditing(false);
setEditError(null); setEditError(null);
}} }}
className="cursor-pointer rounded-md border border-white/10 bg-white/5 px-3 py-1 text-xs text-neutral-200 hover:bg-white/10" className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-1 text-xs text-fg hover:bg-surface-2"
> >
{t('app:friends.action_cancel')} {t('app:friends.action_cancel')}
</button> </button>
@@ -199,7 +219,7 @@ export function MessageBubble({
type="button" type="button"
disabled={busy} disabled={busy}
onClick={() => void handleEditSave()} onClick={() => void handleEditSave()}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-brand-500/90 px-3 py-1 text-xs font-semibold text-white hover:bg-brand-400 disabled:opacity-60" className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-1 text-xs font-semibold text-accent-fg hover:brightness-110 disabled:opacity-60"
> >
{busy && <SpinnerIcon className="h-3.5 w-3.5" />} {busy && <SpinnerIcon className="h-3.5 w-3.5" />}
<span>{t('common:save', { defaultValue: 'Save' })}</span> <span>{t('common:save', { defaultValue: 'Save' })}</span>
@@ -209,14 +229,14 @@ export function MessageBubble({
) : ( ) : (
<div <div
className={ className={
'break-words rounded-2xl px-3.5 py-2 text-sm ' + 'break-words px-3.5 py-2 text-sm ' +
(mine (mine
? 'bg-brand-500/85 text-white' ? 'rounded-[18px_18px_4px_18px] bg-accent text-accent-fg'
: 'border border-white/5 bg-ink-900/70 text-neutral-100') : 'rounded-[18px_18px_18px_4px] border border-line text-fg')
} }
> >
{message.plaintext === null ? ( {message.plaintext === null ? (
<span className="italic text-neutral-400">cannot decrypt</span> <span className="italic opacity-70">cannot decrypt</span>
) : ( ) : (
<> <>
{bodyText.length > 0 && <div>{bodyText}</div>} {bodyText.length > 0 && <div>{bodyText}</div>}
@@ -228,7 +248,7 @@ export function MessageBubble({
<div <div
className={ className={
'mt-1 flex items-center gap-1.5 text-[10px] ' + 'mt-1 flex items-center gap-1.5 text-[10px] ' +
(mine ? 'text-brand-100/70' : 'text-neutral-500') (mine ? 'text-accent-fg/75' : 'text-fg-muted')
} }
> >
<span>{time}</span> <span>{time}</span>
@@ -240,7 +260,7 @@ export function MessageBubble({
)} )}
{showSeen && mine && !editing && !message.deletedAt && ( {showSeen && mine && !editing && !message.deletedAt && (
<p className="mt-0.5 text-right text-[10px] text-neutral-500"> <p className="mt-0.5 text-right text-[10px] text-fg-muted">
{t('app:chats.seen')} {t('app:chats.seen')}
</p> </p>
)} )}
@@ -253,10 +273,10 @@ export function MessageBubble({
type="button" type="button"
onClick={() => void onToggleReaction(r.emoji)} onClick={() => void onToggleReaction(r.emoji)}
className={ className={
'inline-flex cursor-pointer items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' + 'inline-flex cursor-pointer items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(r.mine (r.mine
? 'border-brand-400/40 bg-brand-500/20 text-brand-100' ? 'border-accent/40 bg-accent/20 text-accent'
: 'border-white/10 bg-white/5 text-neutral-200 hover:bg-white/10') : 'border-line bg-surface-2 text-fg hover:bg-surface-3')
} }
> >
<span>{r.emoji}</span> <span>{r.emoji}</span>
@@ -273,7 +293,7 @@ export function MessageBubble({
(mine ? 'right-full pr-2' : 'left-full pl-2') (mine ? 'right-full pr-2' : 'left-full pl-2')
} }
> >
<div className="flex items-center gap-0.5 rounded-lg border border-white/10 bg-ink-900/90 p-1 shadow-lg backdrop-blur-xl"> <div className="flex items-center gap-0.5 rounded-lg border border-line bg-surface-3 p-1 shadow-lg">
<ActionButton <ActionButton
label={t('app:friends.action_accept', { defaultValue: 'React' })} label={t('app:friends.action_accept', { defaultValue: 'React' })}
onClick={() => setPickerOpen((v) => !v)} onClick={() => setPickerOpen((v) => !v)}
@@ -304,7 +324,7 @@ export function MessageBubble({
ref={pickerRef} ref={pickerRef}
role="menu" role="menu"
className={ className={
'absolute z-30 mt-1 flex gap-1 rounded-lg border border-white/10 bg-ink-900/95 p-1.5 shadow-xl backdrop-blur-xl ' + 'absolute z-30 mt-1 flex gap-1 rounded-lg border border-line bg-surface-3 p-1.5 shadow-xl ' +
(mine ? 'right-0' : 'left-0') (mine ? 'right-0' : 'left-0')
} }
> >
@@ -313,7 +333,7 @@ export function MessageBubble({
key={e} key={e}
type="button" type="button"
onClick={() => void handlePickEmoji(e)} onClick={() => void handlePickEmoji(e)}
className="cursor-pointer rounded-md px-2 py-1 text-lg transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40" className="cursor-pointer rounded-md px-2 py-1 text-lg transition hover:bg-surface-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
> >
{e} {e}
</button> </button>
@@ -321,7 +341,7 @@ export function MessageBubble({
<button <button
type="button" type="button"
onClick={() => setPickerOpen(false)} onClick={() => setPickerOpen(false)}
className="cursor-pointer rounded-md px-1.5 py-1 text-neutral-500 transition hover:bg-white/10" className="cursor-pointer rounded-md px-1.5 py-1 text-fg-muted transition hover:bg-surface-2"
> >
<XIcon className="h-4 w-4" /> <XIcon className="h-4 w-4" />
</button> </button>
@@ -334,6 +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({ function CallEventRow({
parsed, parsed,
mine, mine,
@@ -349,8 +390,8 @@ function CallEventRow({
const Icon = isMissed ? PhoneOffIcon : PhoneIcon; const Icon = isMissed ? PhoneOffIcon : PhoneIcon;
const tone = isMissed const tone = isMissed
? 'border-rose-500/20 bg-rose-500/10 text-rose-200' ? 'border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-200'
: 'border-emerald-500/20 bg-emerald-500/10 text-emerald-200'; : 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-200';
const label = const label =
status === 'ended' status === 'ended'
@@ -406,10 +447,10 @@ function ActionButton({
title={label} title={label}
onClick={onClick} onClick={onClick}
className={ className={
'flex h-7 w-7 cursor-pointer items-center justify-center rounded-md transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' + 'flex h-7 w-7 cursor-pointer items-center justify-center rounded-md transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(tone === 'danger' (tone === 'danger'
? 'text-neutral-400 hover:bg-rose-500/20 hover:text-rose-200' ? 'text-fg-muted hover:bg-rose-500/20 hover:text-rose-500 dark:hover:text-rose-200'
: 'text-neutral-400 hover:bg-white/10 hover:text-neutral-100') : 'text-fg-muted hover:bg-surface-2 hover:text-fg')
} }
> >
{icon} {icon}
+5 -5
View File
@@ -35,22 +35,22 @@ export function Modal({ open, title, onClose, children, size = 'md' }: Props) {
aria-modal="true" aria-modal="true"
aria-label={title} aria-label={title}
onClick={onClose} onClick={onClose}
className="fixed inset-0 z-50 flex items-center justify-center bg-ink-950/80 p-6 backdrop-blur-sm animate-fade-in" className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-6 backdrop-blur-sm animate-fade-in"
> >
<div <div
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className={ className={
'relative w-full animate-slide-up rounded-2xl border border-white/10 bg-ink-900/95 shadow-xl backdrop-blur-xl ' + 'relative w-full animate-slide-up rounded-2xl border border-line bg-surface-3 shadow-xl ' +
width width
} }
> >
<header className="flex items-center justify-between border-b border-white/5 px-6 py-4"> <header className="flex items-center justify-between border-b border-line px-6 py-4">
<h2 className="font-display text-lg font-semibold text-white">{title}</h2> <h2 className="font-display text-lg font-semibold text-fg">{title}</h2>
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
aria-label="Close" aria-label="Close"
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-neutral-400 transition hover:bg-white/10 hover:text-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40" className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-2 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
> >
<XIcon className="h-4 w-4" /> <XIcon className="h-4 w-4" />
</button> </button>
@@ -0,0 +1,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>
);
}
+114 -48
View File
@@ -4,110 +4,176 @@ import { NavLink } from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useConversationsContext } from '../context/ConversationsContext'; import { useConversationsContext } from '../context/ConversationsContext';
import { useFriendshipsContext } from '../context/FriendshipsContext'; import { useFriendshipsContext } from '../context/FriendshipsContext';
import { CallBar } from './CallUI'; import { useTheme } from '../context/ThemeContext';
import { import {
ChatBubbleIcon, ChatBubbleIcon,
GearIcon, GearIcon,
LogoMark, LogoMark,
MoonIcon,
ShieldIcon, ShieldIcon,
SignOutIcon, SignOutIcon,
SunIcon,
UsersIcon, UsersIcon,
} from './icons'; } from './icons';
import { UserBar } from './UserBar';
interface NavItem { interface NavItem {
to: string; to: string;
labelKey: string; labelKey: string;
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element; icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
badge?: 'friends' | 'chats';
} }
const BASE_NAV_ITEMS: NavItem[] = [ const PRIMARY_NAV: NavItem[] = [
{ to: '/chats', labelKey: 'app:nav.chats', icon: ChatBubbleIcon }, { to: '/chats', labelKey: 'app:nav.chats', icon: ChatBubbleIcon, badge: 'chats' },
{ to: '/friends', labelKey: 'app:nav.friends', icon: UsersIcon }, { to: '/friends', labelKey: 'app:nav.friends', icon: UsersIcon, badge: 'friends' },
{ to: '/settings', labelKey: 'app:nav.settings', icon: GearIcon },
]; ];
const ADMIN_NAV_ITEM: NavItem = { const ADMIN_NAV: NavItem = {
to: '/admin', to: '/admin',
labelKey: 'app:nav.admin', labelKey: 'app:nav.admin',
icon: ShieldIcon, icon: ShieldIcon,
}; };
// Clean-Rail 72px icon rail. Brand logo top → primary tabs → spacer →
// settings + theme toggle + sign-out at bottom. Active tab shows a pill
// indicator anchored to the left edge of the rail.
export function Sidebar() { export function Sidebar() {
const { t } = useTranslation(['app', 'common']); const { t } = useTranslation(['app']);
const { signOut, profile } = useAuth(); const { signOut, profile } = useAuth();
const { incomingCount } = useFriendshipsContext(); const { incomingCount } = useFriendshipsContext();
const { totalUnread } = useConversationsContext(); const { totalUnread } = useConversationsContext();
const { theme, toggle } = useTheme();
const navItems = profile?.isAdmin ? [...BASE_NAV_ITEMS, ADMIN_NAV_ITEM] : BASE_NAV_ITEMS; const navItems: NavItem[] = profile?.isAdmin ? [...PRIMARY_NAV, ADMIN_NAV] : PRIMARY_NAV;
return ( return (
<aside <aside
aria-label="Primary navigation" aria-label="Primary navigation"
className="flex h-screen w-72 shrink-0 flex-col border-r border-white/5 bg-ink-900/70 backdrop-blur-xl" className="flex h-screen w-[72px] shrink-0 flex-col items-center border-r border-line bg-surface-2/80 py-4 backdrop-blur-xl"
> >
<div className="flex items-center gap-2.5 px-5 pb-3 pt-5"> <div className="flex h-10 w-10 items-center justify-center" title="Netralax">
<LogoMark className="h-7 w-7" /> <LogoMark className="h-8 w-8" />
<span className="font-display text-base font-semibold tracking-tight text-white">
{t('common:app_name')}
</span>
</div> </div>
<nav className="mt-2 flex flex-col gap-0.5 px-3"> <div className="my-3 h-px w-8 bg-line" aria-hidden="true" />
<nav className="flex flex-col items-center gap-2">
{navItems.map((item) => { {navItems.map((item) => {
const badge = const badgeCount =
item.to === '/friends' item.badge === 'friends'
? incomingCount ? incomingCount
: item.to === '/chats' : item.badge === 'chats'
? totalUnread ? totalUnread
: 0; : 0;
const ariaLabel = badge > 0 ? `${t(item.labelKey)} (${badge})` : undefined;
return ( return (
<NavLink <RailNavLink
key={item.to} key={item.to}
to={item.to} to={item.to}
aria-label={ariaLabel} label={t(item.labelKey)}
className={({ isActive }) => icon={item.icon}
[ badge={badgeCount}
'group flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition', />
'focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/50',
isActive
? 'bg-brand-500/15 text-white ring-1 ring-brand-400/30'
: 'text-neutral-400 hover:bg-white/5 hover:text-neutral-100',
].join(' ')
}
>
<item.icon style={{ width: '18px', height: '18px' }} className="transition" />
<span className="flex-1">{t(item.labelKey)}</span>
{badge > 0 && <NavBadge count={badge} />}
</NavLink>
); );
})} })}
</nav> </nav>
<div className="flex-1" /> <div className="flex-1" />
<div className="border-t border-white/5 p-3"> <div className="flex flex-col items-center gap-2">
<CallBar /> <RailNavLink
<UserBar /> to="/settings"
<button label={t('app:nav.settings')}
type="button" icon={GearIcon}
/>
<RailIconButton
label={theme === 'dark' ? t('app:theme.light', { defaultValue: 'Light mode' }) : t('app:theme.dark', { defaultValue: 'Dark mode' })}
onClick={toggle}
icon={theme === 'dark' ? SunIcon : MoonIcon}
/>
<RailIconButton
label={t('app:sidebar.sign_out')}
onClick={() => void signOut()} onClick={() => void signOut()}
className="mt-2 flex w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-xs font-medium text-neutral-400 transition hover:bg-rose-500/10 hover:text-rose-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/40" icon={SignOutIcon}
> tone="danger"
<SignOutIcon className="h-4 w-4" /> />
<span>{t('app:sidebar.sign_out')}</span>
</button>
</div> </div>
</aside> </aside>
); );
} }
function NavBadge({ count }: { count: number }) { interface RailNavLinkProps {
to: string;
label: string;
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
badge?: number;
}
function RailNavLink({ to, label, icon: Icon, badge = 0 }: RailNavLinkProps) {
const ariaLabel = badge > 0 ? `${label} (${badge})` : label;
return (
<NavLink
to={to}
aria-label={ariaLabel}
title={label}
className={({ isActive }) =>
[
'group relative flex h-11 w-11 cursor-pointer items-center justify-center rounded-xl transition',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50',
isActive
? 'bg-accent/15 text-accent'
: 'text-fg-muted hover:bg-surface-3/60 hover:text-fg',
].join(' ')
}
>
{({ isActive }) => (
<>
{isActive && (
<span
aria-hidden="true"
className="absolute -left-4 h-6 w-1 rounded-r-full bg-accent"
/>
)}
<Icon style={{ width: '20px', height: '20px' }} />
{badge > 0 && <RailBadge count={badge} />}
</>
)}
</NavLink>
);
}
interface RailIconButtonProps {
label: string;
onClick: () => void;
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
tone?: 'default' | 'danger';
}
function RailIconButton({ label, onClick, icon: Icon, tone = 'default' }: RailIconButtonProps) {
const toneClass =
tone === 'danger'
? 'text-fg-muted hover:bg-rose-500/10 hover:text-rose-400 focus-visible:ring-rose-400/40'
: 'text-fg-muted hover:bg-surface-3/60 hover:text-fg focus-visible:ring-accent/50';
return (
<button
type="button"
aria-label={label}
title={label}
onClick={onClick}
className={
'flex h-11 w-11 cursor-pointer items-center justify-center rounded-xl transition focus:outline-none focus-visible:ring-2 ' +
toneClass
}
>
<Icon style={{ width: '20px', height: '20px' }} />
</button>
);
}
function RailBadge({ count }: { count: number }) {
const display = count > 99 ? '99+' : String(count); const display = count > 99 ? '99+' : String(count);
return ( return (
<span <span
aria-hidden="true" aria-hidden="true"
className="inline-flex min-w-[20px] items-center justify-center rounded-full bg-rose-500 px-1.5 text-[10px] font-bold leading-tight text-white shadow-[0_0_0_2px_rgba(15,15,24,1)]" className="absolute -right-1 -top-1 inline-flex min-w-[18px] items-center justify-center rounded-full bg-rose-500 px-1 text-[10px] font-bold leading-tight text-white ring-2 ring-surface-2"
> >
{display} {display}
</span> </span>
@@ -20,7 +20,7 @@ export function TypingIndicator({ typingUserIds, members }: Props) {
: t('app:chats.typing_many', { count: typingUserIds.length }); : t('app:chats.typing_many', { count: typingUserIds.length });
return ( return (
<div className="px-6 pb-1 pt-0 text-xs text-neutral-400"> <div className="bg-surface-3 px-6 pb-1 pt-0 text-xs text-fg-muted">
<span className="inline-flex items-center gap-2"> <span className="inline-flex items-center gap-2">
<TypingDots /> <TypingDots />
<span>{text}</span> <span>{text}</span>
@@ -32,9 +32,9 @@ export function TypingIndicator({ typingUserIds, members }: Props) {
function TypingDots() { function TypingDots() {
return ( return (
<span aria-hidden="true" className="inline-flex items-center gap-0.5"> <span aria-hidden="true" className="inline-flex items-center gap-0.5">
<span className="h-1 w-1 rounded-full bg-neutral-500 [animation:pulse_1.2s_ease-in-out_infinite]" /> <span className="h-1 w-1 rounded-full bg-fg-muted [animation:pulse_1.2s_ease-in-out_infinite]" />
<span className="h-1 w-1 rounded-full bg-neutral-500 [animation:pulse_1.2s_ease-in-out_infinite] [animation-delay:0.15s]" /> <span className="h-1 w-1 rounded-full bg-fg-muted [animation:pulse_1.2s_ease-in-out_infinite] [animation-delay:0.15s]" />
<span className="h-1 w-1 rounded-full bg-neutral-500 [animation:pulse_1.2s_ease-in-out_infinite] [animation-delay:0.3s]" /> <span className="h-1 w-1 rounded-full bg-fg-muted [animation:pulse_1.2s_ease-in-out_infinite] [animation-delay:0.3s]" />
</span> </span>
); );
} }
+7 -7
View File
@@ -57,22 +57,22 @@ export function UpdateToast() {
<div <div
role="status" role="status"
aria-live="polite" aria-live="polite"
className="fixed bottom-6 left-6 z-50 w-80 overflow-hidden rounded-2xl border border-white/10 bg-ink-900/95 p-4 shadow-2xl backdrop-blur-xl" className="fixed bottom-6 left-6 z-50 w-80 overflow-hidden rounded-2xl border border-line bg-surface-3 p-4 shadow-2xl"
> >
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-500/25 text-brand-200 ring-1 ring-brand-400/30"> <div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-accent/20 text-accent">
<SparklesIcon className="h-4 w-4" /> <SparklesIcon className="h-4 w-4" />
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-white"> <p className="text-sm font-semibold text-fg">
{t('app:update.available', { defaultValue: 'Update verfügbar' })} {t('app:update.available', { defaultValue: 'Update verfügbar' })}
{state.version ? ' · v' + state.version : ''} {state.version ? ' · v' + state.version : ''}
</p> </p>
{state.notes && ( {state.notes && (
<p className="mt-0.5 line-clamp-3 text-xs text-neutral-400">{state.notes}</p> <p className="mt-0.5 line-clamp-3 text-xs text-fg-muted">{state.notes}</p>
)} )}
{state.error && ( {state.error && (
<p className="mt-1 text-xs text-rose-300">{state.error}</p> <p className="mt-1 text-xs text-rose-500 dark:text-rose-300">{state.error}</p>
)} )}
</div> </div>
{!installing && ( {!installing && (
@@ -80,7 +80,7 @@ export function UpdateToast() {
type="button" type="button"
onClick={() => setDismissed(true)} onClick={() => setDismissed(true)}
aria-label={t('common:close', { defaultValue: 'Schließen' })} aria-label={t('common:close', { defaultValue: 'Schließen' })}
className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-neutral-400 transition hover:bg-white/10 hover:text-neutral-100" className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-fg-muted transition hover:bg-surface-2 hover:text-fg"
> >
<XIcon className="h-3.5 w-3.5" /> <XIcon className="h-3.5 w-3.5" />
</button> </button>
@@ -90,7 +90,7 @@ export function UpdateToast() {
type="button" type="button"
onClick={() => void handleInstall()} onClick={() => void handleInstall()}
disabled={installing} disabled={installing}
className="mt-3 inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-gradient-to-br from-brand-400 to-brand-600 px-3 py-2 text-sm font-semibold text-white transition hover:from-brand-300 hover:to-brand-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/60 disabled:cursor-not-allowed disabled:opacity-60" className="mt-3 inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 disabled:cursor-not-allowed disabled:opacity-60"
> >
{installing ? ( {installing ? (
<> <>
+18 -17
View File
@@ -5,22 +5,19 @@ import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
import { Avatar } from './Avatar';
import { ChevronDownIcon } from './icons'; import { ChevronDownIcon } from './icons';
const PRESENCE_OPTIONS: PresenceState[] = ['online', 'idle', 'dnd', 'invisible', 'offline']; const PRESENCE_OPTIONS: PresenceState[] = ['online', 'idle', 'dnd', 'invisible', 'offline'];
const PRESENCE_DOT: Record<PresenceState, string> = { const PRESENCE_DOT: Record<PresenceState, string> = {
online: 'bg-emerald-400', online: 'bg-emerald-500',
idle: 'bg-amber-400', idle: 'bg-amber-400',
dnd: 'bg-rose-500', dnd: 'bg-rose-500',
invisible: 'bg-neutral-500', invisible: 'bg-neutral-500',
offline: 'bg-neutral-600', offline: 'bg-neutral-400 dark:bg-neutral-600',
}; };
function avatarLetter(input: string | undefined): string {
return (input ?? '?').trim().charAt(0).toUpperCase() || '?';
}
export function UserBar() { export function UserBar() {
const { t } = useTranslation(['app']); const { t } = useTranslation(['app']);
const { profile, refreshProfile } = useAuth(); const { profile, refreshProfile } = useAuth();
@@ -53,34 +50,38 @@ export function UserBar() {
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={open} aria-expanded={open}
className="flex w-full cursor-pointer items-center gap-3 rounded-lg px-2 py-2 text-left transition hover:bg-white/5 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40" className="flex w-full cursor-pointer items-center gap-3 rounded-lg px-2 py-2 text-left transition hover:bg-surface-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
> >
<div className="relative flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-sm font-semibold text-white ring-1 ring-brand-400/30"> <div className="relative">
{avatarLetter(profile?.displayName ?? profile?.username)} <Avatar
url={profile?.avatarUrl ?? null}
displayName={profile?.displayName ?? profile?.username}
className="h-9 w-9 text-sm"
/>
<span <span
className={ className={
'absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full ring-2 ring-ink-900 ' + 'absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full ring-2 ring-surface-2 ' +
PRESENCE_DOT[presence] PRESENCE_DOT[presence]
} }
/> />
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-white"> <p className="truncate text-sm font-medium text-fg">
{profile?.displayName ?? '—'} {profile?.displayName ?? '—'}
</p> </p>
<p className="truncate text-xs text-neutral-400"> <p className="truncate text-xs text-fg-muted">
{profile?.username ? '@' + profile.username : '—'} {t('app:presence.' + presence)}
</p> </p>
</div> </div>
<ChevronDownIcon <ChevronDownIcon
className={'h-4 w-4 text-neutral-500 transition ' + (open ? 'rotate-180' : '')} className={'h-4 w-4 text-fg-muted transition ' + (open ? 'rotate-180' : '')}
/> />
</button> </button>
{open && ( {open && (
<div <div
role="menu" role="menu"
className="absolute bottom-full left-0 right-0 mb-2 overflow-hidden rounded-lg border border-white/10 bg-ink-900/95 shadow-xl backdrop-blur-xl" className="absolute bottom-full left-0 right-0 mb-2 overflow-hidden rounded-lg border border-line bg-surface-3 shadow-xl"
> >
{PRESENCE_OPTIONS.map((opt) => ( {PRESENCE_OPTIONS.map((opt) => (
<button <button
@@ -90,12 +91,12 @@ export function UserBar() {
aria-checked={opt === presence} aria-checked={opt === presence}
onClick={() => void changePresence(opt)} onClick={() => void changePresence(opt)}
disabled={busy} disabled={busy}
className="flex w-full cursor-pointer items-center gap-3 px-3 py-2 text-left text-sm text-neutral-200 transition hover:bg-white/5 disabled:opacity-50" className="flex w-full cursor-pointer items-center gap-3 px-3 py-2 text-left text-sm text-fg transition hover:bg-surface-2 disabled:opacity-50"
> >
<span className={'h-2.5 w-2.5 rounded-full ' + PRESENCE_DOT[opt]} /> <span className={'h-2.5 w-2.5 rounded-full ' + PRESENCE_DOT[opt]} />
<span className="flex-1">{t('app:presence.' + opt)}</span> <span className="flex-1">{t('app:presence.' + opt)}</span>
{opt === presence && ( {opt === presence && (
<span className="text-xs text-brand-300"></span> <span className="text-xs text-accent"></span>
)} )}
</button> </button>
))} ))}
+163 -14
View File
@@ -336,32 +336,181 @@ 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) { export function LogoMark(props: IconProps) {
return ( return (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 32 32" viewBox="0 0 64 64"
fill="none" fill="none"
aria-hidden="true" aria-hidden="true"
{...props} {...props}
> >
<defs> <defs>
<linearGradient id="logo-grad" x1="0" y1="0" x2="32" y2="32" gradientUnits="userSpaceOnUse"> <clipPath id="logo-hex-clip">
<stop offset="0" stopColor="#818CF8" /> <polygon points="32,5 57,19 57,45 32,59 7,45 7,19" />
<stop offset="1" stopColor="#4F46E5" /> </clipPath>
</linearGradient>
</defs> </defs>
<path <polygon
d="M6 9a5 5 0 0 1 5-5h10a5 5 0 0 1 5 5v8a5 5 0 0 1-5 5h-5.5L9 27v-5H11a5 5 0 0 1-5-5V9Z" points="32,5 57,19 57,45 32,59 7,45 7,19"
fill="url(#logo-grad)" fill="#2e1065"
/> />
<path <g clipPath="url(#logo-hex-clip)">
d="M12 14h8M12 10h8" <path
stroke="#0A0A0F" d="M-4 32 Q 16 18 32 32 T 68 32 L 68 64 L -4 64 Z"
strokeWidth="1.75" fill="#7c4dff"
strokeLinecap="round" />
strokeOpacity="0.5" <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"
/> />
</svg> </svg>
); );
} }
// Full lockup: hex icon + "Netralax" wordmark. `tone` decides text colour:
// "dark" = white text (use on dark background), "light" = black text.
export function LogoLockup({
tone = 'dark',
...props
}: IconProps & { tone?: 'dark' | 'light' }) {
const textFill = tone === 'dark' ? '#ffffff' : '#0F172A';
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 260 64"
fill="none"
aria-hidden="true"
{...props}
>
<defs>
<clipPath id="logo-lockup-hex-clip">
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19" />
</clipPath>
</defs>
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19" fill="#2e1065" />
<g clipPath="url(#logo-lockup-hex-clip)">
<path
d="M-4 32 Q 16 18 32 32 T 68 32 L 68 64 L -4 64 Z"
fill="#7c4dff"
/>
<path
d="M-4 32 Q 16 18 32 32 T 68 32"
stroke="#a78bfa"
strokeWidth="2"
fill="none"
/>
</g>
<polygon
points="32,5 57,19 57,45 32,59 7,45 7,19"
fill="none"
stroke="#a78bfa"
strokeWidth="1.5"
opacity="0.4"
/>
<text
x="78"
y="42"
fontFamily="'Space Grotesk', system-ui, sans-serif"
fontSize="30"
fontWeight="600"
letterSpacing="-0.6"
fill={textFill}
>
Netralax
</text>
</svg>
);
}
+3 -1
View File
@@ -18,6 +18,7 @@ import {
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { findExistingDevice } from '../lib/device'; import { findExistingDevice } from '../lib/device';
import { setSecretStoreUser } from '../lib/secretStore';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
interface AuthContextValue { interface AuthContextValue {
@@ -60,7 +61,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const status = (error as { status?: number }).status; const status = (error as { status?: number }).status;
if (status === 401 || status === 403) { if (status === 401 || status === 403) {
// Token genuinely invalid — wipe. // Token genuinely invalid — wipe.
await supabase.auth.signOut().catch(() => { await supabase.auth.signOut({ scope: 'local' }).catch(() => {
/* ignore */ /* ignore */
}); });
setSession(null); setSession(null);
@@ -81,6 +82,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const { data: sub } = supabase.auth.onAuthStateChange((_event, s) => { const { data: sub } = supabase.auth.onAuthStateChange((_event, s) => {
setSession(s); setSession(s);
setReady(true); setReady(true);
void setSecretStoreUser(s?.user.id ?? null);
}); });
return () => { return () => {
cancelled = true; cancelled = true;
+49
View File
@@ -90,6 +90,10 @@ export interface RemoteScreenShare {
participantName: string; participantName: string;
} }
// Visual call modes (Discord-style): grid shows all tiles equally, focus pins
// one speaker with others in a strip, fullscreen is cinema mode.
export type CallMode = 'grid' | 'focus' | 'fullscreen';
interface CallContextValue { interface CallContextValue {
state: CallState; state: CallState;
room: Room | null; room: Room | null;
@@ -102,6 +106,9 @@ interface CallContextValue {
// Remembers the conversation of the last call we left so a sidebar widget // Remembers the conversation of the last call we left so a sidebar widget
// can show "still live — rejoin" while peers stay in the room. // can show "still live — rejoin" while peers stay in the room.
lastCallConversationId: string | null; lastCallConversationId: string | null;
// Clean-Rail UI state — display mode + focused participant id.
callMode: CallMode;
focusedId: string | null;
// Actions: // Actions:
startCall: (conversationId: string, mediaKind?: CallKind) => Promise<void>; startCall: (conversationId: string, mediaKind?: CallKind) => Promise<void>;
joinActiveCall: (conversationId: string, mediaKind?: CallKind) => Promise<void>; joinActiveCall: (conversationId: string, mediaKind?: CallKind) => Promise<void>;
@@ -111,6 +118,8 @@ interface CallContextValue {
toggleMute: () => void; toggleMute: () => void;
toggleScreenShare: () => Promise<void>; toggleScreenShare: () => Promise<void>;
dismissLastCall: () => void; dismissLastCall: () => void;
setCallMode: (mode: CallMode) => void;
setFocusedId: (id: string | null) => void;
} }
const CallContext = createContext<CallContextValue | null>(null); const CallContext = createContext<CallContextValue | null>(null);
@@ -135,6 +144,8 @@ export function CallProvider({ children }: { children: ReactNode }) {
const [isScreenSharing, setIsScreenSharing] = useState(false); const [isScreenSharing, setIsScreenSharing] = useState(false);
const [remoteScreenShares, setRemoteScreenShares] = useState<RemoteScreenShare[]>([]); const [remoteScreenShares, setRemoteScreenShares] = useState<RemoteScreenShare[]>([]);
const [lastCallConversationId, setLastCallConversationId] = useState<string | null>(null); const [lastCallConversationId, setLastCallConversationId] = useState<string | null>(null);
const [callMode, setCallModeState] = useState<CallMode>('grid');
const [focusedId, setFocusedIdState] = useState<string | null>(null);
const signalChannelRef = useRef<RealtimeChannel | null>(null); const signalChannelRef = useRef<RealtimeChannel | null>(null);
const presenceChannelRef = useRef<RealtimeChannel | null>(null); const presenceChannelRef = useRef<RealtimeChannel | null>(null);
@@ -954,6 +965,36 @@ export function CallProvider({ children }: { children: ReactNode }) {
} }
}, [myId, clearRingTimer, clearSoloTimer, disconnectRoom, sendSignal, emitCallEvent]); }, [myId, clearRingTimer, clearSoloTimer, disconnectRoom, sendSignal, emitCallEvent]);
const setCallMode = useCallback((mode: CallMode) => {
setCallModeState(mode);
}, []);
const setFocusedId = useCallback((id: string | null) => {
setFocusedIdState(id);
}, []);
// Reset UI call-mode state when the call leaves any active phase so the next
// call starts fresh at grid/unfocused.
useEffect(() => {
if (state.kind === 'idle' || state.kind === 'error') {
setCallModeState('grid');
setFocusedIdState(null);
}
}, [state.kind]);
// Global Esc: drop out of fullscreen cinema back to grid while in an active
// call. Doesn't hangup and doesn't fire when other dialogs would consume it.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
if (callMode !== 'fullscreen') return;
if (state.kind !== 'connected' && state.kind !== 'connecting') return;
setCallModeState('grid');
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [callMode, state.kind]);
const value = useMemo<CallContextValue>( const value = useMemo<CallContextValue>(
() => ({ () => ({
state, state,
@@ -964,6 +1005,8 @@ export function CallProvider({ children }: { children: ReactNode }) {
isScreenSharing, isScreenSharing,
remoteScreenShares, remoteScreenShares,
lastCallConversationId, lastCallConversationId,
callMode,
focusedId,
startCall, startCall,
joinActiveCall, joinActiveCall,
acceptIncoming, acceptIncoming,
@@ -972,6 +1015,8 @@ export function CallProvider({ children }: { children: ReactNode }) {
toggleMute, toggleMute,
toggleScreenShare, toggleScreenShare,
dismissLastCall, dismissLastCall,
setCallMode,
setFocusedId,
}), }),
[ [
state, state,
@@ -982,6 +1027,8 @@ export function CallProvider({ children }: { children: ReactNode }) {
isScreenSharing, isScreenSharing,
remoteScreenShares, remoteScreenShares,
lastCallConversationId, lastCallConversationId,
callMode,
focusedId,
startCall, startCall,
joinActiveCall, joinActiveCall,
acceptIncoming, acceptIncoming,
@@ -990,6 +1037,8 @@ export function CallProvider({ children }: { children: ReactNode }) {
toggleMute, toggleMute,
toggleScreenShare, toggleScreenShare,
dismissLastCall, dismissLastCall,
setCallMode,
setFocusedId,
], ],
); );
+53
View File
@@ -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;
}
+77
View File
@@ -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;
}
+236
View File
@@ -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 });
}
}
}
+93
View File
@@ -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);
}
}
+8 -1
View File
@@ -4,6 +4,8 @@ import {
sendNotification, sendNotification,
} from '@tauri-apps/plugin-notification'; } from '@tauri-apps/plugin-notification';
import { isTauriRuntime } from './globalShortcut';
// Tracks whether permission has already been requested this session so we // Tracks whether permission has already been requested this session so we
// don't spam the OS prompt. Actual permission state lives in the OS. // don't spam the OS prompt. Actual permission state lives in the OS.
let permissionChecked = false; let permissionChecked = false;
@@ -12,6 +14,11 @@ let permissionGranted = false;
export async function ensureNotificationPermission(): Promise<boolean> { export async function ensureNotificationPermission(): Promise<boolean> {
if (permissionChecked) return permissionGranted; if (permissionChecked) return permissionGranted;
permissionChecked = true; permissionChecked = true;
if (!isTauriRuntime()) {
// Web preview / Chrome — Tauri notification plugin not available.
permissionGranted = false;
return false;
}
try { try {
let granted = await isPermissionGranted(); let granted = await isPermissionGranted();
if (!granted) { if (!granted) {
@@ -20,7 +27,6 @@ export async function ensureNotificationPermission(): Promise<boolean> {
} }
permissionGranted = granted; permissionGranted = granted;
} catch (err: unknown) { } catch (err: unknown) {
// Not running under Tauri (e.g. web preview) — fall back silently.
permissionGranted = false; permissionGranted = false;
console.warn('notification permission check failed', err); console.warn('notification permission check failed', err);
} }
@@ -40,6 +46,7 @@ interface NotifyOpts {
export async function notify({ title, body, force = false }: NotifyOpts): Promise<void> { export async function notify({ title, body, force = false }: NotifyOpts): Promise<void> {
if (!force && isAppFocused()) return; if (!force && isAppFocused()) return;
if (!isTauriRuntime()) return;
const granted = await ensureNotificationPermission(); const granted = await ensureNotificationPermission();
if (!granted) return; if (!granted) return;
try { try {
+58 -4
View File
@@ -1,12 +1,21 @@
import { base64FromBytes, bytesFromBase64, type SecretStore } from '@chat-app/shared/auth'; import { base64FromBytes, bytesFromBase64, type SecretStore } from '@chat-app/shared/auth';
// M1 dev-only impl: persists secrets as base64 in localStorage. import { isTauriRuntime } from './globalShortcut';
// Swap this out for a tauri-plugin-stronghold implementation before release. import { makeSecureFileStore, migrateLocalStorageToVault } from './secureFileStore';
// The SecretStore interface stays identical so callers won't notice.
// Two-tier SecretStore:
// - Tauri runtime: encrypted single-file vault in `appLocalDataDir`
// (`secureFileStore` — XSalsa20-Poly1305 + Argon2id KDF). Survives app
// reinstalls when the OS preserves the data dir.
// - Web / pre-auth: plain localStorage (legacy fallback).
//
// Callers import `devLocalSecretStore` and call `setSecretStoreUser(userId)`
// once the session is known. The singleton object's identity is stable so
// existing imports keep working.
const PREFIX = 'chatapp.secret:'; const PREFIX = 'chatapp.secret:';
export const devLocalSecretStore: SecretStore = { const localStore: SecretStore = {
async getSecret(key: string): Promise<Uint8Array | null> { async getSecret(key: string): Promise<Uint8Array | null> {
const raw = window.localStorage.getItem(PREFIX + key); const raw = window.localStorage.getItem(PREFIX + key);
if (!raw) return null; if (!raw) return null;
@@ -20,3 +29,48 @@ export const devLocalSecretStore: SecretStore = {
window.localStorage.removeItem(PREFIX + key); window.localStorage.removeItem(PREFIX + key);
}, },
}; };
let activeBackend: SecretStore = localStore;
let activeUserId: string | null = null;
export async function setSecretStoreUser(userId: string | null): Promise<void> {
if (userId === activeUserId) return;
activeUserId = userId;
if (userId && isTauriRuntime()) {
const fileStore = makeSecureFileStore(userId);
try {
// Probe write/read to confirm the vault is usable on this machine.
// If anything throws (perm denied, disk full, KDF error), fall back to
// localStorage so the rest of the app keeps working.
await fileStore.getSecret('__probe');
activeBackend = fileStore;
try {
await migrateLocalStorageToVault(userId, PREFIX);
} catch (err: unknown) {
console.warn('vault migration failed', err);
}
} catch (err: unknown) {
console.warn('secure file vault init failed — falling back to localStorage', err);
activeBackend = localStore;
}
} else {
activeBackend = localStore;
}
}
export const devLocalSecretStore: SecretStore = {
async getSecret(key) {
return activeBackend.getSecret(key);
},
async setSecret(key, value) {
return activeBackend.setSecret(key, value);
},
async removeSecret(key) {
return activeBackend.removeSecret(key);
},
};
export function isEncryptedVaultActive(): boolean {
return activeBackend !== localStore;
}
+237
View File
@@ -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);
}
+106
View File
@@ -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();
}
+44
View File
@@ -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);
}
+35
View File
@@ -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;
}
-94
View File
@@ -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;
}
+32 -21
View File
@@ -6,11 +6,15 @@ import { supabase } from './supabase';
// the set of userIds currently in that call. Independent of whether the // the set of userIds currently in that call. Independent of whether the
// viewer is in the room — used to show "Active call · Join" affordances. // viewer is in the room — used to show "Active call · Join" affordances.
// //
// Supabase realtime 2.103+ dedupes channels by topic: calling // Why presence-callbacks + no-removeChannel:
// `supabase.channel(topic)` returns an existing channel if one already exists. // - Supabase realtime 2.103+ dedupes channels by topic. CallContext owns
// That means an observer can't safely register `.on('presence', ...)` because // the same channel during a live call to track its own presence.
// the tracker (CallContext) may have already subscribed it. We side-step this // - If this hook removed the channel on unmount (e.g. when ConversationHeader
// by polling `presenceState()` on the (shared or fresh) channel. // hides during an active call), the live tracking connection dies and the
// banner never shows the rejoin option afterwards.
// - Binding our own presence callbacks (sync/join/leave) ensures the local
// presence state is updated even when CallContext also listens on the same
// channel — multiple callbacks can co-exist.
export function useCallPresence(conversationId: string | undefined): string[] { export function useCallPresence(conversationId: string | undefined): string[] {
const [activeUserIds, setActiveUserIds] = useState<string[]>([]); const [activeUserIds, setActiveUserIds] = useState<string[]>([]);
@@ -24,23 +28,13 @@ export function useCallPresence(conversationId: string | undefined): string[] {
config: { config: {
presence: { presence: {
key: 'observer-' + Math.random().toString(36).slice(2, 8), key: 'observer-' + Math.random().toString(36).slice(2, 8),
// Supabase realtime only sends presence_state/diff events to a // Server only routes presence to channels that opt-in. Without this
// channel that has presence enabled. Without `enabled: true` (and // flag we'd receive zero presence_state/presence_diff messages.
// no `.on('presence', ...)` bindings) the server treats the channel
// as non-presence and `presenceState()` never populates.
enabled: true, enabled: true,
}, },
}, },
}); });
// If Supabase returned a fresh (closed) channel, we own it and must
// subscribe/remove it. If it returned an already-subscribed channel
// (tracker owns it), we just read state.
const ownsChannel = channel.state === 'closed';
if (ownsChannel) {
void channel.subscribe();
}
const resync = () => { const resync = () => {
const state = channel.presenceState() as Record<string, Array<Record<string, unknown>>>; const state = channel.presenceState() as Record<string, Array<Record<string, unknown>>>;
const ids = new Set<string>(); const ids = new Set<string>();
@@ -57,14 +51,31 @@ export function useCallPresence(conversationId: string | undefined): string[] {
}); });
}; };
// Supabase realtime forbids `.on()` after `.subscribe()`. So we only bind
// callbacks when we own the subscribe (channel was 'closed'). For deduped
// shared channels, the polling fallback below covers state changes.
if (channel.state === 'closed') {
channel
.on('presence', { event: 'sync' }, resync)
.on('presence', { event: 'join' }, resync)
.on('presence', { event: 'leave' }, resync);
void channel.subscribe();
}
// Initial poll covers the case where the channel is already 'joined' (we
// dedupe'd onto an existing subscription) and won't fire a fresh sync.
resync(); resync();
const pollId = window.setInterval(resync, 1500); // Belt-and-suspenders: a slow poll catches any drift between callback and
// server state (e.g. if a presence event was dropped during reconnect).
const pollId = window.setInterval(resync, 3000);
return () => { return () => {
window.clearInterval(pollId); window.clearInterval(pollId);
if (ownsChannel) { // Don't removeChannel — the channel is shared with CallContext (and any
void supabase.removeChannel(channel); // sibling observers) via Supabase's topic dedupe. Removing it would
} // tear down a live call's presence tracker. The channel naturally
// outlives the hook; leaks are bounded by the small number of
// conversations a user holds open in a session.
}; };
}, [conversationId]); }, [conversationId]);
+111 -26
View File
@@ -1,18 +1,16 @@
import { loadDevicePrivateKey } from '@chat-app/shared/auth'; import { loadDevicePrivateKey } from '@chat-app/shared/auth';
import { import {
type AttachmentHandle, type AttachmentHandle,
type ChatMessage,
type DecryptedMessage, type DecryptedMessage,
decryptMessages, decryptMessages,
encryptAndUploadAttachment, encryptAndUploadAttachment,
fetchConversationMessages, fetchConversationMessages,
fetchOwnEnvelopes,
fetchSenderDeviceKeys,
insertAttachmentRow, insertAttachmentRow,
MAX_ATTACHMENT_BYTES, MAX_ATTACHMENT_BYTES,
type MessageWithCipher,
sendEncryptedMessage, sendEncryptedMessage,
} from '@chat-app/shared/chat'; } 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { devLocalSecretStore } from './secretStore'; import { devLocalSecretStore } from './secretStore';
@@ -36,7 +34,7 @@ type MessageChangePayload = {
old: Record<string, unknown>; old: Record<string, unknown>;
}; };
function rowToMessage(row: Record<string, unknown>): ChatMessage { function rowToMessage(row: Record<string, unknown>): MessageWithCipher {
return { return {
id: String(row.id), id: String(row.id),
conversationId: String(row.conversation_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, editedAt: row.edited_at ? String(row.edited_at) : null,
deletedAt: row.deleted_at ? String(row.deleted_at) : null, deletedAt: row.deleted_at ? String(row.deleted_at) : null,
createdAt: String(row.created_at), 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]); }, [userId, deviceId]);
const decryptBatch = useCallback( const decryptBatch = useCallback(
async (messages: ChatMessage[]): Promise<DecryptedMessage[]> => { async (messages: MessageWithCipher[]): Promise<DecryptedMessage[]> => {
const priv = privateKeyRef.current; const priv = privateKeyRef.current;
if (!priv || !deviceId || messages.length === 0) { if (!priv || !deviceId || messages.length === 0) {
return messages.map((m) => ({ ...m, plaintext: null })); 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({ return decryptMessages({
client: supabase,
messages, messages,
envelopes, ownDeviceId: deviceId,
senderKeys,
ownPrivateKey: priv, ownPrivateKey: priv,
}); });
}, },
@@ -105,26 +98,81 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
} }
}, [conversationId, decryptBatch]); }, [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( const handleInsert = useCallback(
async (row: Record<string, unknown>) => { async (row: Record<string, unknown>) => {
if (!deviceId) return; if (!conversationId || !deviceId) return;
const msg = rowToMessage(row); const id = String(row.id);
let decrypted: DecryptedMessage = { ...msg, plaintext: null };
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++) { 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]); const [d] = await decryptBatch([msg]);
if (d) { if (d) {
decrypted = d; decrypted = d;
if (d.plaintext !== null) break; 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) => { setState((prev) => {
if (prev.messages.some((m) => m.id === decrypted.id)) return prev; if (prev.messages.some((m) => m.id === decrypted!.id)) return prev;
return { ...prev, messages: [...prev.messages, decrypted] }; return { ...prev, messages: [...prev.messages, decrypted!] };
}); });
}, },
[deviceId, decryptBatch], [conversationId, deviceId, decryptBatch],
); );
const handleUpdate = useCallback( 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(); .subscribe();
return () => { return () => {
@@ -226,7 +290,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
blobNonceHexByHandleId.set(res.handle.id, bytesToPgHex(res.nonce)); 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({ const msg = await sendEncryptedMessage({
client: supabase, client: supabase,
conversationId, conversationId,
@@ -237,7 +301,28 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
...(handles.length > 0 ? { attachmentHandles: handles } : {}), ...(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) { for (const h of handles) {
const blobNonce = blobNonceHexByHandleId.get(h.id) ?? '\\x'; const blobNonce = blobNonceHexByHandleId.get(h.id) ?? '\\x';
await insertAttachmentRow(supabase, msg.id, h, blobNonce); await insertAttachmentRow(supabase, msg.id, h, blobNonce);
+40 -38
View File
@@ -16,6 +16,7 @@ import { extractErrorCode } from '@chat-app/shared/i18n';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Avatar } from '../components/Avatar';
import { import {
AlertIcon, AlertIcon,
CopyIcon, CopyIcon,
@@ -64,9 +65,10 @@ export function AdminPage() {
}, [refresh]); }, [refresh]);
return ( 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> <header>
<h1 className="font-display text-2xl font-semibold tracking-tight text-white"> <h1 className="font-display text-2xl font-semibold tracking-tight text-fg">
{t('app:admin.title')} {t('app:admin.title')}
</h1> </h1>
</header> </header>
@@ -74,16 +76,16 @@ export function AdminPage() {
{error && ( {error && (
<div <div
role="alert" role="alert"
className="flex items-start gap-3 rounded-lg border border-rose-500/20 bg-rose-500/10 p-3 text-sm text-rose-100" className="flex items-start gap-3 rounded-lg border border-rose-500/30 bg-rose-500/10 p-3 text-sm text-rose-700 dark:text-rose-100"
> >
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" /> <AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-500 dark:text-rose-400" />
<p className="min-w-0 flex-1 break-words">{error}</p> <p className="min-w-0 flex-1 break-words">{error}</p>
</div> </div>
)} )}
{loading ? ( {loading ? (
<div className="flex items-center gap-2 text-sm text-neutral-500"> <div className="flex items-center gap-2 text-sm text-fg-muted">
<SpinnerIcon className="h-4 w-4 text-brand-400" /> <SpinnerIcon className="h-4 w-4 text-accent" />
</div> </div>
) : ( ) : (
<> <>
@@ -92,15 +94,16 @@ export function AdminPage() {
<UsersSection users={users} onRefresh={refresh} /> <UsersSection users={users} onRefresh={refresh} />
</> </>
)} )}
</div>
</div> </div>
); );
} }
function Section({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) { function Section({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) {
return ( return (
<section className="rounded-2xl border border-white/10 bg-ink-900/60 p-5 backdrop-blur-xl"> <section className="rounded-2xl border border-line bg-surface-2 p-5">
<header className="mb-4 flex items-center justify-between"> <header className="mb-4 flex items-center justify-between">
<h2 className="text-xs font-semibold uppercase tracking-wide text-neutral-400">{title}</h2> <h2 className="text-xs font-semibold uppercase tracking-[0.1em] text-fg-muted">{title}</h2>
{action} {action}
</header> </header>
{children} {children}
@@ -199,7 +202,7 @@ function InvitesSection({ invites, onRefresh }: { invites: InviteRecord[]; onRef
type="button" type="button"
onClick={() => void handleCreate()} onClick={() => void handleCreate()}
disabled={busy} disabled={busy}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-brand-500/90 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-brand-400 disabled:opacity-60" className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110 disabled:opacity-60"
> >
{busy ? <SpinnerIcon className="h-3.5 w-3.5" /> : <PlusIcon className="h-3.5 w-3.5" />} {busy ? <SpinnerIcon className="h-3.5 w-3.5" /> : <PlusIcon className="h-3.5 w-3.5" />}
<span>{t('app:admin.invites_create')}</span> <span>{t('app:admin.invites_create')}</span>
@@ -207,11 +210,11 @@ function InvitesSection({ invites, onRefresh }: { invites: InviteRecord[]; onRef
} }
> >
{invites.length === 0 ? ( {invites.length === 0 ? (
<p className="text-sm text-neutral-500">{t('app:admin.invites_empty')}</p> <p className="text-sm text-fg-muted">{t('app:admin.invites_empty')}</p>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-left text-sm"> <table className="w-full text-left text-sm">
<thead className="text-[10px] uppercase tracking-wide text-neutral-500"> <thead className="text-[10px] uppercase tracking-[0.1em] text-fg-muted">
<tr> <tr>
<th className="py-2 pr-3">{t('app:admin.invite_col_code')}</th> <th className="py-2 pr-3">{t('app:admin.invite_col_code')}</th>
<th className="py-2 pr-3">{t('app:admin.invite_col_uses')}</th> <th className="py-2 pr-3">{t('app:admin.invite_col_uses')}</th>
@@ -220,7 +223,7 @@ function InvitesSection({ invites, onRefresh }: { invites: InviteRecord[]; onRef
<th className="py-2" /> <th className="py-2" />
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-white/5"> <tbody className="divide-y divide-line">
{invites.map((inv) => { {invites.map((inv) => {
const expired = inv.expiresAt ? new Date(inv.expiresAt) < new Date() : false; const expired = inv.expiresAt ? new Date(inv.expiresAt) < new Date() : false;
const status = inv.disabled const status = inv.disabled
@@ -229,10 +232,10 @@ function InvitesSection({ invites, onRefresh }: { invites: InviteRecord[]; onRef
? t('app:admin.invite_status_expired') ? t('app:admin.invite_status_expired')
: t('app:admin.invite_status_active'); : t('app:admin.invite_status_active');
const statusColor = inv.disabled const statusColor = inv.disabled
? 'text-rose-300' ? 'text-rose-600 dark:text-rose-300'
: expired : expired
? 'text-amber-300' ? 'text-amber-600 dark:text-amber-300'
: 'text-emerald-300'; : 'text-emerald-600 dark:text-emerald-300';
const usesText = inv.usesLimit const usesText = inv.usesLimit
? inv.usesCount + '/' + inv.usesLimit ? inv.usesCount + '/' + inv.usesLimit
: inv.usesCount + '/∞'; : inv.usesCount + '/∞';
@@ -241,9 +244,9 @@ function InvitesSection({ invites, onRefresh }: { invites: InviteRecord[]; onRef
: t('app:admin.invite_expires_never'); : t('app:admin.invite_expires_never');
return ( return (
<tr key={inv.code} className="py-2"> <tr key={inv.code} className="py-2">
<td className="py-2 pr-3 font-mono text-xs">{inv.code}</td> <td className="py-2 pr-3 font-mono text-xs text-fg">{inv.code}</td>
<td className="py-2 pr-3 text-xs text-neutral-400">{usesText}</td> <td className="py-2 pr-3 text-xs text-fg-muted">{usesText}</td>
<td className="py-2 pr-3 text-xs text-neutral-400">{expiresText}</td> <td className="py-2 pr-3 text-xs text-fg-muted">{expiresText}</td>
<td className={'py-2 pr-3 text-xs font-medium ' + statusColor}>{status}</td> <td className={'py-2 pr-3 text-xs font-medium ' + statusColor}>{status}</td>
<td className="py-2 text-right"> <td className="py-2 text-right">
<div className="inline-flex gap-1"> <div className="inline-flex gap-1">
@@ -260,7 +263,7 @@ function InvitesSection({ invites, onRefresh }: { invites: InviteRecord[]; onRef
<button <button
type="button" type="button"
onClick={() => void handleToggle(inv.code, inv.disabled)} onClick={() => void handleToggle(inv.code, inv.disabled)}
className="rounded-md border border-white/10 bg-white/5 px-2 py-1 text-[11px] font-medium text-neutral-300 transition hover:bg-white/10" className="rounded-md border border-line bg-surface-3 px-2 py-1 text-[11px] font-medium text-fg transition hover:brightness-95"
> >
{inv.disabled ? t('app:admin.invites_enable') : t('app:admin.invites_disable')} {inv.disabled ? t('app:admin.invites_enable') : t('app:admin.invites_disable')}
</button> </button>
@@ -301,20 +304,19 @@ function UsersSection({ users, onRefresh }: { users: AdminProfileRow[]; onRefres
return ( return (
<Section title={t('app:admin.users_title')}> <Section title={t('app:admin.users_title')}>
{users.length === 0 ? ( {users.length === 0 ? (
<p className="text-sm text-neutral-500">{t('app:admin.users_empty')}</p> <p className="text-sm text-fg-muted">{t('app:admin.users_empty')}</p>
) : ( ) : (
<ul className="divide-y divide-white/5"> <ul className="divide-y divide-line">
{users.map((u) => { {users.map((u) => {
const letter =
(u.displayName ?? u.username ?? '?').trim().charAt(0).toUpperCase() || '?';
return ( return (
<li key={u.userId} className="flex items-center gap-3 py-3"> <li key={u.userId} className="flex items-center gap-3 py-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-sm font-semibold text-white ring-1 ring-brand-400/30"> <Avatar
{letter} displayName={u.displayName ?? u.username}
</div> className="h-9 w-9 text-sm"
/>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-white">{u.displayName}</p> <p className="truncate text-sm font-semibold text-fg">{u.displayName}</p>
<p className="truncate text-xs text-neutral-500">@{u.username}</p> <p className="truncate text-xs text-fg-muted">@{u.username}</p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<FlagChip <FlagChip
@@ -361,8 +363,8 @@ function Toggle({
return ( return (
<label className="flex cursor-pointer items-start justify-between gap-4"> <label className="flex cursor-pointer items-start justify-between gap-4">
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block text-sm text-neutral-200">{label}</span> <span className="block text-sm text-fg">{label}</span>
{hint && <span className="mt-1 block text-xs text-neutral-500">{hint}</span>} {hint && <span className="mt-1 block text-xs text-fg-muted">{hint}</span>}
</span> </span>
<span className="relative mt-0.5 inline-flex h-6 w-11 shrink-0"> <span className="relative mt-0.5 inline-flex h-6 w-11 shrink-0">
<input <input
@@ -372,8 +374,8 @@ function Toggle({
onChange={(e) => onChange(e.target.checked)} onChange={(e) => onChange(e.target.checked)}
className="peer sr-only" className="peer sr-only"
/> />
<span className="inline-block h-6 w-11 rounded-full bg-neutral-700 transition peer-checked:bg-brand-500/70 peer-disabled:opacity-50" /> <span className="inline-block h-6 w-11 rounded-full bg-surface transition peer-checked:bg-accent peer-disabled:opacity-50" />
<span className="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition peer-checked:translate-x-5" /> <span className="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white shadow transition peer-checked:translate-x-5" />
</span> </span>
</label> </label>
); );
@@ -399,8 +401,8 @@ function IconButton({
className={ className={
'flex h-7 w-7 cursor-pointer items-center justify-center rounded-md border transition focus:outline-none focus-visible:ring-2 ' + 'flex h-7 w-7 cursor-pointer items-center justify-center rounded-md border transition focus:outline-none focus-visible:ring-2 ' +
(tone === 'danger' (tone === 'danger'
? 'border-rose-500/30 bg-rose-500/10 text-rose-200 hover:bg-rose-500/20 focus-visible:ring-rose-400/40' ? 'border-rose-500/40 bg-rose-500/10 text-rose-600 hover:bg-rose-500/20 focus-visible:ring-rose-400/40 dark:text-rose-300'
: 'border-white/10 bg-white/5 text-neutral-300 hover:bg-white/10 focus-visible:ring-brand-400/40') : 'border-line bg-surface-3 text-fg hover:brightness-95 focus-visible:ring-accent/40')
} }
> >
{children} {children}
@@ -421,15 +423,15 @@ function FlagChip({
}) { }) {
const activeClass = const activeClass =
tone === 'danger' tone === 'danger'
? 'border-rose-400/40 bg-rose-500/20 text-rose-100' ? 'border-rose-500/40 bg-rose-500/20 text-rose-700 dark:text-rose-100'
: 'border-brand-400/40 bg-brand-500/20 text-brand-100'; : 'border-accent/40 bg-accent/20 text-accent';
return ( return (
<button <button
type="button" type="button"
onClick={() => onToggle(!active)} onClick={() => onToggle(!active)}
className={ className={
'cursor-pointer rounded-full border px-2.5 py-0.5 text-[11px] font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' + 'cursor-pointer rounded-full border px-2.5 py-0.5 text-[11px] font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(active ? activeClass : 'border-white/10 bg-white/5 text-neutral-400 hover:bg-white/10') (active ? activeClass : 'border-line bg-surface-3 text-fg-muted hover:brightness-95')
} }
> >
{label} {label}
+121 -41
View File
@@ -5,7 +5,14 @@ import { useTranslation } from 'react-i18next';
import { NavLink, Outlet, useParams } from 'react-router-dom'; import { NavLink, Outlet, useParams } from 'react-router-dom';
import { CreateGroupDialog } from '../components/CreateGroupDialog'; 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 { useConversationsContext } from '../context/ConversationsContext';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
@@ -13,6 +20,7 @@ export function ChatsPage() {
const { conversations, loading, error, refresh, unread } = useConversationsContext(); const { conversations, loading, error, refresh, unread } = useConversationsContext();
const { id: activeId } = useParams<{ id: string }>(); const { id: activeId } = useParams<{ id: string }>();
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [query, setQuery] = useState('');
const sorted = useMemo(() => { const sorted = useMemo(() => {
return [...conversations].sort((a, b) => { return [...conversations].sort((a, b) => {
@@ -22,14 +30,26 @@ export function ChatsPage() {
}); });
}, [conversations]); }, [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 ( return (
<div className="flex h-full"> <div className="flex h-full">
<ConversationList <ConversationList
items={sorted} items={filtered}
loading={loading} loading={loading}
error={error} error={error}
activeId={activeId} activeId={activeId}
unread={unread} unread={unread}
query={query}
onQueryChange={setQuery}
onAccept={async (id) => { onAccept={async (id) => {
try { try {
await acceptDm(supabase, id); await acceptDm(supabase, id);
@@ -41,7 +61,7 @@ export function ChatsPage() {
}} }}
onNewGroup={() => setCreateOpen(true)} onNewGroup={() => setCreateOpen(true)}
/> />
<div className="flex-1 border-l border-white/5"> <div className="flex-1 border-l border-line bg-surface-3">
<Outlet /> <Outlet />
</div> </div>
<CreateGroupDialog open={createOpen} onClose={() => setCreateOpen(false)} /> <CreateGroupDialog open={createOpen} onClose={() => setCreateOpen(false)} />
@@ -49,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({ function ConversationList({
items, items,
loading, loading,
error, error,
activeId, activeId,
unread, unread,
query,
onQueryChange,
onAccept, onAccept,
onNewGroup, onNewGroup,
}: { }: ConversationListProps) {
items: ConversationSummary[];
loading: boolean;
error: string | null;
activeId: string | undefined;
unread: Record<string, number>;
onAccept: (id: string) => void;
onNewGroup: () => void;
}) {
const { t } = useTranslation(['app']); const { t } = useTranslation(['app']);
return ( return (
<aside <aside
aria-label="Conversations" aria-label="Conversations"
className="flex h-full w-[320px] shrink-0 flex-col bg-ink-900/40" className="flex h-full w-[320px] shrink-0 flex-col bg-surface-2"
> >
<header className="flex items-center justify-between px-4 pb-3 pt-5"> <header className="flex items-center justify-between px-4 pb-3 pt-5">
<h2 className="font-display text-base font-semibold tracking-tight text-white"> <h2 className="font-display text-base font-semibold tracking-tight text-fg">
{t('app:nav.chats')} {t('app:nav.chats')}
</h2> </h2>
<button <button
@@ -82,29 +108,45 @@ function ConversationList({
onClick={onNewGroup} onClick={onNewGroup}
aria-label={t('app:chats.new_group')} aria-label={t('app:chats.new_group')}
title={t('app:chats.new_group')} title={t('app:chats.new_group')}
className="flex h-8 cursor-pointer items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2.5 text-xs font-medium text-neutral-300 transition hover:bg-white/10 hover:text-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40" className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
> >
<UsersIcon className="h-3.5 w-3.5" /> <AddUserIcon className="h-4 w-4" />
<PlusIcon className="h-3 w-3" />
</button> </button>
</header> </header>
<div className="px-3 pb-2">
<label className="relative block">
<span className="sr-only">{t('app:chats.search', { defaultValue: 'Suche' })}</span>
<SearchIcon
aria-hidden="true"
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-fg-muted"
/>
<input
type="search"
value={query}
onChange={(e) => onQueryChange(e.target.value)}
placeholder={t('app:chats.search_placeholder', { defaultValue: 'Suche…' })}
className="w-full rounded-lg border border-line bg-surface-3 py-2 pl-9 pr-3 text-sm text-fg placeholder-fg-muted transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
/>
</label>
</div>
{loading ? ( {loading ? (
<div className="flex items-center gap-2 px-4 py-2 text-xs text-neutral-500"> <div className="flex items-center gap-2 px-4 py-2 text-xs text-fg-muted">
<SpinnerIcon className="h-3.5 w-3.5 text-brand-400" /> <SpinnerIcon className="h-3.5 w-3.5 text-accent" />
</div> </div>
) : error ? ( ) : error ? (
<p className="px-4 py-2 text-xs text-rose-300">{error}</p> <p className="px-4 py-2 text-xs text-rose-500 dark:text-rose-300">{error}</p>
) : items.length === 0 ? ( ) : items.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center"> <div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-white/10 bg-white/5 text-brand-300"> <div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-line bg-surface-3 text-accent">
<ChatBubbleIcon className="h-5 w-5" /> <ChatBubbleIcon className="h-5 w-5" />
</div> </div>
<p className="text-sm text-neutral-400">{t('app:chats.empty_title')}</p> <p className="text-sm text-fg-muted">{t('app:chats.empty_title')}</p>
<p className="text-xs text-neutral-500">{t('app:chats.empty_subtitle')}</p> <p className="text-xs text-fg-muted/80">{t('app:chats.empty_subtitle')}</p>
</div> </div>
) : ( ) : (
<ul className="flex-1 overflow-y-auto px-2 pb-4"> <ul className="flex-1 overflow-y-auto px-2 pb-2">
{items.map((c) => ( {items.map((c) => (
<li key={c.id}> <li key={c.id}>
<ConversationRow <ConversationRow
@@ -117,6 +159,10 @@ function ConversationList({
))} ))}
</ul> </ul>
)} )}
<div className="shrink-0 border-t border-line bg-surface-2 p-2">
<UserBar />
</div>
</aside> </aside>
); );
} }
@@ -136,16 +182,22 @@ function ConversationRow({
const title = const title =
item.type === 'dm' ? (item.peer?.displayName ?? '?') : (item.name ?? '?'); item.type === 'dm' ? (item.peer?.displayName ?? '?') : (item.name ?? '?');
const handle = item.type === 'dm' ? '@' + (item.peer?.username ?? '?') : ''; const handle = item.type === 'dm' ? '@' + (item.peer?.username ?? '?') : '';
const letter = title.trim().charAt(0).toUpperCase() || '?'; const avatarUrl =
item.type === 'dm' ? (item.peer?.avatarUrl ?? null) : (item.avatarUrl ?? null);
const preview =
item.type === 'dm' ? handle : t('app:chats.group_preview', {
count: item.members.length,
defaultValue: `${item.members.length} Mitglieder`,
});
if (!item.acceptedByMe) { if (!item.acceptedByMe) {
return ( return (
<div className="my-1 rounded-xl border border-amber-500/20 bg-amber-500/5 p-3"> <div className="my-1 rounded-xl border border-amber-500/30 bg-amber-500/5 p-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Avatar letter={letter} /> <ConvAvatar url={avatarUrl} title={title} isGroup={item.type === 'group'} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-white">{title}</p> <p className="truncate text-sm font-semibold text-fg">{title}</p>
<p className="truncate text-xs text-amber-200/80"> <p className="truncate text-xs text-amber-600 dark:text-amber-200/80">
{t('app:friends.incoming_request')} {t('app:friends.incoming_request')}
</p> </p>
</div> </div>
@@ -153,7 +205,7 @@ function ConversationRow({
<button <button
type="button" type="button"
onClick={() => onAccept(item.id)} onClick={() => onAccept(item.id)}
className="mt-3 w-full cursor-pointer rounded-md bg-amber-500/20 px-3 py-1.5 text-xs font-semibold text-amber-100 transition hover:bg-amber-500/30 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/40" className="mt-3 w-full cursor-pointer rounded-md bg-amber-500/20 px-3 py-1.5 text-xs font-semibold text-amber-700 transition hover:bg-amber-500/30 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/40 dark:text-amber-100"
> >
{t('app:friends.action_accept')} {t('app:friends.action_accept')}
</button> </button>
@@ -165,26 +217,28 @@ function ConversationRow({
<NavLink <NavLink
to={'/chats/' + item.id} to={'/chats/' + item.id}
className={ className={
'my-1 flex cursor-pointer items-center gap-3 rounded-xl px-3 py-2.5 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' + 'my-1 flex cursor-pointer items-center gap-3 rounded-xl px-3 py-2.5 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(active ? 'bg-brand-500/15 ring-1 ring-brand-400/30' : 'hover:bg-white/5') (active
? 'bg-accent/15 text-fg'
: 'text-fg hover:bg-surface-3/70')
} }
> >
<Avatar letter={letter} /> <ConvAvatar url={avatarUrl} title={title} isGroup={item.type === 'group'} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p <p
className={ className={
'truncate text-sm ' + 'truncate text-sm ' +
(unreadCount > 0 ? 'font-bold text-white' : 'font-semibold text-white') (unreadCount > 0 ? 'font-bold' : 'font-semibold')
} }
> >
{title} {title}
</p> </p>
<p className="truncate text-xs text-neutral-500">{handle}</p> <p className="truncate text-xs text-fg-muted">{preview}</p>
</div> </div>
{unreadCount > 0 && ( {unreadCount > 0 && (
<span <span
aria-label={'Unread: ' + unreadCount} aria-label={'Unread: ' + unreadCount}
className="inline-flex min-w-[20px] items-center justify-center rounded-full bg-rose-500 px-1.5 text-[10px] font-bold leading-tight text-white" className="inline-flex min-w-[20px] items-center justify-center rounded-full bg-accent px-1.5 text-[10px] font-bold leading-tight text-accent-fg"
> >
{unreadCount > 99 ? '99+' : unreadCount} {unreadCount > 99 ? '99+' : unreadCount}
</span> </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 ( 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} {letter}
</div> </div>
); );
@@ -206,13 +286,13 @@ export function ChatsEmptyState() {
return ( return (
<div className="flex h-full items-center justify-center p-10"> <div className="flex h-full items-center justify-center p-10">
<div className="max-w-md text-center"> <div className="max-w-md text-center">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border border-white/10 bg-white/5 text-brand-300"> <div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border border-line bg-surface-2 text-accent">
<ChatBubbleIcon className="h-6 w-6" /> <ChatBubbleIcon className="h-6 w-6" />
</div> </div>
<h2 className="font-display text-2xl font-semibold tracking-tight text-white"> <h2 className="font-display text-2xl font-semibold tracking-tight text-fg">
{t('app:chats.select_prompt')} {t('app:chats.select_prompt')}
</h2> </h2>
<p className="mt-2 text-sm text-neutral-400">{t('app:chats.select_subtitle')}</p> <p className="mt-2 text-sm text-fg-muted">{t('app:chats.select_subtitle')}</p>
</div> </div>
</div> </div>
); );
+41 -18
View File
@@ -7,9 +7,11 @@ import { ConversationHeader } from '../components/ConversationHeader';
import { GroupInfoPanel } from '../components/GroupInfoPanel'; import { GroupInfoPanel } from '../components/GroupInfoPanel';
import { AlertIcon, ArrowRightIcon, PlusIcon, SpinnerIcon, XIcon } from '../components/icons'; import { AlertIcon, ArrowRightIcon, PlusIcon, SpinnerIcon, XIcon } from '../components/icons';
import { InCallPanel } from '../components/InCallPanel'; import { InCallPanel } from '../components/InCallPanel';
import { IncomingCallPanel } from '../components/IncomingCallPanel';
import { MessageBubble } from '../components/MessageBubble'; import { MessageBubble } from '../components/MessageBubble';
import { TypingIndicator } from '../components/TypingIndicator'; import { TypingIndicator } from '../components/TypingIndicator';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useCall } from '../context/CallContext';
import { useConversationsContext } from '../context/ConversationsContext'; import { useConversationsContext } from '../context/ConversationsContext';
import { useConversationMessages } from '../lib/useConversationMessages'; import { useConversationMessages } from '../lib/useConversationMessages';
import { useMessageReactions } from '../lib/useMessageReactions'; import { useMessageReactions } from '../lib/useMessageReactions';
@@ -157,14 +159,27 @@ export function ConversationPage() {
} }
const isGroup = conversation?.type === 'group'; const isGroup = conversation?.type === 'group';
const { state: callState } = useCall();
// Hide the chat header while this conversation hosts an active call — the
// call topbar inside the dock already shows the channel name + duration,
// and fullscreen cinema needs the whole slot.
const callHereActive =
(callState.kind === 'connected' ||
callState.kind === 'connecting' ||
callState.kind === 'outgoing') &&
callState.conversationId === id;
const incomingHere =
callState.kind === 'incoming' && callState.conversationId === id;
return ( return (
<div className="relative flex h-full flex-col"> <div className="relative flex h-full flex-col">
<ConversationHeader {!callHereActive && (
conversation={conversation} <ConversationHeader
peerPresence={peerPresence} conversation={conversation}
{...(isGroup ? { onInfoClick: () => setInfoPanelOpen(true) } : {})} peerPresence={peerPresence}
/> {...(isGroup ? { onInfoClick: () => setInfoPanelOpen(true) } : {})}
/>
)}
{isGroup && conversation && ( {isGroup && conversation && (
<GroupInfoPanel <GroupInfoPanel
@@ -174,28 +189,36 @@ export function ConversationPage() {
/> />
)} )}
{incomingHere && conversation && <IncomingCallPanel conversation={conversation} />}
{conversation && <InCallPanel conversation={conversation} />} {conversation && <InCallPanel conversation={conversation} />}
<div ref={scrollRef} onScroll={handleScroll} className="flex-1 overflow-y-auto px-6 py-4"> <div ref={scrollRef} onScroll={handleScroll} className="flex-1 overflow-y-auto bg-surface-3 px-6 py-4">
{loading ? ( {loading ? (
<div className="flex items-center gap-2 text-xs text-neutral-500"> <div className="flex items-center gap-2 text-xs text-fg-muted">
<SpinnerIcon className="h-3.5 w-3.5 text-brand-400" /> <SpinnerIcon className="h-3.5 w-3.5 text-accent" />
</div> </div>
) : error ? ( ) : error ? (
<Banner>{error}</Banner> <Banner>{error}</Banner>
) : messages.length === 0 ? ( ) : messages.length === 0 ? (
<p className="text-center text-sm text-neutral-500"></p> <p className="text-center text-sm text-fg-muted"></p>
) : ( ) : (
<ul className="space-y-0.5"> <ul className="space-y-0.5">
{messages.map((m, idx) => { {messages.map((m, idx) => {
const prev = messages[idx - 1]; const prev = messages[idx - 1];
const next = messages[idx + 1];
const grouped = idx > 0 && prev?.senderId === m.senderId; 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 ( return (
<li key={m.id}> <li key={m.id}>
<MessageBubble <MessageBubble
message={m} message={m}
mine={m.senderId === myId} mine={m.senderId === myId}
groupedWithPrev={grouped} groupedWithPrev={grouped}
isLastOfRun={isLastOfRun}
senderDisplayName={senderProfile?.displayName}
senderAvatarUrl={senderProfile?.avatarUrl}
conversationId={id ?? ''} conversationId={id ?? ''}
reactions={reactionsByMessage.get(m.id) ?? []} reactions={reactionsByMessage.get(m.id) ?? []}
onToggleReaction={(emoji) => toggleReaction(m.id, emoji)} onToggleReaction={(emoji) => toggleReaction(m.id, emoji)}
@@ -213,7 +236,7 @@ export function ConversationPage() {
members={conversation?.members ?? []} members={conversation?.members ?? []}
/> />
<form onSubmit={handleSend} className="border-t border-white/5 p-4"> <form onSubmit={handleSend} className="border-t border-line bg-surface-3 p-4">
{sendError && ( {sendError && (
<div className="mb-2"> <div className="mb-2">
<Banner>{sendError}</Banner> <Banner>{sendError}</Banner>
@@ -248,7 +271,7 @@ export function ConversationPage() {
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
aria-label="Bild anhängen" aria-label="Bild anhängen"
title="Bild anhängen" title="Bild anhängen"
className="inline-flex h-11 w-11 cursor-pointer items-center justify-center rounded-lg border border-white/10 bg-white/5 text-neutral-300 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40" className="inline-flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-accent text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
> >
<PlusIcon className="h-4 w-4" /> <PlusIcon className="h-4 w-4" />
</button> </button>
@@ -265,14 +288,14 @@ export function ConversationPage() {
} }
}} }}
rows={1} rows={1}
placeholder="…" placeholder="Nachricht schreiben…"
className="max-h-40 min-h-[44px] flex-1 resize-none rounded-lg border border-white/10 bg-ink-900/60 px-3 py-2.5 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40" className="max-h-40 min-h-[44px] flex-1 resize-none rounded-lg border border-line bg-surface-2 px-3 py-2.5 text-sm text-fg placeholder-fg-muted transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
/> />
<button <button
type="submit" type="submit"
disabled={sending || (text.trim().length === 0 && attachments.length === 0)} disabled={sending || (text.trim().length === 0 && attachments.length === 0)}
aria-busy={sending} aria-busy={sending}
className="inline-flex h-11 w-11 cursor-pointer items-center justify-center rounded-lg bg-gradient-to-br from-brand-400 to-brand-600 text-white transition hover:from-brand-300 hover:to-brand-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/60 disabled:cursor-not-allowed disabled:opacity-60" className="inline-flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-accent text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 disabled:cursor-not-allowed disabled:opacity-60"
> >
{sending ? <SpinnerIcon className="h-4 w-4" /> : <ArrowRightIcon className="h-4 w-4" />} {sending ? <SpinnerIcon className="h-4 w-4" /> : <ArrowRightIcon className="h-4 w-4" />}
</button> </button>
@@ -286,9 +309,9 @@ function Banner({ children }: { children: React.ReactNode }) {
return ( return (
<div <div
role="alert" role="alert"
className="flex items-start gap-3 rounded-lg border border-rose-500/20 bg-rose-500/10 p-3 text-sm text-rose-100" className="flex items-start gap-3 rounded-lg border border-rose-500/30 bg-rose-500/10 p-3 text-sm text-rose-700 dark:text-rose-100"
> >
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" /> <AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-500 dark:text-rose-400" />
<p className="min-w-0 flex-1 break-words">{children}</p> <p className="min-w-0 flex-1 break-words">{children}</p>
</div> </div>
); );
@@ -302,7 +325,7 @@ function AttachmentPreview({ file, onRemove }: { file: File; onRemove: () => voi
return () => URL.revokeObjectURL(u); return () => URL.revokeObjectURL(u);
}, [file]); }, [file]);
return ( return (
<div className="relative overflow-hidden rounded-lg border border-white/10 bg-ink-900/60"> <div className="relative overflow-hidden rounded-lg border border-line bg-surface-2">
{url ? ( {url ? (
<img src={url} alt={file.name} className="block h-20 w-20 object-cover" /> <img src={url} alt={file.name} className="block h-20 w-20 object-cover" />
) : ( ) : (
@@ -312,7 +335,7 @@ function AttachmentPreview({ file, onRemove }: { file: File; onRemove: () => voi
type="button" type="button"
onClick={onRemove} onClick={onRemove}
aria-label="Entfernen" aria-label="Entfernen"
className="absolute right-1 top-1 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-ink-950/80 text-neutral-200 transition hover:bg-rose-500/70 hover:text-white" className="absolute right-1 top-1 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/70 text-white transition hover:bg-rose-500/80"
> >
<XIcon className="h-3 w-3" /> <XIcon className="h-3 w-3" />
</button> </button>
+40 -45
View File
@@ -10,7 +10,9 @@ import {
import { extractErrorCode } from '@chat-app/shared/i18n'; import { extractErrorCode } from '@chat-app/shared/i18n';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { Avatar } from '../components/Avatar';
import { import {
AlertIcon, AlertIcon,
ChatBubbleIcon, ChatBubbleIcon,
@@ -28,6 +30,7 @@ type Tab = 'friends' | 'pending' | 'requests';
export function FriendsPage() { export function FriendsPage() {
const { t } = useTranslation(['app', 'errors']); const { t } = useTranslation(['app', 'errors']);
const { friendships, loading, error, refresh } = useFriendshipsContext(); const { friendships, loading, error, refresh } = useFriendshipsContext();
const navigate = useNavigate();
const [tab, setTab] = useState<Tab>('friends'); const [tab, setTab] = useState<Tab>('friends');
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [debounced, setDebounced] = useState(''); const [debounced, setDebounced] = useState('');
@@ -104,16 +107,17 @@ export function FriendsPage() {
); );
return ( return (
<div className="mx-auto flex h-full max-w-3xl flex-col gap-5 px-6 py-8"> <div className="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"> <header className="flex items-center justify-between gap-4">
<h1 className="font-display text-2xl font-semibold tracking-tight text-white"> <h1 className="font-display text-2xl font-semibold tracking-tight text-fg">
{t('app:friends.title')} {t('app:friends.title')}
</h1> </h1>
</header> </header>
<div className="space-y-3"> <div className="space-y-3">
<div className="relative"> <div className="relative">
<SearchIcon className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-neutral-500" /> <SearchIcon className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-fg-muted" />
<input <input
type="search" type="search"
value={query} value={query}
@@ -122,7 +126,7 @@ export function FriendsPage() {
autoComplete="off" autoComplete="off"
autoCapitalize="none" autoCapitalize="none"
spellCheck={false} spellCheck={false}
className="w-full rounded-lg border border-white/10 bg-ink-900/60 py-2.5 pl-10 pr-3 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40" className="w-full rounded-lg border border-line bg-surface-2 py-2.5 pl-10 pr-3 text-sm text-fg placeholder-fg-muted transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
/> />
</div> </div>
{query.length > 0 && ( {query.length > 0 && (
@@ -138,7 +142,7 @@ export function FriendsPage() {
)} )}
</div> </div>
<div className="flex gap-2 border-b border-white/5"> <div className="flex gap-2 border-b border-line">
<TabButton active={tab === 'friends'} onClick={() => setTab('friends')}> <TabButton active={tab === 'friends'} onClick={() => setTab('friends')}>
{t('app:friends.tab_friends')} · {accepted.length} {t('app:friends.tab_friends')} · {accepted.length}
</TabButton> </TabButton>
@@ -165,7 +169,7 @@ export function FriendsPage() {
onMessage={() => onMessage={() =>
performAction('msg-' + f.peer.userId, async () => { performAction('msg-' + f.peer.userId, async () => {
const id = await createDm(supabase, f.peer.userId); const id = await createDm(supabase, f.peer.userId);
navigateToChat(id); navigate('/chats/' + id);
}) })
} }
onUnfriend={() => onUnfriend={() =>
@@ -206,16 +210,11 @@ export function FriendsPage() {
)} )}
/> />
)} )}
</div>
</div> </div>
); );
} }
function navigateToChat(id: string): void {
// Push history + dispatch popstate so React Router re-evaluates the route.
window.history.pushState(null, '', '/chats/' + id);
window.dispatchEvent(new PopStateEvent('popstate'));
}
function translateError(err: unknown, t: ReturnType<typeof useTranslation>['t']): string { function translateError(err: unknown, t: ReturnType<typeof useTranslation>['t']): string {
const code = extractErrorCode(err); const code = extractErrorCode(err);
if (code) return t('errors:' + code, { defaultValue: t('errors:generic') }); if (code) return t('errors:' + code, { defaultValue: t('errors:generic') });
@@ -241,8 +240,8 @@ function TabButton({
className={ className={
'-mb-px cursor-pointer border-b-2 px-3 pb-2.5 text-sm font-medium transition focus:outline-none ' + '-mb-px cursor-pointer border-b-2 px-3 pb-2.5 text-sm font-medium transition focus:outline-none ' +
(active (active
? 'border-brand-400 text-white' ? 'border-accent text-fg'
: 'border-transparent text-neutral-400 hover:text-neutral-200') : 'border-transparent text-fg-muted hover:text-fg')
} }
> >
{children} {children}
@@ -262,10 +261,11 @@ function FriendList({
const { t } = useTranslation(['app']); const { t } = useTranslation(['app']);
if (items.length === 0) { if (items.length === 0) {
return ( return (
<div className="flex flex-1 flex-col items-center justify-center text-center text-sm text-neutral-500"> <div className="flex flex-1 flex-col items-center justify-center text-center text-sm text-fg-muted">
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-2xl border border-white/10 bg-white/5 text-neutral-400"> <div className="mb-3 flex h-12 w-12 items-center justify-center rounded-2xl border border-line bg-surface-2 text-fg-muted">
<UsersIcon className="h-5 w-5" /> <UsersIcon className="h-5 w-5" />
</div> </div>
<p>{t(emptyKey)}</p> <p>{t(emptyKey)}</p>
</div> </div>
); );
@@ -283,26 +283,21 @@ function FriendList({
function FriendRow({ profile, children }: { profile: ProfileBrief; children: React.ReactNode }) { function FriendRow({ profile, children }: { profile: ProfileBrief; children: React.ReactNode }) {
return ( return (
<div className="flex items-center gap-3 rounded-xl border border-white/5 bg-ink-900/50 px-4 py-3 backdrop-blur-sm"> <div className="flex items-center gap-3 rounded-xl border border-line bg-surface-2 px-4 py-3">
<Avatar profile={profile} /> <Avatar
url={profile.avatarUrl}
displayName={profile.displayName ?? profile.username}
className="h-9 w-9 text-sm"
/>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-white">{profile.displayName}</p> <p className="truncate text-sm font-semibold text-fg">{profile.displayName}</p>
<p className="truncate text-xs text-neutral-500">@{profile.username}</p> <p className="truncate text-xs text-fg-muted">@{profile.username}</p>
</div> </div>
<div className="flex shrink-0 gap-2">{children}</div> <div className="flex shrink-0 gap-2">{children}</div>
</div> </div>
); );
} }
function Avatar({ profile }: { profile: ProfileBrief }) {
const letter = (profile.displayName ?? profile.username ?? '?').trim().charAt(0).toUpperCase();
return (
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-sm font-semibold text-white ring-1 ring-brand-400/30">
{letter || '?'}
</div>
);
}
function FriendActions({ function FriendActions({
busy, busy,
onMessage, onMessage,
@@ -371,7 +366,7 @@ function PrimaryButton({
type="button" type="button"
disabled={busy} disabled={busy}
onClick={onClick} onClick={onClick}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-brand-500/90 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-brand-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 disabled:cursor-not-allowed disabled:opacity-60" className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
> >
{busy ? <SpinnerIcon className="h-3.5 w-3.5" /> : icon} {busy ? <SpinnerIcon className="h-3.5 w-3.5" /> : icon}
<span>{children}</span> <span>{children}</span>
@@ -393,7 +388,7 @@ function SecondaryButton({
type="button" type="button"
disabled={busy} disabled={busy}
onClick={onClick} onClick={onClick}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-xs font-medium text-neutral-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30 disabled:cursor-not-allowed disabled:opacity-60" className="inline-flex cursor-pointer items-center gap-1.5 rounded-md border border-line bg-surface-3 px-3 py-1.5 text-xs font-medium text-fg transition hover:brightness-95 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-60"
> >
{busy && <SpinnerIcon className="h-3.5 w-3.5" />} {busy && <SpinnerIcon className="h-3.5 w-3.5" />}
<span>{children}</span> <span>{children}</span>
@@ -415,7 +410,7 @@ function DangerButton({
type="button" type="button"
disabled={busy} disabled={busy}
onClick={onClick} onClick={onClick}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md border border-rose-500/30 bg-rose-500/10 px-3 py-1.5 text-xs font-medium text-rose-200 transition hover:bg-rose-500/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/40 disabled:cursor-not-allowed disabled:opacity-60" className="inline-flex cursor-pointer items-center gap-1.5 rounded-md border border-rose-500/40 bg-rose-500/10 px-3 py-1.5 text-xs font-medium text-rose-600 transition hover:bg-rose-500/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/40 disabled:cursor-not-allowed disabled:opacity-60 dark:text-rose-300"
> >
{busy && <SpinnerIcon className="h-3.5 w-3.5" />} {busy && <SpinnerIcon className="h-3.5 w-3.5" />}
<span>{children}</span> <span>{children}</span>
@@ -425,8 +420,8 @@ function DangerButton({
function LoadingRow() { function LoadingRow() {
return ( return (
<div className="flex items-center gap-3 text-sm text-neutral-500"> <div className="flex items-center gap-3 text-sm text-fg-muted">
<SpinnerIcon className="h-4 w-4 text-brand-400" /> <SpinnerIcon className="h-4 w-4 text-accent" />
<span></span> <span></span>
</div> </div>
); );
@@ -440,14 +435,14 @@ function Banner({ kind, children }: { kind: 'error' | 'info'; children: React.Re
className={ className={
'flex items-start gap-3 rounded-lg border p-3 text-sm ' + 'flex items-start gap-3 rounded-lg border p-3 text-sm ' +
(isError (isError
? 'border-rose-500/20 bg-rose-500/10 text-rose-100' ? 'border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-100'
: 'border-emerald-500/20 bg-emerald-500/10 text-emerald-100') : 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-100')
} }
> >
{isError ? ( {isError ? (
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" /> <AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-500 dark:text-rose-400" />
) : ( ) : (
<CheckCircleIcon className="mt-0.5 h-5 w-5 shrink-0 text-emerald-400" /> <CheckCircleIcon className="mt-0.5 h-5 w-5 shrink-0 text-emerald-600 dark:text-emerald-400" />
)} )}
<p className="min-w-0 flex-1 break-words">{children}</p> <p className="min-w-0 flex-1 break-words">{children}</p>
</div> </div>
@@ -474,24 +469,24 @@ function SearchResults({
const { t } = useTranslation(['app']); const { t } = useTranslation(['app']);
if (query.length < 2) { if (query.length < 2) {
return <p className="text-xs text-neutral-500">{t('app:friends.search_min_chars')}</p>; return <p className="text-xs text-fg-muted">{t('app:friends.search_min_chars')}</p>;
} }
if (searching) { if (searching) {
return ( return (
<div className="flex items-center gap-2 text-xs text-neutral-500"> <div className="flex items-center gap-2 text-xs text-fg-muted">
<SpinnerIcon className="h-3.5 w-3.5 text-brand-400" /> <SpinnerIcon className="h-3.5 w-3.5 text-accent" />
<span></span> <span></span>
</div> </div>
); );
} }
if (error) return <Banner kind="error">{error}</Banner>; if (error) return <Banner kind="error">{error}</Banner>;
if (results.length === 0) { if (results.length === 0) {
return <p className="text-xs text-neutral-500">{t('app:friends.search_no_results')}</p>; return <p className="text-xs text-fg-muted">{t('app:friends.search_no_results')}</p>;
} }
return ( return (
<div> <div>
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-neutral-500"> <p className="mb-2 text-xs font-medium uppercase tracking-[0.1em] text-fg-muted">
{t('app:friends.search_results_title')} {t('app:friends.search_results_title')}
</p> </p>
<ul className="space-y-1.5"> <ul className="space-y-1.5">
@@ -530,7 +525,7 @@ function SearchActionButton({
if (existing?.status === 'accepted') { if (existing?.status === 'accepted') {
return ( return (
<span className="inline-flex items-center gap-1.5 rounded-md border border-emerald-500/20 bg-emerald-500/10 px-3 py-1.5 text-xs font-medium text-emerald-200"> <span className="inline-flex items-center gap-1.5 rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-1.5 text-xs font-medium text-emerald-700 dark:text-emerald-200">
<CheckCircleIcon className="h-3.5 w-3.5" /> <CheckCircleIcon className="h-3.5 w-3.5" />
{t('app:friends.already_friends')} {t('app:friends.already_friends')}
</span> </span>
@@ -539,7 +534,7 @@ function SearchActionButton({
if (existing?.status === 'pending' && existing.direction === 'outgoing') { if (existing?.status === 'pending' && existing.direction === 'outgoing') {
return ( return (
<span className="inline-flex items-center gap-1.5 rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-neutral-300"> <span className="inline-flex items-center gap-1.5 rounded-md border border-line bg-surface-2 px-3 py-1.5 text-xs text-fg-muted">
{t('app:friends.request_sent')} {t('app:friends.request_sent')}
</span> </span>
); );

Some files were not shown because too many files have changed in this diff Show More