This commit is contained in:
2026-04-18 23:11:35 +02:00
commit f7cfd2a86e
196 changed files with 35538 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{rs,toml}]
indent_size = 4
[Makefile]
indent_style = tab
+42
View File
@@ -0,0 +1,42 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 2022,
"sourceType": "module",
"ecmaFeatures": { "jsx": true }
},
"plugins": ["@typescript-eslint", "import", "simple-import-sort"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:import/recommended",
"plugin:import/typescript",
"prettier"
],
"settings": {
"import/resolver": {
"typescript": { "alwaysTryTypes": true, "project": ["./tsconfig.base.json", "./apps/*/tsconfig.json", "./packages/*/tsconfig.json"] }
}
},
"rules": {
"simple-import-sort/imports": "error",
"simple-import-sort/exports": "error",
"import/first": "error",
"import/newline-after-import": "error",
"import/no-duplicates": "error",
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }],
"@typescript-eslint/consistent-type-imports": ["error", { "prefer": "type-imports" }],
"no-console": ["warn", { "allow": ["warn", "error"] }]
},
"ignorePatterns": [
"node_modules",
"dist",
"build",
".turbo",
".expo",
"src-tauri/target",
"*.config.js",
"*.config.cjs"
]
}
+28
View File
@@ -0,0 +1,28 @@
# Lint + type-check + unit tests across all workspaces on every push/PR.
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm typecheck
- run: pnpm test
+13
View File
@@ -0,0 +1,13 @@
# Tauri multi-OS build placeholder.
# Flesh out with tauri-apps/tauri-action when we're ready to ship binaries.
# Matrix: macos-latest, ubuntu-22.04, windows-latest.
name: Desktop Build
on:
workflow_dispatch:
jobs:
placeholder:
runs-on: ubuntu-latest
steps:
- run: echo "Wire up tauri-apps/tauri-action with a matrix over macos/ubuntu/windows."
+15
View File
@@ -0,0 +1,15 @@
# Mobile build placeholder.
# Wire up EAS Build (https://docs.expo.dev/eas/) once credentials + a project id exist.
# Typical setup:
# - EAS_PROJECT_ID + EXPO_TOKEN as repo secrets
# - `eas build --platform all --non-interactive` on tag push
name: Mobile Build
on:
workflow_dispatch:
jobs:
placeholder:
runs-on: ubuntu-latest
steps:
- run: echo "Wire up EAS Build when ready."
+79
View File
@@ -0,0 +1,79 @@
name: Release desktop app
# Tag a version to trigger a release:
# git tag v0.1.0 && git push --tags
#
# Produces signed Tauri bundles for macOS (arm + intel), Windows, and Linux,
# uploads them to a GitHub Release, and publishes `latest.json` for the
# updater plugin to discover.
on:
push:
tags:
- "v*"
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- platform: macos-14 # apple silicon
args: "--target aarch64-apple-darwin"
- platform: macos-13 # intel
args: "--target x86_64-apple-darwin"
- platform: ubuntu-22.04
args: ""
- platform: windows-latest
args: ""
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.platform == 'macos-14' && 'aarch64-apple-darwin' || matrix.platform == 'macos-13' && 'x86_64-apple-darwin' || '' }}
- name: Install Linux build deps
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt update
sudo apt install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libgtk-3-dev
- name: Install JS deps
run: pnpm install --frozen-lockfile
- name: Build desktop app
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
# Client-side env vars baked into the bundle — paste your prod values
# into the repo's Actions → Secrets so releases point at prod.
VITE_SUPABASE_URL: ${{ secrets.VITE_SUPABASE_URL }}
VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }}
VITE_AUTH_REDIRECT_URL: ${{ secrets.VITE_AUTH_REDIRECT_URL }}
VITE_LIVEKIT_URL: ${{ secrets.VITE_LIVEKIT_URL }}
with:
projectPath: apps/desktop
tagName: ${{ github.ref_name }}
releaseName: "ChatApp ${{ github.ref_name }}"
releaseBody: "See the assets below to download this version."
releaseDraft: true
prerelease: false
args: ${{ matrix.args }}
+66
View File
@@ -0,0 +1,66 @@
# Dependencies
node_modules/
.pnpm-store/
# Build output
dist/
build/
out/
*.tsbuildinfo
# Turbo
.turbo/
# Env
.env
.env.local
.env.*.local
!.env.example
# Expo
.expo/
.expo-shared/
web-build/
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Tauri updater signing key (private — NEVER commit)
.tauri-updater.key
# .tauri-updater.key.pub is public, may be committed
# Tauri
apps/desktop/src-tauri/target/
apps/desktop/src-tauri/gen/
apps/desktop/src-tauri/WixTools/
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# OS
.DS_Store
Thumbs.db
# IDE
.idea/
.vscode/*
!.vscode/settings.json
!.vscode/extensions.json
*.swp
*.swo
# Coverage
coverage/
*.lcov
.nyc_output/
# Misc
.cache/
*.local
+1
View File
@@ -0,0 +1 @@
22
+10
View File
@@ -0,0 +1,10 @@
node_modules
dist
build
.turbo
.expo
coverage
src-tauri/target
pnpm-lock.yaml
*.tsbuildinfo
packages/db-types/src/index.ts
+11
View File
@@ -0,0 +1,11 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"arrowParens": "always",
"endOfLine": "lf",
"bracketSpacing": true,
"jsxSingleQuote": false
}
+12
View File
@@ -0,0 +1,12 @@
{
"recommendations": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"bradlc.vscode-tailwindcss",
"tauri-apps.tauri-vscode",
"rust-lang.rust-analyzer",
"expo.vscode-expo-tools",
"ms-vscode.vscode-typescript-next",
"editorconfig.editorconfig"
]
}
+22
View File
@@ -0,0 +1,22 @@
{
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports": "never"
},
"eslint.workingDirectories": [
{ "pattern": "apps/*" },
{ "pattern": "packages/*" }
],
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
"files.eol": "\n",
"[rust]": {
"editor.defaultFormatter": "rust-lang.rust-analyzer"
},
"tailwindCSS.experimental.classRegex": [
["cn\\(([^)]*)\\)", "'([^']*)'"],
["cva\\(([^)]*)\\)", "'([^']*)'"]
]
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Dennis
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+101
View File
@@ -0,0 +1,101 @@
# chat-app
Private, end-to-end encrypted chat app for a small circle. Self-hosted Supabase backend, clients on iOS, Android, macOS, Windows, and Linux.
Server is zero-knowledge: it only stores ciphertexts and metadata needed to route them.
## Milestone Roadmap
1. **Milestone 1 — Text Chat** (current): auth, identity keys, 1:1 encrypted messaging, local history, push.
2. **Milestone 2 — Voice Calls**: libsodium-secured WebRTC signaling via Supabase Realtime.
3. **Milestone 3 — Video Calls**: same stack, add video tracks + bandwidth handling.
4. **Milestone 4 — Desktop Polish**: feature parity with mobile, tray, notifications.
5. **Milestone 5 — Groups & Channels**: Discord-like group channels with shared ratchet keys.
## Repository Layout
```
apps/
mobile/ Expo + React Native (iOS / Android)
desktop/ Tauri v2 + React + Vite + Tailwind (macOS / Windows / Linux)
packages/
shared/ Business logic: Supabase client, libsodium crypto, auth, chat
db-types/ Generated Supabase database types
ui-web/ React web components shared by desktop (not by React Native)
infra/
supabase/ Self-hosting docs, client env, SQL migrations
.github/workflows/ CI + build placeholders
```
## Tech Stack
- **Mobile**: Expo SDK 52, Expo Router, expo-secure-store, expo-sqlite, expo-notifications, react-native-libsodium.
- **Desktop**: Tauri v2, React 18, Vite, Tailwind, tauri-plugin-stronghold, tauri-plugin-sql, tauri-plugin-notification, Zustand.
- **Backend**: Supabase self-hosted (Postgres + GoTrue + PostgREST + Realtime + Storage + Edge Functions) on Hetzner, Caddy reverse proxy.
- **Crypto**: X25519 identity keys, XChaCha20-Poly1305 envelopes (libsodium / NaCl).
- **Auth**: email magic-link, invite-only.
## Prerequisites
- Node **22+** (see `.nvmrc`)
- pnpm **9+**
- Rust + Cargo (Tauri): https://rustup.rs
- Docker + Docker Compose (for running Supabase locally or on the VPS)
- Platform toolchain per target:
- iOS: Xcode
- Android: Android Studio + SDK
- macOS/Linux/Windows Tauri: see `apps/desktop/README.md`
## Setup
```bash
# 1. Node + pnpm (macOS)
brew install pnpm
corepack enable
# 2. Clone + install
git clone <this repo>
cd chat-app
pnpm install
# 3. Env
cp infra/supabase/.env.example apps/mobile/.env
cp infra/supabase/.env.example apps/desktop/.env
# Fill in SUPABASE_URL and SUPABASE_ANON_KEY from your self-hosted stack.
```
See `infra/supabase/README.md` for spinning up the backend.
## Common Scripts (run from repo root)
| Script | Purpose |
|--------|---------|
| `pnpm dev` | Start every workspace's dev task (Turborepo). |
| `pnpm build` | Build every workspace. |
| `pnpm lint` | ESLint across workspaces. |
| `pnpm typecheck` | TypeScript project-wide type check. |
| `pnpm test` | Vitest across workspaces. |
| `pnpm format` | Prettier write. |
| `pnpm format:check` | Prettier check. |
| `pnpm mobile:dev` | `expo start` for the mobile app. |
| `pnpm mobile:ios` | Native iOS run. |
| `pnpm mobile:android` | Native Android run. |
| `pnpm desktop:dev` | `tauri dev` for the desktop app. |
| `pnpm desktop:build` | Platform-specific Tauri bundle. |
| `pnpm db:types` | Regenerate `@chat-app/db-types` from the running Supabase. |
## Architecture Overview
- **Shared-first**: Anything that can run in both React Native and the Tauri WebView lives in `packages/shared` and is imported via `@chat-app/shared/*`. Both hosts pass in adapters for platform-only concerns (secure storage, SQLite, libsodium backend).
- **Zero-knowledge server**: Messages are encrypted client-side before insert. The server sees ciphertexts, a conversation id, a sender id, and a timestamp — nothing else.
- **Key custody**: X25519 private keys live in platform secure stores only (Keychain/Keystore on mobile, Stronghold on desktop). Public keys live in the `profiles` table.
- **Realtime**: Supabase Realtime delivers new ciphertext rows to subscribed clients. Push notifications are silent (data-only) — the client decrypts and composes the visible notification locally.
## Security Checklist
- [ ] RLS enabled on every user-facing table
- [ ] Invite-only enforced in SQL (invites table + policy)
- [ ] No plaintext in push payloads
- [ ] Service role key never shipped to a client
- [ ] JWT secret rotated on first boot
- [ ] TLS via Caddy at the edge
+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/**'],
},
},
});
+27
View File
@@ -0,0 +1,27 @@
# @chat-app/mobile
Expo + React Native client for iOS and Android.
## Prerequisites
- Node 22+, pnpm 9+
- Xcode (iOS) / Android Studio (Android)
- Optional: `npm i -g eas-cli` for cloud builds
## Dev
```bash
# from repo root
pnpm install
pnpm mobile:dev # expo start
pnpm mobile:ios # native iOS run
pnpm mobile:android # native Android run
```
## Notes
- Uses Expo Router (file-based). Screens live under `app/`.
- Secrets stored via `expo-secure-store` (Keychain / Keystore).
- Local encrypted history via `expo-sqlite`.
- Crypto via `react-native-libsodium`.
- Shared business logic lives in `@chat-app/shared`.
+51
View File
@@ -0,0 +1,51 @@
{
"expo": {
"name": "ChatApp",
"slug": "chat-app",
"version": "0.1.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"scheme": "chatapp",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#0b0b0f"
},
"assetBundlePatterns": ["**/*"],
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.meinname.chatapp",
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false
}
},
"android": {
"package": "com.meinname.chatapp",
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#0b0b0f"
}
},
"plugins": [
"expo-router",
"expo-secure-store",
"expo-sqlite",
[
"expo-notifications",
{
"color": "#0b0b0f"
}
]
],
"experiments": {
"typedRoutes": true
},
"extra": {
"eas": {
"projectId": "REPLACE_WITH_EAS_PROJECT_ID"
}
}
}
}

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