This commit is contained in:
2026-04-18 23:11:35 +02:00
commit f7cfd2a86e
196 changed files with 35538 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
# Vite exposes only VITE_-prefixed env vars to client code.
VITE_SUPABASE_URL=http://127.0.0.1:54321
VITE_SUPABASE_ANON_KEY=sb_publishable_replace_me
VITE_AUTH_REDIRECT_URL=http://localhost:1420/auth/callback
# LiveKit signaling URL. The token comes from the mint-livekit-token edge
# function; the client uses this URL to open the WebSocket connection.
VITE_LIVEKIT_URL=ws://localhost:7880
+1
View File
@@ -0,0 +1 @@
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDg0Q0Y0N0I1Q0U2MjBEMzcKUldRM0RXTE90VWZQaERVWnBGNTVKUVZ2MWZyRktaaDFJaXVWc3NGNUZVb08yVHNxaVp2c2dOL2oK
+32
View File
@@ -0,0 +1,32 @@
# @chat-app/desktop
Tauri v2 + React + Vite + Tailwind desktop client (macOS / Windows / Linux).
## Prerequisites
- Node 22+, pnpm 9+
- Rust + Cargo (install via https://rustup.rs)
- Platform build deps:
- macOS: Xcode Command Line Tools
- Linux: `libwebkit2gtk-4.1-dev`, `build-essential`, `libssl-dev` (Debian/Ubuntu)
- Windows: Microsoft C++ Build Tools + WebView2
## Dev
```bash
# from repo root
pnpm install
pnpm desktop:dev # tauri dev — launches Vite + native window
pnpm desktop:build # production bundle per platform
```
## Layout
- `src/` — React app (Vite bundles into `dist/`).
- `src-tauri/` — Rust backend. `main.rs` wires Tauri plugins.
- `vite.config.ts` — port `1420`, HMR on `1421`, aliases to shared packages.
## Secure Storage
Session tokens + user private keys live in Stronghold (`tauri-plugin-stronghold`).
Local message history lives in SQLite (`tauri-plugin-sql`), DB file encrypted at rest.
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>ChatApp</title>
</head>
<body class="bg-[#0b0b0f] text-white antialiased">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+51
View File
@@ -0,0 +1,51 @@
{
"name": "@chat-app/desktop",
"version": "0.1.0",
"private": true,
"description": "Tauri v2 desktop client (Windows / macOS / Linux)",
"type": "module",
"scripts": {
"dev": "tauri dev",
"vite:dev": "vite",
"build": "tauri build",
"vite:build": "vite build",
"preview": "vite preview",
"lint": "eslint src --ext .ts,.tsx",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"clean": "rm -rf dist .turbo src-tauri/target *.tsbuildinfo"
},
"dependencies": {
"@chat-app/db-types": "workspace:*",
"@chat-app/shared": "workspace:*",
"@chat-app/ui-web": "workspace:*",
"@livekit/components-react": "^2.9.0",
"@supabase/supabase-js": "^2.46.0",
"@tauri-apps/api": "^2.1.1",
"@tauri-apps/plugin-global-shortcut": "^2.3.1",
"@tauri-apps/plugin-notification": "^2.0.1",
"@tauri-apps/plugin-sql": "^2.0.1",
"@tauri-apps/plugin-stronghold": "^2.0.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"i18next": "^23.16.4",
"libsodium-wrappers": "0.7.15",
"livekit-client": "^2.7.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^15.1.1",
"react-router-dom": "^6.28.0",
"zustand": "^5.0.1"
},
"devDependencies": {
"@tauri-apps/cli": "^2.1.0",
"@types/libsodium-wrappers": "^0.7.14",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.15",
"vite": "^5.4.11"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+3
View File
@@ -0,0 +1,3 @@
target/
gen/
WixTools/
+7685
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
[package]
name = "chat-app-desktop"
version = "0.1.0"
description = "ChatApp desktop client"
authors = ["Dennis"]
edition = "2021"
rust-version = "1.77"
[lib]
name = "chat_app_desktop_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-notification = "2"
tauri-plugin-sql = { version = "2", features = ["sqlite"] }
tauri-plugin-stronghold = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies]
tauri-plugin-global-shortcut = "2"
tauri-plugin-updater = "2"
[features]
# This feature is used for production builds or when `devPath` points to the filesystem
# and disables specific features relevant to the dev build.
custom-protocol = ["tauri/custom-protocol"]
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSMicrophoneUsageDescription</key>
<string>ChatApp needs microphone access for voice calls.</string>
<key>NSCameraUsageDescription</key>
<string>ChatApp needs camera access for video calls.</string>
<key>NSScreenCaptureUsageDescription</key>
<string>ChatApp needs screen recording access for screen sharing during calls.</string>
</dict>
</plist>
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
@@ -0,0 +1,20 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default permissions for ChatApp desktop windows.",
"windows": ["main"],
"permissions": [
"core:default",
"notification:default",
"notification:allow-notify",
"notification:allow-is-permission-granted",
"notification:allow-request-permission",
"global-shortcut:allow-register",
"global-shortcut:allow-unregister",
"global-shortcut:allow-is-registered",
"updater:allow-check",
"updater:allow-download",
"updater:allow-install",
"updater:allow-download-and-install"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

+26
View File
@@ -0,0 +1,26 @@
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let mut builder = tauri::Builder::default()
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_sql::Builder::default().build())
.plugin(
tauri_plugin_stronghold::Builder::new(|password| {
// TODO: derive stronghold key from password using argon2 / blake2b.
// Placeholder so project compiles.
password.as_bytes().to_vec()
})
.build(),
);
// Global shortcut + updater plugins are desktop-only (no mobile support).
#[cfg(not(any(target_os = "android", target_os = "ios")))]
{
builder = builder
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.plugin(tauri_plugin_updater::Builder::new().build());
}
builder
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
chat_app_desktop_lib::run();
}
+51
View File
@@ -0,0 +1,51 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ChatApp",
"version": "0.1.0",
"identifier": "com.meinname.chatapp",
"build": {
"beforeDevCommand": "pnpm vite:dev",
"beforeBuildCommand": "pnpm vite:build",
"devUrl": "http://localhost:1420",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "ChatApp",
"width": 1200,
"height": 800,
"minWidth": 800,
"minHeight": 600,
"resizable": true,
"fullscreen": false
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"createUpdaterArtifacts": true
},
"plugins": {
"updater": {
"endpoints": [
"https://github.com/netralax/chat-app/releases/latest/download/latest.json"
],
"pubkey": "REPLACE_WITH_CONTENTS_OF_.tauri-updater.key.pub",
"windows": {
"installMode": "passive"
}
}
}
}
+53
View File
@@ -0,0 +1,53 @@
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import { AppShell } from './components/AppShell';
import { RequireAdmin, RequireAuth, RequireDevice } from './components/guards';
import { AuthProvider } from './context/AuthContext';
import { CallProvider } from './context/CallContext';
import { ConversationsProvider } from './context/ConversationsContext';
import { FriendshipsProvider } from './context/FriendshipsContext';
import { AdminPage } from './pages/AdminPage';
import { AuthCallbackPage } from './pages/AuthCallbackPage';
import { AuthPage } from './pages/AuthPage';
import { ChatsEmptyState, ChatsPage } from './pages/ChatsPage';
import { ConversationPage } from './pages/ConversationPage';
import { DevicePage } from './pages/DevicePage';
import { FriendsPage } from './pages/FriendsPage';
import { SettingsPage } from './pages/SettingsPage';
export function App() {
return (
<AuthProvider>
<FriendshipsProvider>
<ConversationsProvider>
<CallProvider>
<BrowserRouter>
<Routes>
<Route path="/auth" element={<AuthPage />} />
<Route path="/auth/callback" element={<AuthCallbackPage />} />
<Route element={<RequireAuth />}>
<Route path="/device" element={<DevicePage />} />
<Route element={<RequireDevice />}>
<Route element={<AppShell />}>
<Route index element={<Navigate to="/chats" replace />} />
<Route path="/chats" element={<ChatsPage />}>
<Route index element={<ChatsEmptyState />} />
<Route path=":id" element={<ConversationPage />} />
</Route>
<Route path="/friends" element={<FriendsPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route element={<RequireAdmin />}>
<Route path="/admin" element={<AdminPage />} />
</Route>
</Route>
</Route>
</Route>
<Route path="*" element={<Navigate to="/chats" replace />} />
</Routes>
</BrowserRouter>
</CallProvider>
</ConversationsProvider>
</FriendshipsProvider>
</AuthProvider>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { useEffect } from 'react';
import { Outlet } from 'react-router-dom';
import { ensureNotificationPermission } from '../lib/osNotify';
import { CallUI } from './CallUI';
import { Sidebar } from './Sidebar';
import { UpdateToast } from './UpdateToast';
export function AppShell() {
useEffect(() => {
// Prompt once per authenticated shell mount. Module-level guard prevents
// re-asking if the user already responded this session.
void ensureNotificationPermission();
}, []);
return (
<div className="relative flex min-h-screen overflow-hidden bg-ink-950 text-neutral-100">
<ShellBackground />
<div className="relative z-10 flex min-h-screen w-full">
<Sidebar />
<main className="relative flex-1 overflow-hidden">
<div className="h-screen overflow-y-auto">
<Outlet />
</div>
</main>
</div>
<CallUI />
<UpdateToast />
</div>
);
}
// Calmer than the auth-screen background — full-bleed grid + 2 large blobs.
// No animation here so message lists stay readable.
function ShellBackground() {
return (
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
<div className="bg-grid absolute inset-0 opacity-[0.18]" />
<div className="absolute -left-32 top-1/4 h-[420px] w-[420px] rounded-full bg-brand-500/20 blur-3xl" />
<div className="absolute -right-32 bottom-0 h-[420px] w-[420px] rounded-full bg-fuchsia-500/10 blur-3xl" />
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,transparent_45%,rgba(5,5,7,0.6)_100%)]" />
</div>
);
}
@@ -0,0 +1,115 @@
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
import { useEffect, useState } from 'react';
import { supabase } from '../lib/supabase';
import { AlertIcon, SpinnerIcon, XIcon } from './icons';
interface Props {
handle: AttachmentHandle;
}
export function AttachmentImage({ handle }: Props) {
const [blobUrl, setBlobUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [lightboxOpen, setLightboxOpen] = useState(false);
useEffect(() => {
let cancelled = false;
let url: string | null = null;
setError(null);
setBlobUrl(null);
downloadAndDecryptAttachment({ client: supabase, handle })
.then((blob) => {
if (cancelled) return;
url = URL.createObjectURL(blob);
setBlobUrl(url);
})
.catch((err: unknown) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'download failed');
}
});
return () => {
cancelled = true;
if (url) URL.revokeObjectURL(url);
};
}, [handle.id, handle.storagePath, handle.keyB64, handle.nonceB64]);
if (error) {
return (
<div className="mt-2 inline-flex items-center gap-2 rounded-lg border border-rose-500/20 bg-rose-500/10 px-3 py-2 text-xs text-rose-200">
<AlertIcon className="h-4 w-4" />
<span>{error}</span>
</div>
);
}
if (!blobUrl) {
return (
<div className="mt-2 flex h-28 w-28 items-center justify-center rounded-lg border border-white/10 bg-ink-800/60">
<SpinnerIcon className="h-5 w-5 text-brand-400" />
</div>
);
}
return (
<>
<button
type="button"
onClick={() => setLightboxOpen(true)}
aria-label="Bild öffnen"
className="mt-2 block w-fit max-w-full cursor-pointer overflow-hidden rounded-lg border border-white/10 bg-ink-800/40 transition hover:border-white/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
>
<img
src={blobUrl}
alt="attachment"
loading="lazy"
className="block h-auto max-h-80 w-auto max-w-full object-contain"
/>
</button>
{lightboxOpen && <Lightbox url={blobUrl} onClose={() => setLightboxOpen(false)} />}
</>
);
}
function Lightbox({ url, onClose }: { url: string; onClose: () => void }) {
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
document.addEventListener('keydown', onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', onKey);
document.body.style.overflow = prevOverflow;
};
}, [onClose]);
return (
<div
role="dialog"
aria-modal="true"
aria-label="Bildansicht"
onClick={onClose}
className="fixed inset-0 z-50 flex items-center justify-center bg-ink-950/90 p-6 backdrop-blur-sm animate-fade-in"
>
<button
type="button"
onClick={onClose}
aria-label="Schließen"
className="absolute right-4 top-4 flex h-10 w-10 cursor-pointer items-center justify-center rounded-full border border-white/10 bg-ink-900/80 text-neutral-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
>
<XIcon className="h-5 w-5" />
</button>
<img
src={url}
alt="attachment full"
onClick={(e) => e.stopPropagation()}
className="max-h-[92vh] max-w-[92vw] rounded-xl object-contain shadow-2xl"
/>
</div>
);
}
+335
View File
@@ -0,0 +1,335 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { useCall } from '../context/CallContext';
import { useConversationsContext } from '../context/ConversationsContext';
import { useFriendshipsContext } from '../context/FriendshipsContext';
import { ringtone } from '../lib/ringtone';
import { useAnyActiveCall } from '../lib/useAnyActiveCall';
import { useCallPresence } from '../lib/useCallPresence';
import {
MicIcon,
MicOffIcon,
PhoneIcon,
PhoneOffIcon,
SpinnerIcon,
XIcon,
} from './icons';
// Shell-level mount. Handles ringtones + the IncomingCallToast.
// The persistent CallBar (active-call widget) is rendered inside Sidebar so
// users can keep browsing/typing while a call is live.
export function CallUI() {
const { state } = useCall();
useEffect(() => {
if (state.kind === 'outgoing') ringtone.start('outgoing');
else if (state.kind === 'incoming') ringtone.start('incoming');
else ringtone.stop();
}, [state.kind]);
useEffect(() => {
return () => ringtone.stop();
}, []);
return <IncomingCallToast />;
}
// ---------------------------------------------------------------------------
function IncomingCallToast() {
const { t } = useTranslation(['app']);
const { state, acceptIncoming, rejectIncoming } = useCall();
const { friendships } = useFriendshipsContext();
const { conversations } = useConversationsContext();
if (state.kind !== 'incoming') return null;
const conv = conversations.find((c) => c.id === state.conversationId) ?? null;
const callerName =
conv?.members.find((m) => m.userId === state.fromUserId)?.profile?.displayName ??
friendships.find((f) => f.peer.userId === state.fromUserId)?.peer.displayName ??
'?';
const isGroup = conv?.type === 'group';
const groupName = isGroup ? (conv?.name ?? t('app:chats.new_group')) : null;
const letter = (isGroup ? groupName ?? callerName : callerName)
.trim()
.charAt(0)
.toUpperCase() || '?';
return (
<div
role="dialog"
aria-modal="false"
aria-label={t('app:call.incoming_title')}
className="fixed bottom-6 right-6 z-50 w-80 animate-slide-up rounded-2xl border border-white/10 bg-ink-900/95 p-5 shadow-2xl backdrop-blur-xl"
>
<p className="text-xs font-medium uppercase tracking-wide text-neutral-500">
{t('app:call.incoming_title')}
</p>
<div className="mt-3 flex items-center gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-base font-semibold text-white ring-1 ring-brand-400/30">
{letter}
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-display text-base font-semibold text-white">
{isGroup ? groupName : callerName}
</p>
<p className="truncate text-xs text-neutral-400">
{isGroup
? t('app:call.incoming_group_from', {
name: callerName,
defaultValue: callerName + ' ruft Gruppe',
})
: t('app:call.incoming_from', { name: callerName })}
</p>
</div>
</div>
<div className="mt-4 flex gap-2">
<button
type="button"
onClick={rejectIncoming}
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm font-semibold text-rose-200 transition hover:bg-rose-500/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/40"
>
<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-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"
>
<PhoneIcon className="h-4 w-4" />
<span>{t('app:call.accept')}</span>
</button>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// CallBar — persistent widget inside Sidebar. Lets the user keep browsing
// the app while a call is active / connecting / ringing.
// ---------------------------------------------------------------------------
export function CallBar() {
const { state, lastCallConversationId } = useCall();
const { conversations } = useConversationsContext();
const { session } = useAuth();
const myId = session?.user.id ?? null;
// Aggregate observer across every conversation the user is in so a
// "call is live, rejoin" affordance appears in the sidebar whenever ANY
// peer is in a call — not just calls I previously joined.
const convIds = conversations.map((c) => c.id);
const anyActive = useAnyActiveCall(convIds, myId);
if (state.kind === 'connected' || state.kind === 'connecting' || state.kind === 'outgoing') {
return <ActiveCallBar />;
}
// Prefer the conversation I just left, fall back to any other live call.
const rejoinId = lastCallConversationId ?? anyActive?.conversationId ?? null;
if (rejoinId) {
return <RejoinCallBar conversationId={rejoinId} />;
}
return null;
}
function ActiveCallBar() {
const { t } = useTranslation(['app']);
const { state, isMuted, hangup, toggleMute } = useCall();
const { conversations } = useConversationsContext();
const [, forceTick] = useState(0);
useEffect(() => {
if (state.kind !== 'connected') return;
const id = window.setInterval(() => forceTick((v) => v + 1), 1000);
return () => window.clearInterval(id);
}, [state.kind]);
if (state.kind !== 'connected' && state.kind !== 'connecting' && state.kind !== 'outgoing') {
return null;
}
const conv = conversations.find((c) => c.id === state.conversationId) ?? null;
const title =
conv?.type === 'group'
? conv.name ?? '—'
: conv?.peer?.displayName ?? '—';
const statusLabel =
state.kind === 'outgoing'
? t('app:call.outgoing_ringing')
: state.kind === 'connecting'
? t('app:call.connecting')
: t('app:call.voice_connected', { defaultValue: 'Sprachchat verbunden' });
const elapsed =
state.kind === 'connected'
? formatElapsed(Date.now() - new Date(state.startedAt).getTime())
: null;
return (
<div className="mb-2 overflow-hidden rounded-xl border border-emerald-500/20 bg-ink-900/80">
<Link
to={'/chats/' + state.conversationId}
className="flex items-center gap-2.5 px-3 pt-2.5 pb-2 transition hover:bg-white/5 focus:outline-none"
aria-label={statusLabel}
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-emerald-500/15 text-emerald-300">
{state.kind === 'connecting' ? (
<SpinnerIcon className="h-4 w-4" />
) : (
<SignalIcon className="h-4 w-4" />
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-emerald-300">{statusLabel}</p>
<p className="truncate text-xs text-neutral-400">
{title}
{elapsed && <span className="ml-1.5 font-mono text-neutral-500">{elapsed}</span>}
</p>
</div>
</Link>
<div className="grid grid-cols-2 gap-1 border-t border-white/5 bg-ink-950/40 p-1.5">
<IconTile
onClick={toggleMute}
disabled={state.kind !== 'connected'}
label={isMuted ? t('app:call.unmute') : t('app:call.mute')}
tone={isMuted ? 'amber' : 'neutral'}
>
{isMuted ? <MicOffIcon className="h-4 w-4" /> : <MicIcon className="h-4 w-4" />}
</IconTile>
<IconTile onClick={() => void hangup()} label={t('app:call.hangup')} tone="rose">
<PhoneOffIcon className="h-4 w-4" />
</IconTile>
</div>
</div>
);
}
function RejoinCallBar({ conversationId }: { conversationId: string }) {
const { t } = useTranslation(['app', 'common']);
const { joinActiveCall, dismissLastCall } = useCall();
const { conversations } = useConversationsContext();
const { session } = useAuth();
const active = useCallPresence(conversationId);
const myId = session?.user.id;
const others = active.filter((u) => u !== myId);
// Presence polling returns [] on the first tick before it syncs, so we can't
// treat an initial empty list as "room is empty". Only dismiss after we've
// actually seen peers and then watched them leave — and even then confirm
// the empty state for a grace period, since presence_diff events can
// arrive slightly out of order.
const [visible, setVisible] = useState(false);
useEffect(() => {
if (others.length > 0) {
setVisible(true);
return;
}
if (!visible) return;
const id = window.setTimeout(() => {
setVisible(false);
dismissLastCall();
}, 2500);
return () => window.clearTimeout(id);
}, [others.length, visible, dismissLastCall]);
if (!visible) return null;
const conv = conversations.find((c) => c.id === conversationId) ?? null;
const title =
conv?.type === 'group'
? conv.name ?? '—'
: conv?.peer?.displayName ?? '—';
return (
<div className="mb-2 overflow-hidden rounded-xl border border-emerald-500/20 bg-ink-900/80">
<Link
to={'/chats/' + conversationId}
className="flex items-center gap-2.5 px-3 pt-2.5 pb-2 transition hover:bg-white/5 focus:outline-none"
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-emerald-500/15 text-emerald-300">
<SignalIcon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-emerald-300">
{t('app:call.still_live', { defaultValue: 'Anruf läuft noch' })}
</p>
<p className="truncate text-xs text-neutral-400">
{title} · {others.length}
</p>
</div>
</Link>
<div className="grid grid-cols-2 gap-1 border-t border-white/5 bg-ink-950/40 p-1.5">
<IconTile
onClick={() => void joinActiveCall(conversationId, 'audio')}
label={t('app:call.join')}
tone="emerald"
>
<PhoneIcon className="h-4 w-4" />
</IconTile>
<IconTile onClick={dismissLastCall} label={t('common:close', { defaultValue: 'Schließen' })} tone="neutral">
<XIcon className="h-4 w-4" />
</IconTile>
</div>
</div>
);
}
interface IconTileProps {
onClick: () => void;
label: string;
tone: 'neutral' | 'amber' | 'rose' | 'emerald';
disabled?: boolean;
children: React.ReactNode;
}
function IconTile({ onClick, label, tone, disabled, children }: IconTileProps) {
const toneClass =
tone === 'rose'
? 'bg-white/5 text-rose-300 hover:bg-rose-500/20 focus-visible:ring-rose-400/40'
: tone === 'amber'
? 'bg-amber-500/20 text-amber-200 hover:bg-amber-500/30 focus-visible:ring-amber-400/40'
: tone === 'emerald'
? 'bg-emerald-500/20 text-emerald-200 hover:bg-emerald-500/30 focus-visible:ring-emerald-400/40'
: 'bg-white/5 text-neutral-200 hover:bg-white/10 focus-visible:ring-brand-400/40';
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={label}
title={label}
className={
'inline-flex h-9 cursor-pointer items-center justify-center rounded-md transition focus:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-50 ' +
toneClass
}
>
{children}
</button>
);
}
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;
}
@@ -0,0 +1,175 @@
import type { ConversationSummary } from '@chat-app/shared/chat';
import type { PresenceState } from '@chat-app/shared/supabase';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import { useCall } from '../context/CallContext';
import { useCallPresence } from '../lib/useCallPresence';
import { InfoIcon, PhoneIcon, SpinnerIcon, UsersIcon } from './icons';
const PRESENCE_DOT: Record<PresenceState, string> = {
online: 'bg-emerald-400',
idle: 'bg-amber-400',
dnd: 'bg-rose-500',
invisible: 'bg-neutral-500',
offline: 'bg-neutral-600',
};
interface Props {
conversation: ConversationSummary | null;
peerPresence: PresenceState | null;
onInfoClick?: () => void;
}
export function ConversationHeader({ conversation, peerPresence, onInfoClick }: Props) {
if (!conversation) {
return (
<header className="h-[57px] border-b border-white/5 px-6 py-3" aria-busy="true" />
);
}
return (
<>
<HeaderBar
conversation={conversation}
peerPresence={peerPresence}
{...(onInfoClick ? { onInfoClick } : {})}
/>
<ActiveCallBanner conversationId={conversation.id} />
</>
);
}
interface HeaderBarProps {
conversation: ConversationSummary;
peerPresence: PresenceState | null;
onInfoClick?: () => void;
}
function HeaderBar({ conversation, peerPresence, onInfoClick }: HeaderBarProps) {
const { t } = useTranslation(['app']);
const isDm = conversation.type === 'dm';
const title = isDm ? (conversation.peer?.displayName ?? '?') : (conversation.name ?? '?');
const handle = isDm ? '@' + (conversation.peer?.username ?? '?') : '';
const letter = title.trim().charAt(0).toUpperCase() || '?';
// Hide presence when peer chose invisible — reciprocal privacy.
const showPresence = isDm && peerPresence && peerPresence !== 'invisible';
const presenceLabel = peerPresence ? t('app:presence.' + peerPresence) : '';
return (
<header className="flex items-center gap-3 border-b border-white/5 px-6 py-3">
<div className="relative flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-base font-semibold text-white ring-1 ring-brand-400/30">
{isDm ? letter : <UsersIcon className="h-5 w-5" />}
{showPresence && peerPresence && (
<span
aria-hidden="true"
className={
'absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full ring-2 ring-ink-950 ' +
PRESENCE_DOT[peerPresence]
}
/>
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-display text-base font-semibold text-white">{title}</p>
<p className="truncate text-xs text-neutral-500">
{isDm ? (
<>
<span>{handle}</span>
{showPresence && (
<>
<span className="mx-1.5 text-neutral-700">·</span>
<span>{presenceLabel}</span>
</>
)}
</>
) : (
<>
{conversation.members.length} {t('app:nav.friends').toLowerCase()}
</>
)}
</p>
</div>
<CallHeaderButton conversationId={conversation.id} />
{!isDm && onInfoClick && (
<button
type="button"
onClick={onInfoClick}
aria-label={t('app:group.info_title')}
title={t('app:group.info_title')}
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg text-neutral-400 transition hover:bg-white/10 hover:text-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
>
<InfoIcon className="h-4 w-4" />
</button>
)}
</header>
);
}
function CallHeaderButton({ conversationId }: { conversationId: string }) {
const { t } = useTranslation(['app']);
const { state, startCall } = useCall();
const busy = state.kind !== 'idle';
return (
<button
type="button"
onClick={() => void startCall(conversationId, 'audio')}
disabled={busy}
aria-label={t('app:call.start_audio')}
title={busy ? t('app:call.busy') : t('app:call.start_audio')}
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg text-neutral-400 transition hover:bg-white/10 hover:text-brand-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 disabled:cursor-not-allowed disabled:opacity-50"
>
<PhoneIcon className="h-4 w-4" />
</button>
);
}
function ActiveCallBanner({ conversationId }: { conversationId: string }) {
const { t } = useTranslation(['app']);
const { session } = useAuth();
const { state, joinActiveCall } = useCall();
const active = useCallPresence(conversationId);
const myId = session?.user.id;
// Source of truth for "am I in this call" is the CallContext state, not
// presence — my own presence entry can lag behind the room join, which
// would otherwise make the banner flash while I'm already connected.
const iAmIn =
(state.kind === 'connected' ||
state.kind === 'connecting' ||
state.kind === 'outgoing') &&
state.conversationId === conversationId;
// Only show banner when other people are in it and I'm not.
const othersIn = active.filter((u) => u !== myId);
if (iAmIn || othersIn.length === 0) return null;
const busy = state.kind !== 'idle';
return (
<div className="flex items-center gap-3 border-b border-emerald-500/20 bg-emerald-500/10 px-6 py-2.5 text-sm text-emerald-100">
<PhoneIcon className="h-4 w-4 text-emerald-300" />
<span className="flex-1">
{t('app:call.active_in_conv', {
defaultValue: 'Active call · {{count}} in room',
count: othersIn.length,
})}
</span>
<button
type="button"
onClick={() => void joinActiveCall(conversationId, 'audio')}
disabled={busy}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-emerald-500/90 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-emerald-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40 disabled:cursor-not-allowed disabled:opacity-60"
>
{busy ? (
<SpinnerIcon className="h-3.5 w-3.5" />
) : (
<PhoneIcon className="h-3.5 w-3.5" />
)}
<span>{t('app:call.join', { defaultValue: 'Join' })}</span>
</button>
</div>
);
}
@@ -0,0 +1,152 @@
import { createGroup } from '@chat-app/shared/chat';
import { extractErrorCode } from '@chat-app/shared/i18n';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useFriendshipsContext } from '../context/FriendshipsContext';
import { supabase } from '../lib/supabase';
import { AlertIcon, CheckCircleIcon, SpinnerIcon, UsersIcon } from './icons';
import { Modal } from './Modal';
interface Props {
open: boolean;
onClose: () => void;
}
export function CreateGroupDialog({ open, onClose }: Props) {
const { t } = useTranslation(['app', 'errors']);
const navigate = useNavigate();
const { friendships } = useFriendshipsContext();
const acceptedFriends = useMemo(
() => friendships.filter((f) => f.status === 'accepted').map((f) => f.peer),
[friendships],
);
const [name, setName] = useState('');
const [selected, setSelected] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
function toggle(userId: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(userId)) next.delete(userId);
else next.add(userId);
return next;
});
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (busy || name.trim().length === 0) return;
setBusy(true);
setError(null);
try {
const id = await createGroup({
client: supabase,
name,
memberUserIds: Array.from(selected),
});
onClose();
setName('');
setSelected(new Set());
navigate('/chats/' + id);
} catch (err: unknown) {
const code = extractErrorCode(err);
setError(
code
? t('errors:' + code, { defaultValue: t('errors:generic') })
: err instanceof Error
? err.message
: t('errors:generic'),
);
} finally {
setBusy(false);
}
}
return (
<Modal open={open} onClose={onClose} title={t('app:group.create_title')}>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<label className="block text-xs font-medium uppercase tracking-wide text-neutral-400">
{t('app:group.create_name_label')}
</label>
<input
type="text"
required
maxLength={64}
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('app:group.create_name_placeholder')}
className="w-full rounded-lg border border-white/10 bg-ink-800 px-3 py-2.5 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
/>
</div>
<div className="space-y-2">
<label className="block text-xs font-medium uppercase tracking-wide text-neutral-400">
{t('app:group.create_members_label')}{' '}
<span className="text-neutral-500">({selected.size})</span>
</label>
{acceptedFriends.length === 0 ? (
<div className="flex items-center gap-3 rounded-lg border border-white/5 bg-ink-800/60 p-4 text-xs text-neutral-400">
<UsersIcon className="h-4 w-4" />
<span>{t('app:group.create_members_empty')}</span>
</div>
) : (
<ul className="max-h-72 space-y-1 overflow-y-auto rounded-lg border border-white/5 bg-ink-800/40 p-1">
{acceptedFriends.map((f) => {
const active = selected.has(f.userId);
const letter =
(f.displayName ?? f.username ?? '?').trim().charAt(0).toUpperCase() || '?';
return (
<li key={f.userId}>
<button
type="button"
onClick={() => toggle(f.userId)}
aria-pressed={active}
className={
'flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' +
(active ? 'bg-brand-500/15 ring-1 ring-brand-400/30' : 'hover:bg-white/5')
}
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-xs font-semibold text-white ring-1 ring-brand-400/30">
{letter}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-white">{f.displayName}</p>
<p className="truncate text-xs text-neutral-500">@{f.username}</p>
</div>
{active && <CheckCircleIcon className="h-4 w-4 text-brand-300" />}
</button>
</li>
);
})}
</ul>
)}
</div>
{error && (
<div
role="alert"
className="flex items-start gap-3 rounded-lg border border-rose-500/20 bg-rose-500/10 p-3 text-sm text-rose-100"
>
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" />
<p className="min-w-0 flex-1 break-words">{error}</p>
</div>
)}
<button
type="submit"
disabled={busy || name.trim().length === 0}
className="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-4 py-2.5 text-sm font-semibold text-white shadow-glow 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"
>
{busy && <SpinnerIcon className="h-4 w-4" />}
<span>{t(busy ? 'app:group.create_cta_loading' : 'app:group.create_cta')}</span>
</button>
</form>
</Modal>
);
}
@@ -0,0 +1,127 @@
import type { DeviceRecord } from '@chat-app/shared/auth';
import { extractErrorCode } from '@chat-app/shared/i18n';
import { useCallback, useId, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { detectDesktopPlatform, registerCurrentDevice } from '../lib/device';
import { AlertIcon, ArrowRightIcon, LockIcon, ShieldIcon, SpinnerIcon } from './icons';
interface Props {
userId: string;
defaultName: string;
onRegistered: (device: DeviceRecord) => void;
}
export function DeviceRegistration({ userId, defaultName, onRegistered }: Props) {
const { t } = useTranslation(['auth', 'errors']);
const nameId = useId();
const [name, setName] = useState(defaultName);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
const trimmed = name.trim();
if (!trimmed) return;
setBusy(true);
setError(null);
try {
const device = await registerCurrentDevice({ userId, name: trimmed });
onRegistered(device);
} catch (err: unknown) {
const code = extractErrorCode(err);
if (code) {
setError(t(`errors:${code}`, { defaultValue: t('errors:generic') }));
} else if (err instanceof Error) {
setError(err.message);
} else {
setError(t('errors:generic'));
}
} finally {
setBusy(false);
}
},
[name, userId, onRegistered, t],
);
const platform = detectDesktopPlatform();
return (
<form
onSubmit={handleSubmit}
className="w-full max-w-md animate-slide-up rounded-2xl border border-white/10 bg-ink-900/70 p-7 shadow-glow backdrop-blur-xl sm:p-8"
>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-brand-500/20 ring-1 ring-brand-400/30">
<LockIcon className="h-5 w-5 text-brand-300" />
</div>
<div>
<h2 className="font-display text-lg font-semibold text-white">{t('auth:device.title')}</h2>
<p className="text-xs text-neutral-400">{t('auth:device.subtitle')}</p>
</div>
</div>
<div className="mt-6 space-y-1.5">
<label
htmlFor={nameId}
className="block text-xs font-medium uppercase tracking-wide text-neutral-400"
>
{t('auth:device.name_label')}
</label>
<input
id={nameId}
type="text"
required
autoFocus
maxLength={64}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('auth:device.name_placeholder')}
className="w-full rounded-lg border border-white/10 bg-ink-800 px-3 py-2.5 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
/>
<p className="text-xs text-neutral-500">{t('auth:device.name_hint')}</p>
</div>
<div className="mt-5 rounded-lg border border-amber-500/20 bg-amber-500/10 p-3 text-xs text-amber-200">
<div className="flex items-start gap-2">
<ShieldIcon className="mt-0.5 h-4 w-4 shrink-0 text-amber-300" />
<span className="min-w-0 flex-1 break-words">{t('auth:device.security_note_dev')}</span>
</div>
</div>
<button
type="submit"
disabled={busy || name.trim().length === 0}
aria-busy={busy}
className="group mt-6 inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-gradient-to-br from-brand-400 to-brand-600 px-4 py-3 text-sm font-semibold text-white shadow-glow transition hover:from-brand-300 hover:to-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-400/60 focus:ring-offset-2 focus:ring-offset-ink-900 disabled:cursor-not-allowed disabled:opacity-60"
>
{busy ? (
<>
<SpinnerIcon className="h-4 w-4" />
<span>{t('auth:device.cta_loading')}</span>
</>
) : (
<>
<span>{t('auth:device.cta')}</span>
<ArrowRightIcon className="h-4 w-4 transition group-hover:translate-x-0.5" />
</>
)}
</button>
{error && (
<div
role="alert"
className="mt-4 flex items-start gap-3 rounded-lg border border-rose-500/20 bg-rose-500/10 p-3 text-sm text-rose-100"
>
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" />
<p className="min-w-0 flex-1 break-words">{error}</p>
</div>
)}
<p className="mt-4 text-center text-xs text-neutral-500">
{t('auth:device.device_platform', { defaultValue: 'Platform' })}: {platform}
</p>
</form>
);
}
@@ -0,0 +1,240 @@
import { addGroupMember, type ConversationSummary, leaveGroup } from '@chat-app/shared/chat';
import { extractErrorCode } from '@chat-app/shared/i18n';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { useFriendshipsContext } from '../context/FriendshipsContext';
import { supabase } from '../lib/supabase';
import { AlertIcon, PlusIcon, SignOutIcon, SpinnerIcon, UsersIcon, XIcon } from './icons';
interface Props {
open: boolean;
onClose: () => void;
conversation: ConversationSummary;
}
export function GroupInfoPanel({ open, onClose, conversation }: Props) {
const { t } = useTranslation(['app', 'errors']);
const { session } = useAuth();
const { friendships } = useFriendshipsContext();
const navigate = useNavigate();
const [busyLeave, setBusyLeave] = useState(false);
const [busyAddId, setBusyAddId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const myId = session?.user.id;
const myRole =
conversation.members.find((m) => m.userId === myId)?.role ?? conversation.myRole;
const canAdd = myRole === 'admin' || myRole === 'mod';
const addableFriends = useMemo(() => {
const memberIds = new Set(conversation.members.map((m) => m.userId));
return friendships
.filter((f) => f.status === 'accepted')
.map((f) => f.peer)
.filter((p) => !memberIds.has(p.userId));
}, [friendships, conversation.members]);
async function handleAdd(userId: string) {
if (busyAddId) return;
setBusyAddId(userId);
setError(null);
try {
await addGroupMember(supabase, conversation.id, userId);
} catch (err: unknown) {
const code = extractErrorCode(err);
setError(
code
? t('errors:' + code, { defaultValue: t('errors:generic') })
: err instanceof Error
? err.message
: t('errors:generic'),
);
} finally {
setBusyAddId(null);
}
}
async function handleLeave() {
if (busyLeave) return;
if (!window.confirm(t('app:group.info_leave_confirm'))) return;
setBusyLeave(true);
setError(null);
try {
await leaveGroup(supabase, conversation.id);
onClose();
navigate('/chats');
} catch (err: unknown) {
const code = extractErrorCode(err);
setError(
code
? t('errors:' + code, { defaultValue: t('errors:generic') })
: err instanceof Error
? err.message
: t('errors:generic'),
);
} finally {
setBusyLeave(false);
}
}
if (!open) return null;
const roleLabel = (role: string) =>
role === 'admin'
? t('app:group.info_role_admin')
: role === 'mod'
? t('app:group.info_role_mod')
: t('app:group.info_role_member');
return (
<aside
role="dialog"
aria-label={t('app:group.info_title')}
className="absolute inset-y-0 right-0 z-20 flex w-[320px] flex-col border-l border-white/5 bg-ink-900/95 shadow-xl backdrop-blur-xl animate-slide-up"
>
<header className="flex items-center justify-between border-b border-white/5 px-5 py-4">
<div>
<p className="text-xs uppercase tracking-wide text-neutral-500">
{t('app:group.info_title')}
</p>
<h3 className="mt-0.5 font-display text-base font-semibold text-white">
{conversation.name ?? '—'}
</h3>
</div>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-neutral-400 transition hover:bg-white/10 hover:text-neutral-100"
>
<XIcon className="h-4 w-4" />
</button>
</header>
<div className="flex-1 space-y-6 overflow-y-auto p-5">
<section>
<h4 className="mb-2 text-xs font-medium uppercase tracking-wide text-neutral-500">
{t('app:group.info_members')} ({conversation.members.length})
</h4>
<ul className="space-y-1">
{conversation.members.map((m) => {
const name = m.profile?.displayName ?? '?';
const handle = m.profile?.username ? '@' + m.profile.username : '';
const letter = name.trim().charAt(0).toUpperCase() || '?';
return (
<li
key={m.userId}
className="flex items-center gap-3 rounded-lg px-2 py-1.5"
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-xs font-semibold text-white ring-1 ring-brand-400/30">
{letter}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-white">
{name}
{m.userId === myId && (
<span className="ml-1.5 text-xs text-neutral-500">· you</span>
)}
</p>
<p className="truncate text-xs text-neutral-500">{handle}</p>
</div>
<span
className={
'rounded-full border px-2 py-0.5 text-[10px] font-medium ' +
(m.role === 'admin'
? 'border-brand-400/30 bg-brand-500/15 text-brand-200'
: m.role === 'mod'
? 'border-amber-500/30 bg-amber-500/10 text-amber-200'
: 'border-white/10 bg-white/5 text-neutral-300')
}
>
{roleLabel(m.role)}
</span>
</li>
);
})}
</ul>
</section>
{canAdd && (
<section>
<h4 className="mb-2 text-xs font-medium uppercase tracking-wide text-neutral-500">
{t('app:group.info_add_title')}
</h4>
{addableFriends.length === 0 ? (
<div className="flex items-center gap-3 rounded-lg border border-white/5 bg-ink-800/60 p-3 text-xs text-neutral-400">
<UsersIcon className="h-4 w-4" />
<span>{t('app:group.info_add_empty')}</span>
</div>
) : (
<>
<p className="mb-2 text-xs text-neutral-500">
{t('app:group.info_add_help')}
</p>
<ul className="space-y-1">
{addableFriends.map((f) => {
const letter =
(f.displayName ?? f.username ?? '?').trim().charAt(0).toUpperCase() || '?';
const busy = busyAddId === f.userId;
return (
<li key={f.userId}>
<button
type="button"
disabled={busy}
onClick={() => void handleAdd(f.userId)}
className="flex w-full cursor-pointer items-center gap-3 rounded-lg px-2 py-1.5 text-left transition hover:bg-white/5 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 disabled:opacity-60"
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-xs font-semibold text-white ring-1 ring-brand-400/30">
{letter}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-white">{f.displayName}</p>
<p className="truncate text-xs text-neutral-500">@{f.username}</p>
</div>
{busy ? (
<SpinnerIcon className="h-4 w-4 text-brand-400" />
) : (
<PlusIcon className="h-4 w-4 text-neutral-400" />
)}
</button>
</li>
);
})}
</ul>
</>
)}
</section>
)}
{error && (
<div
role="alert"
className="flex items-start gap-3 rounded-lg border border-rose-500/20 bg-rose-500/10 p-3 text-sm text-rose-100"
>
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" />
<p className="min-w-0 flex-1 break-words">{error}</p>
</div>
)}
</div>
<footer className="border-t border-white/5 p-4">
<button
type="button"
onClick={() => void handleLeave()}
disabled={busyLeave}
className="inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-xs font-medium text-rose-200 transition hover:bg-rose-500/20 disabled:opacity-60"
>
{busyLeave ? (
<SpinnerIcon className="h-3.5 w-3.5" />
) : (
<SignOutIcon className="h-3.5 w-3.5" />
)}
<span>{t('app:group.info_leave')}</span>
</button>
</footer>
</aside>
);
}
+450
View File
@@ -0,0 +1,450 @@
import type { ConversationSummary } from '@chat-app/shared/chat';
import type { RemoteTrack } from 'livekit-client';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import { type RemoteScreenShare, useCall } from '../context/CallContext';
import {
getPttSettings,
type PttSettings,
subscribePttSettings,
} from '../lib/pttSettings';
import {
LockIcon,
MicIcon,
MicOffIcon,
MonitorShareIcon,
MonitorStopIcon,
PhoneOffIcon,
SpinnerIcon,
} from './icons';
interface Props {
conversation: ConversationSummary;
}
// Discord-style call widget rendered above the message list when the user is
// in the current conversation's call. Shows participant avatars + controls.
export function InCallPanel({ conversation }: Props) {
const { t } = useTranslation(['app']);
const {
state,
remoteParticipants,
isMuted,
isE2EEActive,
toggleMute,
hangup,
isScreenSharing,
remoteScreenShares,
toggleScreenShare,
} = useCall();
const { session } = useAuth();
const myId = session?.user.id ?? null;
const active =
(state.kind === 'connected' ||
state.kind === 'connecting' ||
state.kind === 'outgoing') &&
state.conversationId === conversation.id;
if (!active) return null;
const remoteIds = new Set<string>(
remoteParticipants.map((p) => p.identity).filter((s): s is string => Boolean(s)),
);
const tiles: ParticipantTileData[] = [];
if (myId) {
const me = conversation.members.find((m) => m.userId === myId) ?? null;
tiles.push({
userId: myId,
displayName: me?.profile?.displayName ?? '?',
avatarUrl: me?.profile?.avatarUrl ?? null,
self: true,
speaking: false,
muted: isMuted,
});
}
for (const m of conversation.members) {
if (m.userId === myId) continue;
if (!remoteIds.has(m.userId)) continue;
tiles.push({
userId: m.userId,
displayName: m.profile?.displayName ?? '?',
avatarUrl: m.profile?.avatarUrl ?? null,
self: false,
speaking: false,
muted: false,
});
}
const statusLabel =
state.kind === 'outgoing'
? t('app:call.outgoing_ringing')
: state.kind === 'connecting'
? t('app:call.connecting')
: remoteParticipants.length === 0
? t('app:call.waiting_for_peers', { defaultValue: 'Warte auf andere…' })
: t('app:call.connected');
return (
<section
aria-label={t('app:call.in_call', { defaultValue: 'Im Anruf' })}
className="border-b border-white/5 bg-gradient-to-b from-ink-900/80 to-ink-950/40 px-6 py-5"
>
<div className="mb-4 flex items-center gap-2 text-xs font-medium text-emerald-300">
{state.kind === 'connecting' ? (
<SpinnerIcon className="h-3.5 w-3.5" />
) : (
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400/60" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-400" />
</span>
)}
<span className="uppercase tracking-wide">{statusLabel}</span>
{isE2EEActive && (
<span
className="ml-2 inline-flex items-center gap-1 rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-emerald-200 ring-1 ring-emerald-400/30"
title={t('app:call.e2ee_active_hint', {
defaultValue: 'Audio + Video sind Ende-zu-Ende-verschlüsselt',
})}
>
<LockIcon className="h-3 w-3" />
E2EE
</span>
)}
</div>
<div className="flex flex-wrap justify-center gap-4">
{tiles.map((p) => (
<ParticipantTile key={p.userId} {...p} />
))}
</div>
{remoteScreenShares.length > 0 && (
<div className="mt-4 space-y-3">
{remoteScreenShares.map((s) => {
const member = conversation.members.find((m) => m.userId === s.participantId);
return (
<ScreenShareViewer
key={s.track.sid ?? s.participantId}
share={s}
avatarUrl={member?.profile?.avatarUrl ?? null}
displayName={member?.profile?.displayName ?? s.participantName}
/>
);
})}
</div>
)}
<div className="mt-5 flex items-center justify-center gap-2">
<ControlButton
onClick={toggleMute}
disabled={state.kind !== 'connected'}
active={isMuted}
label={isMuted ? t('app:call.unmute') : t('app:call.mute')}
tone={isMuted ? 'amber' : 'neutral'}
>
{isMuted ? <MicOffIcon className="h-5 w-5" /> : <MicIcon className="h-5 w-5" />}
</ControlButton>
<ControlButton
onClick={() => void toggleScreenShare()}
disabled={state.kind !== 'connected'}
active={isScreenSharing}
label={
isScreenSharing
? t('app:call.stop_share_screen', { defaultValue: 'Screen-Share stoppen' })
: t('app:call.share_screen', { defaultValue: 'Bildschirm teilen' })
}
tone={isScreenSharing ? 'emerald' : 'neutral'}
>
{isScreenSharing ? (
<MonitorStopIcon className="h-5 w-5" />
) : (
<MonitorShareIcon className="h-5 w-5" />
)}
</ControlButton>
<ControlButton
onClick={() => void hangup()}
label={t('app:call.hangup')}
tone="rose"
>
<PhoneOffIcon className="h-5 w-5" />
</ControlButton>
</div>
<PttHint />
</section>
);
}
interface ScreenShareViewerProps {
share: RemoteScreenShare;
avatarUrl: string | null;
displayName: string;
}
function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShareViewerProps) {
const { t } = useTranslation(['app']);
const videoRef = useRef<HTMLVideoElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const [watching, setWatching] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const letter = displayName.trim().charAt(0).toUpperCase() || '?';
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/20 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/10 bg-emerald-500/10 px-3 py-1.5 text-xs text-emerald-200">
<MonitorShareIcon className="h-3.5 w-3.5 shrink-0" />
<span className="truncate flex-1">
{t('app:call.is_sharing_screen', {
name: displayName,
defaultValue: displayName + ' teilt den Bildschirm',
})}
</span>
{watching && (
<>
<button
type="button"
onClick={toggleFullscreen}
aria-label={t('app:call.fullscreen', { defaultValue: 'Vollbild' })}
title={t('app:call.fullscreen', { defaultValue: 'Vollbild' })}
className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-emerald-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40"
>
<FullscreenIcon className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => {
if (document.fullscreenElement === containerRef.current) {
void document.exitFullscreen();
}
setWatching(false);
}}
aria-label={t('app:call.stop_watching', { defaultValue: 'Nicht mehr anschauen' })}
title={t('app:call.stop_watching', { defaultValue: 'Nicht mehr anschauen' })}
className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-emerald-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40"
>
<span aria-hidden="true" className="text-[14px] leading-none">×</span>
</button>
</>
)}
</div>
{watching ? (
<video
ref={videoRef}
autoPlay
playsInline
muted
onDoubleClick={toggleFullscreen}
className={
'block cursor-zoom-in bg-black ' +
(isFullscreen ? 'h-full w-full flex-1 object-contain' : 'w-full')
}
/>
) : (
<button
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" />
</>
);
}
return (
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-brand-500/40 via-fuchsia-500/20 to-emerald-500/30">
<span
aria-hidden="true"
className="text-[120px] font-display font-bold text-white/20 blur-[2px]"
>
{letter}
</span>
</div>
);
}
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>
);
}
function PttHint() {
const [ptt, setPtt] = useState<PttSettings>(() => getPttSettings());
useEffect(() => subscribePttSettings(setPtt), []);
if (!ptt.enabled) return null;
return (
<p className="mt-3 text-center text-[11px] text-neutral-500">
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">
{ptt.keyLabel}
</kbd>
</p>
);
}
interface ParticipantTileData {
userId: string;
displayName: string;
avatarUrl: string | null;
self: boolean;
speaking: boolean;
muted: boolean;
}
function ParticipantTile(p: ParticipantTileData) {
const letter = p.displayName.trim().charAt(0).toUpperCase() || '?';
return (
<div className="flex w-28 flex-col items-center gap-2">
<div
className={
'relative flex h-20 w-20 items-center justify-center rounded-2xl bg-gradient-to-br from-brand-500/40 to-brand-700/40 text-2xl font-semibold text-white ring-2 transition ' +
(p.speaking
? 'ring-emerald-400 shadow-[0_0_24px_rgba(52,211,153,0.35)]'
: 'ring-white/10')
}
>
{p.avatarUrl ? (
<img
src={p.avatarUrl}
alt=""
className="h-full w-full rounded-2xl object-cover"
/>
) : (
<span aria-hidden="true">{letter}</span>
)}
{p.muted && (
<span
aria-hidden="true"
className="absolute -bottom-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full bg-amber-500 ring-2 ring-ink-950"
>
<MicOffIcon className="h-3 w-3 text-ink-950" />
</span>
)}
</div>
<p className="max-w-full truncate text-xs font-medium text-neutral-200">
{p.displayName}
{p.self && (
<span className="ml-1 text-neutral-500">·&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,45 @@
import { changeLocale, SUPPORTED_LOCALES, type SupportedLocale } from '@chat-app/shared/i18n';
import { useTranslation } from 'react-i18next';
const LABELS: Record<SupportedLocale, string> = {
en: 'EN',
de: 'DE',
};
export function LanguageSwitcher({ compact = false }: { compact?: boolean }) {
const { i18n } = useTranslation();
const current = (i18n.resolvedLanguage ?? i18n.language) as SupportedLocale;
return (
<div
role="group"
aria-label="Language"
className={
'inline-flex items-center rounded-full border border-white/10 bg-white/5 p-0.5 text-[11px] font-medium ' +
(compact ? '' : 'backdrop-blur')
}
>
{SUPPORTED_LOCALES.map((locale) => {
const active = locale === current;
return (
<button
key={locale}
type="button"
aria-pressed={active}
onClick={() => {
if (!active) void changeLocale(locale);
}}
className={
'cursor-pointer rounded-full px-2.5 py-1 transition focus:outline-none focus:ring-2 focus:ring-brand-400/40 ' +
(active
? 'bg-brand-500/25 text-white ring-1 ring-brand-400/40'
: 'text-neutral-400 hover:text-neutral-200')
}
>
{LABELS[locale]}
</button>
);
})}
</div>
);
}
@@ -0,0 +1,418 @@
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
import {
type DecryptedMessage,
editEncryptedMessage,
parseMessagePayload,
softDeleteMessage,
} from '@chat-app/shared/chat';
import { extractErrorCode } from '@chat-app/shared/i18n';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import { devLocalSecretStore } from '../lib/secretStore';
import { supabase } from '../lib/supabase';
import type { AggregatedReaction } from '../lib/useMessageReactions';
import { AttachmentImage } from './AttachmentImage';
import { PencilIcon, PhoneIcon, PhoneOffIcon, SmileIcon, SpinnerIcon, TrashIcon, XIcon } from './icons';
const EMOJI_CHOICES = ['👍', '❤️', '😂', '🎉', '🔥', '😮', '😢', '🙏'];
const EDIT_WINDOW_MS = 24 * 60 * 60 * 1000;
interface Props {
message: DecryptedMessage;
mine: boolean;
groupedWithPrev: boolean;
conversationId: string;
reactions: AggregatedReaction[];
onToggleReaction: (emoji: string) => Promise<void>;
showSeen?: boolean;
}
export function MessageBubble({
message,
mine,
groupedWithPrev,
conversationId,
reactions,
onToggleReaction,
showSeen = false,
}: Props) {
const { t } = useTranslation(['app']);
const { session, device } = useAuth();
const parsed = parseMessagePayload(message.plaintext);
const initialText = parsed.kind === 'text' ? parsed.text : '';
const initialAttachments = parsed.kind === 'text' ? parsed.attachments : [];
const [editing, setEditing] = useState(false);
const [editText, setEditText] = useState(initialText);
const [busy, setBusy] = useState(false);
const [editError, setEditError] = useState<string | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const pickerRef = useRef<HTMLDivElement>(null);
const createdAt = new Date(message.createdAt);
const age = Date.now() - createdAt.getTime();
const withinEditWindow = age < EDIT_WINDOW_MS;
const bodyText = initialText;
const attachments = initialAttachments;
const canEdit =
parsed.kind === 'text' &&
mine &&
withinEditWindow &&
!message.deletedAt &&
attachments.length === 0;
const canDelete = parsed.kind === 'text' && mine && !message.deletedAt;
const time = new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(
createdAt,
);
useEffect(() => {
if (!pickerOpen) return;
function onClickOutside(e: MouseEvent) {
if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) {
setPickerOpen(false);
}
}
document.addEventListener('mousedown', onClickOutside);
return () => document.removeEventListener('mousedown', onClickOutside);
}, [pickerOpen]);
const handleEditSave = useCallback(async () => {
if (!session || !device) return;
const trimmed = editText.trim();
if (!trimmed || trimmed === bodyText) {
setEditing(false);
setEditError(null);
return;
}
setBusy(true);
setEditError(null);
try {
const priv = await loadDevicePrivateKey(devLocalSecretStore, session.user.id, device.id);
if (!priv) throw new Error('private key not loaded');
await editEncryptedMessage({
client: supabase,
messageId: message.id,
conversationId,
newPlaintext: trimmed,
senderPrivateKey: priv,
});
setEditing(false);
} catch (err: unknown) {
const code = extractErrorCode(err);
setEditError(
code
? t('errors:' + code, { defaultValue: t('errors:generic') })
: err instanceof Error
? err.message
: t('errors:generic'),
);
} finally {
setBusy(false);
}
}, [editText, message.id, message.plaintext, conversationId, session, device, t]);
const handleDelete = useCallback(async () => {
if (busy) return;
setBusy(true);
try {
await softDeleteMessage(supabase, message.id);
} catch (err: unknown) {
console.error('delete failed', err);
} finally {
setBusy(false);
}
}, [busy, message.id]);
const handlePickEmoji = useCallback(
async (emoji: string) => {
setPickerOpen(false);
try {
await onToggleReaction(emoji);
} catch (err: unknown) {
console.error('toggleReaction failed', err);
}
},
[onToggleReaction],
);
if (message.deletedAt) {
return (
<div className={'flex ' + (mine ? 'justify-end' : 'justify-start')}>
<div className="max-w-[70%] rounded-2xl border border-white/5 bg-white/5 px-3.5 py-1.5 text-xs italic text-neutral-500">
{t('app:chats.deleted')}
</div>
</div>
);
}
if (parsed.kind === 'call_event') {
return <CallEventRow parsed={parsed} mine={mine} time={time} />;
}
return (
<div className={'flex ' + (mine ? 'justify-end' : 'justify-start')}>
<div
className={
'group relative max-w-[70%] ' + (groupedWithPrev ? 'mt-0.5' : 'mt-2')
}
>
{editing ? (
<div className="rounded-2xl border border-brand-400/40 bg-ink-900/80 p-2 backdrop-blur-xl">
<textarea
value={editText}
onChange={(e) => setEditText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
void handleEditSave();
}
if (e.key === 'Escape') {
setEditing(false);
setEditError(null);
}
}}
rows={2}
autoFocus
className="w-full resize-none rounded-md bg-ink-800 px-3 py-2 text-sm text-white outline-none focus:ring-2 focus:ring-brand-400/60"
/>
{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">
{editError}
</p>
)}
<div className="mt-1.5 flex justify-end gap-2">
<button
type="button"
onClick={() => {
setEditing(false);
setEditError(null);
}}
className="cursor-pointer rounded-md border border-white/10 bg-white/5 px-3 py-1 text-xs text-neutral-200 hover:bg-white/10"
>
{t('app:friends.action_cancel')}
</button>
<button
type="button"
disabled={busy}
onClick={() => void handleEditSave()}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-brand-500/90 px-3 py-1 text-xs font-semibold text-white hover:bg-brand-400 disabled:opacity-60"
>
{busy && <SpinnerIcon className="h-3.5 w-3.5" />}
<span>{t('common:save', { defaultValue: 'Save' })}</span>
</button>
</div>
</div>
) : (
<div
className={
'break-words rounded-2xl px-3.5 py-2 text-sm ' +
(mine
? 'bg-brand-500/85 text-white'
: 'border border-white/5 bg-ink-900/70 text-neutral-100')
}
>
{message.plaintext === null ? (
<span className="italic text-neutral-400">cannot decrypt</span>
) : (
<>
{bodyText.length > 0 && <div>{bodyText}</div>}
{attachments.map((a) => (
<AttachmentImage key={a.id} handle={a} />
))}
</>
)}
<div
className={
'mt-1 flex items-center gap-1.5 text-[10px] ' +
(mine ? 'text-brand-100/70' : 'text-neutral-500')
}
>
<span>{time}</span>
{message.editedAt && !message.deletedAt && (
<span className="italic">· {t('app:chats.edited')}</span>
)}
</div>
</div>
)}
{showSeen && mine && !editing && !message.deletedAt && (
<p className="mt-0.5 text-right text-[10px] text-neutral-500">
{t('app:chats.seen')}
</p>
)}
{reactions.length > 0 && !editing && (
<div className={'mt-1 flex flex-wrap gap-1 ' + (mine ? 'justify-end' : 'justify-start')}>
{reactions.map((r) => (
<button
key={r.emoji}
type="button"
onClick={() => void onToggleReaction(r.emoji)}
className={
'inline-flex cursor-pointer items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' +
(r.mine
? 'border-brand-400/40 bg-brand-500/20 text-brand-100'
: 'border-white/10 bg-white/5 text-neutral-200 hover:bg-white/10')
}
>
<span>{r.emoji}</span>
<span className="text-[10px] font-medium">{r.count}</span>
</button>
))}
</div>
)}
{!editing && (
<div
className={
'pointer-events-none absolute top-0 z-20 opacity-0 transition group-hover:pointer-events-auto group-hover:opacity-100 ' +
(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">
<ActionButton
label={t('app:friends.action_accept', { defaultValue: 'React' })}
onClick={() => setPickerOpen((v) => !v)}
icon={<SmileIcon className="h-4 w-4" />}
/>
{canEdit && (
<ActionButton
label="Edit"
onClick={() => {
setEditText(message.plaintext ?? '');
setEditing(true);
}}
icon={<PencilIcon className="h-4 w-4" />}
/>
)}
{canDelete && (
<ActionButton
label="Delete"
onClick={() => void handleDelete()}
icon={<TrashIcon className="h-4 w-4" />}
tone="danger"
/>
)}
</div>
{pickerOpen && (
<div
ref={pickerRef}
role="menu"
className={
'absolute z-30 mt-1 flex gap-1 rounded-lg border border-white/10 bg-ink-900/95 p-1.5 shadow-xl backdrop-blur-xl ' +
(mine ? 'right-0' : 'left-0')
}
>
{EMOJI_CHOICES.map((e) => (
<button
key={e}
type="button"
onClick={() => void handlePickEmoji(e)}
className="cursor-pointer rounded-md px-2 py-1 text-lg transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
>
{e}
</button>
))}
<button
type="button"
onClick={() => setPickerOpen(false)}
className="cursor-pointer rounded-md px-1.5 py-1 text-neutral-500 transition hover:bg-white/10"
>
<XIcon className="h-4 w-4" />
</button>
</div>
)}
</div>
)}
</div>
</div>
);
}
function CallEventRow({
parsed,
mine,
time,
}: {
parsed: { status: string; mediaKind: string; durationSec: number };
mine: boolean;
time: string;
}) {
const { t } = useTranslation(['app']);
const status = parsed.status;
const isMissed = status === 'missed' || status === 'declined';
const Icon = isMissed ? PhoneOffIcon : PhoneIcon;
const tone = isMissed
? 'border-rose-500/20 bg-rose-500/10 text-rose-200'
: 'border-emerald-500/20 bg-emerald-500/10 text-emerald-200';
const label =
status === 'ended'
? mine
? t('app:chats.call_outgoing', { defaultValue: 'Outgoing call' })
: t('app:chats.call_incoming', { defaultValue: 'Incoming call' })
: status === 'missed'
? mine
? t('app:chats.call_no_answer', { defaultValue: 'No answer' })
: t('app:chats.call_missed', { defaultValue: 'Missed call' })
: t('app:chats.call_declined', { defaultValue: 'Call declined' });
const duration = parsed.durationSec > 0 ? formatDuration(parsed.durationSec) : null;
return (
<div className="my-2 flex justify-center">
<div
className={
'inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-medium ' + tone
}
>
<Icon className="h-3.5 w-3.5" />
<span>{label}</span>
{duration && <span className="font-mono text-[11px] opacity-80">· {duration}</span>}
<span className="text-[10px] opacity-60">· {time}</span>
</div>
</div>
);
}
function formatDuration(totalSec: number): string {
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
if (m === 0) return s + 's';
return m + ':' + s.toString().padStart(2, '0');
}
function ActionButton({
label,
onClick,
icon,
tone,
}: {
label: string;
onClick: () => void;
icon: React.ReactNode;
tone?: 'danger';
}) {
return (
<button
type="button"
aria-label={label}
title={label}
onClick={onClick}
className={
'flex h-7 w-7 cursor-pointer items-center justify-center rounded-md transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' +
(tone === 'danger'
? 'text-neutral-400 hover:bg-rose-500/20 hover:text-rose-200'
: 'text-neutral-400 hover:bg-white/10 hover:text-neutral-100')
}
>
{icon}
</button>
);
}
+62
View File
@@ -0,0 +1,62 @@
import { useEffect } from 'react';
import { XIcon } from './icons';
interface Props {
open: boolean;
title: string;
onClose: () => void;
children: React.ReactNode;
size?: 'md' | 'lg';
}
export function Modal({ open, title, onClose, children, size = 'md' }: Props) {
useEffect(() => {
if (!open) return;
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
document.addEventListener('keydown', onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', onKey);
document.body.style.overflow = prevOverflow;
};
}, [open, onClose]);
if (!open) return null;
const width = size === 'lg' ? 'max-w-xl' : 'max-w-md';
return (
<div
role="dialog"
aria-modal="true"
aria-label={title}
onClick={onClose}
className="fixed inset-0 z-50 flex items-center justify-center bg-ink-950/80 p-6 backdrop-blur-sm animate-fade-in"
>
<div
onClick={(e) => e.stopPropagation()}
className={
'relative w-full animate-slide-up rounded-2xl border border-white/10 bg-ink-900/95 shadow-xl backdrop-blur-xl ' +
width
}
>
<header className="flex items-center justify-between border-b border-white/5 px-6 py-4">
<h2 className="font-display text-lg font-semibold text-white">{title}</h2>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-neutral-400 transition hover:bg-white/10 hover:text-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
>
<XIcon className="h-4 w-4" />
</button>
</header>
<div className="max-h-[75vh] overflow-y-auto p-6">{children}</div>
</div>
</div>
);
}
+115
View File
@@ -0,0 +1,115 @@
import { useTranslation } from 'react-i18next';
import { NavLink } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { useConversationsContext } from '../context/ConversationsContext';
import { useFriendshipsContext } from '../context/FriendshipsContext';
import { CallBar } from './CallUI';
import {
ChatBubbleIcon,
GearIcon,
LogoMark,
ShieldIcon,
SignOutIcon,
UsersIcon,
} from './icons';
import { UserBar } from './UserBar';
interface NavItem {
to: string;
labelKey: string;
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
}
const BASE_NAV_ITEMS: NavItem[] = [
{ to: '/chats', labelKey: 'app:nav.chats', icon: ChatBubbleIcon },
{ to: '/friends', labelKey: 'app:nav.friends', icon: UsersIcon },
{ to: '/settings', labelKey: 'app:nav.settings', icon: GearIcon },
];
const ADMIN_NAV_ITEM: NavItem = {
to: '/admin',
labelKey: 'app:nav.admin',
icon: ShieldIcon,
};
export function Sidebar() {
const { t } = useTranslation(['app', 'common']);
const { signOut, profile } = useAuth();
const { incomingCount } = useFriendshipsContext();
const { totalUnread } = useConversationsContext();
const navItems = profile?.isAdmin ? [...BASE_NAV_ITEMS, ADMIN_NAV_ITEM] : BASE_NAV_ITEMS;
return (
<aside
aria-label="Primary navigation"
className="flex h-screen w-72 shrink-0 flex-col border-r border-white/5 bg-ink-900/70 backdrop-blur-xl"
>
<div className="flex items-center gap-2.5 px-5 pb-3 pt-5">
<LogoMark className="h-7 w-7" />
<span className="font-display text-base font-semibold tracking-tight text-white">
{t('common:app_name')}
</span>
</div>
<nav className="mt-2 flex flex-col gap-0.5 px-3">
{navItems.map((item) => {
const badge =
item.to === '/friends'
? incomingCount
: item.to === '/chats'
? totalUnread
: 0;
const ariaLabel = badge > 0 ? `${t(item.labelKey)} (${badge})` : undefined;
return (
<NavLink
key={item.to}
to={item.to}
aria-label={ariaLabel}
className={({ isActive }) =>
[
'group flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/50',
isActive
? 'bg-brand-500/15 text-white ring-1 ring-brand-400/30'
: 'text-neutral-400 hover:bg-white/5 hover:text-neutral-100',
].join(' ')
}
>
<item.icon style={{ width: '18px', height: '18px' }} className="transition" />
<span className="flex-1">{t(item.labelKey)}</span>
{badge > 0 && <NavBadge count={badge} />}
</NavLink>
);
})}
</nav>
<div className="flex-1" />
<div className="border-t border-white/5 p-3">
<CallBar />
<UserBar />
<button
type="button"
onClick={() => void signOut()}
className="mt-2 flex w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-xs font-medium text-neutral-400 transition hover:bg-rose-500/10 hover:text-rose-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/40"
>
<SignOutIcon className="h-4 w-4" />
<span>{t('app:sidebar.sign_out')}</span>
</button>
</div>
</aside>
);
}
function NavBadge({ count }: { count: number }) {
const display = count > 99 ? '99+' : String(count);
return (
<span
aria-hidden="true"
className="inline-flex min-w-[20px] items-center justify-center rounded-full bg-rose-500 px-1.5 text-[10px] font-bold leading-tight text-white shadow-[0_0_0_2px_rgba(15,15,24,1)]"
>
{display}
</span>
);
}
@@ -0,0 +1,40 @@
import type { ConversationMember } from '@chat-app/shared/chat';
import { useTranslation } from 'react-i18next';
interface Props {
typingUserIds: string[];
members: ConversationMember[];
}
export function TypingIndicator({ typingUserIds, members }: Props) {
const { t } = useTranslation(['app']);
if (typingUserIds.length === 0) return null;
const names = typingUserIds
.map((id) => members.find((m) => m.userId === id)?.profile?.displayName)
.filter((n): n is string => typeof n === 'string' && n.length > 0);
const text =
names.length === 1
? t('app:chats.typing_one', { name: names[0] })
: t('app:chats.typing_many', { count: typingUserIds.length });
return (
<div className="px-6 pb-1 pt-0 text-xs text-neutral-400">
<span className="inline-flex items-center gap-2">
<TypingDots />
<span>{text}</span>
</span>
</div>
);
}
function TypingDots() {
return (
<span aria-hidden="true" className="inline-flex items-center gap-0.5">
<span className="h-1 w-1 rounded-full bg-neutral-500 [animation:pulse_1.2s_ease-in-out_infinite]" />
<span className="h-1 w-1 rounded-full bg-neutral-500 [animation:pulse_1.2s_ease-in-out_infinite] [animation-delay:0.15s]" />
<span className="h-1 w-1 rounded-full bg-neutral-500 [animation:pulse_1.2s_ease-in-out_infinite] [animation-delay:0.3s]" />
</span>
);
}
+113
View File
@@ -0,0 +1,113 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
checkForUpdate,
IDLE_UPDATE_STATE,
installUpdate,
type UpdateState,
} from '../lib/appUpdates';
import { SparklesIcon, SpinnerIcon, XIcon } from './icons';
// Light-weight update UX: check once on mount, then every hour while the
// app is open. When an update exists show a toast with "Install & Restart"
// and a dismiss button. Dismiss is session-scoped — on next launch we check
// again.
export function UpdateToast() {
const { t } = useTranslation(['app']);
const [state, setState] = useState<UpdateState>(IDLE_UPDATE_STATE);
const [dismissed, setDismissed] = useState(false);
const [installing, setInstalling] = useState(false);
const [progressPct, setProgressPct] = useState<number | null>(null);
useEffect(() => {
let cancelled = false;
const run = async () => {
const next = await checkForUpdate();
if (!cancelled) setState(next);
};
void run();
const id = window.setInterval(run, 60 * 60 * 1000);
return () => {
cancelled = true;
window.clearInterval(id);
};
}, []);
if (!state.available || dismissed) return null;
const handleInstall = async () => {
setInstalling(true);
setProgressPct(0);
try {
await installUpdate((downloaded, total) => {
if (total && total > 0) setProgressPct((downloaded / total) * 100);
});
} catch (err: unknown) {
setState((s) => ({
...s,
error: err instanceof Error ? err.message : 'install failed',
}));
setInstalling(false);
setProgressPct(null);
}
};
return (
<div
role="status"
aria-live="polite"
className="fixed bottom-6 left-6 z-50 w-80 overflow-hidden rounded-2xl border border-white/10 bg-ink-900/95 p-4 shadow-2xl backdrop-blur-xl"
>
<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">
<SparklesIcon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-white">
{t('app:update.available', { defaultValue: 'Update verfügbar' })}
{state.version ? ' · v' + state.version : ''}
</p>
{state.notes && (
<p className="mt-0.5 line-clamp-3 text-xs text-neutral-400">{state.notes}</p>
)}
{state.error && (
<p className="mt-1 text-xs text-rose-300">{state.error}</p>
)}
</div>
{!installing && (
<button
type="button"
onClick={() => setDismissed(true)}
aria-label={t('common:close', { defaultValue: 'Schließen' })}
className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-neutral-400 transition hover:bg-white/10 hover:text-neutral-100"
>
<XIcon className="h-3.5 w-3.5" />
</button>
)}
</div>
<button
type="button"
onClick={() => void handleInstall()}
disabled={installing}
className="mt-3 inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-gradient-to-br from-brand-400 to-brand-600 px-3 py-2 text-sm font-semibold text-white transition hover:from-brand-300 hover:to-brand-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/60 disabled:cursor-not-allowed disabled:opacity-60"
>
{installing ? (
<>
<SpinnerIcon className="h-3.5 w-3.5" />
<span>
{progressPct != null
? t('app:update.downloading', { defaultValue: 'Lade…' }) +
' ' +
Math.round(progressPct) +
'%'
: t('app:update.installing', { defaultValue: 'Installiere…' })}
</span>
</>
) : (
<span>{t('app:update.install', { defaultValue: 'Installieren & Neustarten' })}</span>
)}
</button>
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
import { updateOwnProfile } from '@chat-app/shared/auth';
import type { PresenceState } from '@chat-app/shared/supabase';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import { supabase } from '../lib/supabase';
import { ChevronDownIcon } from './icons';
const PRESENCE_OPTIONS: PresenceState[] = ['online', 'idle', 'dnd', 'invisible', 'offline'];
const PRESENCE_DOT: Record<PresenceState, string> = {
online: 'bg-emerald-400',
idle: 'bg-amber-400',
dnd: 'bg-rose-500',
invisible: 'bg-neutral-500',
offline: 'bg-neutral-600',
};
function avatarLetter(input: string | undefined): string {
return (input ?? '?').trim().charAt(0).toUpperCase() || '?';
}
export function UserBar() {
const { t } = useTranslation(['app']);
const { profile, refreshProfile } = useAuth();
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const presence = profile?.presenceState ?? 'offline';
async function changePresence(next: PresenceState) {
if (busy || next === presence) {
setOpen(false);
return;
}
setBusy(true);
try {
await updateOwnProfile(supabase, { presenceState: next });
await refreshProfile();
} catch (err: unknown) {
console.error('updatePresence failed', err);
} finally {
setBusy(false);
setOpen(false);
}
}
return (
<div className="relative">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={open}
className="flex w-full cursor-pointer items-center gap-3 rounded-lg px-2 py-2 text-left transition hover:bg-white/5 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
>
<div className="relative flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-sm font-semibold text-white ring-1 ring-brand-400/30">
{avatarLetter(profile?.displayName ?? profile?.username)}
<span
className={
'absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full ring-2 ring-ink-900 ' +
PRESENCE_DOT[presence]
}
/>
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-white">
{profile?.displayName ?? '—'}
</p>
<p className="truncate text-xs text-neutral-400">
{profile?.username ? '@' + profile.username : '—'}
</p>
</div>
<ChevronDownIcon
className={'h-4 w-4 text-neutral-500 transition ' + (open ? 'rotate-180' : '')}
/>
</button>
{open && (
<div
role="menu"
className="absolute bottom-full left-0 right-0 mb-2 overflow-hidden rounded-lg border border-white/10 bg-ink-900/95 shadow-xl backdrop-blur-xl"
>
{PRESENCE_OPTIONS.map((opt) => (
<button
key={opt}
type="button"
role="menuitemradio"
aria-checked={opt === presence}
onClick={() => void changePresence(opt)}
disabled={busy}
className="flex w-full cursor-pointer items-center gap-3 px-3 py-2 text-left text-sm text-neutral-200 transition hover:bg-white/5 disabled:opacity-50"
>
<span className={'h-2.5 w-2.5 rounded-full ' + PRESENCE_DOT[opt]} />
<span className="flex-1">{t('app:presence.' + opt)}</span>
{opt === presence && (
<span className="text-xs text-brand-300"></span>
)}
</button>
))}
</div>
)}
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { SpinnerIcon } from './icons';
function FullScreenSpinner({ label }: { label?: string }) {
return (
<main className="flex min-h-screen items-center justify-center bg-ink-950 p-6">
<div className="flex items-center gap-3 text-neutral-400">
<SpinnerIcon className="h-5 w-5 text-brand-400" />
{label && <span className="text-sm font-medium">{label}</span>}
</div>
</main>
);
}
// Forces a session. Sends to /auth otherwise.
export function RequireAuth() {
const { session, ready } = useAuth();
const loc = useLocation();
if (!ready) return <FullScreenSpinner />;
if (!session) return <Navigate to="/auth" replace state={{ from: loc.pathname }} />;
return <Outlet />;
}
// Forces a registered device on this install. Sends to /device otherwise.
export function RequireDevice() {
const { device, deviceLookupDone } = useAuth();
if (!deviceLookupDone) return <FullScreenSpinner />;
if (!device) return <Navigate to="/device" replace />;
return <Outlet />;
}
// Admin-only route gate. Non-admins bounce to /chats — RLS still enforces
// server-side, this is purely a UX shortcut.
export function RequireAdmin() {
const { profile } = useAuth();
if (profile && !profile.isAdmin) return <Navigate to="/chats" replace />;
return <Outlet />;
}
+367
View File
@@ -0,0 +1,367 @@
// Inline SVG icons — no icon library dependency. Lucide-style stroke=1.75.
type IconProps = React.SVGProps<SVGSVGElement>;
function Base({ children, ...props }: IconProps & { children: React.ReactNode }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
{...props}
>
{children}
</svg>
);
}
export function MailIcon(props: IconProps) {
return (
<Base {...props}>
<rect x="3" y="5" width="18" height="14" rx="2" />
<path d="m3 7 9 6 9-6" />
</Base>
);
}
export function AtIcon(props: IconProps) {
return (
<Base {...props}>
<circle cx="12" cy="12" r="4" />
<path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-4 8" />
</Base>
);
}
export function TicketIcon(props: IconProps) {
return (
<Base {...props}>
<path d="M3 8a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v2a2 2 0 1 0 0 4v2a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-2a2 2 0 1 0 0-4V8Z" />
<path d="M9 6v12" strokeDasharray="2 3" />
</Base>
);
}
export function ArrowRightIcon(props: IconProps) {
return (
<Base {...props}>
<path d="M5 12h14" />
<path d="m13 6 6 6-6 6" />
</Base>
);
}
export function CheckCircleIcon(props: IconProps) {
return (
<Base {...props}>
<circle cx="12" cy="12" r="9" />
<path d="m8.5 12.5 2.5 2.5 4.5-5" />
</Base>
);
}
export function AlertIcon(props: IconProps) {
return (
<Base {...props}>
<path d="M12 9v4" />
<path d="M12 17h.01" />
<path d="M10.3 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z" />
</Base>
);
}
export function ShieldIcon(props: IconProps) {
return (
<Base {...props}>
<path d="M12 3 4 6v6c0 5 3.5 8.5 8 9 4.5-.5 8-4 8-9V6l-8-3Z" />
<path d="m9.5 12.5 2 2 3.5-4" />
</Base>
);
}
export function LockIcon(props: IconProps) {
return (
<Base {...props}>
<rect x="4" y="11" width="16" height="10" rx="2" />
<path d="M8 11V8a4 4 0 1 1 8 0v3" />
</Base>
);
}
export function SparklesIcon(props: IconProps) {
return (
<Base {...props}>
<path d="M12 3v4" />
<path d="M12 17v4" />
<path d="M3 12h4" />
<path d="M17 12h4" />
<path d="m6 6 2 2" />
<path d="m16 16 2 2" />
<path d="m6 18 2-2" />
<path d="m16 8 2-2" />
</Base>
);
}
export function SpinnerIcon(props: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
{...props}
>
<circle
cx="12"
cy="12"
r="9"
stroke="currentColor"
strokeOpacity="0.25"
strokeWidth="2.5"
/>
<path
d="M21 12a9 9 0 0 0-9-9"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
>
<animateTransform
attributeName="transform"
type="rotate"
from="0 12 12"
to="360 12 12"
dur="0.8s"
repeatCount="indefinite"
/>
</path>
</svg>
);
}
export function ChatBubbleIcon(props: IconProps) {
return (
<Base {...props}>
<path d="M21 15a2 2 0 0 1-2 2H8l-5 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z" />
</Base>
);
}
export function UsersIcon(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="M22 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</Base>
);
}
export function GearIcon(props: IconProps) {
return (
<Base {...props}>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z" />
</Base>
);
}
export function SearchIcon(props: IconProps) {
return (
<Base {...props}>
<circle cx="11" cy="11" r="7" />
<path d="m21 21-4.3-4.3" />
</Base>
);
}
export function PlusIcon(props: IconProps) {
return (
<Base {...props}>
<path d="M12 5v14M5 12h14" />
</Base>
);
}
export function SignOutIcon(props: IconProps) {
return (
<Base {...props}>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<path d="m16 17 5-5-5-5" />
<path d="M21 12H9" />
</Base>
);
}
export function MenuIcon(props: IconProps) {
return (
<Base {...props}>
<path d="M3 6h18M3 12h18M3 18h18" />
</Base>
);
}
export function ChevronDownIcon(props: IconProps) {
return (
<Base {...props}>
<path d="m6 9 6 6 6-6" />
</Base>
);
}
export function PencilIcon(props: IconProps) {
return (
<Base {...props}>
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
</Base>
);
}
export function TrashIcon(props: IconProps) {
return (
<Base {...props}>
<path d="M3 6h18" />
<path d="m19 6-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6" />
<path d="M14 11v6" />
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
</Base>
);
}
export function SmileIcon(props: IconProps) {
return (
<Base {...props}>
<circle cx="12" cy="12" r="9" />
<path d="M8 14s1.5 2 4 2 4-2 4-2" />
<path d="M9 9h.01" />
<path d="M15 9h.01" />
</Base>
);
}
export function CopyIcon(props: IconProps) {
return (
<Base {...props}>
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</Base>
);
}
export function PhoneIcon(props: IconProps) {
return (
<Base {...props}>
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6A19.79 19.79 0 0 1 2.12 4.18 2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92Z" />
</Base>
);
}
export function PhoneOffIcon(props: IconProps) {
return (
<Base {...props}>
<path d="M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-3.48-3.05" />
<path d="M22 2 2 22" />
<path d="M6.12 2H4.11A2 2 0 0 0 2.12 4.18c.17 1.39.5 2.72 1 3.97" />
</Base>
);
}
export function MicIcon(props: IconProps) {
return (
<Base {...props}>
<rect x="9" y="2" width="6" height="12" rx="3" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
<path d="M12 19v4" />
<path d="M8 23h8" />
</Base>
);
}
export function MicOffIcon(props: IconProps) {
return (
<Base {...props}>
<path d="M1 1l22 22" />
<path d="M9 9v3a3 3 0 0 0 5.12 2.12" />
<path d="M15 9.34V4a3 3 0 0 0-5.94-.6" />
<path d="M17 16.95A7 7 0 0 1 5 12v-2" />
<path d="M19 10v2a7 7 0 0 1-.11 1.23" />
<path d="M12 19v4" />
<path d="M8 23h8" />
</Base>
);
}
export function InfoIcon(props: IconProps) {
return (
<Base {...props}>
<circle cx="12" cy="12" r="9" />
<path d="M12 16v-4" />
<path d="M12 8h.01" />
</Base>
);
}
export function XIcon(props: IconProps) {
return (
<Base {...props}>
<path d="M18 6 6 18M6 6l12 12" />
</Base>
);
}
export function MonitorShareIcon(props: IconProps) {
return (
<Base {...props}>
<rect x="3" y="4" width="18" height="12" rx="2" />
<path d="M8 20h8M12 16v4" />
<path d="M12 12V7M9.5 9.5L12 7l2.5 2.5" />
</Base>
);
}
export function MonitorStopIcon(props: IconProps) {
return (
<Base {...props}>
<rect x="3" y="4" width="18" height="12" rx="2" />
<path d="M8 20h8M12 16v4" />
<path d="M9 9h6v3H9z" fill="currentColor" />
</Base>
);
}
export function LogoMark(props: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 32 32"
fill="none"
aria-hidden="true"
{...props}
>
<defs>
<linearGradient id="logo-grad" x1="0" y1="0" x2="32" y2="32" gradientUnits="userSpaceOnUse">
<stop offset="0" stopColor="#818CF8" />
<stop offset="1" stopColor="#4F46E5" />
</linearGradient>
</defs>
<path
d="M6 9a5 5 0 0 1 5-5h10a5 5 0 0 1 5 5v8a5 5 0 0 1-5 5h-5.5L9 27v-5H11a5 5 0 0 1-5-5V9Z"
fill="url(#logo-grad)"
/>
<path
d="M12 14h8M12 10h8"
stroke="#0A0A0F"
strokeWidth="1.75"
strokeLinecap="round"
strokeOpacity="0.5"
/>
</svg>
);
}
+158
View File
@@ -0,0 +1,158 @@
import {
type DeviceRecord,
getOwnProfile,
signOut as supabaseSignOut,
type Profile,
} from '@chat-app/shared/auth';
import { changeLocale, isSupportedLocale } from '@chat-app/shared/i18n';
import type { Session } from '@supabase/supabase-js';
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import { useTranslation } from 'react-i18next';
import { findExistingDevice } from '../lib/device';
import { supabase } from '../lib/supabase';
interface AuthContextValue {
session: Session | null;
profile: Profile | null;
device: DeviceRecord | null;
// null while we're still resolving the very first auth state.
ready: boolean;
// null until a device lookup has finished for the current session.
deviceLookupDone: boolean;
refreshProfile: () => Promise<void>;
refreshDevice: () => Promise<void>;
setDevice: (device: DeviceRecord | null) => void;
signOut: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const { i18n } = useTranslation();
const [session, setSession] = useState<Session | null>(null);
const [ready, setReady] = useState(false);
const [profile, setProfile] = useState<Profile | null>(null);
const [device, setDevice] = useState<DeviceRecord | null>(null);
const [deviceLookupDone, setDeviceLookupDone] = useState(false);
// Initial session + auth subscription. We verify the cached JWT against the
// server (via getUser) once on mount. Only purge the session on an
// unambiguous 401/403 — a network failure (Supabase stack offline) must not
// log the user out, otherwise every local `supabase stop` wipes their session.
useEffect(() => {
let cancelled = false;
(async () => {
const { data: sessionRes } = await supabase.auth.getSession();
if (cancelled) return;
if (sessionRes.session) {
const { error } = await supabase.auth.getUser();
if (cancelled) return;
if (error) {
const status = (error as { status?: number }).status;
if (status === 401 || status === 403) {
// Token genuinely invalid — wipe.
await supabase.auth.signOut().catch(() => {
/* ignore */
});
setSession(null);
} else {
// Network / server unreachable — keep cached session, let reads
// fail gracefully and recover when the stack is back.
console.warn('auth.getUser failed, keeping cached session:', error);
setSession(sessionRes.session);
}
} else {
setSession(sessionRes.session);
}
} else {
setSession(null);
}
setReady(true);
})();
const { data: sub } = supabase.auth.onAuthStateChange((_event, s) => {
setSession(s);
setReady(true);
});
return () => {
cancelled = true;
sub.subscription.unsubscribe();
};
}, []);
const refreshProfile = useCallback(async () => {
if (!session) {
setProfile(null);
return;
}
const p = await getOwnProfile(supabase);
setProfile(p);
if (p && p.locale !== i18n.resolvedLanguage && isSupportedLocale(p.locale)) {
void changeLocale(p.locale);
}
}, [session, i18n.resolvedLanguage]);
const refreshDevice = useCallback(async () => {
if (!session) {
setDevice(null);
setDeviceLookupDone(false);
return;
}
setDeviceLookupDone(false);
const found = await findExistingDevice(session.user.id);
setDevice(found);
setDeviceLookupDone(true);
}, [session]);
// Re-pull profile + device whenever session flips.
useEffect(() => {
if (!session) {
setProfile(null);
setDevice(null);
setDeviceLookupDone(false);
return;
}
void refreshProfile().catch((err: unknown) => {
console.error('refreshProfile failed', err);
});
void refreshDevice().catch((err: unknown) => {
console.error('refreshDevice failed', err);
setDeviceLookupDone(true);
});
}, [session, refreshProfile, refreshDevice]);
const signOut = useCallback(async () => {
await supabaseSignOut(supabase);
}, []);
const value = useMemo<AuthContextValue>(
() => ({
session,
profile,
device,
ready,
deviceLookupDone,
refreshProfile,
refreshDevice,
setDevice,
signOut,
}),
[session, profile, device, ready, deviceLookupDone, refreshProfile, refreshDevice, signOut],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used inside <AuthProvider>');
return ctx;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,239 @@
import { type ConversationSummary, listConversations } from '@chat-app/shared/chat';
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { playNotificationTone } from '../lib/notificationSound';
import { notify } from '../lib/osNotify';
import { supabase } from '../lib/supabase';
import { useAuth } from './AuthContext';
const LAST_READ_STORAGE_KEY = 'chatapp.conv_last_read';
const EPOCH = new Date(0).toISOString();
type LastReadMap = Record<string, string>;
function loadLastReadMap(): LastReadMap {
try {
const raw = window.localStorage.getItem(LAST_READ_STORAGE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw) as unknown;
return parsed && typeof parsed === 'object' ? (parsed as LastReadMap) : {};
} catch {
return {};
}
}
function saveLastReadMap(map: LastReadMap): void {
try {
window.localStorage.setItem(LAST_READ_STORAGE_KEY, JSON.stringify(map));
} catch {
/* quota / private mode */
}
}
interface ConversationsContextValue {
conversations: ConversationSummary[];
loading: boolean;
error: string | null;
unread: Record<string, number>;
totalUnread: number;
refresh: () => Promise<void>;
markRead: (conversationId: string) => void;
setActiveConversation: (conversationId: string | null) => void;
}
const ConversationsContext = createContext<ConversationsContextValue | null>(null);
export function ConversationsProvider({ children }: { children: ReactNode }) {
const { session, profile } = useAuth();
const myId = session?.user.id;
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [unread, setUnread] = useState<Record<string, number>>({});
const lastReadRef = useRef<LastReadMap>(loadLastReadMap());
const activeConvIdRef = useRef<string | null>(null);
const presenceRef = useRef(profile?.presenceState ?? 'offline');
const conversationsRef = useRef<ConversationSummary[]>([]);
conversationsRef.current = conversations;
useEffect(() => {
presenceRef.current = profile?.presenceState ?? 'offline';
}, [profile?.presenceState]);
const computeUnreadForConv = useCallback(
async (convId: string): Promise<number> => {
if (!myId) return 0;
const since = lastReadRef.current[convId] ?? EPOCH;
const { count, error: cErr } = await supabase
.from('messages')
.select('id', { count: 'exact', head: true })
.eq('conversation_id', convId)
.gt('created_at', since)
.neq('sender_id', myId);
if (cErr) return 0;
return count ?? 0;
},
[myId],
);
const refresh = useCallback(async () => {
if (!myId) {
setConversations([]);
setUnread({});
setLoading(false);
return;
}
try {
setLoading(true);
const convs = await listConversations(supabase);
setConversations(convs);
const entries = await Promise.all(
convs.map(async (c) => [c.id, await computeUnreadForConv(c.id)] as const),
);
const next: Record<string, number> = {};
for (const [id, count] of entries) {
// Active conversation is always read.
next[id] = id === activeConvIdRef.current ? 0 : count;
}
setUnread(next);
setError(null);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'failed to load conversations');
} finally {
setLoading(false);
}
}, [myId, computeUnreadForConv]);
const markRead = useCallback((convId: string) => {
const now = new Date().toISOString();
lastReadRef.current[convId] = now;
saveLastReadMap(lastReadRef.current);
setUnread((prev) => (prev[convId] ? { ...prev, [convId]: 0 } : prev));
}, []);
const setActiveConversation = useCallback(
(convId: string | null) => {
activeConvIdRef.current = convId;
if (convId) markRead(convId);
},
[markRead],
);
useEffect(() => {
if (!myId) {
setConversations([]);
setUnread({});
setLoading(false);
return;
}
void refresh();
const channel = supabase
.channel('conv-ctx:' + myId)
.on(
'postgres_changes',
{ event: '*', schema: 'public', table: 'conversation_members' },
() => {
void refresh();
},
)
.on('postgres_changes', { event: '*', schema: 'public', table: 'conversations' }, () => {
void refresh();
})
.on(
'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'messages' },
(payload: { new: Record<string, unknown> }) => {
const row = payload.new as unknown as { conversation_id: string; sender_id: string };
const fromSelf = row.sender_id === myId;
const active = row.conversation_id === activeConvIdRef.current;
if (!fromSelf) {
if (active) {
// Viewing this conversation — implicit read.
markRead(row.conversation_id);
} else {
setUnread((prev) => ({
...prev,
[row.conversation_id]: (prev[row.conversation_id] ?? 0) + 1,
}));
// Notification sound + OS notification — respect DND. Body stays
// empty because message content is E2E-encrypted and only
// decryptable in the conversation view (not at this hook level).
if (presenceRef.current !== 'dnd') {
playNotificationTone();
const conv = conversationsRef.current.find(
(c) => c.id === row.conversation_id,
);
const sender = conv?.members.find(
(m) => m.userId === row.sender_id,
)?.profile;
const senderName = sender?.displayName ?? '…';
const title =
conv?.type === 'group'
? (conv.name ?? 'Neue Nachricht') + ' · ' + senderName
: senderName;
void notify({ title, body: 'Neue Nachricht' });
}
}
}
// Re-pull conversations to update lastMessageAt ordering.
void refresh();
},
)
.subscribe();
return () => {
void supabase.removeChannel(channel);
};
}, [myId, refresh, markRead]);
const totalUnread = useMemo(() => {
let s = 0;
for (const v of Object.values(unread)) s += v;
return s;
}, [unread]);
const value = useMemo<ConversationsContextValue>(
() => ({
conversations,
loading,
error,
unread,
totalUnread,
refresh,
markRead,
setActiveConversation,
}),
[
conversations,
loading,
error,
unread,
totalUnread,
refresh,
markRead,
setActiveConversation,
],
);
return <ConversationsContext.Provider value={value}>{children}</ConversationsContext.Provider>;
}
export function useConversationsContext(): ConversationsContextValue {
const ctx = useContext(ConversationsContext);
if (!ctx) throw new Error('useConversationsContext must be used inside <ConversationsProvider>');
return ctx;
}
@@ -0,0 +1,53 @@
import { type Friendship } from '@chat-app/shared/friends';
import { createContext, type ReactNode, useContext, useMemo } from 'react';
import { useFriendships } from '../lib/useFriendships';
import { useAuth } from './AuthContext';
interface FriendshipsContextValue {
friendships: Friendship[];
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
acceptedCount: number;
outgoingCount: number;
incomingCount: number;
}
const FriendshipsContext = createContext<FriendshipsContextValue | null>(null);
// Single source of truth for the friendships list. Subscribes once via the
// realtime channel and shares derived counters (accepted / outgoing / incoming)
// with the sidebar badge AND the FriendsPage.
export function FriendshipsProvider({ children }: { children: ReactNode }) {
const { session } = useAuth();
const { friendships, loading, error, refresh } = useFriendships(session?.user.id);
const value = useMemo<FriendshipsContextValue>(() => {
let accepted = 0;
let outgoing = 0;
let incoming = 0;
for (const f of friendships) {
if (f.status === 'accepted') accepted++;
else if (f.status === 'pending' && f.direction === 'outgoing') outgoing++;
else if (f.status === 'pending' && f.direction === 'incoming') incoming++;
}
return {
friendships,
loading,
error,
refresh,
acceptedCount: accepted,
outgoingCount: outgoing,
incomingCount: incoming,
};
}, [friendships, loading, error, refresh]);
return <FriendshipsContext.Provider value={value}>{children}</FriendshipsContext.Provider>;
}
export function useFriendshipsContext(): FriendshipsContextValue {
const ctx = useContext(FriendshipsContext);
if (!ctx) throw new Error('useFriendshipsContext must be used inside <FriendshipsProvider>');
return ctx;
}
+11
View File
@@ -0,0 +1,11 @@
# apps/desktop/src/lib
Desktop-local helpers that depend on Tauri plugins.
Expected contents:
- `stronghold.ts` — wraps `tauri-plugin-stronghold` for secret storage; adapter for `@chat-app/shared/auth`.
- `sqlite.ts` — wraps `tauri-plugin-sql` (SQLite), exposes migration runner.
- `notifications.ts` — wraps `tauri-plugin-notification`.
- `sodium.ts` — binds `libsodium-wrappers` (WASM) to the crypto adapter interface.
- `store.ts` — Zustand store setup (auth state, chat state slices).
+76
View File
@@ -0,0 +1,76 @@
import { check as checkUpdate } from '@tauri-apps/plugin-updater';
import { isTauriRuntime } from './globalShortcut';
export interface UpdateState {
available: boolean;
version: string | null;
notes: string | null;
downloading: boolean;
downloaded: boolean;
error: string | null;
}
export const IDLE_UPDATE_STATE: UpdateState = {
available: false,
version: null,
notes: null,
downloading: false,
downloaded: false,
error: null,
};
type UpdateHandle = Awaited<ReturnType<typeof checkUpdate>>;
let cachedUpdate: UpdateHandle | null = null;
export async function checkForUpdate(): Promise<UpdateState> {
if (!isTauriRuntime()) {
return { ...IDLE_UPDATE_STATE };
}
try {
const update = await checkUpdate();
if (!update) {
cachedUpdate = null;
return { ...IDLE_UPDATE_STATE };
}
cachedUpdate = update;
return {
available: true,
version: update.version ?? null,
notes: update.body ?? null,
downloading: false,
downloaded: false,
error: null,
};
} catch (err: unknown) {
console.warn('checkForUpdate failed', err);
return {
...IDLE_UPDATE_STATE,
error: err instanceof Error ? err.message : 'update check failed',
};
}
}
// Downloads + installs the previously checked update. On Windows the app
// quits during install (passive installer); on macOS/Linux tauri triggers
// a relaunch automatically.
export async function installUpdate(
onProgress?: (downloaded: number, total: number | null) => void,
): Promise<void> {
if (!cachedUpdate) {
throw new Error('no pending update — call checkForUpdate() first');
}
let total: number | null = null;
let downloaded = 0;
await cachedUpdate.downloadAndInstall((event) => {
if (event.event === 'Started') {
total = event.data.contentLength ?? null;
downloaded = 0;
} else if (event.event === 'Progress') {
downloaded += event.data.chunkLength;
}
onProgress?.(downloaded, total);
});
cachedUpdate = null;
}
+107
View File
@@ -0,0 +1,107 @@
// Audio quality preferences for outgoing voice/music. `voice` is the default
// — Opus 48 kbps stereo with full DSP (echo cancellation + noise suppression
// + AGC). `hifi` bumps to 96 kbps stereo Opus and disables all DSP so music,
// instruments, or broadcast-style voice streams stay uncoloured.
const STORAGE_KEY = 'chatapp.audio';
export type AudioQuality = 'voice' | 'hifi';
export interface AudioSettings {
quality: AudioQuality;
}
const DEFAULTS: AudioSettings = {
quality: 'voice',
};
export interface AudioQualityParams {
label: string;
bitrateKbps: number;
stereo: boolean;
sampleRateHz: number;
// DSP toggles — off for hifi so music isn't coloured by noise-suppression.
echoCancellation: boolean;
noiseSuppression: boolean;
autoGainControl: boolean;
}
const PARAMS: Record<AudioQuality, AudioQualityParams> = {
voice: {
label: 'Voice · 48 kbps',
bitrateKbps: 48,
stereo: false,
sampleRateHz: 48_000,
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
hifi: {
label: 'HiFi · 510 kbps Stereo',
bitrateKbps: 510, // Opus max, ~CD-quality stereo
stereo: true,
sampleRateHz: 48_000,
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
},
};
export const AUDIO_QUALITY_ORDER: ReadonlyArray<AudioQuality> = ['voice', 'hifi'];
export function getAudioQualityParams(q: AudioQuality): AudioQualityParams {
return PARAMS[q];
}
type Listener = (s: AudioSettings) => void;
const listeners = new Set<Listener>();
let cached: AudioSettings | null = null;
function isQuality(v: unknown): v is AudioQuality {
return v === 'voice' || v === 'hifi';
}
function read(): AudioSettings {
if (cached) return cached;
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) {
cached = DEFAULTS;
return cached;
}
const parsed = JSON.parse(raw) as Partial<AudioSettings>;
cached = {
quality: isQuality(parsed.quality) ? parsed.quality : DEFAULTS.quality,
};
return cached;
} catch {
cached = DEFAULTS;
return cached;
}
}
function write(s: AudioSettings): void {
cached = s;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
} catch {
/* quota / private mode */
}
for (const l of listeners) l(s);
}
export function getAudioSettings(): AudioSettings {
return read();
}
export function updateAudioSettings(patch: Partial<AudioSettings>): AudioSettings {
const next = { ...read(), ...patch };
write(next);
return next;
}
export function subscribeAudioSettings(listener: Listener): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
+111
View File
@@ -0,0 +1,111 @@
import { ExternalE2EEKeyProvider } from 'livekit-client';
// Vite-native Worker import. `?worker` triggers a dedicated build chunk
// shipped as a classic/module worker. The default export is the Worker
// constructor; we instantiate once per tab and reuse.
import LivekitE2EEWorker from 'livekit-client/e2ee-worker?worker';
const STORAGE_KEY = 'chatapp.e2ee';
export interface CallE2EESettings {
enabled: boolean;
}
// Default ON — the whole point of this project is zero-knowledge, so honour
// that for the SFU path too. Can be turned off for debugging or if a user's
// browser lacks RTCRtpScriptTransform / Insertable Streams support.
const DEFAULTS: CallE2EESettings = { enabled: true };
type Listener = (s: CallE2EESettings) => void;
const listeners = new Set<Listener>();
let cached: CallE2EESettings | null = null;
function read(): CallE2EESettings {
if (cached) return cached;
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) {
cached = DEFAULTS;
return cached;
}
const parsed = JSON.parse(raw) as Partial<CallE2EESettings>;
cached = {
enabled: typeof parsed.enabled === 'boolean' ? parsed.enabled : DEFAULTS.enabled,
};
return cached;
} catch {
cached = DEFAULTS;
return cached;
}
}
function write(s: CallE2EESettings): void {
cached = s;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
} catch {
/* quota / private mode */
}
for (const l of listeners) l(s);
}
export function getCallE2EESettings(): CallE2EESettings {
return read();
}
export function updateCallE2EESettings(patch: Partial<CallE2EESettings>): CallE2EESettings {
const next = { ...read(), ...patch };
write(next);
return next;
}
export function subscribeCallE2EESettings(listener: Listener): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
// Shared single Worker instance — LiveKit supports reusing it across rooms.
let workerInstance: Worker | null = null;
function getWorker(): Worker {
if (!workerInstance) {
workerInstance = new LivekitE2EEWorker();
}
return workerInstance;
}
// Feature detection — Insertable Streams (RTCRtpScriptTransform or the older
// encodedStreams API) is required for LiveKit E2EE. Returns false on browsers
// that can't encrypt the media path (the caller then skips the `e2ee` option).
export function isE2EESupported(): boolean {
if (typeof window === 'undefined') return false;
const hasScriptTransform =
typeof (window as unknown as { RTCRtpScriptTransform?: unknown }).RTCRtpScriptTransform !==
'undefined';
const sender = (window as unknown as { RTCRtpSender?: { prototype?: unknown } }).RTCRtpSender;
const hasEncodedStreams =
!!sender?.prototype &&
'createEncodedStreams' in (sender.prototype as Record<string, unknown>);
return hasScriptTransform || hasEncodedStreams;
}
interface E2EEBundle {
keyProvider: ExternalE2EEKeyProvider;
worker: Worker;
}
// Derives a stable per-conversation passphrase entirely client-side. The
// passphrase is never transmitted anywhere; each member computes it locally
// from the conversation id they already hold via RLS-protected Supabase data.
// A stronger variant would ship a random per-conversation secret through the
// existing E2E envelope system — deferred to M3.
export async function createCallE2EE(conversationId: string): Promise<E2EEBundle> {
const keyProvider = new ExternalE2EEKeyProvider();
const salt = 'chat-app-voice-e2ee-v1';
const enc = new TextEncoder();
const buf = await crypto.subtle.digest('SHA-256', enc.encode(salt + ':' + conversationId));
const hex = Array.from(new Uint8Array(buf))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
await keyProvider.setKey(hex);
return { keyProvider, worker: getWorker() };
}
+73
View File
@@ -0,0 +1,73 @@
// One-shot call sound effects via WebAudio. Generated on the fly so no
// binary assets needed. Intentionally short + low-volume — these fire
// multiple times per call and shouldn't feel intrusive.
type Sfx = 'join' | 'leave' | 'end';
let ctx: AudioContext | null = null;
function getCtx(): AudioContext | null {
if (ctx) return ctx;
const AudioCtx =
window.AudioContext ??
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AudioCtx) return null;
ctx = new AudioCtx();
return ctx;
}
function beep(freq: number, durationSec: number, delaySec: number, gain = 0.15): void {
const c = getCtx();
if (!c) return;
const osc = c.createOscillator();
const g = c.createGain();
osc.type = 'sine';
osc.frequency.value = freq;
osc.connect(g);
g.connect(c.destination);
const t0 = c.currentTime + delaySec;
g.gain.setValueAtTime(0, t0);
g.gain.linearRampToValueAtTime(gain, t0 + 0.02);
g.gain.exponentialRampToValueAtTime(0.001, t0 + durationSec);
osc.start(t0);
osc.stop(t0 + durationSec + 0.02);
}
export async function playSfx(kind: Sfx): Promise<void> {
const c = getCtx();
if (!c) return;
if (c.state === 'suspended') {
try {
await c.resume();
} catch {
/* ignore */
}
}
switch (kind) {
case 'join':
// Rising two-note chirp — someone entered.
beep(523.25, 0.12, 0, 0.16); // C5
beep(783.99, 0.18, 0.1, 0.16); // G5
break;
case 'leave':
// Falling two-note — someone left.
beep(659.25, 0.12, 0, 0.14); // E5
beep(329.63, 0.18, 0.1, 0.14); // E4
break;
case 'end':
// Soft descending thud — call ended.
beep(440, 0.18, 0, 0.14); // A4
beep(293.66, 0.26, 0.14, 0.14); // D4
break;
}
}
export function playJoinBeep(): Promise<void> {
return playSfx('join');
}
export function playLeaveBeep(): Promise<void> {
return playSfx('leave');
}
export function playEndBeep(): Promise<void> {
return playSfx('end');
}
+29
View File
@@ -0,0 +1,29 @@
import type { CryptoBackend } from '@chat-app/shared/crypto';
import _sodium from 'libsodium-wrappers';
// Build the libsodium-backed CryptoBackend. Awaits the WASM ready-gate once,
// then returns a synchronous implementation of the CryptoBackend contract.
export async function createLibsodiumBackend(): Promise<CryptoBackend> {
await _sodium.ready;
const s = _sodium;
return {
name: 'libsodium-wrappers',
nonceLength: s.crypto_box_NONCEBYTES,
publicKeyLength: s.crypto_box_PUBLICKEYBYTES,
privateKeyLength: s.crypto_box_SECRETKEYBYTES,
secretboxKeyLength: s.crypto_secretbox_KEYBYTES,
secretboxNonceLength: s.crypto_secretbox_NONCEBYTES,
randomBytes: (n: number) => s.randombytes_buf(n),
generateKeyPair: () => {
const kp = s.crypto_box_keypair();
return { publicKey: kp.publicKey, privateKey: kp.privateKey };
},
box: (plaintext, nonce, recipientPublicKey, senderPrivateKey) =>
s.crypto_box_easy(plaintext, nonce, recipientPublicKey, senderPrivateKey),
boxOpen: (ciphertext, nonce, senderPublicKey, recipientPrivateKey) =>
s.crypto_box_open_easy(ciphertext, nonce, senderPublicKey, recipientPrivateKey),
secretbox: (plaintext, nonce, key) => s.crypto_secretbox_easy(plaintext, nonce, key),
secretboxOpen: (ciphertext, nonce, key) => s.crypto_secretbox_open_easy(ciphertext, nonce, key),
};
}
+73
View File
@@ -0,0 +1,73 @@
import {
deviceIdStorageKey,
listOwnDevices,
loadDevicePrivateKey,
provisionNewDevice,
touchDeviceLastSeen,
type DeviceRecord,
} from '@chat-app/shared/auth';
import type { DevicePlatform } from '@chat-app/shared/supabase';
import { devLocalSecretStore } from './secretStore';
import { supabase } from './supabase';
export function detectDesktopPlatform(): DevicePlatform {
const ua =
typeof navigator !== 'undefined' && typeof navigator.userAgent === 'string'
? navigator.userAgent.toLowerCase()
: '';
if (ua.includes('mac')) return 'macos';
if (ua.includes('win')) return 'windows';
return 'linux';
}
export function readLocalDeviceId(userId: string): string | null {
return window.localStorage.getItem(deviceIdStorageKey(userId));
}
function writeLocalDeviceId(userId: string, deviceId: string): void {
window.localStorage.setItem(deviceIdStorageKey(userId), deviceId);
}
export function clearLocalDeviceId(userId: string): void {
window.localStorage.removeItem(deviceIdStorageKey(userId));
}
// Look up the current install's device record. Returns null when either:
// - no device id is cached locally, or
// - the cached id was deleted server-side (e.g. wiped from Studio).
// In both cases the UI should prompt the user to register a fresh device.
export async function findExistingDevice(userId: string): Promise<DeviceRecord | null> {
const cachedId = readLocalDeviceId(userId);
if (!cachedId) return null;
const all = await listOwnDevices(supabase);
const hit = all.find((d) => d.id === cachedId) ?? null;
if (!hit) return null;
const priv = await loadDevicePrivateKey(devLocalSecretStore, userId, hit.id);
if (!priv) {
// Server row exists but we lost the private key locally — treat as fresh install.
return null;
}
void touchDeviceLastSeen(supabase, hit.id).catch(() => {
/* non-fatal */
});
return hit;
}
export async function registerCurrentDevice(params: {
userId: string;
name: string;
}): Promise<DeviceRecord> {
const device = await provisionNewDevice({
client: supabase,
secretStore: devLocalSecretStore,
userId: params.userId,
name: params.name,
platform: detectDesktopPlatform(),
});
writeLocalDeviceId(params.userId, device.id);
return device;
}
+14
View File
@@ -0,0 +1,14 @@
function required(name: string, value: string | undefined): string {
if (!value || value.length === 0) {
throw new Error(
`Missing env var ${name}. Copy apps/desktop/.env.example to apps/desktop/.env and fill it.`,
);
}
return value;
}
export const env = {
supabaseUrl: required('VITE_SUPABASE_URL', import.meta.env.VITE_SUPABASE_URL),
supabaseAnonKey: required('VITE_SUPABASE_ANON_KEY', import.meta.env.VITE_SUPABASE_ANON_KEY),
authRedirectUrl: required('VITE_AUTH_REDIRECT_URL', import.meta.env.VITE_AUTH_REDIRECT_URL),
};
+57
View File
@@ -0,0 +1,57 @@
import {
isRegistered,
register,
type ShortcutEvent,
unregister,
} from '@tauri-apps/plugin-global-shortcut';
// Maps a KeyboardEvent.code (what our PTT settings store) into the shortcut
// string accepted by tauri-plugin-global-shortcut. The plugin follows the
// [keyboard-types] crate naming which mostly matches DOM `event.code`, but
// single-key aliases (e.g. "Space", "F5") work as-is.
export function codeToShortcut(code: string): string {
if (code.startsWith('Key')) return code.slice(3); // KeyV -> V
if (code.startsWith('Digit')) return code.slice(5); // Digit1 -> 1
// Space, F1..F24, Escape, Enter, Tab, Arrow*, etc. pass through unchanged.
return code;
}
export async function registerPttShortcut(
code: string,
onPress: () => void,
onRelease: () => void,
): Promise<boolean> {
const shortcut = codeToShortcut(code);
try {
if (await isRegistered(shortcut)) {
await unregister(shortcut);
}
await register(shortcut, (event: ShortcutEvent) => {
if (event.state === 'Pressed') onPress();
else if (event.state === 'Released') onRelease();
});
return true;
} catch (err: unknown) {
console.warn('registerPttShortcut failed', { code, err });
return false;
}
}
export async function unregisterPttShortcut(code: string): Promise<void> {
const shortcut = codeToShortcut(code);
try {
if (await isRegistered(shortcut)) {
await unregister(shortcut);
}
} catch (err: unknown) {
console.warn('unregisterPttShortcut failed', { code, err });
}
}
// Detects whether we're running under Tauri. When running in a pure web
// preview (vite dev in a browser without Tauri), importing the plugin still
// works but calls fall through to window.__TAURI_INTERNALS__ which doesn't
// exist — use this guard to skip registration cleanly.
export function isTauriRuntime(): boolean {
return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
}
+46
View File
@@ -0,0 +1,46 @@
import {
DEFAULT_LOCALE,
detectBrowserLocale,
initI18n,
isSupportedLocale,
type SupportedLocale,
} from '@chat-app/shared/i18n';
const LOCAL_STORAGE_KEY = 'chatapp.locale';
export function getCachedLocale(): SupportedLocale | null {
try {
const raw = window.localStorage.getItem(LOCAL_STORAGE_KEY);
return isSupportedLocale(raw) ? raw : null;
} catch {
return null;
}
}
export function cacheLocale(locale: SupportedLocale): void {
try {
window.localStorage.setItem(LOCAL_STORAGE_KEY, locale);
} catch {
// Ignore (private mode, quota, etc.). Profile row remains source of truth.
}
}
// Precedence for the very first paint (before we know the user):
// 1. cached choice from a previous session
// 2. navigator language(s)
// 3. DEFAULT_LOCALE (en)
export function resolveInitialLocale(): SupportedLocale {
return (
getCachedLocale() ??
detectBrowserLocale(typeof navigator !== 'undefined' ? navigator.languages : undefined) ??
DEFAULT_LOCALE
);
}
export function bootstrapI18n(): void {
const initialLocale = resolveInitialLocale();
initI18n({
initialLocale,
onLanguageChanged: (locale) => cacheLocale(locale),
});
}
+45
View File
@@ -0,0 +1,45 @@
// Two-tone notification chime generated via WebAudio. No asset file needed.
// Throttled so a burst of messages doesn't turn into a machine gun.
let lastPlay = 0;
export function playNotificationTone(): void {
const now = Date.now();
if (now - lastPlay < 800) return;
lastPlay = now;
const AudioCtx =
window.AudioContext ??
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AudioCtx) return;
const ctx = new AudioCtx();
const master = ctx.createGain();
master.connect(ctx.destination);
master.gain.value = 0.12;
const tones: { freq: number; delay: number }[] = [
{ freq: 880, delay: 0 },
{ freq: 1320, delay: 0.08 },
];
for (const { freq, delay } of tones) {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = freq;
osc.connect(gain);
gain.connect(master);
const t0 = ctx.currentTime + delay;
gain.gain.setValueAtTime(0, t0);
gain.gain.linearRampToValueAtTime(1, t0 + 0.015);
gain.gain.exponentialRampToValueAtTime(0.001, t0 + 0.28);
osc.start(t0);
osc.stop(t0 + 0.3);
}
window.setTimeout(() => {
void ctx.close().catch(() => {
/* ignore */
});
}, 600);
}
+50
View File
@@ -0,0 +1,50 @@
import {
isPermissionGranted,
requestPermission,
sendNotification,
} from '@tauri-apps/plugin-notification';
// Tracks whether permission has already been requested this session so we
// don't spam the OS prompt. Actual permission state lives in the OS.
let permissionChecked = false;
let permissionGranted = false;
export async function ensureNotificationPermission(): Promise<boolean> {
if (permissionChecked) return permissionGranted;
permissionChecked = true;
try {
let granted = await isPermissionGranted();
if (!granted) {
const result = await requestPermission();
granted = result === 'granted';
}
permissionGranted = granted;
} catch (err: unknown) {
// Not running under Tauri (e.g. web preview) — fall back silently.
permissionGranted = false;
console.warn('notification permission check failed', err);
}
return permissionGranted;
}
export function isAppFocused(): boolean {
return typeof document !== 'undefined' && !document.hidden && document.hasFocus();
}
interface NotifyOpts {
title: string;
body?: string;
// Force notification even when app is focused. Default: suppress if focused.
force?: boolean;
}
export async function notify({ title, body, force = false }: NotifyOpts): Promise<void> {
if (!force && isAppFocused()) return;
const granted = await ensureNotificationPermission();
if (!granted) return;
try {
sendNotification({ title, ...(body ? { body } : {}) });
} catch (err: unknown) {
console.error('sendNotification failed', err);
}
}
+83
View File
@@ -0,0 +1,83 @@
// Local-only user preferences for push-to-talk. Stored in localStorage because
// the server is zero-knowledge and doesn't need to know input-device details.
const STORAGE_KEY = 'chatapp.ptt';
export interface PttSettings {
enabled: boolean;
// KeyboardEvent.code of the hold-to-talk key (e.g. 'Space', 'KeyV').
key: string;
// Human-readable label derived from the key — kept in settings so we don't
// re-derive it on every render. Updated together with `key`.
keyLabel: string;
}
const DEFAULTS: PttSettings = {
enabled: false,
key: 'Space',
keyLabel: 'Space',
};
type Listener = (s: PttSettings) => void;
const listeners = new Set<Listener>();
let cached: PttSettings | null = null;
function read(): PttSettings {
if (cached) return cached;
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) {
cached = DEFAULTS;
return cached;
}
const parsed = JSON.parse(raw) as Partial<PttSettings>;
cached = {
enabled: typeof parsed.enabled === 'boolean' ? parsed.enabled : DEFAULTS.enabled,
key: typeof parsed.key === 'string' && parsed.key ? parsed.key : DEFAULTS.key,
keyLabel:
typeof parsed.keyLabel === 'string' && parsed.keyLabel
? parsed.keyLabel
: DEFAULTS.keyLabel,
};
return cached;
} catch {
cached = DEFAULTS;
return cached;
}
}
function write(s: PttSettings): void {
cached = s;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
} catch {
/* quota / private mode */
}
for (const l of listeners) l(s);
}
export function getPttSettings(): PttSettings {
return read();
}
export function updatePttSettings(patch: Partial<PttSettings>): PttSettings {
const next = { ...read(), ...patch };
write(next);
return next;
}
export function subscribePttSettings(listener: Listener): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
// Turns a KeyboardEvent.code into a short human label (best-effort).
export function keyCodeToLabel(code: string): string {
if (code === 'Space') return 'Space';
if (code.startsWith('Key')) return code.slice(3);
if (code.startsWith('Digit')) return code.slice(5);
if (code.startsWith('Numpad')) return 'Num' + code.slice(6);
if (code.startsWith('Arrow')) return code.slice(5) + ' Arrow';
return code;
}
+76
View File
@@ -0,0 +1,76 @@
// Looping WebAudio ringtones. Two patterns:
// - outgoing: long calling tone, 3s cycle
// - incoming: classic "ring ring" double beep, 2s cycle
type Pattern = 'outgoing' | 'incoming';
class Ringtone {
private ctx: AudioContext | null = null;
private interval: number | null = null;
private pattern: Pattern | null = null;
start(pattern: Pattern): void {
if (this.pattern === pattern) return; // already playing this pattern
this.stop();
const AudioCtx =
window.AudioContext ??
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AudioCtx) return;
this.ctx = new AudioCtx();
this.pattern = pattern;
const play = pattern === 'outgoing' ? this.playOutgoing : this.playIncoming;
play.call(this);
this.interval = window.setInterval(
() => play.call(this),
pattern === 'outgoing' ? 3000 : 2000,
);
}
stop(): void {
if (this.interval !== null) {
window.clearInterval(this.interval);
this.interval = null;
}
if (this.ctx) {
void this.ctx.close().catch(() => {
/* ignore */
});
this.ctx = null;
}
this.pattern = null;
}
private beep(freq: number, durationSec: number, delaySec: number, gain = 0.18): void {
const ctx = this.ctx;
if (!ctx) return;
const osc = ctx.createOscillator();
const g = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = freq;
osc.connect(g);
g.connect(ctx.destination);
const t0 = ctx.currentTime + delaySec;
g.gain.setValueAtTime(0, t0);
g.gain.linearRampToValueAtTime(gain, t0 + 0.02);
g.gain.exponentialRampToValueAtTime(0.001, t0 + durationSec);
osc.start(t0);
osc.stop(t0 + durationSec + 0.02);
}
private playOutgoing(): void {
// Soft calling tone — single warm note.
this.beep(440, 0.4, 0, 0.14);
this.beep(440, 0.4, 0.6, 0.14);
}
private playIncoming(): void {
// Classic double-ring "ring ring".
this.beep(880, 0.18, 0, 0.22);
this.beep(660, 0.18, 0.22, 0.22);
this.beep(880, 0.18, 0.6, 0.22);
this.beep(660, 0.18, 0.82, 0.22);
}
}
export const ringtone = new Ringtone();
+139
View File
@@ -0,0 +1,139 @@
// Screen-share quality presets modelled on Discord's tiers. Values are the
// upper bounds — LiveKit + WebRTC's congestion control dynamically drop to
// lower spatial/temporal layers (SVC with VP9) when the uplink degrades, so
// these numbers behave as "up to" caps, not constant bitrates.
const STORAGE_KEY = 'chatapp.screenshare';
export type ScreenSharePreset =
| 'auto'
| '720p30'
| '720p60'
| '1080p30'
| '1080p60'
| '1440p60'
| '4k60';
export interface ScreenShareSettings {
preset: ScreenSharePreset;
}
const DEFAULTS: ScreenShareSettings = {
preset: 'auto',
};
export interface PresetParams {
dims: { width: number; height: number } | null; // null = browser picks native
framerate: number;
bitrateKbps: number;
label: string;
}
const PRESET_PARAMS: Record<ScreenSharePreset, PresetParams> = {
auto: { dims: null, framerate: 60, bitrateKbps: 8000, label: 'Auto (Original)' },
'720p30': {
dims: { width: 1280, height: 720 },
framerate: 30,
bitrateKbps: 2500,
label: '720p · 30 fps',
},
'720p60': {
dims: { width: 1280, height: 720 },
framerate: 60,
bitrateKbps: 3500,
label: '720p · 60 fps',
},
'1080p30': {
dims: { width: 1920, height: 1080 },
framerate: 30,
bitrateKbps: 4000,
label: '1080p · 30 fps',
},
'1080p60': {
dims: { width: 1920, height: 1080 },
framerate: 60,
bitrateKbps: 6000,
label: '1080p · 60 fps',
},
'1440p60': {
dims: { width: 2560, height: 1440 },
framerate: 60,
bitrateKbps: 8000,
label: '1440p · 60 fps',
},
'4k60': {
dims: { width: 3840, height: 2160 },
framerate: 60,
bitrateKbps: 10_000,
label: '4K · 60 fps',
},
};
export const PRESET_ORDER: ReadonlyArray<ScreenSharePreset> = [
'auto',
'720p30',
'720p60',
'1080p30',
'1080p60',
'1440p60',
'4k60',
];
export function getPresetParams(p: ScreenSharePreset): PresetParams {
return PRESET_PARAMS[p];
}
type Listener = (s: ScreenShareSettings) => void;
const listeners = new Set<Listener>();
let cached: ScreenShareSettings | null = null;
function isPreset(v: unknown): v is ScreenSharePreset {
return typeof v === 'string' && v in PRESET_PARAMS;
}
function read(): ScreenShareSettings {
if (cached) return cached;
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) {
cached = DEFAULTS;
return cached;
}
const parsed = JSON.parse(raw) as Partial<ScreenShareSettings>;
cached = {
preset: isPreset(parsed.preset) ? parsed.preset : DEFAULTS.preset,
};
return cached;
} catch {
cached = DEFAULTS;
return cached;
}
}
function write(s: ScreenShareSettings): void {
cached = s;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
} catch {
/* quota / private mode */
}
for (const l of listeners) l(s);
}
export function getScreenShareSettings(): ScreenShareSettings {
return read();
}
export function updateScreenShareSettings(
patch: Partial<ScreenShareSettings>,
): ScreenShareSettings {
const next = { ...read(), ...patch };
write(next);
return next;
}
export function subscribeScreenShareSettings(listener: Listener): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
+22
View File
@@ -0,0 +1,22 @@
import { base64FromBytes, bytesFromBase64, type SecretStore } from '@chat-app/shared/auth';
// M1 dev-only impl: persists secrets as base64 in localStorage.
// Swap this out for a tauri-plugin-stronghold implementation before release.
// The SecretStore interface stays identical so callers won't notice.
const PREFIX = 'chatapp.secret:';
export const devLocalSecretStore: SecretStore = {
async getSecret(key: string): Promise<Uint8Array | null> {
const raw = window.localStorage.getItem(PREFIX + key);
if (!raw) return null;
return bytesFromBase64(raw);
},
async setSecret(key: string, value: Uint8Array): Promise<void> {
const encoded = await base64FromBytes(value);
window.localStorage.setItem(PREFIX + key, encoded);
},
async removeSecret(key: string): Promise<void> {
window.localStorage.removeItem(PREFIX + key);
},
};
+15
View File
@@ -0,0 +1,15 @@
import type { KeyValueStore } from '@chat-app/shared/supabase';
// Tauri webview exposes a persistent per-app-identifier localStorage.
// Good enough for session tokens. Private keys go to Stronghold later.
export const localStorageAdapter: KeyValueStore = {
async getItem(key: string): Promise<string | null> {
return window.localStorage.getItem(key);
},
async setItem(key: string, value: string): Promise<void> {
window.localStorage.setItem(key, value);
},
async removeItem(key: string): Promise<void> {
window.localStorage.removeItem(key);
},
};
+13
View File
@@ -0,0 +1,13 @@
import { createClient } from '@chat-app/shared/supabase';
import { env } from './env';
import { localStorageAdapter } from './storage';
export const supabase = createClient({
url: env.supabaseUrl,
anonKey: env.supabaseAnonKey,
sessionStorage: localStorageAdapter,
// We manually parse the callback URL in App.tsx because Tauri webview
// loads a fresh route rather than appending hash params to the current one.
detectSessionInUrl: false,
});
+94
View File
@@ -0,0 +1,94 @@
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;
}
+72
View File
@@ -0,0 +1,72 @@
import { useEffect, useState } from 'react';
import { supabase } from './supabase';
// Watches the `call-presence:<conversationId>` realtime channel and returns
// the set of userIds currently in that call. Independent of whether the
// viewer is in the room — used to show "Active call · Join" affordances.
//
// Supabase realtime 2.103+ dedupes channels by topic: calling
// `supabase.channel(topic)` returns an existing channel if one already exists.
// That means an observer can't safely register `.on('presence', ...)` because
// the tracker (CallContext) may have already subscribed it. We side-step this
// by polling `presenceState()` on the (shared or fresh) channel.
export function useCallPresence(conversationId: string | undefined): string[] {
const [activeUserIds, setActiveUserIds] = useState<string[]>([]);
useEffect(() => {
if (!conversationId) {
setActiveUserIds([]);
return;
}
const channel = supabase.channel('call-presence:' + conversationId, {
config: {
presence: {
key: 'observer-' + Math.random().toString(36).slice(2, 8),
// Supabase realtime only sends presence_state/diff events to a
// channel that has presence enabled. Without `enabled: true` (and
// no `.on('presence', ...)` bindings) the server treats the channel
// as non-presence and `presenceState()` never populates.
enabled: true,
},
},
});
// If Supabase returned a fresh (closed) channel, we own it and must
// subscribe/remove it. If it returned an already-subscribed channel
// (tracker owns it), we just read state.
const ownsChannel = channel.state === 'closed';
if (ownsChannel) {
void channel.subscribe();
}
const resync = () => {
const state = channel.presenceState() as Record<string, Array<Record<string, unknown>>>;
const ids = new Set<string>();
for (const list of Object.values(state)) {
for (const entry of list) {
const uid = entry?.userId;
if (typeof uid === 'string') ids.add(uid);
}
}
setActiveUserIds((prev) => {
const next = Array.from(ids).sort();
if (prev.length === next.length && prev.every((v, i) => v === next[i])) return prev;
return next;
});
};
resync();
const pollId = window.setInterval(resync, 1500);
return () => {
window.clearInterval(pollId);
if (ownsChannel) {
void supabase.removeChannel(channel);
}
};
}, [conversationId]);
return activeUserIds;
}
@@ -0,0 +1,266 @@
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
import {
type AttachmentHandle,
type ChatMessage,
type DecryptedMessage,
decryptMessages,
encryptAndUploadAttachment,
fetchConversationMessages,
fetchOwnEnvelopes,
fetchSenderDeviceKeys,
insertAttachmentRow,
MAX_ATTACHMENT_BYTES,
sendEncryptedMessage,
} from '@chat-app/shared/chat';
import { bytesToPgHex } from '@chat-app/shared/supabase';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { devLocalSecretStore } from './secretStore';
import { supabase } from './supabase';
interface State {
messages: DecryptedMessage[];
loading: boolean;
error: string | null;
}
interface Args {
conversationId: string | undefined;
userId: string | undefined;
deviceId: string | undefined;
}
type MessageChangePayload = {
eventType: 'INSERT' | 'UPDATE' | 'DELETE';
new: Record<string, unknown>;
old: Record<string, unknown>;
};
function rowToMessage(row: Record<string, unknown>): ChatMessage {
return {
id: String(row.id),
conversationId: String(row.conversation_id),
senderId: String(row.sender_id),
senderDeviceId: row.sender_device_id ? String(row.sender_device_id) : null,
replyToId: row.reply_to_id ? String(row.reply_to_id) : null,
editedAt: row.edited_at ? String(row.edited_at) : null,
deletedAt: row.deleted_at ? String(row.deleted_at) : null,
createdAt: String(row.created_at),
};
}
export function useConversationMessages({ conversationId, userId, deviceId }: Args): State & {
send: (text: string, images?: File[]) => Promise<void>;
refresh: () => Promise<void>;
} {
const [state, setState] = useState<State>({ messages: [], loading: true, error: null });
const privateKeyRef = useRef<Uint8Array | null>(null);
// Load own private key once per (user, device).
useEffect(() => {
privateKeyRef.current = null;
if (!userId || !deviceId) return;
void loadDevicePrivateKey(devLocalSecretStore, userId, deviceId).then((pk) => {
privateKeyRef.current = pk;
});
}, [userId, deviceId]);
const decryptBatch = useCallback(
async (messages: ChatMessage[]): Promise<DecryptedMessage[]> => {
const priv = privateKeyRef.current;
if (!priv || !deviceId || messages.length === 0) {
return messages.map((m) => ({ ...m, plaintext: null }));
}
const ids = messages.map((m) => m.id);
const senderDeviceIds = messages
.map((m) => m.senderDeviceId)
.filter((v): v is string => v != null);
const [envelopes, senderKeys] = await Promise.all([
fetchOwnEnvelopes(supabase, ids, deviceId),
fetchSenderDeviceKeys(supabase, senderDeviceIds),
]);
return decryptMessages({
messages,
envelopes,
senderKeys,
ownPrivateKey: priv,
});
},
[deviceId],
);
const refresh = useCallback(async () => {
if (!conversationId) return;
try {
setState((prev) => ({ ...prev, loading: true }));
const rows = await fetchConversationMessages(supabase, conversationId);
const decrypted = await decryptBatch(rows);
setState({ messages: decrypted, loading: false, error: null });
} catch (err: unknown) {
setState((prev) => ({
...prev,
loading: false,
error: err instanceof Error ? err.message : 'failed to load messages',
}));
}
}, [conversationId, decryptBatch]);
// Realtime INSERT handler — decrypt + append (with retry for envelope race).
const handleInsert = useCallback(
async (row: Record<string, unknown>) => {
if (!deviceId) return;
const msg = rowToMessage(row);
let decrypted: DecryptedMessage = { ...msg, plaintext: null };
for (let attempt = 0; attempt < 6; attempt++) {
const [d] = await decryptBatch([msg]);
if (d) {
decrypted = d;
if (d.plaintext !== null) break;
}
await new Promise((r) => window.setTimeout(r, 120 * (attempt + 1)));
}
setState((prev) => {
if (prev.messages.some((m) => m.id === decrypted.id)) return prev;
return { ...prev, messages: [...prev.messages, decrypted] };
});
},
[deviceId, decryptBatch],
);
const handleUpdate = useCallback(
async (row: Record<string, unknown>) => {
const partial = rowToMessage(row);
setState((prev) => {
const idx = prev.messages.findIndex((m) => m.id === partial.id);
if (idx === -1) return prev;
const existing = prev.messages[idx];
if (!existing) return prev;
const next = [...prev.messages];
next[idx] = {
...existing,
editedAt: partial.editedAt,
deletedAt: partial.deletedAt,
};
return { ...prev, messages: next };
});
if (partial.editedAt && !partial.deletedAt) {
const [decrypted] = await decryptBatch([partial]);
if (!decrypted) return;
setState((prev) => {
const idx = prev.messages.findIndex((m) => m.id === decrypted.id);
if (idx === -1) return prev;
const next = [...prev.messages];
next[idx] = decrypted;
return { ...prev, messages: next };
});
}
},
[decryptBatch],
);
const handleDelete = useCallback((row: Record<string, unknown>) => {
const id = String(row.id);
setState((prev) => ({
...prev,
messages: prev.messages.filter((m) => m.id !== id),
}));
}, []);
useEffect(() => {
if (!conversationId || !userId || !deviceId) return;
void refresh();
const channel = supabase
.channel('conv:' + conversationId)
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'messages',
filter: 'conversation_id=eq.' + conversationId,
},
(payload: MessageChangePayload) => {
if (payload.eventType === 'INSERT') {
void handleInsert(payload.new);
} else if (payload.eventType === 'UPDATE') {
void handleUpdate(payload.new);
} else if (payload.eventType === 'DELETE') {
handleDelete(payload.old);
}
},
)
.subscribe();
return () => {
void supabase.removeChannel(channel);
};
}, [conversationId, userId, deviceId, refresh, handleInsert, handleUpdate, handleDelete]);
const send = useCallback(
async (text: string, images: File[] = []) => {
const trimmed = text.trim();
if ((!trimmed && images.length === 0) || !conversationId || !userId || !deviceId) return;
const priv = privateKeyRef.current;
if (!priv) throw new Error('private key not loaded');
// 1. Upload + encrypt each image. Collect handles + raw blob nonces
// (so the public attachment row can reference the blob-level nonce).
const handles: AttachmentHandle[] = [];
const blobNonceHexByHandleId = new Map<string, string>();
for (const file of images) {
if (file.size > MAX_ATTACHMENT_BYTES) {
throw new Error('attachment exceeds max size (10 MB)');
}
const dims = await readImageDimensions(file);
const res = await encryptAndUploadAttachment({
client: supabase,
conversationId,
file,
mimeType: file.type || 'application/octet-stream',
sizeBytes: file.size,
...(dims.width !== undefined ? { width: dims.width } : {}),
...(dims.height !== undefined ? { height: dims.height } : {}),
});
handles.push(res.handle);
blobNonceHexByHandleId.set(res.handle.id, bytesToPgHex(res.nonce));
}
// 2. Send message (inserts messages + envelopes in one helper).
const msg = await sendEncryptedMessage({
client: supabase,
conversationId,
plaintext: trimmed,
senderUserId: userId,
senderDeviceId: deviceId,
senderPrivateKey: priv,
...(handles.length > 0 ? { attachmentHandles: handles } : {}),
});
// 3. Insert public attachment metadata rows pointing at the new message.
for (const h of handles) {
const blobNonce = blobNonceHexByHandleId.get(h.id) ?? '\\x';
await insertAttachmentRow(supabase, msg.id, h, blobNonce);
}
},
[conversationId, userId, deviceId],
);
return useMemo(() => ({ ...state, send, refresh }), [state, send, refresh]);
}
// Best-effort image dimension probe. Falls back silently on non-images.
async function readImageDimensions(file: File): Promise<{ width?: number; height?: number }> {
if (!file.type.startsWith('image/')) return {};
const url = URL.createObjectURL(file);
try {
return await new Promise<{ width?: number; height?: number }>((resolve) => {
const img = new Image();
img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight });
img.onerror = () => resolve({});
img.src = url;
});
} finally {
URL.revokeObjectURL(url);
}
}
+53
View File
@@ -0,0 +1,53 @@
import { type Friendship, listFriendships } from '@chat-app/shared/friends';
import { useCallback, useEffect, useState } from 'react';
import { supabase } from './supabase';
interface FriendshipsState {
friendships: Friendship[];
loading: boolean;
error: string | null;
}
// Subscribes to the `friendships` realtime channel and re-pulls the typed
// list whenever an INSERT/UPDATE/DELETE touches one of the caller's rows.
export function useFriendships(userId: string | undefined): FriendshipsState & {
refresh: () => Promise<void>;
} {
const [state, setState] = useState<FriendshipsState>({
friendships: [],
loading: true,
error: null,
});
const refresh = useCallback(async () => {
try {
const items = await listFriendships(supabase);
setState({ friendships: items, loading: false, error: null });
} catch (err: unknown) {
setState((prev) => ({
...prev,
loading: false,
error: err instanceof Error ? err.message : 'failed to load friendships',
}));
}
}, []);
useEffect(() => {
if (!userId) return;
void refresh();
const channel = supabase
.channel('friendships:' + userId)
.on('postgres_changes', { event: '*', schema: 'public', table: 'friendships' }, () => {
void refresh();
})
.subscribe();
return () => {
void supabase.removeChannel(channel);
};
}, [userId, refresh]);
return { ...state, refresh };
}
+110
View File
@@ -0,0 +1,110 @@
import {
addReaction,
listReactionsForMessages,
type MessageReaction,
removeReaction,
} from '@chat-app/shared/chat';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { supabase } from './supabase';
export interface AggregatedReaction {
emoji: string;
count: number;
userIds: string[];
mine: boolean;
}
export interface UseMessageReactionsResult {
byMessage: Map<string, AggregatedReaction[]>;
toggle: (messageId: string, emoji: string) => Promise<void>;
}
// Batch-fetches reactions for the given message ids + subscribes to the
// message_reactions table. Re-pulls on any change (batch is cheap).
export function useMessageReactions(
messageIds: string[],
myId: string | undefined,
): UseMessageReactionsResult {
const idsKey = useMemo(() => messageIds.join(','), [messageIds]);
const [rows, setRows] = useState<MessageReaction[]>([]);
const refresh = useCallback(async () => {
if (messageIds.length === 0) {
setRows([]);
return;
}
try {
const data = await listReactionsForMessages(supabase, messageIds);
setRows(data);
} catch (err: unknown) {
console.error('listReactionsForMessages failed', err);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [idsKey]);
useEffect(() => {
void refresh();
if (messageIds.length === 0) return;
const channel = supabase
.channel('reactions:' + idsKey.slice(0, 32))
.on(
'postgres_changes',
{ event: '*', schema: 'public', table: 'message_reactions' },
(payload: { new: Record<string, unknown>; old: Record<string, unknown> }) => {
const mid =
(payload.new?.message_id as string | undefined) ??
(payload.old?.message_id as string | undefined);
if (mid && messageIds.includes(mid)) {
void refresh();
}
},
)
.subscribe();
return () => {
void supabase.removeChannel(channel);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [idsKey, refresh]);
const byMessage = useMemo(() => {
const out = new Map<string, AggregatedReaction[]>();
for (const r of rows) {
const list = out.get(r.messageId) ?? [];
const existing = list.find((a) => a.emoji === r.emoji);
if (existing) {
existing.count += 1;
existing.userIds.push(r.userId);
if (r.userId === myId) existing.mine = true;
} else {
list.push({
emoji: r.emoji,
count: 1,
userIds: [r.userId],
mine: r.userId === myId,
});
}
out.set(r.messageId, list);
}
return out;
}, [rows, myId]);
const toggle = useCallback(
async (messageId: string, emoji: string) => {
if (!myId) return;
const current = byMessage.get(messageId) ?? [];
const existing = current.find((a) => a.emoji === emoji);
if (existing?.mine) {
await removeReaction(supabase, messageId, emoji);
} else {
await addReaction(supabase, messageId, emoji);
}
await refresh();
},
[byMessage, myId, refresh],
);
return { byMessage, toggle };
}
+60
View File
@@ -0,0 +1,60 @@
import { listPeerReadsForMessages, markMessagesRead } from '@chat-app/shared/chat';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { supabase } from './supabase';
// Tracks which of our own messages the peer has read. For groups this would
// return a per-message Map<userId, readAt>; M1 is DM-focused so we just return
// a Set of message ids read by the single peer.
export function useMessageReads(
messageIds: string[],
peerUserId: string | undefined,
): { peerReadSet: Set<string>; refresh: () => Promise<void> } {
const idsKey = useMemo(() => messageIds.join(','), [messageIds]);
const [peerReadSet, setPeerReadSet] = useState<Set<string>>(new Set());
const refresh = useCallback(async () => {
if (!peerUserId || messageIds.length === 0) {
setPeerReadSet(new Set());
return;
}
try {
const s = await listPeerReadsForMessages(supabase, messageIds, peerUserId);
setPeerReadSet(s);
} catch (err: unknown) {
console.error('listPeerReadsForMessages failed', err);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [peerUserId, idsKey]);
useEffect(() => {
void refresh();
if (!peerUserId) return;
const channel = supabase
.channel('reads:' + peerUserId)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'message_reads',
filter: 'user_id=eq.' + peerUserId,
},
() => {
void refresh();
},
)
.subscribe();
return () => {
void supabase.removeChannel(channel);
};
}, [peerUserId, refresh]);
return { peerReadSet, refresh };
}
// Mark a batch of messages as read. Caller uses this when they arrive while
// the conversation is actively being viewed.
export async function markRead(messageIds: string[]): Promise<void> {
await markMessagesRead(supabase, messageIds);
}
+51
View File
@@ -0,0 +1,51 @@
import type { PresenceState } from '@chat-app/shared/supabase';
import { useEffect, useState } from 'react';
import { supabase } from './supabase';
// Subscribe to a single peer's presence_state via Supabase realtime.
// Returns null until the first row arrives, or when userId is undefined.
export function usePeerPresence(userId: string | undefined): PresenceState | null {
const [presence, setPresence] = useState<PresenceState | null>(null);
useEffect(() => {
if (!userId) {
setPresence(null);
return;
}
let cancelled = false;
void supabase
.from('profiles')
.select('presence_state')
.eq('user_id', userId)
.maybeSingle()
.then(({ data }) => {
if (!cancelled) setPresence(data?.presence_state ?? null);
});
const channel = supabase
.channel('peer-presence:' + userId)
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'profiles',
filter: 'user_id=eq.' + userId,
},
(payload: { new: Record<string, unknown> }) => {
const next = payload.new['presence_state'];
if (typeof next === 'string') setPresence(next as PresenceState);
},
)
.subscribe();
return () => {
cancelled = true;
void supabase.removeChannel(channel);
};
}, [userId]);
return presence;
}
+95
View File
@@ -0,0 +1,95 @@
import type { RealtimeChannel } from '@supabase/supabase-js';
import { useCallback, useEffect, useRef, useState } from 'react';
import { supabase } from './supabase';
// Typing events live on a Realtime BROADCAST channel — no DB writes.
// Each typer pings once every 2s while actively typing. Receivers keep a
// per-user timestamp and show the indicator for 4s after the last ping.
const SEND_THROTTLE_MS = 2000;
const RECEIVE_TTL_MS = 4000;
export interface UseTypingChannel {
typingUserIds: string[];
notifyTyping: () => void;
notifyStopTyping: () => void;
}
export function useTypingChannel(
conversationId: string | undefined,
myId: string | undefined,
): UseTypingChannel {
const [typingUserIds, setTypingUserIds] = useState<string[]>([]);
const channelRef = useRef<RealtimeChannel | null>(null);
const lastSentRef = useRef(0);
const receivedRef = useRef<Map<string, number>>(new Map());
useEffect(() => {
receivedRef.current = new Map();
setTypingUserIds([]);
if (!conversationId || !myId) return;
const channel = supabase.channel('typing:' + conversationId, {
config: { broadcast: { self: false } },
});
channelRef.current = channel;
channel.on('broadcast', { event: 'typing' }, (msg) => {
const payload = msg.payload as { userId?: string; stop?: boolean };
const uid = payload.userId;
if (!uid || uid === myId) return;
if (payload.stop) {
receivedRef.current.delete(uid);
} else {
receivedRef.current.set(uid, Date.now());
}
setTypingUserIds(collectRecent(receivedRef.current));
});
void channel.subscribe();
const interval = window.setInterval(() => {
const active = collectRecent(receivedRef.current);
setTypingUserIds((prev) => {
if (prev.length === active.length && prev.every((v, i) => v === active[i])) return prev;
return active;
});
}, 1000);
return () => {
window.clearInterval(interval);
void supabase.removeChannel(channel);
channelRef.current = null;
};
}, [conversationId, myId]);
const notifyTyping = useCallback(() => {
const ch = channelRef.current;
if (!ch || !myId) return;
const now = Date.now();
if (now - lastSentRef.current < SEND_THROTTLE_MS) return;
lastSentRef.current = now;
void ch.send({ type: 'broadcast', event: 'typing', payload: { userId: myId } });
}, [myId]);
const notifyStopTyping = useCallback(() => {
const ch = channelRef.current;
if (!ch || !myId) return;
lastSentRef.current = 0;
void ch.send({ type: 'broadcast', event: 'typing', payload: { userId: myId, stop: true } });
}, [myId]);
return { typingUserIds, notifyTyping, notifyStopTyping };
}
function collectRecent(map: Map<string, number>): string[] {
const now = Date.now();
const out: string[] = [];
for (const [uid, ts] of map) {
if (now - ts < RECEIVE_TTL_MS) out.push(uid);
else map.delete(uid);
}
return out.sort();
}
+31
View File
@@ -0,0 +1,31 @@
import { setCryptoBackend } from '@chat-app/shared/crypto';
import React from 'react';
import ReactDOM from 'react-dom/client';
import { App } from './App';
import { createLibsodiumBackend } from './lib/cryptoBackend';
import { bootstrapI18n } from './lib/i18n';
import './styles/globals.css';
async function boot(): Promise<void> {
bootstrapI18n();
setCryptoBackend(await createLibsodiumBackend());
const rootEl = document.getElementById('root');
if (!rootEl) throw new Error('Root element #root not found');
ReactDOM.createRoot(rootEl).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
}
void boot().catch((err: unknown) => {
console.error('boot failed', err);
const rootEl = document.getElementById('root');
if (rootEl) {
rootEl.innerHTML =
'<pre style="padding:2rem;color:#f88;font-family:monospace">Boot failed. Check the browser console.</pre>';
}
});
+438
View File
@@ -0,0 +1,438 @@
import {
type AdminProfileFlag,
type AdminProfileRow,
type AdminSetting,
createInvite,
deleteInvite,
type InviteRecord,
listAdminSettings,
listAllProfiles,
listInvites,
setInviteDisabled,
setUserFlag,
updateAdminSetting,
} from '@chat-app/shared/admin';
import { extractErrorCode } from '@chat-app/shared/i18n';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
AlertIcon,
CopyIcon,
PlusIcon,
SpinnerIcon,
TrashIcon,
} from '../components/icons';
import { supabase } from '../lib/supabase';
export function AdminPage() {
const { t } = useTranslation(['app', 'errors']);
const [settings, setSettings] = useState<AdminSetting[]>([]);
const [invites, setInvites] = useState<InviteRecord[]>([]);
const [users, setUsers] = useState<AdminProfileRow[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
try {
setLoading(true);
const [s, i, u] = await Promise.all([
listAdminSettings(supabase),
listInvites(supabase),
listAllProfiles(supabase),
]);
setSettings(s);
setInvites(i);
setUsers(u);
setError(null);
} catch (err: unknown) {
const code = extractErrorCode(err);
setError(
code
? t('errors:' + code, { defaultValue: t('errors:generic') })
: err instanceof Error
? err.message
: t('errors:generic'),
);
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
void refresh();
}, [refresh]);
return (
<div className="mx-auto flex max-w-4xl flex-col gap-6 px-6 py-8">
<header>
<h1 className="font-display text-2xl font-semibold tracking-tight text-white">
{t('app:admin.title')}
</h1>
</header>
{error && (
<div
role="alert"
className="flex items-start gap-3 rounded-lg border border-rose-500/20 bg-rose-500/10 p-3 text-sm text-rose-100"
>
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" />
<p className="min-w-0 flex-1 break-words">{error}</p>
</div>
)}
{loading ? (
<div className="flex items-center gap-2 text-sm text-neutral-500">
<SpinnerIcon className="h-4 w-4 text-brand-400" />
</div>
) : (
<>
<SettingsSection settings={settings} onRefresh={refresh} />
<InvitesSection invites={invites} onRefresh={refresh} />
<UsersSection users={users} onRefresh={refresh} />
</>
)}
</div>
);
}
function Section({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) {
return (
<section className="rounded-2xl border border-white/10 bg-ink-900/60 p-5 backdrop-blur-xl">
<header className="mb-4 flex items-center justify-between">
<h2 className="text-xs font-semibold uppercase tracking-wide text-neutral-400">{title}</h2>
{action}
</header>
{children}
</section>
);
}
// --- Settings --------------------------------------------------------------
function SettingsSection({ settings, onRefresh }: { settings: AdminSetting[]; onRefresh: () => Promise<void> }) {
const { t } = useTranslation(['app']);
const [busy, setBusy] = useState(false);
const invitesEnabled = Boolean(settings.find((s) => s.key === 'invites_enabled')?.value);
async function handleToggleInvites(next: boolean) {
if (busy) return;
setBusy(true);
try {
await updateAdminSetting(supabase, 'invites_enabled', next);
await onRefresh();
} catch (err: unknown) {
console.error(err);
} finally {
setBusy(false);
}
}
return (
<Section title={t('app:admin.settings_title')}>
<Toggle
label={t('app:admin.invites_enabled')}
hint={t('app:admin.invites_enabled_hint')}
checked={invitesEnabled}
disabled={busy}
onChange={(v) => void handleToggleInvites(v)}
/>
</Section>
);
}
// --- Invites ---------------------------------------------------------------
function InvitesSection({ invites, onRefresh }: { invites: InviteRecord[]; onRefresh: () => Promise<void> }) {
const { t } = useTranslation(['app']);
const [busy, setBusy] = useState(false);
const [copiedCode, setCopiedCode] = useState<string | null>(null);
async function handleCreate() {
if (busy) return;
setBusy(true);
try {
await createInvite(supabase, {});
await onRefresh();
} catch (err: unknown) {
console.error(err);
} finally {
setBusy(false);
}
}
async function handleToggle(code: string, disabled: boolean) {
try {
await setInviteDisabled(supabase, code, !disabled);
await onRefresh();
} catch (err: unknown) {
console.error(err);
}
}
async function handleDelete(code: string) {
if (!window.confirm(code + ' ?')) return;
try {
await deleteInvite(supabase, code);
await onRefresh();
} catch (err: unknown) {
console.error(err);
}
}
async function handleCopy(code: string) {
try {
await navigator.clipboard.writeText(code);
setCopiedCode(code);
window.setTimeout(() => setCopiedCode((c) => (c === code ? null : c)), 1400);
} catch {
/* ignore */
}
}
return (
<Section
title={t('app:admin.invites_title')}
action={
<button
type="button"
onClick={() => void handleCreate()}
disabled={busy}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-brand-500/90 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-brand-400 disabled:opacity-60"
>
{busy ? <SpinnerIcon className="h-3.5 w-3.5" /> : <PlusIcon className="h-3.5 w-3.5" />}
<span>{t('app:admin.invites_create')}</span>
</button>
}
>
{invites.length === 0 ? (
<p className="text-sm text-neutral-500">{t('app:admin.invites_empty')}</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="text-[10px] uppercase tracking-wide text-neutral-500">
<tr>
<th className="py-2 pr-3">{t('app:admin.invite_col_code')}</th>
<th className="py-2 pr-3">{t('app:admin.invite_col_uses')}</th>
<th className="py-2 pr-3">{t('app:admin.invite_col_expires')}</th>
<th className="py-2 pr-3">{t('app:admin.invite_col_status')}</th>
<th className="py-2" />
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{invites.map((inv) => {
const expired = inv.expiresAt ? new Date(inv.expiresAt) < new Date() : false;
const status = inv.disabled
? t('app:admin.invite_status_disabled')
: expired
? t('app:admin.invite_status_expired')
: t('app:admin.invite_status_active');
const statusColor = inv.disabled
? 'text-rose-300'
: expired
? 'text-amber-300'
: 'text-emerald-300';
const usesText = inv.usesLimit
? inv.usesCount + '/' + inv.usesLimit
: inv.usesCount + '/∞';
const expiresText = inv.expiresAt
? new Date(inv.expiresAt).toLocaleDateString()
: t('app:admin.invite_expires_never');
return (
<tr key={inv.code} className="py-2">
<td className="py-2 pr-3 font-mono text-xs">{inv.code}</td>
<td className="py-2 pr-3 text-xs text-neutral-400">{usesText}</td>
<td className="py-2 pr-3 text-xs text-neutral-400">{expiresText}</td>
<td className={'py-2 pr-3 text-xs font-medium ' + statusColor}>{status}</td>
<td className="py-2 text-right">
<div className="inline-flex gap-1">
<IconButton
label={
copiedCode === inv.code
? t('app:admin.invites_copied')
: t('app:admin.invites_copy')
}
onClick={() => void handleCopy(inv.code)}
>
<CopyIcon className="h-3.5 w-3.5" />
</IconButton>
<button
type="button"
onClick={() => void handleToggle(inv.code, inv.disabled)}
className="rounded-md border border-white/10 bg-white/5 px-2 py-1 text-[11px] font-medium text-neutral-300 transition hover:bg-white/10"
>
{inv.disabled ? t('app:admin.invites_enable') : t('app:admin.invites_disable')}
</button>
<IconButton
label={t('app:admin.invites_delete')}
tone="danger"
onClick={() => void handleDelete(inv.code)}
>
<TrashIcon className="h-3.5 w-3.5" />
</IconButton>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</Section>
);
}
// --- Users -----------------------------------------------------------------
function UsersSection({ users, onRefresh }: { users: AdminProfileRow[]; onRefresh: () => Promise<void> }) {
const { t } = useTranslation(['app']);
async function toggle(userId: string, flag: AdminProfileFlag, value: boolean) {
try {
await setUserFlag(supabase, userId, flag, value);
await onRefresh();
} catch (err: unknown) {
console.error(err);
}
}
return (
<Section title={t('app:admin.users_title')}>
{users.length === 0 ? (
<p className="text-sm text-neutral-500">{t('app:admin.users_empty')}</p>
) : (
<ul className="divide-y divide-white/5">
{users.map((u) => {
const letter =
(u.displayName ?? u.username ?? '?').trim().charAt(0).toUpperCase() || '?';
return (
<li key={u.userId} className="flex items-center gap-3 py-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-sm font-semibold text-white ring-1 ring-brand-400/30">
{letter}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-white">{u.displayName}</p>
<p className="truncate text-xs text-neutral-500">@{u.username}</p>
</div>
<div className="flex gap-2">
<FlagChip
label={t('app:admin.users_flag_admin')}
active={u.isAdmin}
onToggle={(v) => void toggle(u.userId, 'is_admin', v)}
/>
<FlagChip
label={t('app:admin.users_flag_blocked_inviting')}
active={u.blockedFromInviting}
onToggle={(v) => void toggle(u.userId, 'blocked_from_inviting', v)}
/>
<FlagChip
label={t('app:admin.users_flag_banned')}
tone="danger"
active={u.banned}
onToggle={(v) => void toggle(u.userId, 'banned', v)}
/>
</div>
</li>
);
})}
</ul>
)}
</Section>
);
}
// --- Bits ------------------------------------------------------------------
function Toggle({
label,
hint,
checked,
disabled,
onChange,
}: {
label: string;
hint?: string;
checked: boolean;
disabled?: boolean;
onChange: (next: boolean) => void;
}) {
return (
<label className="flex cursor-pointer items-start justify-between gap-4">
<span className="min-w-0 flex-1">
<span className="block text-sm text-neutral-200">{label}</span>
{hint && <span className="mt-1 block text-xs text-neutral-500">{hint}</span>}
</span>
<span className="relative mt-0.5 inline-flex h-6 w-11 shrink-0">
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
className="peer sr-only"
/>
<span className="inline-block h-6 w-11 rounded-full bg-neutral-700 transition peer-checked:bg-brand-500/70 peer-disabled:opacity-50" />
<span className="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition peer-checked:translate-x-5" />
</span>
</label>
);
}
function IconButton({
label,
children,
onClick,
tone,
}: {
label: string;
children: React.ReactNode;
onClick: () => void;
tone?: 'danger';
}) {
return (
<button
type="button"
onClick={onClick}
aria-label={label}
title={label}
className={
'flex h-7 w-7 cursor-pointer items-center justify-center rounded-md border transition focus:outline-none focus-visible:ring-2 ' +
(tone === 'danger'
? 'border-rose-500/30 bg-rose-500/10 text-rose-200 hover:bg-rose-500/20 focus-visible:ring-rose-400/40'
: 'border-white/10 bg-white/5 text-neutral-300 hover:bg-white/10 focus-visible:ring-brand-400/40')
}
>
{children}
</button>
);
}
function FlagChip({
label,
active,
onToggle,
tone,
}: {
label: string;
active: boolean;
onToggle: (next: boolean) => void;
tone?: 'danger';
}) {
const activeClass =
tone === 'danger'
? 'border-rose-400/40 bg-rose-500/20 text-rose-100'
: 'border-brand-400/40 bg-brand-500/20 text-brand-100';
return (
<button
type="button"
onClick={() => onToggle(!active)}
className={
'cursor-pointer rounded-full border px-2.5 py-0.5 text-[11px] font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' +
(active ? activeClass : 'border-white/10 bg-white/5 text-neutral-400 hover:bg-white/10')
}
>
{label}
</button>
);
}
@@ -0,0 +1,46 @@
import { completeSessionFromUrl } from '@chat-app/shared/auth';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Navigate } from 'react-router-dom';
import { AlertIcon, SpinnerIcon } from '../components/icons';
import { supabase } from '../lib/supabase';
type State = { kind: 'pending' } | { kind: 'done' } | { kind: 'error'; message: string };
export function AuthCallbackPage() {
const { t } = useTranslation(['common', 'errors']);
const [state, setState] = useState<State>({ kind: 'pending' });
useEffect(() => {
completeSessionFromUrl(supabase, window.location.href)
.then(() => {
window.history.replaceState(null, '', '/chats');
setState({ kind: 'done' });
})
.catch((err: unknown) => {
setState({
kind: 'error',
message: err instanceof Error ? err.message : t('errors:generic'),
});
});
}, [t]);
if (state.kind === 'done') return <Navigate to="/chats" replace />;
return (
<main className="flex min-h-screen items-center justify-center bg-ink-950 p-6">
{state.kind === 'pending' ? (
<div className="flex items-center gap-3 text-neutral-400">
<SpinnerIcon className="h-5 w-5 text-brand-400" />
<span className="text-sm font-medium">{t('common:finalising_session')}</span>
</div>
) : (
<div className="flex max-w-md items-start gap-3 rounded-lg border border-rose-500/20 bg-rose-500/10 p-4 text-sm text-rose-100">
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" />
<p className="min-w-0 flex-1 break-words">{state.message}</p>
</div>
)}
</main>
);
}
+625
View File
@@ -0,0 +1,625 @@
import {
loginWithMagicLink,
signUpWithMagicLink,
verifyMagicLinkOtp,
} from '@chat-app/shared/auth';
import {
extractErrorCode,
isSupportedLocale,
type SupportedLocale,
} from '@chat-app/shared/i18n';
import { useCallback, useId, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Navigate } from 'react-router-dom';
import {
AlertIcon,
ArrowRightIcon,
AtIcon,
CheckCircleIcon,
LockIcon,
LogoMark,
MailIcon,
ShieldIcon,
SparklesIcon,
SpinnerIcon,
TicketIcon,
} from '../components/icons';
import { LanguageSwitcher } from '../components/LanguageSwitcher';
import { useAuth } from '../context/AuthContext';
import { env } from '../lib/env';
import { supabase } from '../lib/supabase';
type Mode = 'signup' | 'login';
type UiState =
| { kind: 'idle' }
| { kind: 'sending' }
| { kind: 'sent'; email: string }
| { kind: 'error'; message: string };
const USERNAME_PATTERN = /^[a-z0-9_]{3,32}$/;
export function AuthPage() {
const { session } = useAuth();
const { t, i18n } = useTranslation(['auth', 'common', 'errors']);
const [ui, setUi] = useState<UiState>({ kind: 'idle' });
const [mode, setMode] = useState<Mode>('signup');
const [email, setEmail] = useState('');
const [username, setUsername] = useState('');
const [inviteCode, setInviteCode] = useState('DEV-INVITE-001');
const usernameValid = useMemo(() => USERNAME_PATTERN.test(username), [username]);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
setUi({ kind: 'sending' });
try {
if (mode === 'signup') {
const activeLocale = i18n.resolvedLanguage ?? i18n.language;
await signUpWithMagicLink(supabase, {
email,
username,
inviteCode,
redirectTo: env.authRedirectUrl,
...(isSupportedLocale(activeLocale)
? { locale: activeLocale satisfies SupportedLocale }
: {}),
});
} else {
await loginWithMagicLink(supabase, email, env.authRedirectUrl);
}
setUi({ kind: 'sent', email });
} catch (err: unknown) {
const code = extractErrorCode(err);
const message = code
? t('errors:' + code, { defaultValue: t('errors:generic') })
: err instanceof Error
? err.message
: t('errors:generic');
setUi({ kind: 'error', message });
}
},
[mode, email, username, inviteCode, i18n, t],
);
// Already signed in? Bounce to chats. Guards take it from here.
if (session) return <Navigate to="/chats" replace />;
return (
<Shell>
<BrandPanel />
<FormCard
mode={mode}
onModeChange={setMode}
email={email}
onEmailChange={setEmail}
username={username}
onUsernameChange={setUsername}
usernameValid={usernameValid}
inviteCode={inviteCode}
onInviteChange={setInviteCode}
ui={ui}
onSubmit={handleSubmit}
/>
</Shell>
);
}
// ---------------------------------------------------------------------------
// Layout
// ---------------------------------------------------------------------------
function Shell({ children }: { children: React.ReactNode }) {
return (
<main className="relative min-h-screen overflow-hidden bg-ink-950 text-neutral-100">
<BackgroundStage />
<div className="relative z-10 grid min-h-screen w-full grid-cols-1 gap-0 lg:grid-cols-[minmax(0,1fr)_minmax(440px,560px)]">
{children}
</div>
</main>
);
}
function BackgroundStage() {
return (
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
<div className="bg-grid absolute inset-0 opacity-[0.28]" />
<div className="absolute -left-40 top-[22%] h-[560px] w-[560px] -translate-y-1/2 rounded-full bg-brand-500/30 blur-3xl animate-blob-a xl:h-[680px] xl:w-[680px] 2xl:h-[820px] 2xl:w-[820px]" />
<div className="absolute left-[38%] top-[60%] h-[520px] w-[520px] -translate-y-1/2 rounded-full bg-fuchsia-500/20 blur-3xl animate-blob-b xl:h-[640px] xl:w-[640px] 2xl:h-[780px] 2xl:w-[780px]" />
<div className="absolute -right-32 top-[12%] h-[420px] w-[420px] rounded-full bg-indigo-500/20 blur-3xl animate-blob-b xl:h-[520px] xl:w-[520px]" />
<div className="absolute -right-20 bottom-0 h-[460px] w-[460px] rounded-full bg-rose-500/10 blur-3xl animate-blob-a xl:h-[560px] xl:w-[560px]" />
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-transparent to-ink-950/80" />
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,transparent_40%,rgba(5,5,7,0.65)_100%)]" />
</div>
);
}
function BrandPanel() {
const { t } = useTranslation(['auth', 'common']);
return (
<section className="relative hidden lg:block">
<div className="grid h-full grid-rows-[auto_1fr_auto] px-10 py-10 xl:px-14 xl:py-14 2xl:px-20 2xl:py-16">
<header className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<LogoMark className="h-9 w-9" />
<span className="font-display text-lg font-semibold tracking-tight">
{t('common:app_name')}
</span>
</div>
<LanguageSwitcher />
</header>
<div className="flex items-center">
<div className="w-full max-w-xl animate-fade-in xl:max-w-2xl 2xl:max-w-3xl">
<p className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs font-medium text-neutral-300 backdrop-blur">
<ShieldIcon className="h-3.5 w-3.5 text-emerald-400" />
{t('auth:brand.badge')}
</p>
<h1 className="mt-6 font-display text-4xl font-semibold leading-[1.05] tracking-tight text-white xl:text-5xl 2xl:text-6xl">
{t('auth:brand.title_line_1')}
<br />
{t('auth:brand.title_line_2')}
</h1>
<p className="mt-5 max-w-lg text-base leading-relaxed text-neutral-400 xl:text-lg 2xl:max-w-xl">
{t('auth:brand.subtitle')}
</p>
<dl className="mt-10 grid grid-cols-1 gap-4 sm:grid-cols-2 xl:mt-12 xl:gap-5 2xl:grid-cols-3">
<Feature
icon={<LockIcon className="h-5 w-5 text-brand-300" />}
title={t('auth:brand.feature_zk_title')}
desc={t('auth:brand.feature_zk_desc')}
/>
<Feature
icon={<SparklesIcon className="h-5 w-5 text-brand-300" />}
title={t('auth:brand.feature_selfhost_title')}
desc={t('auth:brand.feature_selfhost_desc')}
/>
<Feature
icon={<ShieldIcon className="h-5 w-5 text-brand-300" />}
title={t('auth:brand.feature_invite_title')}
desc={t('auth:brand.feature_invite_desc')}
/>
</dl>
</div>
</div>
<footer className="flex items-center justify-between text-xs text-neutral-500">
<span>v0.1.0 · {t('common:dev_build')}</span>
<span className="inline-flex items-center gap-1.5 text-neutral-600">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />
{t('common:local_stack_online')}
</span>
</footer>
</div>
</section>
);
}
function Feature({ icon, title, desc }: { icon: React.ReactNode; title: string; desc: string }) {
return (
<div className="rounded-xl border border-white/5 bg-white/5 p-4 backdrop-blur">
<div className="flex items-center gap-2">
{icon}
<dt className="text-sm font-semibold text-white">{title}</dt>
</div>
<dd className="mt-1.5 text-sm text-neutral-400">{desc}</dd>
</div>
);
}
interface FormCardProps {
mode: Mode;
onModeChange: (m: Mode) => void;
email: string;
onEmailChange: (v: string) => void;
username: string;
onUsernameChange: (v: string) => void;
usernameValid: boolean;
inviteCode: string;
onInviteChange: (v: string) => void;
ui: UiState;
onSubmit: (e: React.FormEvent) => void;
}
function FormCard({
mode,
onModeChange,
email,
onEmailChange,
username,
onUsernameChange,
usernameValid,
inviteCode,
onInviteChange,
ui,
onSubmit,
}: FormCardProps) {
const { t } = useTranslation(['auth']);
const busy = ui.kind === 'sending';
const emailId = useId();
const usernameId = useId();
const inviteId = useId();
const titleKey = mode === 'signup' ? 'auth:signup.title' : 'auth:login.title';
const subtitleKey = mode === 'signup' ? 'auth:signup.subtitle' : 'auth:login.subtitle';
const ctaKey = mode === 'signup' ? 'auth:signup.cta' : 'auth:login.cta';
const ctaSendingKey = mode === 'signup' ? 'auth:signup.cta_sending' : 'auth:login.cta_sending';
return (
<section className="relative flex items-center justify-center px-5 py-10 sm:px-8 lg:px-10 xl:px-16">
<div className="absolute left-6 right-6 top-6 flex items-center justify-between lg:hidden">
<div className="flex items-center gap-2.5">
<LogoMark className="h-7 w-7" />
<span className="font-display text-base font-semibold tracking-tight">ChatApp</span>
</div>
<LanguageSwitcher compact />
</div>
<div className="w-full max-w-md animate-slide-up">
<div className="rounded-2xl border border-white/10 bg-ink-900/70 p-6 shadow-glow backdrop-blur-xl sm:p-8">
<header className="mb-6">
<h2 className="font-display text-2xl font-semibold tracking-tight text-white">
{t(titleKey)}
</h2>
<p className="mt-1.5 text-sm text-neutral-400">{t(subtitleKey)}</p>
</header>
<Segmented mode={mode} onChange={onModeChange} />
<form onSubmit={onSubmit} className="mt-6 space-y-4" noValidate>
<Field
id={emailId}
label={t('auth:fields.email')}
icon={<MailIcon className="h-4 w-4" />}
input={
<input
id={emailId}
type="email"
name="email"
inputMode="email"
autoComplete="email"
required
placeholder={t('auth:fields.email_placeholder')}
value={email}
onChange={(e) => onEmailChange(e.target.value)}
className="w-full rounded-lg border border-white/10 bg-ink-800 py-2.5 pl-10 pr-3 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
/>
}
/>
{mode === 'signup' && (
<>
<Field
id={usernameId}
label={t('auth:fields.username')}
hint={
username.length > 0 && !usernameValid
? t('auth:fields.username_invalid')
: t('auth:fields.username_hint')
}
invalid={username.length > 0 && !usernameValid}
icon={<AtIcon className="h-4 w-4" />}
input={
<input
id={usernameId}
type="text"
name="username"
autoComplete="username"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
required
placeholder={t('auth:fields.username_placeholder')}
value={username}
onChange={(e) => onUsernameChange(e.target.value.toLowerCase())}
pattern={USERNAME_PATTERN.source}
className="w-full rounded-lg border border-white/10 bg-ink-800 py-2.5 pl-10 pr-3 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
/>
}
/>
<Field
id={inviteId}
label={t('auth:fields.invite_code')}
hint={t('auth:fields.invite_hint')}
icon={<TicketIcon className="h-4 w-4" />}
input={
<input
id={inviteId}
type="text"
name="invite"
autoCorrect="off"
spellCheck={false}
required
value={inviteCode}
onChange={(e) => onInviteChange(e.target.value)}
className="w-full rounded-lg border border-white/10 bg-ink-800 py-2.5 pl-10 pr-3 text-sm font-mono text-white transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
/>
}
/>
</>
)}
<button
type="submit"
disabled={busy}
aria-busy={busy}
className="group 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-4 py-3 text-sm font-semibold text-white shadow-glow transition hover:from-brand-300 hover:to-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-400/60 focus:ring-offset-2 focus:ring-offset-ink-900 disabled:cursor-not-allowed disabled:opacity-60"
>
{busy ? (
<>
<SpinnerIcon className="h-4 w-4" />
<span>{t(ctaSendingKey)}</span>
</>
) : (
<>
<span>{t(ctaKey)}</span>
<ArrowRightIcon className="h-4 w-4 transition group-hover:translate-x-0.5" />
</>
)}
</button>
<StatusBanner ui={ui} />
{ui.kind === 'sent' && <OtpForm email={ui.email} />}
</form>
<Footer mode={mode} onModeChange={onModeChange} />
</div>
<p className="mt-6 text-center text-xs text-neutral-500">{t('auth:legal_note')}</p>
</div>
</section>
);
}
function Segmented({ mode, onChange }: { mode: Mode; onChange: (m: Mode) => void }) {
const { t } = useTranslation(['auth']);
return (
<div
role="tablist"
aria-label="Authentication mode"
className="relative grid grid-cols-2 rounded-lg border border-white/10 bg-ink-800 p-1 text-sm"
>
<div
aria-hidden="true"
className="absolute bottom-1 top-1 w-[calc(50%-4px)] rounded-md bg-brand-500/20 ring-1 ring-brand-400/40 transition-transform duration-200"
style={{ transform: 'translateX(' + (mode === 'signup' ? '0%' : 'calc(100% + 4px)') + ')' }}
/>
<SegmentButton active={mode === 'signup'} onClick={() => onChange('signup')}>
{t('auth:tab_signup')}
</SegmentButton>
<SegmentButton active={mode === 'login'} onClick={() => onChange('login')}>
{t('auth:tab_login')}
</SegmentButton>
</div>
);
}
function SegmentButton({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
role="tab"
aria-selected={active}
onClick={onClick}
className={
'relative z-10 cursor-pointer rounded-md px-3 py-2 font-medium transition focus:outline-none ' +
(active ? 'text-white' : 'text-neutral-400 hover:text-neutral-200')
}
>
{children}
</button>
);
}
function Field({
id,
label,
hint,
icon,
input,
invalid,
}: {
id: string;
label: string;
hint?: string;
icon: React.ReactNode;
input: React.ReactNode;
invalid?: boolean;
}) {
return (
<div className="space-y-1.5">
<label htmlFor={id} className="block text-xs font-medium uppercase tracking-wide text-neutral-400">
{label}
</label>
<div className="relative">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500">
{icon}
</span>
{input}
</div>
{hint && (
<p className={'text-xs ' + (invalid ? 'text-rose-400' : 'text-neutral-500')}>{hint}</p>
)}
</div>
);
}
function StatusBanner({ ui }: { ui: UiState }) {
const { t } = useTranslation(['auth']);
if (ui.kind === 'sent') {
return (
<div
role="status"
className="flex items-start gap-3 rounded-lg border border-emerald-500/20 bg-emerald-500/10 p-3.5 text-sm text-emerald-100"
>
<CheckCircleIcon className="mt-0.5 h-5 w-5 shrink-0 text-emerald-400" />
<div className="min-w-0 flex-1 space-y-1">
<p className="break-words font-medium">
{t('auth:sent_banner', { email: ui.email })}
</p>
<p className="break-words text-xs text-emerald-200/80">
<InbucketHint />
</p>
</div>
</div>
);
}
if (ui.kind === 'error') {
return (
<div
role="alert"
className="flex items-start gap-3 rounded-lg border border-rose-500/20 bg-rose-500/10 p-3.5 text-sm text-rose-100"
>
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" />
<p className="min-w-0 flex-1 break-words">{ui.message}</p>
</div>
);
}
return null;
}
function InbucketHint() {
const { t } = useTranslation(['auth']);
const parts = t('auth:sent_banner_hint').split('Inbucket');
if (parts.length === 1) return <span>{t('auth:sent_banner_hint')}</span>;
return (
<span>
{parts[0]}
<a
href="http://127.0.0.1:54324"
target="_blank"
rel="noreferrer"
className="underline underline-offset-2 hover:text-white"
>
Inbucket
</a>
{parts[1]}
</span>
);
}
function OtpForm({ email }: { email: string }) {
const { t } = useTranslation(['auth', 'errors']);
const [token, setToken] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputId = useId();
async function handleVerify(e: React.FormEvent) {
e.preventDefault();
e.stopPropagation();
if (busy || token.length !== 6) return;
setBusy(true);
setError(null);
try {
await verifyMagicLinkOtp(supabase, email, token);
// Session updates via Supabase subscription; AuthPage Navigate redirects.
} catch (err: unknown) {
const code = extractErrorCode(err);
setError(
code
? t('errors:' + code, { defaultValue: t('errors:generic') })
: err instanceof Error
? err.message
: t('errors:generic'),
);
} finally {
setBusy(false);
}
}
return (
<div className="mt-4 space-y-2 rounded-lg border border-white/10 bg-ink-800/50 p-4">
<label
htmlFor={inputId}
className="block text-xs font-medium uppercase tracking-wide text-neutral-400"
>
{t('auth:otp_label')}
</label>
<input
id={inputId}
type="text"
inputMode="numeric"
autoComplete="one-time-code"
pattern="\d{6}"
maxLength={6}
value={token}
onChange={(e) => setToken(e.target.value.replace(/\D/g, ''))}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
void handleVerify(e);
}
}}
placeholder={t('auth:otp_placeholder')}
autoFocus
className="w-full rounded-lg border border-white/10 bg-ink-900 px-3 py-3 text-center font-mono text-xl tracking-[0.4em] text-white placeholder-neutral-600 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
/>
<p className="text-xs text-neutral-500">{t('auth:otp_hint')}</p>
{error && (
<p
role="alert"
className="break-words rounded-md border border-rose-500/20 bg-rose-500/10 px-3 py-2 text-xs text-rose-200"
>
{error}
</p>
)}
<button
type="button"
disabled={busy || token.length !== 6}
onClick={(e) => void handleVerify(e)}
aria-busy={busy}
className="mt-1 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-4 py-2.5 text-sm font-semibold text-white shadow-glow transition hover:from-brand-300 hover:to-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-400/60 disabled:cursor-not-allowed disabled:opacity-60"
>
{busy && <SpinnerIcon className="h-4 w-4" />}
<span>{t(busy ? 'auth:otp_cta_loading' : 'auth:otp_cta')}</span>
</button>
</div>
);
}
function Footer({ mode, onModeChange }: { mode: Mode; onModeChange: (m: Mode) => void }) {
const { t } = useTranslation(['auth']);
const promptKey = mode === 'signup' ? 'auth:footer_signup_prompt' : 'auth:footer_login_prompt';
const switchKey =
mode === 'signup' ? 'auth:footer_switch_to_login' : 'auth:footer_switch_to_signup';
return (
<div className="mt-6 flex items-center justify-between border-t border-white/5 pt-5 text-xs text-neutral-500">
<span>
{t(promptKey)}{' '}
<button
type="button"
onClick={() => onModeChange(mode === 'signup' ? 'login' : 'signup')}
className="cursor-pointer font-medium text-brand-300 underline-offset-2 hover:text-brand-200 hover:underline focus:outline-none focus:ring-2 focus:ring-brand-400/40 focus:ring-offset-2 focus:ring-offset-ink-900"
>
{t(switchKey)}
</button>
</span>
<a
href="http://127.0.0.1:54323"
target="_blank"
rel="noreferrer"
className="hover:text-neutral-300"
>
{t('auth:footer_studio')}
</a>
</div>
);
}
+219
View File
@@ -0,0 +1,219 @@
import { acceptDm, type ConversationSummary } from '@chat-app/shared/chat';
import { extractErrorCode } from '@chat-app/shared/i18n';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { NavLink, Outlet, useParams } from 'react-router-dom';
import { CreateGroupDialog } from '../components/CreateGroupDialog';
import { ChatBubbleIcon, PlusIcon, SpinnerIcon, UsersIcon } from '../components/icons';
import { useConversationsContext } from '../context/ConversationsContext';
import { supabase } from '../lib/supabase';
export function ChatsPage() {
const { conversations, loading, error, refresh, unread } = useConversationsContext();
const { id: activeId } = useParams<{ id: string }>();
const [createOpen, setCreateOpen] = useState(false);
const sorted = useMemo(() => {
return [...conversations].sort((a, b) => {
const ta = a.lastMessageAt ?? a.createdAt;
const tb = b.lastMessageAt ?? b.createdAt;
return tb.localeCompare(ta);
});
}, [conversations]);
return (
<div className="flex h-full">
<ConversationList
items={sorted}
loading={loading}
error={error}
activeId={activeId}
unread={unread}
onAccept={async (id) => {
try {
await acceptDm(supabase, id);
await refresh();
} catch (err: unknown) {
const code = extractErrorCode(err);
console.error('acceptDm failed', code ?? err);
}
}}
onNewGroup={() => setCreateOpen(true)}
/>
<div className="flex-1 border-l border-white/5">
<Outlet />
</div>
<CreateGroupDialog open={createOpen} onClose={() => setCreateOpen(false)} />
</div>
);
}
function ConversationList({
items,
loading,
error,
activeId,
unread,
onAccept,
onNewGroup,
}: {
items: ConversationSummary[];
loading: boolean;
error: string | null;
activeId: string | undefined;
unread: Record<string, number>;
onAccept: (id: string) => void;
onNewGroup: () => void;
}) {
const { t } = useTranslation(['app']);
return (
<aside
aria-label="Conversations"
className="flex h-full w-[320px] shrink-0 flex-col bg-ink-900/40"
>
<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">
{t('app:nav.chats')}
</h2>
<button
type="button"
onClick={onNewGroup}
aria-label={t('app:chats.new_group')}
title={t('app:chats.new_group')}
className="flex h-8 cursor-pointer items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-2.5 text-xs font-medium text-neutral-300 transition hover:bg-white/10 hover:text-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
>
<UsersIcon className="h-3.5 w-3.5" />
<PlusIcon className="h-3 w-3" />
</button>
</header>
{loading ? (
<div className="flex items-center gap-2 px-4 py-2 text-xs text-neutral-500">
<SpinnerIcon className="h-3.5 w-3.5 text-brand-400" />
</div>
) : error ? (
<p className="px-4 py-2 text-xs text-rose-300">{error}</p>
) : items.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-white/10 bg-white/5 text-brand-300">
<ChatBubbleIcon className="h-5 w-5" />
</div>
<p className="text-sm text-neutral-400">{t('app:chats.empty_title')}</p>
<p className="text-xs text-neutral-500">{t('app:chats.empty_subtitle')}</p>
</div>
) : (
<ul className="flex-1 overflow-y-auto px-2 pb-4">
{items.map((c) => (
<li key={c.id}>
<ConversationRow
item={c}
active={c.id === activeId}
unreadCount={unread[c.id] ?? 0}
onAccept={onAccept}
/>
</li>
))}
</ul>
)}
</aside>
);
}
function ConversationRow({
item,
active,
unreadCount,
onAccept,
}: {
item: ConversationSummary;
active: boolean;
unreadCount: number;
onAccept: (id: string) => void;
}) {
const { t } = useTranslation(['app']);
const title =
item.type === 'dm' ? (item.peer?.displayName ?? '?') : (item.name ?? '?');
const handle = item.type === 'dm' ? '@' + (item.peer?.username ?? '?') : '';
const letter = title.trim().charAt(0).toUpperCase() || '?';
if (!item.acceptedByMe) {
return (
<div className="my-1 rounded-xl border border-amber-500/20 bg-amber-500/5 p-3">
<div className="flex items-center gap-3">
<Avatar letter={letter} />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-white">{title}</p>
<p className="truncate text-xs text-amber-200/80">
{t('app:friends.incoming_request')}
</p>
</div>
</div>
<button
type="button"
onClick={() => onAccept(item.id)}
className="mt-3 w-full cursor-pointer rounded-md bg-amber-500/20 px-3 py-1.5 text-xs font-semibold text-amber-100 transition hover:bg-amber-500/30 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/40"
>
{t('app:friends.action_accept')}
</button>
</div>
);
}
return (
<NavLink
to={'/chats/' + item.id}
className={
'my-1 flex cursor-pointer items-center gap-3 rounded-xl px-3 py-2.5 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' +
(active ? 'bg-brand-500/15 ring-1 ring-brand-400/30' : 'hover:bg-white/5')
}
>
<Avatar letter={letter} />
<div className="min-w-0 flex-1">
<p
className={
'truncate text-sm ' +
(unreadCount > 0 ? 'font-bold text-white' : 'font-semibold text-white')
}
>
{title}
</p>
<p className="truncate text-xs text-neutral-500">{handle}</p>
</div>
{unreadCount > 0 && (
<span
aria-label={'Unread: ' + unreadCount}
className="inline-flex min-w-[20px] items-center justify-center rounded-full bg-rose-500 px-1.5 text-[10px] font-bold leading-tight text-white"
>
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</NavLink>
);
}
function Avatar({ letter }: { letter: string }) {
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>
);
}
export function ChatsEmptyState() {
const { t } = useTranslation(['app']);
return (
<div className="flex h-full items-center justify-center p-10">
<div className="max-w-md text-center">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border border-white/10 bg-white/5 text-brand-300">
<ChatBubbleIcon className="h-6 w-6" />
</div>
<h2 className="font-display text-2xl font-semibold tracking-tight text-white">
{t('app:chats.select_prompt')}
</h2>
<p className="mt-2 text-sm text-neutral-400">{t('app:chats.select_subtitle')}</p>
</div>
</div>
);
}
+321
View File
@@ -0,0 +1,321 @@
import { extractErrorCode } from '@chat-app/shared/i18n';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import { ConversationHeader } from '../components/ConversationHeader';
import { GroupInfoPanel } from '../components/GroupInfoPanel';
import { AlertIcon, ArrowRightIcon, PlusIcon, SpinnerIcon, XIcon } from '../components/icons';
import { InCallPanel } from '../components/InCallPanel';
import { MessageBubble } from '../components/MessageBubble';
import { TypingIndicator } from '../components/TypingIndicator';
import { useAuth } from '../context/AuthContext';
import { useConversationsContext } from '../context/ConversationsContext';
import { useConversationMessages } from '../lib/useConversationMessages';
import { useMessageReactions } from '../lib/useMessageReactions';
import { markRead as markMessagesReadRemote, useMessageReads } from '../lib/useMessageReads';
import { usePeerPresence } from '../lib/usePeerPresence';
import { useTypingChannel } from '../lib/useTypingChannel';
const STICK_THRESHOLD = 80;
export function ConversationPage() {
const { t } = useTranslation(['app', 'errors']);
const { id } = useParams<{ id: string }>();
const { session, device } = useAuth();
const { conversations, setActiveConversation, markRead } = useConversationsContext();
const conversation = useMemo(
() => conversations.find((c) => c.id === id) ?? null,
[conversations, id],
);
const peerId = conversation?.peer?.userId;
const peerPresence = usePeerPresence(peerId);
const { messages, loading, error, send } = useConversationMessages({
conversationId: id,
userId: session?.user.id,
deviceId: device?.id,
});
const messageIds = useMemo(() => messages.map((m) => m.id), [messages]);
const { byMessage: reactionsByMessage, toggle: toggleReaction } = useMessageReactions(
messageIds,
session?.user.id,
);
const myId = session?.user.id;
// Peer read tracking — only for 1:1 DMs.
const ownMessageIds = useMemo(
() => messages.filter((m) => m.senderId === myId).map((m) => m.id),
[messages, myId],
);
const { peerReadSet } = useMessageReads(ownMessageIds, peerId);
const lastSeenMessageId = useMemo(() => {
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i];
if (m && m.senderId === myId && peerReadSet.has(m.id)) return m.id;
}
return null;
}, [messages, peerReadSet, myId]);
// Typing channel.
const { typingUserIds, notifyTyping, notifyStopTyping } = useTypingChannel(id, myId);
// Mark incoming messages as read (server-side, visible to peer if both sides
// have receipts on). Runs whenever new messages arrive or id changes.
useEffect(() => {
if (!id || messages.length === 0 || !myId) return;
const incoming = messages.filter((m) => m.senderId !== myId).map((m) => m.id);
if (incoming.length === 0) return;
void markMessagesReadRemote(incoming).catch((err: unknown) => {
console.error('markMessagesRead failed', err);
});
}, [id, messages, myId]);
const [text, setText] = useState('');
const [sending, setSending] = useState(false);
const [sendError, setSendError] = useState<string | null>(null);
const [stickToBottom, setStickToBottom] = useState(true);
const [attachments, setAttachments] = useState<File[]>([]);
const [infoPanelOpen, setInfoPanelOpen] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!id) return;
setActiveConversation(id);
return () => {
setActiveConversation(null);
};
}, [id, setActiveConversation]);
useEffect(() => {
if (id && messages.length > 0) markRead(id);
}, [id, messages.length, markRead]);
useEffect(() => {
const el = scrollRef.current;
if (!el || !stickToBottom) return;
el.scrollTop = el.scrollHeight;
}, [messages.length, stickToBottom]);
useEffect(() => {
setStickToBottom(true);
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [id]);
const handleScroll = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
setStickToBottom(distanceFromBottom < STICK_THRESHOLD);
}, []);
async function handleSend(e?: React.FormEvent) {
e?.preventDefault();
if ((!text.trim() && attachments.length === 0) || sending) return;
setSending(true);
setSendError(null);
try {
await send(text, attachments);
setText('');
setAttachments([]);
if (fileInputRef.current) fileInputRef.current.value = '';
setStickToBottom(true);
notifyStopTyping();
} catch (err: unknown) {
const code = extractErrorCode(err);
setSendError(
code
? t('errors:' + code, { defaultValue: t('errors:generic') })
: err instanceof Error
? err.message
: t('errors:generic'),
);
} finally {
setSending(false);
}
}
function handleFilesChosen(list: FileList | null) {
if (!list) return;
const next: File[] = [];
for (let i = 0; i < list.length; i++) {
const f = list[i];
if (!f) continue;
if (!f.type.startsWith('image/')) continue;
if (f.size > 10 * 1024 * 1024) {
setSendError('Datei zu groß (max 10 MB)');
continue;
}
next.push(f);
}
setAttachments((prev) => [...prev, ...next].slice(0, 4));
}
const isGroup = conversation?.type === 'group';
return (
<div className="relative flex h-full flex-col">
<ConversationHeader
conversation={conversation}
peerPresence={peerPresence}
{...(isGroup ? { onInfoClick: () => setInfoPanelOpen(true) } : {})}
/>
{isGroup && conversation && (
<GroupInfoPanel
open={infoPanelOpen}
onClose={() => setInfoPanelOpen(false)}
conversation={conversation}
/>
)}
{conversation && <InCallPanel conversation={conversation} />}
<div ref={scrollRef} onScroll={handleScroll} className="flex-1 overflow-y-auto px-6 py-4">
{loading ? (
<div className="flex items-center gap-2 text-xs text-neutral-500">
<SpinnerIcon className="h-3.5 w-3.5 text-brand-400" />
</div>
) : error ? (
<Banner>{error}</Banner>
) : messages.length === 0 ? (
<p className="text-center text-sm text-neutral-500"></p>
) : (
<ul className="space-y-0.5">
{messages.map((m, idx) => {
const prev = messages[idx - 1];
const grouped = idx > 0 && prev?.senderId === m.senderId;
return (
<li key={m.id}>
<MessageBubble
message={m}
mine={m.senderId === myId}
groupedWithPrev={grouped}
conversationId={id ?? ''}
reactions={reactionsByMessage.get(m.id) ?? []}
onToggleReaction={(emoji) => toggleReaction(m.id, emoji)}
showSeen={m.id === lastSeenMessageId}
/>
</li>
);
})}
</ul>
)}
</div>
<TypingIndicator
typingUserIds={typingUserIds}
members={conversation?.members ?? []}
/>
<form onSubmit={handleSend} className="border-t border-white/5 p-4">
{sendError && (
<div className="mb-2">
<Banner>{sendError}</Banner>
</div>
)}
{attachments.length > 0 && (
<div className="mb-2 flex flex-wrap gap-2">
{attachments.map((file, idx) => (
<AttachmentPreview
key={idx}
file={file}
onRemove={() =>
setAttachments((prev) => prev.filter((_, i) => i !== idx))
}
/>
))}
</div>
)}
<div className="flex items-end gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => handleFilesChosen(e.target.files)}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
aria-label="Bild anhängen"
title="Bild anhängen"
className="inline-flex h-11 w-11 cursor-pointer items-center justify-center rounded-lg border border-white/10 bg-white/5 text-neutral-300 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
>
<PlusIcon className="h-4 w-4" />
</button>
<textarea
value={text}
onChange={(e) => {
setText(e.target.value);
if (e.target.value.length > 0) notifyTyping();
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
void handleSend();
}
}}
rows={1}
placeholder="…"
className="max-h-40 min-h-[44px] flex-1 resize-none rounded-lg border border-white/10 bg-ink-900/60 px-3 py-2.5 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
/>
<button
type="submit"
disabled={sending || (text.trim().length === 0 && attachments.length === 0)}
aria-busy={sending}
className="inline-flex h-11 w-11 cursor-pointer items-center justify-center rounded-lg bg-gradient-to-br from-brand-400 to-brand-600 text-white transition hover:from-brand-300 hover:to-brand-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/60 disabled:cursor-not-allowed disabled:opacity-60"
>
{sending ? <SpinnerIcon className="h-4 w-4" /> : <ArrowRightIcon className="h-4 w-4" />}
</button>
</div>
</form>
</div>
);
}
function Banner({ children }: { children: React.ReactNode }) {
return (
<div
role="alert"
className="flex items-start gap-3 rounded-lg border border-rose-500/20 bg-rose-500/10 p-3 text-sm text-rose-100"
>
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" />
<p className="min-w-0 flex-1 break-words">{children}</p>
</div>
);
}
function AttachmentPreview({ file, onRemove }: { file: File; onRemove: () => void }) {
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
const u = URL.createObjectURL(file);
setUrl(u);
return () => URL.revokeObjectURL(u);
}, [file]);
return (
<div className="relative overflow-hidden rounded-lg border border-white/10 bg-ink-900/60">
{url ? (
<img src={url} alt={file.name} className="block h-20 w-20 object-cover" />
) : (
<div className="h-20 w-20" />
)}
<button
type="button"
onClick={onRemove}
aria-label="Entfernen"
className="absolute right-1 top-1 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-ink-950/80 text-neutral-200 transition hover:bg-rose-500/70 hover:text-white"
>
<XIcon className="h-3 w-3" />
</button>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { useNavigate } from 'react-router-dom';
import { DeviceRegistration } from '../components/DeviceRegistration';
import { useAuth } from '../context/AuthContext';
export function DevicePage() {
const { session, profile, setDevice } = useAuth();
const navigate = useNavigate();
if (!session) return null; // Guarded by RequireAuth, but be defensive.
const defaultName =
(profile?.displayName ?? profile?.username ?? 'Desktop') + ' Desktop';
return (
<main className="relative flex min-h-screen items-center justify-center overflow-hidden bg-ink-950 px-5 py-10 text-neutral-100 sm:px-8">
<BackgroundStage />
<div className="relative z-10 w-full max-w-md">
<DeviceRegistration
userId={session.user.id}
defaultName={defaultName}
onRegistered={(device) => {
setDevice(device);
navigate('/chats', { replace: true });
}}
/>
</div>
</main>
);
}
function BackgroundStage() {
return (
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
<div className="bg-grid absolute inset-0 opacity-[0.28]" />
<div className="absolute -left-32 top-1/4 h-[460px] w-[460px] rounded-full bg-brand-500/25 blur-3xl" />
<div className="absolute -right-32 bottom-0 h-[460px] w-[460px] rounded-full bg-fuchsia-500/15 blur-3xl" />
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,transparent_45%,rgba(5,5,7,0.65)_100%)]" />
</div>
);
}
+573
View File
@@ -0,0 +1,573 @@
import { createDm } from '@chat-app/shared/chat';
import {
acceptFriendRequest,
type Friendship,
type ProfileBrief,
removeFriendship,
searchProfiles,
sendFriendRequest,
} from '@chat-app/shared/friends';
import { extractErrorCode } from '@chat-app/shared/i18n';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
AlertIcon,
ChatBubbleIcon,
CheckCircleIcon,
PlusIcon,
SearchIcon,
SpinnerIcon,
UsersIcon,
} from '../components/icons';
import { useFriendshipsContext } from '../context/FriendshipsContext';
import { supabase } from '../lib/supabase';
type Tab = 'friends' | 'pending' | 'requests';
export function FriendsPage() {
const { t } = useTranslation(['app', 'errors']);
const { friendships, loading, error, refresh } = useFriendshipsContext();
const [tab, setTab] = useState<Tab>('friends');
const [query, setQuery] = useState('');
const [debounced, setDebounced] = useState('');
const [results, setResults] = useState<ProfileBrief[]>([]);
const [searching, setSearching] = useState(false);
const [searchError, setSearchError] = useState<string | null>(null);
const [pendingId, setPendingId] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
useEffect(() => {
const id = window.setTimeout(() => setDebounced(query.trim()), 250);
return () => window.clearTimeout(id);
}, [query]);
useEffect(() => {
if (debounced.length < 2) {
setResults([]);
setSearchError(null);
return;
}
let cancelled = false;
setSearching(true);
searchProfiles(supabase, debounced)
.then((data) => {
if (!cancelled) {
setResults(data);
setSearchError(null);
}
})
.catch((err: unknown) => {
if (!cancelled) setSearchError(translateError(err, t));
})
.finally(() => {
if (!cancelled) setSearching(false);
});
return () => {
cancelled = true;
};
}, [debounced, t]);
const friendsByPeerId = useMemo(() => {
const map = new Map<string, Friendship>();
for (const f of friendships) map.set(f.peer.userId, f);
return map;
}, [friendships]);
const accepted = useMemo(
() => friendships.filter((f) => f.status === 'accepted'),
[friendships],
);
const outgoing = useMemo(
() => friendships.filter((f) => f.status === 'pending' && f.direction === 'outgoing'),
[friendships],
);
const incoming = useMemo(
() => friendships.filter((f) => f.status === 'pending' && f.direction === 'incoming'),
[friendships],
);
const performAction = useCallback(
async (id: string, fn: () => Promise<void>) => {
setPendingId(id);
setActionError(null);
try {
await fn();
await refresh();
} catch (err: unknown) {
setActionError(translateError(err, t));
} finally {
setPendingId(null);
}
},
[refresh, t],
);
return (
<div className="mx-auto flex h-full max-w-3xl flex-col gap-5 px-6 py-8">
<header className="flex items-center justify-between gap-4">
<h1 className="font-display text-2xl font-semibold tracking-tight text-white">
{t('app:friends.title')}
</h1>
</header>
<div className="space-y-3">
<div className="relative">
<SearchIcon className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-neutral-500" />
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value.toLowerCase())}
placeholder={t('app:friends.search_placeholder')}
autoComplete="off"
autoCapitalize="none"
spellCheck={false}
className="w-full rounded-lg border border-white/10 bg-ink-900/60 py-2.5 pl-10 pr-3 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
/>
</div>
{query.length > 0 && (
<SearchResults
query={debounced}
searching={searching}
results={results}
error={searchError}
friendsByPeerId={friendsByPeerId}
pendingId={pendingId}
onAction={performAction}
/>
)}
</div>
<div className="flex gap-2 border-b border-white/5">
<TabButton active={tab === 'friends'} onClick={() => setTab('friends')}>
{t('app:friends.tab_friends')} · {accepted.length}
</TabButton>
<TabButton active={tab === 'pending'} onClick={() => setTab('pending')}>
{t('app:friends.tab_pending')} · {outgoing.length}
</TabButton>
<TabButton active={tab === 'requests'} onClick={() => setTab('requests')}>
{t('app:friends.tab_requests')} · {incoming.length}
</TabButton>
</div>
{error && <Banner kind="error">{error}</Banner>}
{actionError && <Banner kind="error">{actionError}</Banner>}
{loading ? (
<LoadingRow />
) : tab === 'friends' ? (
<FriendList
items={accepted}
emptyKey="app:friends.empty_friends"
renderActions={(f) => (
<FriendActions
busy={pendingId === f.peer.userId || pendingId === 'msg-' + f.peer.userId}
onMessage={() =>
performAction('msg-' + f.peer.userId, async () => {
const id = await createDm(supabase, f.peer.userId);
navigateToChat(id);
})
}
onUnfriend={() =>
performAction(f.peer.userId, () => removeFriendship(supabase, f.peer.userId))
}
/>
)}
/>
) : tab === 'pending' ? (
<FriendList
items={outgoing}
emptyKey="app:friends.empty_pending"
renderActions={(f) => (
<SecondaryButton
busy={pendingId === f.peer.userId}
onClick={() =>
performAction(f.peer.userId, () => removeFriendship(supabase, f.peer.userId))
}
>
{t('app:friends.action_cancel')}
</SecondaryButton>
)}
/>
) : (
<FriendList
items={incoming}
emptyKey="app:friends.empty_requests"
renderActions={(f) => (
<RequestActions
busy={pendingId === f.peer.userId}
onAccept={() =>
performAction(f.peer.userId, () => acceptFriendRequest(supabase, f.peer.userId))
}
onDecline={() =>
performAction(f.peer.userId, () => removeFriendship(supabase, f.peer.userId))
}
/>
)}
/>
)}
</div>
);
}
function navigateToChat(id: string): void {
// Push history + dispatch popstate so React Router re-evaluates the route.
window.history.pushState(null, '', '/chats/' + id);
window.dispatchEvent(new PopStateEvent('popstate'));
}
function translateError(err: unknown, t: ReturnType<typeof useTranslation>['t']): string {
const code = extractErrorCode(err);
if (code) return t('errors:' + code, { defaultValue: t('errors:generic') });
if (err instanceof Error) return err.message;
return t('errors:generic');
}
// ---------------------------------------------------------------------------
function TabButton({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={
'-mb-px cursor-pointer border-b-2 px-3 pb-2.5 text-sm font-medium transition focus:outline-none ' +
(active
? 'border-brand-400 text-white'
: 'border-transparent text-neutral-400 hover:text-neutral-200')
}
>
{children}
</button>
);
}
function FriendList({
items,
emptyKey,
renderActions,
}: {
items: Friendship[];
emptyKey: string;
renderActions: (f: Friendship) => React.ReactNode;
}) {
const { t } = useTranslation(['app']);
if (items.length === 0) {
return (
<div className="flex flex-1 flex-col items-center justify-center text-center text-sm text-neutral-500">
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-2xl border border-white/10 bg-white/5 text-neutral-400">
<UsersIcon className="h-5 w-5" />
</div>
<p>{t(emptyKey)}</p>
</div>
);
}
return (
<ul className="space-y-1.5">
{items.map((f) => (
<li key={f.peer.userId}>
<FriendRow profile={f.peer}>{renderActions(f)}</FriendRow>
</li>
))}
</ul>
);
}
function FriendRow({ profile, children }: { profile: ProfileBrief; children: React.ReactNode }) {
return (
<div className="flex items-center gap-3 rounded-xl border border-white/5 bg-ink-900/50 px-4 py-3 backdrop-blur-sm">
<Avatar profile={profile} />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-white">{profile.displayName}</p>
<p className="truncate text-xs text-neutral-500">@{profile.username}</p>
</div>
<div className="flex shrink-0 gap-2">{children}</div>
</div>
);
}
function Avatar({ profile }: { profile: ProfileBrief }) {
const letter = (profile.displayName ?? profile.username ?? '?').trim().charAt(0).toUpperCase();
return (
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-sm font-semibold text-white ring-1 ring-brand-400/30">
{letter || '?'}
</div>
);
}
function FriendActions({
busy,
onMessage,
onUnfriend,
}: {
busy: boolean;
onMessage: () => void;
onUnfriend: () => void;
}) {
const { t } = useTranslation(['app']);
return (
<>
<PrimaryButton
busy={busy}
onClick={onMessage}
icon={<ChatBubbleIcon className="h-3.5 w-3.5" />}
>
{t('app:friends.action_message')}
</PrimaryButton>
<DangerButton busy={busy} onClick={onUnfriend}>
{t('app:friends.action_unfriend')}
</DangerButton>
</>
);
}
function RequestActions({
busy,
onAccept,
onDecline,
}: {
busy: boolean;
onAccept: () => void;
onDecline: () => void;
}) {
const { t } = useTranslation(['app']);
return (
<>
<PrimaryButton
busy={busy}
onClick={onAccept}
icon={<CheckCircleIcon className="h-3.5 w-3.5" />}
>
{t('app:friends.action_accept')}
</PrimaryButton>
<SecondaryButton busy={busy} onClick={onDecline}>
{t('app:friends.action_decline')}
</SecondaryButton>
</>
);
}
function PrimaryButton({
busy,
onClick,
icon,
children,
}: {
busy: boolean;
onClick: () => void;
icon?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<button
type="button"
disabled={busy}
onClick={onClick}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-brand-500/90 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-brand-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 disabled:cursor-not-allowed disabled:opacity-60"
>
{busy ? <SpinnerIcon className="h-3.5 w-3.5" /> : icon}
<span>{children}</span>
</button>
);
}
function SecondaryButton({
busy,
onClick,
children,
}: {
busy: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
disabled={busy}
onClick={onClick}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-xs font-medium text-neutral-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30 disabled:cursor-not-allowed disabled:opacity-60"
>
{busy && <SpinnerIcon className="h-3.5 w-3.5" />}
<span>{children}</span>
</button>
);
}
function DangerButton({
busy,
onClick,
children,
}: {
busy: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
disabled={busy}
onClick={onClick}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md border border-rose-500/30 bg-rose-500/10 px-3 py-1.5 text-xs font-medium text-rose-200 transition hover:bg-rose-500/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/40 disabled:cursor-not-allowed disabled:opacity-60"
>
{busy && <SpinnerIcon className="h-3.5 w-3.5" />}
<span>{children}</span>
</button>
);
}
function LoadingRow() {
return (
<div className="flex items-center gap-3 text-sm text-neutral-500">
<SpinnerIcon className="h-4 w-4 text-brand-400" />
<span></span>
</div>
);
}
function Banner({ kind, children }: { kind: 'error' | 'info'; children: React.ReactNode }) {
const isError = kind === 'error';
return (
<div
role={isError ? 'alert' : 'status'}
className={
'flex items-start gap-3 rounded-lg border p-3 text-sm ' +
(isError
? 'border-rose-500/20 bg-rose-500/10 text-rose-100'
: 'border-emerald-500/20 bg-emerald-500/10 text-emerald-100')
}
>
{isError ? (
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" />
) : (
<CheckCircleIcon className="mt-0.5 h-5 w-5 shrink-0 text-emerald-400" />
)}
<p className="min-w-0 flex-1 break-words">{children}</p>
</div>
);
}
function SearchResults({
query,
searching,
results,
error,
friendsByPeerId,
pendingId,
onAction,
}: {
query: string;
searching: boolean;
results: ProfileBrief[];
error: string | null;
friendsByPeerId: Map<string, Friendship>;
pendingId: string | null;
onAction: (id: string, fn: () => Promise<void>) => Promise<void>;
}) {
const { t } = useTranslation(['app']);
if (query.length < 2) {
return <p className="text-xs text-neutral-500">{t('app:friends.search_min_chars')}</p>;
}
if (searching) {
return (
<div className="flex items-center gap-2 text-xs text-neutral-500">
<SpinnerIcon className="h-3.5 w-3.5 text-brand-400" />
<span></span>
</div>
);
}
if (error) return <Banner kind="error">{error}</Banner>;
if (results.length === 0) {
return <p className="text-xs text-neutral-500">{t('app:friends.search_no_results')}</p>;
}
return (
<div>
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-neutral-500">
{t('app:friends.search_results_title')}
</p>
<ul className="space-y-1.5">
{results.map((p) => {
const existing = friendsByPeerId.get(p.userId);
return (
<li key={p.userId}>
<FriendRow profile={p}>
<SearchActionButton
profile={p}
existing={existing}
busy={pendingId === p.userId}
onAction={onAction}
/>
</FriendRow>
</li>
);
})}
</ul>
</div>
);
}
function SearchActionButton({
profile,
existing,
busy,
onAction,
}: {
profile: ProfileBrief;
existing: Friendship | undefined;
busy: boolean;
onAction: (id: string, fn: () => Promise<void>) => Promise<void>;
}) {
const { t } = useTranslation(['app']);
if (existing?.status === 'accepted') {
return (
<span className="inline-flex items-center gap-1.5 rounded-md border border-emerald-500/20 bg-emerald-500/10 px-3 py-1.5 text-xs font-medium text-emerald-200">
<CheckCircleIcon className="h-3.5 w-3.5" />
{t('app:friends.already_friends')}
</span>
);
}
if (existing?.status === 'pending' && existing.direction === 'outgoing') {
return (
<span className="inline-flex items-center gap-1.5 rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-neutral-300">
{t('app:friends.request_sent')}
</span>
);
}
if (existing?.status === 'pending' && existing.direction === 'incoming') {
return (
<PrimaryButton
busy={busy}
onClick={() =>
void onAction(profile.userId, () => acceptFriendRequest(supabase, profile.userId))
}
icon={<CheckCircleIcon className="h-3.5 w-3.5" />}
>
{t('app:friends.action_accept')}
</PrimaryButton>
);
}
return (
<PrimaryButton
busy={busy}
onClick={() =>
void onAction(profile.userId, () => sendFriendRequest(supabase, profile.userId))
}
icon={<PlusIcon className="h-3.5 w-3.5" />}
>
{t('app:friends.send_request')}
</PrimaryButton>
);
}
+456
View File
@@ -0,0 +1,456 @@
import { updateOwnProfile } from '@chat-app/shared/auth';
import {
changeLocale as changeLocaleI18n,
SUPPORTED_LOCALES,
type SupportedLocale,
} from '@chat-app/shared/i18n';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { LockIcon } from '../components/icons';
import { useAuth } from '../context/AuthContext';
import {
getPttSettings,
keyCodeToLabel,
type PttSettings,
subscribePttSettings,
updatePttSettings,
} from '../lib/pttSettings';
import {
AUDIO_QUALITY_ORDER,
type AudioQuality,
type AudioSettings,
getAudioQualityParams,
getAudioSettings,
subscribeAudioSettings,
updateAudioSettings,
} from '../lib/audioSettings';
import {
type CallE2EESettings,
getCallE2EESettings,
isE2EESupported,
subscribeCallE2EESettings,
updateCallE2EESettings,
} from '../lib/callE2EE';
import {
getPresetParams,
getScreenShareSettings,
PRESET_ORDER,
type ScreenSharePreset,
type ScreenShareSettings,
subscribeScreenShareSettings,
updateScreenShareSettings,
} from '../lib/screenShareSettings';
import { supabase } from '../lib/supabase';
const LOCALE_LABELS: Record<SupportedLocale, string> = {
en: 'English',
de: 'Deutsch',
};
export function SettingsPage() {
const { t, i18n } = useTranslation(['app', 'common', 'auth']);
const { profile, device, refreshProfile, signOut } = useAuth();
const [busy, setBusy] = useState(false);
async function patchProfile(patch: Parameters<typeof updateOwnProfile>[1]) {
setBusy(true);
try {
await updateOwnProfile(supabase, patch);
await refreshProfile();
} catch (err: unknown) {
console.error('updateProfile failed', err);
} finally {
setBusy(false);
}
}
async function handleLocaleChange(locale: SupportedLocale) {
await changeLocaleI18n(locale);
void patchProfile({ locale });
}
return (
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
<header className="mb-2">
<h1 className="font-display text-2xl font-semibold tracking-tight text-white">
{t('app:settings.title')}
</h1>
</header>
{/* Account */}
<Section title={t('app:settings.section_account')}>
<Row label={t('auth:signed_in.username')} value={profile?.username ?? '—'} />
<Row label={t('auth:signed_in.display_name')} value={profile?.displayName ?? '—'} />
<Row label={t('auth:signed_in.email')} value={profile?.userId ?? '—'} mono />
</Section>
{/* Appearance */}
<Section title={t('app:settings.section_appearance')}>
<SettingRow label={t('app:settings.language')}>
<div className="inline-flex rounded-lg border border-white/10 bg-ink-800 p-1">
{SUPPORTED_LOCALES.map((locale) => {
const active = (i18n.resolvedLanguage ?? i18n.language) === locale;
return (
<button
key={locale}
type="button"
disabled={busy}
onClick={() => void handleLocaleChange(locale)}
className={
'cursor-pointer rounded-md px-3 py-1.5 text-xs font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' +
(active
? 'bg-brand-500/25 text-white ring-1 ring-brand-400/40'
: 'text-neutral-400 hover:text-neutral-200')
}
>
{LOCALE_LABELS[locale]}
</button>
);
})}
</div>
</SettingRow>
</Section>
{/* Privacy */}
<Section title={t('app:settings.section_privacy')}>
<Toggle
label={t('app:settings.show_read_receipts')}
hint={t('app:settings.show_read_receipts_hint')}
checked={profile?.showReadReceipts ?? true}
disabled={busy || !profile}
onChange={(v) => void patchProfile({ showReadReceipts: v })}
/>
<Toggle
label={t('app:settings.allow_dms_strangers')}
hint={t('app:settings.allow_dms_strangers_hint')}
checked={profile?.allowDmsFromStrangers ?? true}
disabled={busy || !profile}
onChange={(v) => void patchProfile({ allowDmsFromStrangers: v })}
/>
</Section>
{/* Voice / Push-to-Talk + Audio Quality + E2EE */}
<Section title={t('app:settings.section_voice', { defaultValue: 'Sprache' })}>
<AudioQualityControls />
<div className="mt-3 border-t border-white/5 pt-3">
<PttControls />
</div>
<div className="mt-3 border-t border-white/5 pt-3">
<CallE2EEControls />
</div>
</Section>
{/* Screen-share quality */}
<Section title={t('app:settings.section_screen_share', { defaultValue: 'Bildschirmfreigabe' })}>
<ScreenShareControls />
</Section>
{/* Devices */}
<Section title={t('app:settings.section_devices')}>
{device && (
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/5 p-4">
<div className="flex items-center gap-2 text-sm font-semibold text-emerald-200">
<LockIcon className="h-4 w-4" />
{t('app:settings.this_device')}
</div>
<dl className="mt-3 space-y-1.5 text-xs">
<Row label={t('auth:signed_in.display_name')} value={device.name} />
<Row label={t('auth:signed_in.device_platform')} value={device.platform} />
<Row label={t('auth:signed_in.user_id')} value={device.id} mono />
</dl>
</div>
)}
</Section>
{/* Danger zone */}
<Section title={t('app:settings.danger_zone')}>
<button
type="button"
onClick={() => void signOut()}
className="cursor-pointer rounded-lg border border-rose-500/30 bg-rose-500/10 px-4 py-2.5 text-sm font-semibold text-rose-200 transition hover:bg-rose-500/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/50"
>
{t('app:settings.sign_out')}
</button>
</Section>
</div>
);
}
function PttControls() {
const { t } = useTranslation(['app']);
const [ptt, setPtt] = useState<PttSettings>(() => getPttSettings());
const [capturing, setCapturing] = useState(false);
useEffect(() => {
return subscribePttSettings(setPtt);
}, []);
useEffect(() => {
if (!capturing) return;
const onKey = (e: KeyboardEvent) => {
e.preventDefault();
if (e.code === 'Escape') {
setCapturing(false);
return;
}
updatePttSettings({ key: e.code, keyLabel: keyCodeToLabel(e.code) });
setCapturing(false);
};
window.addEventListener('keydown', onKey, { capture: true });
return () => window.removeEventListener('keydown', onKey, { capture: true });
}, [capturing]);
return (
<>
<Toggle
label={t('app:settings.ptt_enabled', { defaultValue: 'Push-to-Talk' })}
hint={t('app:settings.ptt_enabled_hint', {
defaultValue:
'Mic bleibt stumm bis die Taste gedrückt wird. Sonst overrides der normale Mute-Button.',
})}
checked={ptt.enabled}
onChange={(v) => updatePttSettings({ enabled: v })}
/>
<SettingRow
label={t('app:settings.ptt_key', { defaultValue: 'Hotkey' })}
>
<button
type="button"
onClick={() => setCapturing((v) => !v)}
className={
'inline-flex min-w-[7rem] cursor-pointer items-center justify-center rounded-lg border px-3 py-1.5 text-xs font-mono font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' +
(capturing
? 'border-brand-400 bg-brand-500/20 text-white animate-pulse'
: 'border-white/10 bg-ink-800 text-neutral-200 hover:bg-ink-700')
}
>
{capturing
? t('app:settings.ptt_press_key', { defaultValue: 'Taste drücken…' })
: ptt.keyLabel}
</button>
</SettingRow>
</>
);
}
function CallE2EEControls() {
const { t } = useTranslation(['app']);
const [cfg, setCfg] = useState<CallE2EESettings>(() => getCallE2EESettings());
const [supported] = useState<boolean>(() => isE2EESupported());
useEffect(() => subscribeCallE2EESettings(setCfg), []);
return (
<>
<Toggle
label={t('app:settings.e2ee_calls', { defaultValue: 'Ende-zu-Ende-Verschlüsselung (Calls)' })}
hint={
supported
? t('app:settings.e2ee_calls_hint', {
defaultValue:
'Audio + Video werden vor dem Upload verschlüsselt. Der Server sieht nur Ciphertext. Alle Teilnehmer müssen die Option aktiv haben.',
})
: t('app:settings.e2ee_calls_unsupported', {
defaultValue:
'Dein Browser unterstützt keine Insertable Streams. E2EE-Calls nicht verfügbar.',
})
}
checked={cfg.enabled && supported}
disabled={!supported}
onChange={(v) => updateCallE2EESettings({ enabled: v })}
/>
</>
);
}
function AudioQualityControls() {
const { t } = useTranslation(['app']);
const [cfg, setCfg] = useState<AudioSettings>(() => getAudioSettings());
useEffect(() => subscribeAudioSettings(setCfg), []);
const params = getAudioQualityParams(cfg.quality);
const labels: Record<AudioQuality, string> = {
voice: t('app:settings.audio_voice', { defaultValue: 'Sprache (Empfohlen)' }),
hifi: t('app:settings.audio_hifi', { defaultValue: 'HiFi / Musik' }),
};
return (
<>
<SettingRow label={t('app:settings.audio_quality', { defaultValue: 'Audio-Qualität' })}>
<div className="inline-flex rounded-lg border border-white/10 bg-ink-800 p-1">
{AUDIO_QUALITY_ORDER.map((q) => {
const active = cfg.quality === q;
return (
<button
key={q}
type="button"
onClick={() => updateAudioSettings({ quality: q })}
className={
'cursor-pointer rounded-md px-3 py-1 text-xs font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' +
(active
? 'bg-brand-500/25 text-white ring-1 ring-brand-400/40'
: 'text-neutral-400 hover:text-neutral-200')
}
>
{labels[q]}
</button>
);
})}
</div>
</SettingRow>
<div className="rounded-lg border border-white/5 bg-ink-900/40 p-3 text-[11px] text-neutral-400">
<div className="grid grid-cols-4 gap-3">
<Stat label="Bitrate" value={params.bitrateKbps + ' kbps'} />
<Stat label="Channels" value={params.stereo ? 'Stereo' : 'Mono'} />
<Stat label="Sample" value={params.sampleRateHz / 1000 + ' kHz'} />
<Stat label="DSP" value={params.echoCancellation ? 'On' : 'Off'} />
</div>
</div>
<p className="text-xs text-neutral-500">
{cfg.quality === 'hifi'
? t('app:settings.audio_hifi_hint', {
defaultValue:
'Stereo 510 kbps Opus ohne Noise-Suppression/Echo-Cancellation — bester Musik/Broadcast-Sound. Erfordert ruhige Umgebung.',
})
: t('app:settings.audio_voice_hint', {
defaultValue:
'Mono 48 kbps mit Noise-Suppression, Echo-Cancellation und Auto-Gain. Optimiert für Sprache im Raum.',
})}
</p>
</>
);
}
function ScreenShareControls() {
const { t } = useTranslation(['app']);
const [cfg, setCfg] = useState<ScreenShareSettings>(() => getScreenShareSettings());
useEffect(() => subscribeScreenShareSettings(setCfg), []);
const params = getPresetParams(cfg.preset);
return (
<>
<SettingRow label={t('app:settings.screen_share_quality', { defaultValue: 'Qualität' })}>
<select
value={cfg.preset}
onChange={(e) =>
updateScreenShareSettings({ preset: e.target.value as ScreenSharePreset })
}
className="cursor-pointer rounded-lg border border-white/10 bg-ink-800 px-3 py-1.5 text-xs text-neutral-200 focus:border-brand-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
>
{PRESET_ORDER.map((p) => (
<option key={p} value={p}>
{getPresetParams(p).label}
</option>
))}
</select>
</SettingRow>
<div className="rounded-lg border border-white/5 bg-ink-900/40 p-3 text-[11px] text-neutral-400">
<div className="grid grid-cols-3 gap-3">
<Stat label="Bitrate (max)" value={formatBitrate(params.bitrateKbps)} />
<Stat
label="Resolution"
value={params.dims ? params.dims.width + '×' + params.dims.height : 'Auto'}
/>
<Stat label="Framerate" value={params.framerate + ' fps'} />
</div>
</div>
<p className="text-xs text-neutral-500">
{t('app:settings.screen_share_hint', {
defaultValue:
'WebRTC passt Bitrate + Auflösung dynamisch an die Netzwerkqualität an (SVC/VP9). Die Werte sind Obergrenzen. Änderungen greifen beim nächsten Call.',
})}
</p>
</>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div>
<div className="text-[10px] uppercase tracking-wide text-neutral-500">{label}</div>
<div className="mt-0.5 font-mono text-neutral-200">{value}</div>
</div>
);
}
function formatBitrate(kbps: number): string {
if (kbps >= 1000) {
return (kbps / 1000).toFixed(kbps % 1000 === 0 ? 0 : 1) + ' Mbps';
}
return kbps + ' kbps';
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="rounded-2xl border border-white/10 bg-ink-900/60 p-5 backdrop-blur-xl">
<h2 className="mb-4 text-xs font-semibold uppercase tracking-wide text-neutral-400">
{title}
</h2>
<div className="space-y-3">{children}</div>
</section>
);
}
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex items-center justify-between gap-4">
<dt className="text-sm text-neutral-500">{label}</dt>
<dd
className={
'max-w-[60%] truncate text-right text-sm text-neutral-200 ' +
(mono ? 'font-mono text-xs' : '')
}
title={value}
>
{value}
</dd>
</div>
);
}
function SettingRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-center justify-between gap-4">
<span className="text-sm text-neutral-200">{label}</span>
{children}
</div>
);
}
function Toggle({
label,
hint,
checked,
disabled,
onChange,
}: {
label: string;
hint?: string;
checked: boolean;
disabled?: boolean;
onChange: (next: boolean) => void;
}) {
return (
<label className="flex cursor-pointer items-start justify-between gap-4">
<span className="min-w-0 flex-1">
<span className="block text-sm text-neutral-200">{label}</span>
{hint && <span className="mt-1 block text-xs text-neutral-500">{hint}</span>}
</span>
<span className="relative mt-0.5 inline-flex h-6 w-11 shrink-0">
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
className="peer sr-only"
/>
<span className="inline-block h-6 w-11 rounded-full bg-neutral-700 transition peer-checked:bg-brand-500/70 peer-disabled:opacity-50" />
<span className="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition peer-checked:translate-x-5" />
</span>
</label>
);
}
View File
+49
View File
@@ -0,0 +1,49 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Outfit:wght@500;600;700&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
color-scheme: dark;
}
html,
body,
#root {
height: 100%;
}
body {
@apply bg-ink-950 text-neutral-100 font-sans antialiased;
font-feature-settings: 'cv11', 'ss01';
}
::selection {
@apply bg-brand-500/40 text-white;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
}
@layer utilities {
.scrollbar-none::-webkit-scrollbar {
display: none;
}
.bg-grid {
background-image:
linear-gradient(rgba(255, 255, 255, 0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px);
background-size: 32px 32px;
background-position: -1px -1px;
}
}
+8
View File
@@ -0,0 +1,8 @@
/// <reference types="vite/client" />
// LiveKit ships a prebuilt E2EE worker bundle. Vite's `?worker` suffix
// converts it into a Worker constructor at build-time.
declare module 'livekit-client/e2ee-worker?worker' {
const WorkerCtor: new (options?: WorkerOptions) => Worker;
export default WorkerCtor;
}
+82
View File
@@ -0,0 +1,82 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
'./index.html',
'./src/**/*.{ts,tsx}',
'../../packages/ui-web/src/**/*.{ts,tsx}',
],
theme: {
extend: {
fontFamily: {
sans: [
'Inter',
'ui-sans-serif',
'system-ui',
'-apple-system',
'Segoe UI',
'Roboto',
'Helvetica Neue',
'Arial',
'sans-serif',
],
display: ['Outfit', 'Inter', 'system-ui', 'sans-serif'],
mono: [
'JetBrains Mono',
'ui-monospace',
'SFMono-Regular',
'Menlo',
'Monaco',
'Consolas',
'monospace',
],
},
colors: {
ink: {
950: '#050507',
900: '#0A0A0F',
800: '#111118',
700: '#1A1A24',
600: '#252533',
500: '#3A3A4D',
},
brand: {
50: '#EEF0FF',
100: '#DDE0FF',
200: '#BDC2FF',
300: '#9198FF',
400: '#6D73FF',
500: '#4F46E5',
600: '#4338CA',
700: '#3730A3',
800: '#312E81',
900: '#1E1B4B',
},
},
animation: {
'blob-a': 'blob 22s ease-in-out infinite',
'blob-b': 'blob 28s ease-in-out infinite reverse',
'fade-in': 'fadeIn 400ms ease-out',
'slide-up': 'slideUp 350ms ease-out',
},
keyframes: {
blob: {
'0%, 100%': { transform: 'translate(0px, 0px) scale(1)' },
'33%': { transform: 'translate(40px, -60px) scale(1.1)' },
'66%': { transform: 'translate(-30px, 30px) scale(0.9)' },
},
fadeIn: {
from: { opacity: '0' },
to: { opacity: '1' },
},
slideUp: {
from: { opacity: '0', transform: 'translateY(8px)' },
to: { opacity: '1', transform: 'translateY(0)' },
},
},
boxShadow: {
glow: '0 0 40px -8px rgba(99, 102, 241, 0.35)',
},
},
},
plugins: [],
};
+20
View File
@@ -0,0 +1,20 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": false,
"noEmit": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"paths": {
"@/*": ["./src/*"],
"@shared/*": ["../../packages/shared/src/*"],
"@db-types/*": ["../../packages/db-types/src/*"],
"@ui-web/*": ["../../packages/ui-web/src/*"]
}
},
"include": ["src/**/*", "vite.config.ts"],
"exclude": ["node_modules", "dist", "src-tauri/target"]
}
+38
View File
@@ -0,0 +1,38 @@
import react from '@vitejs/plugin-react';
import path from 'node:path';
import process from 'node:process';
import { defineConfig } from 'vite';
const host = process.env.TAURI_DEV_HOST;
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@shared': path.resolve(__dirname, '../../packages/shared/src'),
'@db-types': path.resolve(__dirname, '../../packages/db-types/src'),
'@ui-web': path.resolve(__dirname, '../../packages/ui-web/src'),
},
},
optimizeDeps: {
// libsodium-wrappers 0.7.16 ships a broken "import" condition in its
// package exports (the ESM bundle references a sibling ./libsodium.mjs
// that isn't in the published artefact). Force esbuild to pick the
// "require" condition so the self-contained CJS build is used.
include: ['libsodium-wrappers'],
esbuildOptions: {
conditions: ['require', 'node', 'default'],
},
},
clearScreen: false,
server: {
port: 1420,
strictPort: true,
host: host ?? false,
...(host ? { hmr: { protocol: 'ws' as const, host, port: 1421 } } : {}),
watch: {
ignored: ['**/src-tauri/**'],
},
},
});