initial
This commit is contained in:
@@ -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
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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."
|
||||||
@@ -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."
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"jsxSingleQuote": false
|
||||||
|
}
|
||||||
Vendored
+12
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+22
@@ -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\\(([^)]*)\\)", "'([^']*)'"]
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDg0Q0Y0N0I1Q0U2MjBEMzcKUldRM0RXTE90VWZQaERVWnBGNTVKUVZ2MWZyRktaaDFJaXVWc3NGNUZVb08yVHNxaVp2c2dOL2oK
|
||||||
@@ -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.
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
target/
|
||||||
|
gen/
|
||||||
|
WixTools/
|
||||||
Generated
+7685
File diff suppressed because it is too large
Load Diff
@@ -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"]
|
||||||
@@ -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>
|
||||||
@@ -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 |
@@ -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");
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
|
<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">· 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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).
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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() };
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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>';
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+8
@@ -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;
|
||||||
|
}
|
||||||
@@ -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: [],
|
||||||
|
};
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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/**'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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`.
|
||||||
@@ -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
Reference in New Issue
Block a user