Compare commits

...

26 Commits

Author SHA1 Message Date
byGalax a4c9b959a9 fix: editable decrypt + windows realtime wake (v0.7.1)
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
Edit message decrypt:
- handleUpdate in useConversationMessages now refetches the canonical row
  via REST after a realtime UPDATE instead of trusting the realtime
  payload's bytea encoding. Same pattern as handleInsert — base64 vs
  `\x…` hex serialisation varies across supabase/postgrest versions and
  was silently producing undecryptable ciphertext for edited messages
  on the receiver side

Windows WebView2 background throttling:
- ConversationsContext, useFriendships and useConversationMessages now
  listen for visibilitychange / focus / online events and trigger both a
  fresh REST refresh and a best-effort channel.subscribe() on wake.
  WebView2 aggressively throttles background WebSockets and was dropping
  realtime events entirely while the window was minimised, so new
  messages and friend acceptances only surfaced after a manual reload

Bump tauri version 0.7.0 -> 0.7.1
2026-04-20 22:15:49 +02:00
byGalax 37becba7e2 feat: audio devices + fullscreen + banner cleanup (v0.7.0)
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
Audio device selection:
- audioSettings: persisted inputDeviceId + outputDeviceId
- CallContext: uses stored input deviceId on mic enable, new
  setAudioInputDevice / setAudioOutputDevice actions that hot-swap
  without reconnect. Output swap applies HTMLMediaElement.setSinkId
  to every attached remote-audio element (LiveKit's switchActiveDevice
  only tracks elements it attached itself)
- SettingsPage: new "Mikrofon" + "Ausgabegerät" selects with
  enumerateDevices, devicechange listener, permission-probe button.
  setSinkId-unsupported fallback is messaged but non-blocking

Fullscreen:
- FullscreenCall was absolute inset-0 z-40 which trapped it inside the
  <main> pane — sidebar + chat-list stayed visible. Switched to
  fixed inset-0 z-[60] so the call overlays the whole window
  Discord-style
- ScreenShareViewer fullscreen: CSS-only toggle (native Fullscreen API
  unreliable under Tauri WKWebView), portalled to document.body when
  active so no ancestor stacking context can clip it. Esc exits

ActiveCallBanner:
- cleanup effect returned early when presence was entirely empty,
  leaving the "1 im Raum" fallback stuck after both peers left. Now
  schedules dismissLastCall as soon as othersIn.length === 0, with a
  3s grace window to absorb presence re-sync flicker

Bump tauri version 0.6.0 -> 0.7.0
2026-04-20 21:26:10 +02:00
byGalax eb8f9857ff feat: device backup/restore + quick wins + username casing
Backup / restore flow:
- deviceBackup.ts: v2 format wrapping userId + deviceId + privateKey in
  an encrypted JSON payload so restore can re-seed localStorage, vault,
  and reattach to the existing server-side device row without provisioning
  a new one (conv-key bundles stay valid, no "awaiting key" state)
- shared/auth: restoreDeviceFromServerRecord — verifies session.user.id
  matches the backup's userId, confirms the server device row still
  exists, then writes the private key into the local secret store
- BackupExportDialog — passphrase + confirm, generates portable string,
  copy + download .txt
- DeviceRestore — textarea + passphrase → seeds vault + writes
  deviceId cache, treats this install as the original device
- DevicePage now has tabs "Neu einrichten" / "Backup wiederherstellen"
- BackupPromptBanner — post-registration nudge, reads sessionStorage
  signal from fresh provisions and persists "never-ask-again" in
  localStorage so it stops nagging
- SettingsPage backup section: uses the new dialog; removes the
  dangerous in-place key import (restore now lives in the device flow)

Username casing:
- Migration 20260420000002 drops lower() from the handle_new_user trigger
  and widens the regex to [A-Za-z0-9_]. profiles.username is citext so
  uniqueness + lookups stay case-insensitive regardless of stored casing
- Shared auth: trim() only, no toLowerCase on signup/lookups/search.
  ilike handles CI anyway and citext makes client normalisation redundant
- AuthPage regex + input preserve case, FriendsPage search preserves case
- i18n (de+en): updated username_hint / username_invalid / ERR_USERNAME_INVALID
  to reflect the new rule

Quick wins:
- React Router v7 future flags (v7_startTransition + v7_relativeSplatPath)
  set on BrowserRouter — silences the upgrade warning
- appUpdates.checkForUpdate: swallow benign network/fetch/"could not
  fetch valid release JSON" cases silently instead of console spam
- osNotify: persist an "asked" marker in localStorage so the permission
  prompt only fires once per install (OS already persists the answer,
  but the plugin re-queries loudly otherwise)
2026-04-20 19:07:31 +02:00
byGalax de431386ea feat: reply + search + forward + archive/mute + error boundary
Messages:
- Reply-to: hover action, composer chip with cancel, quote bubble inside
  the replying message with tap-to-jump + amber highlight ring
- Search: header search button toggles in-conversation search bar with
  prev/next + match counter, auto-jump to active match
- Forward: multi-select conversation picker. Attachments are now carried
  over: download + decrypt source, re-encrypt under each target conv-key,
  re-upload with fresh per-attachment keys, insert new attachment rows

Conversations:
- Archive + mute per member. New migration 20260420000001 adds `archived`
  + `muted_until` on conversation_members. Shared helpers:
  setConversationArchived / setConversationMutedUntil / isConversationMuted
- ChatsPage: archive toggle in header with unread badge for archived
  bucket, split active/archived lists, muted indicator (BellOff icon,
  dimmed unread badge)
- ConversationRowMenu via createPortal (escapes sidebar overflow clip),
  forwardRef-based MenuItem so submenu positioning refs survive React 18
- ConversationsContext: suppresses notification sound + OS notif when
  target conversation is muted
- Refresh on `profiles UPDATE` realtime so peer avatar / displayName
  changes flow to conversation.members without manual refresh

Resilience:
- ErrorBoundary (Discord-style): centred spinner + escalating copy, no
  manual reload button. Backoff retry schedule [2s, 4s, 8s, 15s, 30s].
  Uses a keyed Fragment (not a div wrapper) so flex h-full chains survive
- App wrapped root + per-route RouteBoundary, conversation-level boundary
- AuthContext: flip `ready` immediately on cached session read; validate
  getUser in background so a stalled/offline Supabase doesn't freeze the
  app on the loading spinner

Crypto:
- Swap libsodium-wrappers -> libsodium-wrappers-sumo (compact build was
  missing crypto_pwhash so Argon2id vault KDF threw, falling back to
  plaintext localStorage on every launch)
- Shim d.ts for sumo types (sumo is API superset, no official types ship)
- vite optimizeDeps includes sumo with the "require" condition
- secureFileStore: exists(dir) check before mkdir; surface genuine
  permission errors instead of silent catch

Tauri:
- fs scope adds `$APPLOCALDATA` direct entry (not just /**) so the app
  data directory itself can be mkdir'd on first launch

Chat layout:
- Skip call_event messages when computing avatar run boundaries so a
  regular bubble followed by a call event from the same sender still
  shows its avatar
2026-04-20 15:42:49 +02:00
byGalax 1fab2edc57 fix(messages): peer avatar visibility in chat bubbles
- ConversationsContext: subscribe to profiles UPDATE realtime so
  conversation members[].profile picks up peer avatar / displayName changes
  without a manual refresh
- ConversationPage: skip call_event messages when computing run boundaries.
  Previously a peer bubble followed by a call event from the same sender
  was treated as mid-run -> avatar slot collapsed to a placeholder
- DM peer-profile fallback already added in previous commit covers transient
  member-lookup misses
2026-04-20 00:49:21 +02:00
byGalax 4db65993d5 feat: redesign + avatars + theme + call presence fixes (v0.6.0)
Release desktop app / build (, windows-latest) (push) Has been cancelled
Release desktop app / build (--target universal-apple-darwin --bundles app,updater, macos-14) (push) Has been cancelled
Visual:
- Light/dark theme via ThemeContext + CSS vars
- New design tokens (surface/fg/accent/line/etc.) across all pages
- Reusable Avatar component (img + letter fallback) wired into UserBar,
  ConversationHeader, ChatsPage, FriendsPage, GroupInfoPanel, IncomingCallPanel

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

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

After

Width:  |  Height:  |  Size: 667 B

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

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 948 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 928 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

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

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

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

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 611 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 858 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

+14
View File
@@ -0,0 +1,14 @@
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<defs>
<clipPath id="cp02">
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19"/>
</clipPath>
</defs>
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19" fill="#2e1065"/>
<g clip-path="url(#cp02)">
<path d="M-4 32 Q 16 18 32 32 T 68 32 L 68 64 L -4 64 Z" fill="#7c4dff"/>
<path d="M-4 32 Q 16 18 32 32 T 68 32" stroke="#a78bfa" stroke-width="2" fill="none"/>
</g>
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19"
fill="none" stroke="#a78bfa" stroke-width="1.5" opacity="0.4"/>
</svg>

After

Width:  |  Height:  |  Size: 667 B

+1
View File
@@ -3,6 +3,7 @@ pub fn run() {
let mut builder = tauri::Builder::default()
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_sql::Builder::default().build())
.plugin(tauri_plugin_fs::init())
.plugin(
tauri_plugin_stronghold::Builder::new(|password| {
// TODO: derive stronghold key from password using argon2 / blake2b.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ChatApp",
"version": "0.1.0",
"version": "0.7.1",
"identifier": "com.meinname.chatapp",
"build": {
"beforeDevCommand": "pnpm vite:dev",
+52 -3
View File
@@ -1,11 +1,14 @@
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import { BrowserRouter, Navigate, Outlet, Route, Routes } from 'react-router-dom';
import { AppShell } from './components/AppShell';
import { ErrorBoundary } from './components/ErrorBoundary';
import { UpdateToast } from './components/UpdateToast';
import { RequireAdmin, RequireAuth, RequireDevice } from './components/guards';
import { AuthProvider } from './context/AuthContext';
import { CallProvider } from './context/CallContext';
import { ConversationsProvider } from './context/ConversationsContext';
import { FriendshipsProvider } from './context/FriendshipsContext';
import { ThemeProvider } from './context/ThemeContext';
import { AdminPage } from './pages/AdminPage';
import { AuthCallbackPage } from './pages/AuthCallbackPage';
import { AuthPage } from './pages/AuthPage';
@@ -15,39 +18,85 @@ import { DevicePage } from './pages/DevicePage';
import { FriendsPage } from './pages/FriendsPage';
import { SettingsPage } from './pages/SettingsPage';
// Isolates each top-level route so a crash in one page doesn't take the whole
// shell down. The boundary auto-retries via `ErrorBoundary`'s backoff schedule.
function RouteBoundary({ scope }: { scope: string }) {
return (
<ErrorBoundary scope={scope}>
<Outlet />
</ErrorBoundary>
);
}
export function App() {
return (
<ErrorBoundary scope="root">
<ThemeProvider>
<AuthProvider>
<FriendshipsProvider>
<ConversationsProvider>
<CallProvider>
<BrowserRouter>
<BrowserRouter
future={{
// Opt into v7 behaviour early so the upgrade is a no-op:
// - `v7_startTransition` wraps navigations in startTransition
// so Suspense / concurrent rendering deal with the new tree
// - `v7_relativeSplatPath` matches relative paths inside
// splat routes against the parent splat segment (not the
// full matched path). Our tree has no splat routes today
// but this kills the runtime warning and future-proofs.
v7_startTransition: true,
v7_relativeSplatPath: true,
}}
>
<Routes>
<Route element={<RouteBoundary scope="auth" />}>
<Route path="/auth" element={<AuthPage />} />
<Route path="/auth/callback" element={<AuthCallbackPage />} />
</Route>
<Route element={<RequireAuth />}>
<Route element={<RouteBoundary scope="device" />}>
<Route path="/device" element={<DevicePage />} />
</Route>
<Route element={<RequireDevice />}>
<Route element={<AppShell />}>
<Route index element={<Navigate to="/chats" replace />} />
<Route element={<RouteBoundary scope="chats" />}>
<Route path="/chats" element={<ChatsPage />}>
<Route index element={<ChatsEmptyState />} />
<Route path=":id" element={<ConversationPage />} />
<Route
path=":id"
element={
<ErrorBoundary scope="conversation">
<ConversationPage />
</ErrorBoundary>
}
/>
</Route>
</Route>
<Route element={<RouteBoundary scope="friends" />}>
<Route path="/friends" element={<FriendsPage />} />
</Route>
<Route element={<RouteBoundary scope="settings" />}>
<Route path="/settings" element={<SettingsPage />} />
</Route>
<Route element={<RequireAdmin />}>
<Route element={<RouteBoundary scope="admin" />}>
<Route path="/admin" element={<AdminPage />} />
</Route>
</Route>
</Route>
</Route>
</Route>
<Route path="*" element={<Navigate to="/chats" replace />} />
</Routes>
<UpdateToast />
</BrowserRouter>
</CallProvider>
</ConversationsProvider>
</FriendshipsProvider>
</AuthProvider>
</ThemeProvider>
</ErrorBoundary>
);
}
+14 -6
View File
@@ -1,20 +1,28 @@
import { useEffect } from 'react';
import { Outlet } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { startConversationKeySync } from '../lib/conversationKeySync';
import { ensureNotificationPermission } from '../lib/osNotify';
import { BackupPromptBanner } from './BackupPromptBanner';
import { CallUI } from './CallUI';
import { Sidebar } from './Sidebar';
import { UpdateToast } from './UpdateToast';
export function AppShell() {
const { session, device } = useAuth();
useEffect(() => {
// Prompt once per authenticated shell mount. Module-level guard prevents
// re-asking if the user already responded this session.
void ensureNotificationPermission();
}, []);
useEffect(() => {
if (!session?.user.id || !device?.id) return;
return startConversationKeySync(session.user.id, device.id);
}, [session?.user.id, device?.id]);
return (
<div className="relative flex min-h-screen overflow-hidden bg-ink-950 text-neutral-100">
<div className="relative flex min-h-screen overflow-hidden bg-surface text-fg">
<ShellBackground />
<div className="relative z-10 flex min-h-screen w-full">
<Sidebar />
@@ -25,16 +33,16 @@ export function AppShell() {
</main>
</div>
<CallUI />
<UpdateToast />
<BackupPromptBanner />
</div>
);
}
// Calmer than the auth-screen background — full-bleed grid + 2 large blobs.
// No animation here so message lists stay readable.
// Subtle ambient background — only renders in dark mode where the app's
// visual language expects depth. Light mode stays clean and flat.
function ShellBackground() {
return (
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
<div aria-hidden="true" className="pointer-events-none absolute inset-0 hidden dark:block">
<div className="bg-grid absolute inset-0 opacity-[0.18]" />
<div className="absolute -left-32 top-1/4 h-[420px] w-[420px] rounded-full bg-brand-500/20 blur-3xl" />
<div className="absolute -right-32 bottom-0 h-[420px] w-[420px] rounded-full bg-fuchsia-500/10 blur-3xl" />
+45
View File
@@ -0,0 +1,45 @@
// Reusable avatar that prefers an uploaded image and falls back to a coloured
// letter circle. Use this everywhere the app needs to render a profile.
interface Props {
url?: string | null | undefined;
displayName?: string | null | undefined;
className?: string;
// Background gradient classes used when no image is set. Defaults to a
// brand-tinted fallback; callers can override (e.g. to colour-by-id).
fallbackClass?: string;
alt?: string;
}
export function Avatar({
url,
displayName,
className = 'h-10 w-10',
fallbackClass = 'bg-accent/20 text-accent',
alt,
}: Props) {
if (url) {
return (
<img
src={url}
alt={alt ?? displayName ?? ''}
className={'shrink-0 rounded-full object-cover ' + className}
draggable={false}
/>
);
}
const letter = (displayName ?? '?').trim().charAt(0).toUpperCase() || '?';
return (
<div
aria-hidden={alt ? undefined : 'true'}
className={
'flex shrink-0 items-center justify-center rounded-full font-semibold ' +
fallbackClass +
' ' +
className
}
>
{letter}
</div>
);
}
@@ -0,0 +1,251 @@
import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { exportDeviceBackup } from '../lib/deviceBackup';
import { AlertIcon, CopyIcon, LockIcon, ShieldIcon, SpinnerIcon, XIcon } from './icons';
interface Props {
open: boolean;
userId: string;
deviceId: string;
privateKey: Uint8Array;
onClose: () => void;
}
// Exports the device's private key + identity into a passphrase-protected
// portable string. The user can store this string anywhere (password manager,
// printed paper, encrypted file on a USB stick). Without it, losing local
// storage on this install means losing all past conversation keys.
export function BackupExportDialog({ open, userId, deviceId, privateKey, onClose }: Props) {
const { t } = useTranslation(['app']);
const [passphrase, setPassphrase] = useState('');
const [confirm, setConfirm] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [backup, setBackup] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const canGenerate = useMemo(() => {
return passphrase.length >= 8 && passphrase === confirm && !busy;
}, [passphrase, confirm, busy]);
const reset = useCallback(() => {
setPassphrase('');
setConfirm('');
setBackup(null);
setError(null);
setCopied(false);
}, []);
const handleClose = useCallback(() => {
reset();
onClose();
}, [reset, onClose]);
const handleGenerate = useCallback(async () => {
if (!canGenerate) return;
setBusy(true);
setError(null);
try {
const str = await exportDeviceBackup({ userId, deviceId, privateKey, passphrase });
setBackup(str);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}, [canGenerate, userId, deviceId, privateKey, passphrase]);
const handleCopy = useCallback(async () => {
if (!backup) return;
try {
await navigator.clipboard.writeText(backup);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch {
/* fall back — user can select manually */
}
}, [backup]);
const handleDownload = useCallback(() => {
if (!backup) return;
const blob = new Blob([backup], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `chatapp-device-backup-${deviceId.slice(0, 8)}.txt`;
a.click();
URL.revokeObjectURL(url);
}, [backup, deviceId]);
if (!open) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-label={t('app:backup.export_title', { defaultValue: 'Gerätesicherung erstellen' })}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={handleClose}
>
<div
onClick={(e) => e.stopPropagation()}
className="flex max-h-[90vh] w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-line bg-surface-3 shadow-xl"
>
<header className="flex items-center justify-between border-b border-line px-5 py-3">
<div className="flex items-center gap-2">
<ShieldIcon className="h-4 w-4 text-accent" />
<h3 className="font-display text-sm font-semibold text-fg">
{t('app:backup.export_title', { defaultValue: 'Gerätesicherung erstellen' })}
</h3>
</div>
<button
type="button"
onClick={handleClose}
aria-label="Close"
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-2 hover:text-fg"
>
<XIcon className="h-4 w-4" />
</button>
</header>
<div className="flex-1 overflow-y-auto p-5">
{!backup ? (
<>
<p className="text-sm text-fg-muted">
{t('app:backup.export_explainer', {
defaultValue:
'Verschlüssele den Geräteschlüssel mit einer Passphrase. Ohne Passphrase UND Backup-String ist keine Wiederherstellung möglich.',
})}
</p>
<div className="mt-4 space-y-3">
<div className="space-y-1">
<label className="block text-xs font-medium uppercase tracking-wide text-fg-muted">
{t('app:backup.passphrase', { defaultValue: 'Passphrase' })}
</label>
<input
type="password"
autoFocus
minLength={8}
value={passphrase}
onChange={(e) => setPassphrase(e.target.value)}
placeholder="min. 8 Zeichen"
className="w-full rounded-lg border border-line bg-surface-2 px-3 py-2.5 text-sm text-fg placeholder-fg-muted transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
/>
</div>
<div className="space-y-1">
<label className="block text-xs font-medium uppercase tracking-wide text-fg-muted">
{t('app:backup.passphrase_confirm', { defaultValue: 'Passphrase wiederholen' })}
</label>
<input
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
className="w-full rounded-lg border border-line bg-surface-2 px-3 py-2.5 text-sm text-fg placeholder-fg-muted transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
/>
</div>
{confirm.length > 0 && confirm !== passphrase && (
<p className="text-xs text-rose-500 dark:text-rose-300">
{t('app:backup.passphrase_mismatch', { defaultValue: 'Passphrasen stimmen nicht überein.' })}
</p>
)}
</div>
<div className="mt-4 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-700 dark:text-amber-200">
<div className="flex items-start gap-2">
<AlertIcon className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-300" />
<span>
{t('app:backup.export_warning', {
defaultValue:
'Anthropic: Backup + Passphrase sicher aufbewahren. Passphrase kann nicht wiederhergestellt werden.',
})}
</span>
</div>
</div>
{error && (
<p role="alert" className="mt-3 text-sm text-rose-600 dark:text-rose-200">
{error}
</p>
)}
</>
) : (
<>
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/10 p-3 text-xs text-emerald-700 dark:text-emerald-200">
<div className="flex items-start gap-2">
<LockIcon className="mt-0.5 h-4 w-4 shrink-0" />
<span>
{t('app:backup.export_success', {
defaultValue:
'Backup erstellt. Speichere diesen String + Passphrase in einem Passwortmanager oder drucke ihn aus.',
})}
</span>
</div>
</div>
<textarea
readOnly
value={backup}
rows={8}
onFocus={(e) => e.currentTarget.select()}
className="mt-3 w-full resize-none rounded-lg border border-line bg-surface-2 p-3 font-mono text-[11px] leading-relaxed text-fg"
/>
<div className="mt-3 flex gap-2">
<button
type="button"
onClick={() => void handleCopy()}
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-medium text-fg transition hover:bg-surface-3"
>
<CopyIcon className="h-4 w-4" />
<span>
{copied
? t('app:backup.copied', { defaultValue: 'Kopiert!' })
: t('app:backup.copy', { defaultValue: 'Kopieren' })}
</span>
</button>
<button
type="button"
onClick={handleDownload}
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-medium text-fg transition hover:bg-surface-3"
>
{t('app:backup.download', { defaultValue: 'Als Datei speichern' })}
</button>
</div>
</>
)}
</div>
<footer className="flex items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3">
{!backup ? (
<>
<button
type="button"
onClick={handleClose}
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg transition hover:bg-surface-2"
>
{t('app:friends.action_cancel', { defaultValue: 'Abbrechen' })}
</button>
<button
type="button"
onClick={() => void handleGenerate()}
disabled={!canGenerate}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-60"
>
{busy && <SpinnerIcon className="h-4 w-4" />}
<span>{t('app:backup.generate', { defaultValue: 'Backup erstellen' })}</span>
</button>
</>
) : (
<button
type="button"
onClick={handleClose}
className="cursor-pointer rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110"
>
{t('app:backup.done', { defaultValue: 'Fertig' })}
</button>
)}
</footer>
</div>
</div>
);
}
@@ -0,0 +1,122 @@
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import { devLocalSecretStore } from '../lib/secretStore';
import { BackupExportDialog } from './BackupExportDialog';
import { ShieldIcon, XIcon } from './icons';
const DISMISS_KEY = 'chatapp.backup.prompt.dismissed';
const SESSION_KEY = 'chatapp.backup.prompt';
// Post-registration nudge: right after a fresh device provision we set
// `chatapp.backup.prompt` in sessionStorage. This component reads it and
// shows a floating "mach jetzt ein Backup" banner until the user either
// creates one or explicitly dismisses (persisted in localStorage so we stop
// nagging across reloads).
export function BackupPromptBanner() {
const { t } = useTranslation(['app']);
const { profile, device } = useAuth();
const [visible, setVisible] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [privateKey, setPrivateKey] = useState<Uint8Array | null>(null);
useEffect(() => {
try {
if (window.localStorage.getItem(DISMISS_KEY) === '1') return;
if (window.sessionStorage.getItem(SESSION_KEY) !== '1') return;
setVisible(true);
} catch {
/* storage unavailable */
}
}, []);
const dismiss = useCallback((persist: boolean) => {
setVisible(false);
try {
window.sessionStorage.removeItem(SESSION_KEY);
if (persist) window.localStorage.setItem(DISMISS_KEY, '1');
} catch {
/* ignore */
}
}, []);
const openDialog = useCallback(async () => {
if (!profile?.userId || !device?.id) return;
const priv = await loadDevicePrivateKey(devLocalSecretStore, profile.userId, device.id);
if (!priv) return;
setPrivateKey(priv);
setDialogOpen(true);
}, [profile, device]);
const closeDialog = useCallback(() => {
setDialogOpen(false);
if (privateKey) {
for (let i = 0; i < privateKey.length; i++) privateKey[i] = 0;
}
setPrivateKey(null);
// After the user interacts with the dialog, drop the banner regardless
// of whether they actually completed the backup — they're aware now.
dismiss(true);
}, [privateKey, dismiss]);
if (!visible) return null;
return (
<>
<div
role="status"
className="fixed bottom-6 left-1/2 z-40 flex w-[min(92vw,520px)] -translate-x-1/2 items-start gap-3 rounded-2xl border border-amber-500/40 bg-amber-500/10 p-4 text-sm text-amber-800 shadow-xl backdrop-blur-md dark:text-amber-100"
>
<ShieldIcon className="mt-0.5 h-5 w-5 shrink-0 text-amber-600 dark:text-amber-300" />
<div className="min-w-0 flex-1">
<p className="font-semibold">
{t('app:backup.prompt_title', { defaultValue: 'Erstelle jetzt ein Geräte-Backup' })}
</p>
<p className="mt-0.5 text-xs text-amber-700/90 dark:text-amber-200/90">
{t('app:backup.prompt_body', {
defaultValue:
'Ohne Backup verlierst du Zugriff auf alte Nachrichten, wenn Browser oder Gerät ihren Speicher verlieren. Dauert 10 Sekunden.',
})}
</p>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() => void openDialog()}
className="cursor-pointer rounded-md bg-amber-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-amber-500"
>
{t('app:backup.prompt_create', { defaultValue: 'Jetzt erstellen' })}
</button>
<button
type="button"
onClick={() => dismiss(true)}
className="cursor-pointer rounded-md border border-amber-500/40 bg-transparent px-3 py-1.5 text-xs font-semibold text-amber-700 transition hover:bg-amber-500/15 dark:text-amber-200"
>
{t('app:backup.prompt_never', { defaultValue: 'Nicht mehr fragen' })}
</button>
</div>
</div>
<button
type="button"
onClick={() => dismiss(false)}
aria-label={t('app:backup.prompt_dismiss', { defaultValue: 'Später' })}
className="cursor-pointer text-amber-600/70 transition hover:text-amber-600 dark:text-amber-200/70 dark:hover:text-amber-200"
>
<XIcon className="h-4 w-4" />
</button>
</div>
{dialogOpen && profile?.userId && device?.id && privateKey && (
<BackupExportDialog
open={dialogOpen}
userId={profile.userId}
deviceId={device.id}
privateKey={privateKey}
onClose={closeDialog}
/>
)}
</>
);
}
@@ -0,0 +1,170 @@
import { useTranslation } from 'react-i18next';
import {
MicIcon,
MicOffIcon,
MonitorShareIcon,
MonitorStopIcon,
PhoneOffIcon,
UsersIcon,
VideoIcon,
} from './icons';
interface Props {
muted: boolean;
sharing: boolean;
video: boolean;
onToggleMute: () => void;
onToggleShare: () => void;
onToggleVideo?: () => void;
onHangup: () => void;
onOpenParticipants?: () => void;
/** Compact variant used inside the docked call (36px buttons). */
compact?: boolean;
/** Glass variant used when controls float on fullscreen cinema mode. */
glass?: boolean;
/** Disable transient interactions while the call isn't fully connected. */
disabledMedia?: boolean;
}
export function CallControls({
muted,
sharing,
video,
onToggleMute,
onToggleShare,
onToggleVideo,
onHangup,
onOpenParticipants,
compact = false,
glass = false,
disabledMedia = false,
}: Props) {
const { t } = useTranslation(['app']);
const wrapBase = glass
? 'glass-pill flex items-center justify-center gap-2 rounded-[18px] p-2.5'
: 'flex items-center justify-center gap-2 border-t border-line bg-surface-3 p-3';
const btnSize = compact ? 'h-9 w-9 rounded-[10px]' : 'h-12 w-12 rounded-[14px]';
const hangupSize = compact ? 'h-9 w-[54px] rounded-[10px]' : 'h-12 w-[72px] rounded-[14px]';
return (
<div className={wrapBase}>
<CallButton
label={muted ? t('app:call.unmute') : t('app:call.mute')}
active={muted}
activeTone="danger"
onClick={onToggleMute}
disabled={disabledMedia}
glass={glass}
className={btnSize}
>
{muted ? <MicOffIcon className="h-5 w-5" /> : <MicIcon className="h-5 w-5" />}
</CallButton>
{onToggleVideo && (
<CallButton
label={t('app:call.start_video', { defaultValue: 'Video' })}
active={video}
activeTone="accent"
onClick={onToggleVideo}
disabled={disabledMedia}
glass={glass}
className={btnSize}
>
<VideoIcon className="h-5 w-5" />
</CallButton>
)}
<CallButton
label={
sharing
? t('app:call.stop_share_screen', { defaultValue: 'Screen-Share stoppen' })
: t('app:call.share_screen', { defaultValue: 'Bildschirm teilen' })
}
active={sharing}
activeTone="accent"
onClick={onToggleShare}
disabled={disabledMedia}
glass={glass}
className={btnSize}
>
{sharing ? (
<MonitorStopIcon className="h-5 w-5" />
) : (
<MonitorShareIcon className="h-5 w-5" />
)}
</CallButton>
{onOpenParticipants && (
<CallButton
label={t('app:call.participants', { defaultValue: 'Teilnehmer' })}
onClick={onOpenParticipants}
glass={glass}
className={btnSize}
>
<UsersIcon className="h-5 w-5" />
</CallButton>
)}
<CallButton
label={t('app:call.hangup')}
onClick={onHangup}
tone="danger"
glass={glass}
className={hangupSize}
>
<PhoneOffIcon className="h-5 w-5" />
</CallButton>
</div>
);
}
interface CallButtonProps {
label: string;
onClick: () => void;
disabled?: boolean;
active?: boolean;
activeTone?: 'accent' | 'danger';
tone?: 'default' | 'danger';
glass?: boolean;
className?: string;
children: React.ReactNode;
}
function CallButton({
label,
onClick,
disabled,
active,
activeTone = 'accent',
tone = 'default',
glass = false,
className = '',
children,
}: CallButtonProps) {
const base =
'inline-flex cursor-pointer items-center justify-center border transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:cursor-not-allowed disabled:opacity-50';
let toneClass: string;
if (tone === 'danger') {
toneClass = 'bg-rose-600 text-white border-rose-600 hover:bg-rose-500';
} else if (active && activeTone === 'danger') {
toneClass = 'bg-rose-600 text-white border-rose-600 hover:bg-rose-500';
} else if (active) {
toneClass = 'bg-accent text-accent-fg border-accent hover:brightness-110';
} else if (glass) {
toneClass =
'border-white/15 bg-white/10 text-white hover:bg-white/15';
} else {
toneClass = 'border-line bg-surface-2 text-fg hover:bg-surface';
}
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={label}
aria-pressed={active}
title={label}
className={`${base} ${toneClass} ${className}`}
>
{children}
</button>
);
}
@@ -0,0 +1,254 @@
import { CrownIcon, LockIcon, MicOffIcon, MonitorShareIcon } from './icons';
export type AvatarColorKey = 'violet' | 'amber' | 'rose' | 'teal';
const AVATAR_COLORS: Record<AvatarColorKey, { bg: string; fg: string }> = {
// Tailwind classes tuned per spec: violet/amber/rose/teal swatches in both
// light and dark modes. Dark pairs invert to keep contrast readable.
violet: {
bg: 'bg-violet-200 dark:bg-violet-900/60',
fg: 'text-violet-800 dark:text-violet-200',
},
amber: {
bg: 'bg-amber-200 dark:bg-amber-900/60',
fg: 'text-amber-900 dark:text-amber-200',
},
rose: {
bg: 'bg-rose-200 dark:bg-rose-900/60',
fg: 'text-rose-900 dark:text-rose-200',
},
teal: {
bg: 'bg-teal-200 dark:bg-teal-900/60',
fg: 'text-teal-900 dark:text-teal-200',
},
};
const COLOR_KEYS: AvatarColorKey[] = ['violet', 'amber', 'rose', 'teal'];
// Stable per-user color from identity string — keeps avatars visually
// consistent across re-mounts without needing a color field on the profile.
export function colorKeyFor(id: string): AvatarColorKey {
let hash = 0;
for (let i = 0; i < id.length; i++) {
hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
}
return COLOR_KEYS[hash % COLOR_KEYS.length]!;
}
export interface ParticipantTileProps {
userId: string;
displayName: string;
avatarUrl: string | null;
me: boolean;
muted: boolean;
speaking: boolean;
sharing: boolean;
video: boolean;
e2ee: boolean;
size?: 'default' | 'small';
focused?: boolean;
onClick?: () => void;
onOpenScreenShare?: () => void;
}
export function CallParticipantTile(props: ParticipantTileProps) {
const {
displayName,
me,
muted,
speaking,
sharing,
video,
e2ee,
size = 'default',
focused = false,
onClick,
onOpenScreenShare,
} = props;
const small = size === 'small';
const borderClass = speaking
? 'border-emerald-500 shadow-[0_0_0_2px_rgba(22,163,74,0.25)] dark:border-emerald-400'
: focused
? 'border-accent'
: 'border-line hover:border-accent';
return (
<div
onClick={onClick}
className={
'relative flex flex-col overflow-hidden rounded-[14px] border bg-surface-3 transition ' +
(onClick ? 'cursor-pointer ' : '') +
borderClass +
(small ? ' min-w-[140px]' : '')
}
>
{sharing ? (
<ScreenshareStub
displayName={displayName}
small={small}
onOpen={onOpenScreenShare}
/>
) : video ? (
<VideoStub {...props} small={small} />
) : (
<AudioContent {...props} small={small} />
)}
<div className="pointer-events-none absolute inset-x-2 bottom-2 flex items-center justify-between gap-2 rounded-lg glass-chip px-2.5 py-1.5">
<div className="flex min-w-0 items-center gap-1.5 text-xs font-semibold">
{me && <CrownIcon className="h-3 w-3 shrink-0 text-amber-300" />}
<span className="truncate">
{displayName}
{me ? ' (du)' : ''}
</span>
{e2ee && (
<span
aria-label="E2E verschlüsselt"
title="E2E verschlüsselt"
className="flex opacity-70"
>
<LockIcon className="h-3 w-3" />
</span>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
{muted && (
<span className="flex h-5 w-5 items-center justify-center rounded-md bg-rose-500/80 text-white">
<MicOffIcon className="h-3 w-3" />
</span>
)}
{sharing && (
<span className="flex h-5 w-5 items-center justify-center rounded-md bg-emerald-500/80 text-white">
<MonitorShareIcon className="h-3 w-3" />
</span>
)}
</div>
</div>
</div>
);
}
function AudioContent({
userId,
displayName,
avatarUrl,
speaking,
small,
}: ParticipantTileProps & { small: boolean }) {
const key = colorKeyFor(userId);
const colors = AVATAR_COLORS[key];
const letter = displayName.trim().charAt(0).toUpperCase() || '?';
return (
<div className="flex min-h-0 flex-1 items-center justify-center p-4">
<div
className={
'relative flex items-center justify-center rounded-full ' +
(small ? 'h-11 w-11' : 'h-[72px] w-[72px]')
}
>
{speaking && (
<span
aria-hidden="true"
className="absolute -inset-1.5 animate-audio-pulse rounded-full border-2 border-emerald-500 dark:border-emerald-400"
/>
)}
{avatarUrl ? (
<img
src={avatarUrl}
alt=""
className="relative h-full w-full rounded-full object-cover"
/>
) : (
<span
className={
'relative flex h-full w-full items-center justify-center rounded-full font-bold ' +
colors.bg + ' ' + colors.fg + ' ' +
(small ? 'text-lg' : 'text-2xl')
}
>
{letter}
</span>
)}
</div>
</div>
);
}
function VideoStub({
userId,
displayName,
avatarUrl,
small,
}: ParticipantTileProps & { small: boolean }) {
// Video capture playback is out of scope for this UI iteration — show the
// audio-avatar gradient background as a placeholder so the layout is stable
// when a participant enables video.
const key = colorKeyFor(userId);
const colors = AVATAR_COLORS[key];
const letter = displayName.trim().charAt(0).toUpperCase() || '?';
return (
<div className="flex min-h-0 flex-1 items-center justify-center bg-gradient-to-br from-accent/20 to-surface-2 p-4">
<div
className={
'flex items-center justify-center rounded-full font-bold ' +
colors.bg + ' ' + colors.fg + ' ' +
(small ? 'h-11 w-11 text-lg' : 'h-[72px] w-[72px] text-2xl')
}
>
{avatarUrl ? (
<img src={avatarUrl} alt="" className="h-full w-full rounded-full object-cover" />
) : (
letter
)}
</div>
</div>
);
}
interface ScreenshareStubProps {
displayName: string;
small: boolean;
onOpen?: (() => void) | undefined;
}
// Fake browser window placeholder — click to open the real <video> viewer.
// Matches the design spec's "screenshare-stub" look.
function ScreenshareStub({ displayName, small, onOpen }: ScreenshareStubProps) {
return (
<button
type="button"
onClick={
onOpen
? (e) => {
e.stopPropagation();
onOpen();
}
: undefined
}
aria-label={`${displayName} teilt Bildschirm`}
className="relative flex min-h-0 flex-1 cursor-pointer items-center justify-center bg-ink-900 p-3 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
<div className="h-[85%] w-[90%] overflow-hidden rounded-lg border border-white/10 bg-ink-700">
<div className="flex h-[20px] items-center gap-1 bg-ink-600 px-2">
<span className="h-1.5 w-1.5 rounded-full bg-ink-500" />
<span className="h-1.5 w-1.5 rounded-full bg-ink-500" />
<span className="h-1.5 w-1.5 rounded-full bg-ink-500" />
</div>
<div className={'flex flex-col gap-2 ' + (small ? 'gap-[3px] p-1.5' : 'p-3.5')}>
<div className={'w-[60%] rounded ' + (small ? 'h-[3px]' : 'h-1.5') + ' bg-ink-500'} />
<div className={'w-[80%] rounded ' + (small ? 'h-[3px]' : 'h-1.5') + ' bg-ink-500'} />
<div className={'w-[40%] rounded ' + (small ? 'h-[3px]' : 'h-1.5') + ' bg-ink-500'} />
<div className={'my-1 rounded ' + (small ? 'h-[14px]' : 'h-10') + ' bg-accent/30'} />
<div className={'w-[70%] rounded ' + (small ? 'h-[3px]' : 'h-1.5') + ' bg-ink-500'} />
</div>
</div>
{!small && (
<div className="absolute left-3 top-3 flex items-center gap-1.5 glass-chip rounded-lg px-2.5 py-1 text-[10px] font-medium">
<MonitorShareIcon className="h-3 w-3" />
<span>{displayName} teilt Bildschirm</span>
</div>
)}
</button>
);
}
+103 -229
View File
@@ -1,26 +1,20 @@
import { useEffect, useState } from 'react';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { useCall } from '../context/CallContext';
import { useConversationsContext } from '../context/ConversationsContext';
import { useFriendshipsContext } from '../context/FriendshipsContext';
import { ringtone } from '../lib/ringtone';
import { useAnyActiveCall } from '../lib/useAnyActiveCall';
import { useCallPresence } from '../lib/useCallPresence';
import {
MicIcon,
MicOffIcon,
PhoneIcon,
PhoneOffIcon,
SpinnerIcon,
XIcon,
} from './icons';
import { AvatarColorKey, colorKeyFor } from './CallParticipantTile';
import { LockIcon, MonitorShareIcon, PhoneIcon, PhoneOffIcon } from './icons';
// Shell-level mount. Handles ringtones + the IncomingCallToast.
// The persistent CallBar (active-call widget) is rendered inside Sidebar so
// users can keep browsing/typing while a call is live.
// Shell-level mount. Responsibilities:
// - Start/stop ringtones by call-state transition.
// - Toast-style incoming-call banner for conversations the user isn't in
// (clicking the toast routes to the docked IncomingCallPanel).
// - PiP widget when an active call is live but the user is looking at a
// different route.
export function CallUI() {
const { state } = useCall();
@@ -34,19 +28,37 @@ export function CallUI() {
return () => ringtone.stop();
}, []);
return <IncomingCallToast />;
return (
<>
<IncomingCallToast />
<PipCall />
</>
);
}
// ---------------------------------------------------------------------------
const TOAST_AVATAR: Record<AvatarColorKey, string> = {
violet: 'bg-violet-500/90 text-white',
amber: 'bg-amber-500/90 text-white',
rose: 'bg-rose-500/90 text-white',
teal: 'bg-teal-500/90 text-white',
};
function IncomingCallToast() {
const { t } = useTranslation(['app']);
const { state, acceptIncoming, rejectIncoming } = useCall();
const { friendships } = useFriendshipsContext();
const { conversations } = useConversationsContext();
const navigate = useNavigate();
const location = useLocation();
if (state.kind !== 'incoming') return null;
// When the user is already looking at the target conversation, the docked
// IncomingCallPanel handles the UI; hide the toast to avoid double chrome.
if (location.pathname === '/chats/' + state.conversationId) return null;
const conv = conversations.find((c) => c.id === state.conversationId) ?? null;
const callerName =
conv?.members.find((m) => m.userId === state.fromUserId)?.profile?.displayName ??
@@ -54,44 +66,55 @@ function IncomingCallToast() {
'?';
const isGroup = conv?.type === 'group';
const groupName = isGroup ? (conv?.name ?? t('app:chats.new_group')) : null;
const letter = (isGroup ? groupName ?? callerName : callerName)
.trim()
.charAt(0)
.toUpperCase() || '?';
const title = isGroup ? groupName ?? callerName : callerName;
const letter = title.trim().charAt(0).toUpperCase() || '?';
const color = colorKeyFor(state.fromUserId);
return (
<div
role="dialog"
aria-modal="false"
aria-label={t('app:call.incoming_title')}
className="fixed bottom-6 right-6 z-50 w-80 animate-slide-up rounded-2xl border border-white/10 bg-ink-900/95 p-5 shadow-2xl backdrop-blur-xl"
className="fixed bottom-6 right-6 z-50 w-80 animate-slide-up overflow-hidden rounded-2xl border border-line bg-surface-3 shadow-call-card-dark"
>
<p className="text-xs font-medium uppercase tracking-wide text-neutral-500">
<button
type="button"
onClick={() => navigate('/chats/' + state.conversationId)}
className="group block w-full cursor-pointer p-5 text-left transition hover:bg-surface-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-fg-muted">
{t('app:call.incoming_title')}
</p>
<div className="mt-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">
<div className="mt-2 flex items-center gap-3">
<div
className={
'flex h-11 w-11 shrink-0 items-center justify-center rounded-full font-semibold ' +
TOAST_AVATAR[color]
}
>
{letter}
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-display text-base font-semibold text-white">
{isGroup ? groupName : callerName}
</p>
<p className="truncate text-xs text-neutral-400">
<p className="truncate font-display text-base font-semibold text-fg">{title}</p>
<p className="flex items-center gap-1.5 truncate text-xs text-fg-muted">
<LockIcon className="h-3 w-3" />
<span>
{isGroup
? t('app:call.incoming_group_from', {
name: callerName,
defaultValue: callerName + ' ruft Gruppe',
})
: t('app:call.incoming_from', { name: callerName })}
</span>
</p>
</div>
</div>
<div className="mt-4 flex gap-2">
</button>
<div className="flex gap-2 px-5 pb-5">
<button
type="button"
onClick={rejectIncoming}
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm font-semibold text-rose-200 transition hover:bg-rose-500/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/40"
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-lg border border-rose-500/40 bg-transparent px-3 py-2 text-sm font-semibold text-rose-600 transition hover:bg-rose-500/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-500/40 dark:text-rose-400"
>
<PhoneOffIcon className="h-4 w-4" />
<span>{t('app:call.decline')}</span>
@@ -99,7 +122,7 @@ function IncomingCallToast() {
<button
type="button"
onClick={() => void acceptIncoming()}
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-lg bg-emerald-500/90 px-3 py-2 text-sm font-semibold text-white transition hover:bg-emerald-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40"
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-lg bg-emerald-600 px-3 py-2 text-sm font-semibold text-white transition hover:bg-emerald-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50"
>
<PhoneIcon className="h-4 w-4" />
<span>{t('app:call.accept')}</span>
@@ -110,226 +133,77 @@ function IncomingCallToast() {
}
// ---------------------------------------------------------------------------
// CallBar — persistent widget inside Sidebar. Lets the user keep browsing
// the app while a call is active / connecting / ringing.
// PiP widget — shown when the user has an active call but is browsing
// somewhere else. Clicking expands back to the call's conversation.
// ---------------------------------------------------------------------------
export function CallBar() {
const { state, lastCallConversationId } = useCall();
const { conversations } = useConversationsContext();
const { session } = useAuth();
const myId = session?.user.id ?? null;
// Aggregate observer across every conversation the user is in so a
// "call is live, rejoin" affordance appears in the sidebar whenever ANY
// peer is in a call — not just calls I previously joined.
const convIds = conversations.map((c) => c.id);
const anyActive = useAnyActiveCall(convIds, myId);
if (state.kind === 'connected' || state.kind === 'connecting' || state.kind === 'outgoing') {
return <ActiveCallBar />;
}
// Prefer the conversation I just left, fall back to any other live call.
const rejoinId = lastCallConversationId ?? anyActive?.conversationId ?? null;
if (rejoinId) {
return <RejoinCallBar conversationId={rejoinId} />;
}
return null;
}
function ActiveCallBar() {
function PipCall() {
const { t } = useTranslation(['app']);
const { state, isMuted, hangup, toggleMute } = useCall();
const {
state,
remoteParticipants,
remoteScreenShares,
hangup,
} = useCall();
const navigate = useNavigate();
const location = useLocation();
const { conversations } = useConversationsContext();
const [, forceTick] = useState(0);
useEffect(() => {
if (state.kind !== 'connected') return;
const id = window.setInterval(() => forceTick((v) => v + 1), 1000);
return () => window.clearInterval(id);
}, [state.kind]);
const active =
state.kind === 'connected' ||
state.kind === 'connecting' ||
state.kind === 'outgoing';
if (!active) return null;
if (state.kind !== 'connected' && state.kind !== 'connecting' && state.kind !== 'outgoing') {
return null;
}
// Only render when the user is NOT currently viewing the call's
// conversation. Inside that conversation the full dock is visible already.
if (location.pathname === '/chats/' + state.conversationId) return null;
const conv = conversations.find((c) => c.id === state.conversationId) ?? null;
const title =
conv?.type === 'group'
? conv.name ?? '—'
? conv?.name ?? t('app:chats.new_group')
: conv?.peer?.displayName ?? '—';
const statusLabel =
state.kind === 'outgoing'
? t('app:call.outgoing_ringing')
: state.kind === 'connecting'
? t('app:call.connecting')
: t('app:call.voice_connected', { defaultValue: 'Sprachchat verbunden' });
const elapsed =
state.kind === 'connected'
? formatElapsed(Date.now() - new Date(state.startedAt).getTime())
: null;
const participantCount = 1 + remoteParticipants.length;
const someoneSharing = remoteScreenShares.length > 0;
return (
<div className="mb-2 overflow-hidden rounded-xl border border-emerald-500/20 bg-ink-900/80">
<Link
to={'/chats/' + state.conversationId}
className="flex items-center gap-2.5 px-3 pt-2.5 pb-2 transition hover:bg-white/5 focus:outline-none"
aria-label={statusLabel}
<div
role="dialog"
aria-label={t('app:call.active_in_conv', { defaultValue: 'Aktiver Anruf' })}
onClick={() => navigate('/chats/' + state.conversationId)}
className="fixed bottom-5 right-5 z-40 flex w-[260px] animate-slide-in-call cursor-pointer items-center gap-2.5 rounded-[14px] border border-accent bg-surface-3 p-2.5 shadow-pip-call transition hover:-translate-y-0.5"
>
<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" />
<div className="flex h-11 w-11 shrink-0 items-center justify-center overflow-hidden rounded-[10px] bg-surface-2">
{someoneSharing ? (
<MonitorShareIcon className="h-5 w-5 text-accent" />
) : (
<SignalIcon className="h-4 w-4" />
<PhoneIcon className="h-5 w-5 text-accent" />
)}
</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 className="truncate text-[13px] font-semibold text-fg">
{title} · {participantCount}
</p>
<p className="mt-0.5 flex items-center gap-1.5 text-[11px] text-fg-muted">
<span
aria-hidden="true"
className="h-1.5 w-1.5 rounded-full bg-rose-500 animate-live-dot"
/>
<span>Live · {t('app:call.tap_to_open', { defaultValue: 'tippe zum Öffnen' })}</span>
</p>
</div>
</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
}
onClick={(e) => {
e.stopPropagation();
void hangup();
}}
aria-label={t('app:call.hangup')}
className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full bg-rose-600 text-white transition hover:bg-rose-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/60"
>
{children}
<PhoneOffIcon className="h-4 w-4" />
</button>
</div>
);
}
function SignalIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" {...props}>
<path d="M5 12v0" />
<path d="M9 9v6" />
<path d="M13 6v12" />
<path d="M17 9v6" />
</svg>
);
}
function formatElapsed(ms: number): string {
const total = Math.max(0, Math.floor(ms / 1000));
const mm = Math.floor(total / 60).toString().padStart(2, '0');
const ss = (total % 60).toString().padStart(2, '0');
return mm + ':' + ss;
}
@@ -1,31 +1,39 @@
import type { ConversationSummary } from '@chat-app/shared/chat';
import type { PresenceState } from '@chat-app/shared/supabase';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import { useCall } from '../context/CallContext';
import { useCallPresence } from '../lib/useCallPresence';
import { InfoIcon, PhoneIcon, SpinnerIcon, UsersIcon } from './icons';
import { Avatar } from './Avatar';
import {
InfoIcon,
PhoneIcon,
SearchIcon,
SpinnerIcon,
UsersIcon,
VideoIcon,
} from './icons';
const PRESENCE_DOT: Record<PresenceState, string> = {
online: 'bg-emerald-400',
online: 'bg-emerald-500',
idle: 'bg-amber-400',
dnd: 'bg-rose-500',
invisible: 'bg-neutral-500',
offline: 'bg-neutral-600',
offline: 'bg-neutral-400 dark:bg-neutral-600',
};
interface Props {
conversation: ConversationSummary | null;
peerPresence: PresenceState | null;
onInfoClick?: () => void;
onSearchClick?: () => void;
}
export function ConversationHeader({ conversation, peerPresence, onInfoClick }: Props) {
export function ConversationHeader({ conversation, peerPresence, onInfoClick, onSearchClick }: Props) {
if (!conversation) {
return (
<header className="h-[57px] border-b border-white/5 px-6 py-3" aria-busy="true" />
);
return <header className="h-[57px] border-b border-line px-6 py-3" aria-busy="true" />;
}
return (
@@ -34,6 +42,7 @@ export function ConversationHeader({ conversation, peerPresence, onInfoClick }:
conversation={conversation}
peerPresence={peerPresence}
{...(onInfoClick ? { onInfoClick } : {})}
{...(onSearchClick ? { onSearchClick } : {})}
/>
<ActiveCallBanner conversationId={conversation.id} />
</>
@@ -44,43 +53,52 @@ interface HeaderBarProps {
conversation: ConversationSummary;
peerPresence: PresenceState | null;
onInfoClick?: () => void;
onSearchClick?: () => void;
}
function HeaderBar({ conversation, peerPresence, onInfoClick }: HeaderBarProps) {
function HeaderBar({ conversation, peerPresence, onInfoClick, onSearchClick }: 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() || '?';
const peerAvatar = isDm ? (conversation.peer?.avatarUrl ?? null) : (conversation.avatarUrl ?? null);
// Hide presence when peer chose invisible — reciprocal privacy.
const showPresence = isDm && peerPresence && peerPresence !== 'invisible';
const presenceLabel = peerPresence ? t('app:presence.' + peerPresence) : '';
return (
<header className="flex items-center gap-3 border-b border-white/5 px-6 py-3">
<div className="relative flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-base font-semibold text-white ring-1 ring-brand-400/30">
{isDm ? letter : <UsersIcon className="h-5 w-5" />}
<header className="flex items-center gap-3 border-b border-line bg-surface-3 px-6 py-3">
<div className="relative">
{isDm ? (
<Avatar url={peerAvatar} displayName={title} className="h-10 w-10 text-base" />
) : peerAvatar ? (
<Avatar url={peerAvatar} displayName={title} className="h-10 w-10 text-base" />
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-accent/20 text-accent">
<UsersIcon className="h-5 w-5" />
</div>
)}
{showPresence && peerPresence && (
<span
aria-hidden="true"
className={
'absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full ring-2 ring-ink-950 ' +
'absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full ring-2 ring-surface-3 ' +
PRESENCE_DOT[peerPresence]
}
/>
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-display text-base font-semibold text-white">{title}</p>
<p className="truncate text-xs text-neutral-500">
<p className="truncate font-display text-base font-semibold text-fg">{title}</p>
<p className="truncate text-xs text-fg-muted">
{isDm ? (
<>
<span>{handle}</span>
{showPresence && (
<>
<span className="mx-1.5 text-neutral-700">·</span>
<span className="mx-1.5 text-fg-muted/50">·</span>
<span>{presenceLabel}</span>
</>
)}
@@ -93,75 +111,133 @@ function HeaderBar({ conversation, peerPresence, onInfoClick }: HeaderBarProps)
</p>
</div>
<CallHeaderButton conversationId={conversation.id} />
<HeaderActionButton
label={t('app:chats.search', { defaultValue: 'Suche' })}
icon={SearchIcon}
{...(onSearchClick ? { onClick: onSearchClick } : {})}
/>
<CallHeaderButton conversationId={conversation.id} kind="audio" />
<CallHeaderButton conversationId={conversation.id} kind="video" />
{!isDm && onInfoClick && (
<button
type="button"
<HeaderActionButton
label={t('app:group.info_title')}
icon={InfoIcon}
onClick={onInfoClick}
aria-label={t('app:group.info_title')}
title={t('app:group.info_title')}
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg text-neutral-400 transition hover:bg-white/10 hover:text-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
>
<InfoIcon className="h-4 w-4" />
</button>
/>
)}
</header>
);
}
function CallHeaderButton({ conversationId }: { conversationId: string }) {
const { t } = useTranslation(['app']);
const { state, startCall } = useCall();
const busy = state.kind !== 'idle';
interface HeaderActionButtonProps {
label: string;
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
onClick?: () => void;
disabled?: boolean;
tone?: 'default' | 'accent';
}
function HeaderActionButton({
label,
icon: Icon,
onClick,
disabled,
tone = 'default',
}: HeaderActionButtonProps) {
const toneClass =
tone === 'accent'
? 'text-accent hover:bg-accent/10'
: 'text-fg-muted hover:bg-surface-2 hover:text-fg';
return (
<button
type="button"
onClick={() => void startCall(conversationId, 'audio')}
disabled={busy}
aria-label={t('app:call.start_audio')}
title={busy ? t('app:call.busy') : t('app:call.start_audio')}
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg text-neutral-400 transition hover:bg-white/10 hover:text-brand-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 disabled:cursor-not-allowed disabled:opacity-50"
onClick={onClick}
disabled={disabled}
aria-label={label}
title={label}
className={
'flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50 ' +
toneClass
}
>
<PhoneIcon className="h-4 w-4" />
<Icon className="h-4 w-4" />
</button>
);
}
function CallHeaderButton({
conversationId,
kind,
}: {
conversationId: string;
kind: 'audio' | 'video';
}) {
const { t } = useTranslation(['app']);
const { state, startCall } = useCall();
const busy = state.kind !== 'idle';
const label =
kind === 'video'
? t('app:call.start_video', { defaultValue: 'Video-Anruf' })
: t('app:call.start_audio');
const Icon = kind === 'video' ? VideoIcon : PhoneIcon;
return (
<HeaderActionButton
label={busy ? t('app:call.busy') : label}
icon={Icon}
onClick={() => void startCall(conversationId, kind)}
disabled={busy}
/>
);
}
function ActiveCallBanner({ conversationId }: { conversationId: string }) {
const { t } = useTranslation(['app']);
const { session } = useAuth();
const { state, joinActiveCall } = useCall();
const { state, joinActiveCall, lastCallConversationId, dismissLastCall } = useCall();
const active = useCallPresence(conversationId);
const myId = session?.user.id;
// Source of truth for "am I in this call" is the CallContext state, not
// presence — my own presence entry can lag behind the room join, which
// would otherwise make the banner flash while I'm already connected.
const iAmIn =
(state.kind === 'connected' ||
state.kind === 'connecting' ||
state.kind === 'outgoing') &&
state.conversationId === conversationId;
// Only show banner when other people are in it and I'm not.
const othersIn = active.filter((u) => u !== myId);
if (iAmIn || othersIn.length === 0) return null;
// Fallback signal: I just left this conv with peers still inside. Covers the
// brief window after hangup where presence may not have re-synced yet (the
// realtime channel can churn while ConversationHeader remounts).
const justLeft = state.kind === 'idle' && lastCallConversationId === conversationId;
// Once presence confirms the room is empty, drop the "just left" hint so the
// banner hides cleanly instead of sticking forever. Grace window handles the
// brief gap between hangup and presence re-sync so we don't flicker.
useEffect(() => {
if (!justLeft) return;
if (othersIn.length > 0) return; // still live — keep banner
const id = window.setTimeout(() => dismissLastCall(), 3000);
return () => window.clearTimeout(id);
}, [justLeft, othersIn.length, dismissLastCall]);
if (iAmIn) return null;
if (othersIn.length === 0 && !justLeft) return null;
const count = Math.max(othersIn.length, justLeft ? 1 : 0);
const busy = state.kind !== 'idle';
return (
<div className="flex items-center gap-3 border-b border-emerald-500/20 bg-emerald-500/10 px-6 py-2.5 text-sm text-emerald-100">
<PhoneIcon className="h-4 w-4 text-emerald-300" />
<div className="flex items-center gap-3 border-b border-emerald-500/30 bg-emerald-500/10 px-6 py-2.5 text-sm text-emerald-700 dark:text-emerald-100">
<PhoneIcon className="h-4 w-4 text-emerald-600 dark:text-emerald-300" />
<span className="flex-1">
{t('app:call.active_in_conv', {
defaultValue: 'Active call · {{count}} in room',
count: othersIn.length,
count,
})}
</span>
<button
type="button"
onClick={() => void joinActiveCall(conversationId, 'audio')}
disabled={busy}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-emerald-500/90 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-emerald-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40 disabled:cursor-not-allowed disabled:opacity-60"
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-emerald-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40 disabled:cursor-not-allowed disabled:opacity-60"
>
{busy ? (
<SpinnerIcon className="h-3.5 w-3.5" />
@@ -0,0 +1,264 @@
import {
muteDurationToIso,
setConversationArchived,
setConversationMutedUntil,
} from '@chat-app/shared/chat';
import { forwardRef, useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { supabase } from '../lib/supabase';
import { ArchiveIcon, BellIcon, BellOffIcon, MoreVerticalIcon } from './icons';
interface Props {
conversationId: string;
archived: boolean;
mutedUntil: string | null;
}
interface MuteOption {
key: string;
labelKey: string;
labelDefault: string;
minutes: number | null;
}
// Muted-forever sentinel ≈ 100 years. UI treats any future timestamp as muted
// until that moment; 100y is indistinguishable from "forever" at the UX level
// without requiring a dedicated `bool muted_forever` column.
const FOREVER_MINUTES = 100 * 365 * 24 * 60;
const MUTE_OPTIONS: MuteOption[] = [
{ key: '1h', labelKey: 'app:chats.mute_1h', labelDefault: '1 Stunde', minutes: 60 },
{ key: '8h', labelKey: 'app:chats.mute_8h', labelDefault: '8 Stunden', minutes: 8 * 60 },
{ key: '24h', labelKey: 'app:chats.mute_24h', labelDefault: '24 Stunden', minutes: 24 * 60 },
{ key: '1w', labelKey: 'app:chats.mute_1w', labelDefault: '1 Woche', minutes: 7 * 24 * 60 },
{
key: 'forever',
labelKey: 'app:chats.mute_forever',
labelDefault: 'Bis auf Weiteres',
minutes: FOREVER_MINUTES,
},
];
interface MenuPos {
top: number;
left: number;
}
// Per-conversation row context-menu. Renders via portal so the submenu can
// escape the sidebar's `overflow-y-auto` clipping context. Position is
// computed from the trigger's bounding rect — menu anchors right-aligned
// under the trigger so it doesn't push off-screen on narrow windows.
export function ConversationRowMenu({ conversationId, archived, mutedUntil }: Props) {
const { t } = useTranslation(['app']);
const [open, setOpen] = useState(false);
const [submenuOpen, setSubmenuOpen] = useState<'mute' | null>(null);
const [menuPos, setMenuPos] = useState<MenuPos | null>(null);
const [submenuPos, setSubmenuPos] = useState<MenuPos | null>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const muteItemRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!open) return;
function onDocClick(e: MouseEvent) {
const target = e.target as Node;
if (triggerRef.current?.contains(target)) return;
if (menuRef.current?.contains(target)) return;
setOpen(false);
setSubmenuOpen(null);
}
function onEsc(e: KeyboardEvent) {
if (e.key === 'Escape') {
setOpen(false);
setSubmenuOpen(null);
}
}
document.addEventListener('mousedown', onDocClick);
document.addEventListener('keydown', onEsc);
return () => {
document.removeEventListener('mousedown', onDocClick);
document.removeEventListener('keydown', onEsc);
};
}, [open]);
useEffect(() => {
if (!open) {
setMenuPos(null);
setSubmenuPos(null);
return;
}
const rect = triggerRef.current?.getBoundingClientRect();
if (!rect) return;
// Anchor: right edge aligns with trigger's right edge, menu hangs below.
const menuWidth = 208;
setMenuPos({
top: rect.bottom + 4,
left: Math.max(8, rect.right - menuWidth),
});
}, [open]);
useEffect(() => {
if (submenuOpen !== 'mute') {
setSubmenuPos(null);
return;
}
const rect = muteItemRef.current?.getBoundingClientRect();
if (!rect) return;
const submenuWidth = 192;
const viewportWidth = window.innerWidth;
// Prefer right of the item. Flip to left when it would overflow viewport.
const wantLeft = rect.right + 4;
const flip = wantLeft + submenuWidth > viewportWidth - 8;
setSubmenuPos({
top: rect.top,
left: flip ? rect.left - submenuWidth - 4 : wantLeft,
});
}, [submenuOpen]);
const isMuted =
mutedUntil !== null && new Date(mutedUntil).getTime() > Date.now();
const handleArchive = useCallback(
async (next: boolean) => {
setOpen(false);
try {
await setConversationArchived(supabase, conversationId, next);
} catch (err: unknown) {
console.error('archive toggle failed', err);
}
},
[conversationId],
);
const handleMute = useCallback(
async (minutes: number | null) => {
setOpen(false);
setSubmenuOpen(null);
try {
await setConversationMutedUntil(
supabase,
conversationId,
muteDurationToIso(minutes),
);
} catch (err: unknown) {
console.error('mute toggle failed', err);
}
},
[conversationId],
);
return (
<>
<button
ref={triggerRef}
type="button"
aria-label={t('app:chats.row_menu', { defaultValue: 'Aktionen' })}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setOpen((v) => !v);
}}
onMouseDown={(e) => e.stopPropagation()}
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted opacity-0 transition group-hover:opacity-100 hover:bg-surface-3 hover:text-fg focus:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
<MoreVerticalIcon className="h-4 w-4" />
</button>
{open &&
menuPos &&
createPortal(
<div
ref={menuRef}
role="menu"
style={{ top: menuPos.top, left: menuPos.left }}
className="fixed z-50 w-52 rounded-lg border border-line bg-surface-3 p-1 shadow-xl"
>
<MenuItem
icon={<ArchiveIcon className="h-4 w-4" />}
label={
archived
? t('app:chats.unarchive', { defaultValue: 'Entarchivieren' })
: t('app:chats.archive', { defaultValue: 'Archivieren' })
}
onClick={() => void handleArchive(!archived)}
/>
<MenuItem
ref={muteItemRef}
icon={
isMuted ? (
<BellOffIcon className="h-4 w-4" />
) : (
<BellIcon className="h-4 w-4" />
)
}
label={
isMuted
? t('app:chats.unmute', { defaultValue: 'Stummschaltung aufheben' })
: t('app:chats.mute', { defaultValue: 'Stummschalten' })
}
onClick={() => {
if (isMuted) void handleMute(null);
else setSubmenuOpen((v) => (v === 'mute' ? null : 'mute'));
}}
hasSubmenu={!isMuted}
/>
</div>,
document.body,
)}
{open &&
submenuOpen === 'mute' &&
submenuPos &&
createPortal(
<div
role="menu"
style={{ top: submenuPos.top, left: submenuPos.left }}
className="fixed z-50 w-48 rounded-lg border border-line bg-surface-3 p-1 shadow-xl"
>
{MUTE_OPTIONS.map((opt) => (
<MenuItem
key={opt.key}
label={t(opt.labelKey, { defaultValue: opt.labelDefault })}
onClick={() => void handleMute(opt.minutes)}
/>
))}
</div>,
document.body,
)}
</>
);
}
interface MenuItemProps {
icon?: React.ReactNode;
label: string;
onClick: () => void;
hasSubmenu?: boolean;
}
// React 18 requires forwardRef for function components to receive refs —
// without it the `ref` prop is stripped before reaching the component and
// measurement-dependent submenus never position.
const MenuItem = forwardRef<HTMLButtonElement, MenuItemProps>(
({ icon, label, onClick, hasSubmenu }, ref) => (
<button
ref={ref}
type="button"
role="menuitem"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onClick();
}}
onMouseDown={(e) => e.stopPropagation()}
className="flex w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg transition hover:bg-surface-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
{icon && <span className="shrink-0 text-fg-muted">{icon}</span>}
<span className="min-w-0 flex-1 truncate">{label}</span>
{hasSubmenu && <span className="shrink-0 text-xs text-fg-muted"></span>}
</button>
),
);
MenuItem.displayName = 'MenuItem';
@@ -0,0 +1,160 @@
import {
type DeviceRecord,
restoreDeviceFromServerRecord,
} from '@chat-app/shared/auth';
import { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
decodePrivateKeyFromBackup,
importDeviceBackup,
} from '../lib/deviceBackup';
import { devLocalSecretStore } from '../lib/secretStore';
import { supabase } from '../lib/supabase';
import { writeLocalDeviceId } from '../lib/device';
import { AlertIcon, ArrowRightIcon, LockIcon, ShieldIcon, SpinnerIcon } from './icons';
interface Props {
userId: string;
onRestored: (device: DeviceRecord) => void;
}
// Restores a device from a user-provided backup string. The backup embeds
// userId + deviceId + X25519 private key; we verify userId matches the current
// session, confirm the device row still exists server-side, and then re-seed
// the local vault + cached deviceId so the app treats this install as the
// original device (conv-key bundles stay valid, no "awaiting" state).
export function DeviceRestore({ userId, onRestored }: Props) {
const { t } = useTranslation(['app', 'errors']);
const [backup, setBackup] = useState('');
const [passphrase, setPassphrase] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
if (!backup.trim() || passphrase.length < 1 || busy) return;
setBusy(true);
setError(null);
let privateKey: Uint8Array | null = null;
try {
const payload = await importDeviceBackup(backup.trim(), passphrase);
privateKey = decodePrivateKeyFromBackup(payload);
const device = await restoreDeviceFromServerRecord({
client: supabase,
secretStore: devLocalSecretStore,
userId: payload.userId,
deviceId: payload.deviceId,
privateKey,
});
// Cache deviceId locally so findExistingDevice picks it up on next load.
writeLocalDeviceId(userId, device.id);
onRestored(device);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : String(err));
} finally {
if (privateKey) {
for (let i = 0; i < privateKey.length; i++) privateKey[i] = 0;
}
setBusy(false);
}
},
[backup, passphrase, userId, onRestored, busy],
);
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-emerald-500/20 ring-1 ring-emerald-400/30">
<ShieldIcon className="h-5 w-5 text-emerald-300" />
</div>
<div>
<h2 className="font-display text-lg font-semibold text-white">
{t('app:backup.restore_title', { defaultValue: 'Backup wiederherstellen' })}
</h2>
<p className="text-xs text-neutral-400">
{t('app:backup.restore_subtitle', {
defaultValue: 'Bringe einen zuvor erstellten Backup-String + Passphrase mit.',
})}
</p>
</div>
</div>
<div className="mt-6 space-y-3">
<div className="space-y-1">
<label className="block text-xs font-medium uppercase tracking-wide text-neutral-400">
{t('app:backup.backup_string', { defaultValue: 'Backup-String' })}
</label>
<textarea
required
rows={5}
value={backup}
onChange={(e) => setBackup(e.target.value)}
placeholder="chatapp-backup-v1…"
className="w-full resize-none rounded-lg border border-white/10 bg-ink-800 px-3 py-2.5 font-mono text-[11px] leading-relaxed 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-1">
<label className="block text-xs font-medium uppercase tracking-wide text-neutral-400">
{t('app:backup.passphrase', { defaultValue: 'Passphrase' })}
</label>
<input
type="password"
required
value={passphrase}
onChange={(e) => setPassphrase(e.target.value)}
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>
<div className="mt-5 rounded-lg border border-brand-500/20 bg-brand-500/10 p-3 text-xs text-brand-100">
<div className="flex items-start gap-2">
<LockIcon className="mt-0.5 h-4 w-4 shrink-0 text-brand-300" />
<span className="min-w-0 flex-1 break-words">
{t('app:backup.restore_hint', {
defaultValue:
'Nach Wiederherstellung übernimmt dieses Gerät die alte Identität — existierende Nachrichten sind wieder entschlüsselbar.',
})}
</span>
</div>
</div>
<button
type="submit"
disabled={busy || backup.trim().length === 0 || passphrase.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-emerald-400 to-emerald-600 px-4 py-3 text-sm font-semibold text-white shadow-glow transition hover:from-emerald-300 hover:to-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-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('app:backup.restoring', { defaultValue: 'Wiederherstellen…' })}</span>
</>
) : (
<>
<span>{t('app:backup.restore_cta', { defaultValue: 'Gerät wiederherstellen' })}</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>
)}
</form>
);
}
@@ -0,0 +1,133 @@
import { Component, type ErrorInfo, Fragment, type ReactNode } from 'react';
import { SpinnerIcon } from './icons';
interface Props {
children: ReactNode;
/**
* Optional scope label shown in logs / devtools. Defaults to `root` — set
* per boundary (e.g. `route`, `conversation`) so multiple boundaries can be
* distinguished at a glance.
*/
scope?: string;
/**
* If the retry count exceeds this, the boundary stops auto-retrying and
* shows a more helpful message (still without a button — Discord-style,
* the app keeps trying but hints the user to hold on or check network).
*/
maxAutoRetries?: number;
}
interface State {
error: Error | null;
retryKey: number;
attempt: number;
}
const RETRY_DELAYS_MS = [2000, 4000, 8000, 15000, 30000];
// Discord-style error boundary.
// - Catches render-time errors in its subtree.
// - Shows a centred spinner + status text. Never renders a manual "Reload"
// button; the boundary remounts its children on an exponential-backoff
// schedule so the UI self-heals once the underlying issue clears (typical
// causes: a realtime reconnect, a transient network blip, or a race that
// only fires once).
// - Escalates the label after each failed retry so the user sees that the
// app is trying, rather than silent infinite spinning.
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null, retryKey: 0, attempt: 0 };
private retryTimer: number | null = null;
static getDerivedStateFromError(error: Error): Partial<State> {
return { error };
}
override componentDidCatch(error: Error, info: ErrorInfo): void {
const scope = this.props.scope ?? 'root';
// We explicitly log here — the boundary itself swallows the error from
// React, so without this the failure would be invisible in production.
console.error('[ErrorBoundary:' + scope + '] caught render error', error, info);
}
override componentDidUpdate(_prev: Props, prevState: State): void {
if (this.state.error && !prevState.error) {
this.scheduleRetry();
}
}
override componentWillUnmount(): void {
if (this.retryTimer !== null) {
window.clearTimeout(this.retryTimer);
this.retryTimer = null;
}
}
private scheduleRetry(): void {
if (this.retryTimer !== null) return;
const attempt = this.state.attempt;
const delay =
RETRY_DELAYS_MS[Math.min(attempt, RETRY_DELAYS_MS.length - 1)] ??
RETRY_DELAYS_MS[RETRY_DELAYS_MS.length - 1] ??
30000;
this.retryTimer = window.setTimeout(() => {
this.retryTimer = null;
this.setState((prev) => ({
error: null,
retryKey: prev.retryKey + 1,
attempt: prev.attempt + 1,
}));
}, delay);
}
override render(): ReactNode {
if (this.state.error) {
const max = this.props.maxAutoRetries ?? RETRY_DELAYS_MS.length;
const escalated = this.state.attempt >= max;
return <RetryingScreen escalated={escalated} attempt={this.state.attempt} />;
}
// `retryKey` forces a remount of the subtree so hooks re-run cleanly after
// an error (otherwise stale state from the crashed tree can immediately
// re-throw). Use a keyed Fragment so the boundary doesn't inject an extra
// wrapper div — that would break `flex h-full` chains (e.g. AppShell →
// Outlet → page column).
return <Fragment key={this.state.retryKey}>{this.props.children}</Fragment>;
}
}
function RetryingScreen({ escalated, attempt }: { escalated: boolean; attempt: number }) {
const primary = escalated
? 'Verbindungsprobleme…'
: attempt === 0
? 'Einen Moment bitte'
: 'Versuche erneut zu laden…';
const secondary = escalated
? 'Prüfe deine Internetverbindung. Wir versuchen es weiter.'
: 'Die App lädt sich gleich selbst neu.';
return (
<div
role="status"
aria-live="polite"
className="flex min-h-screen w-full items-center justify-center bg-surface-3 px-6"
>
<div className="flex flex-col items-center gap-4 text-center">
<div className="relative flex h-16 w-16 items-center justify-center">
<span
aria-hidden="true"
className="absolute inset-0 rounded-full border-2 border-accent/20"
/>
<span
aria-hidden="true"
className="absolute inset-0 rounded-full border-2 border-accent border-r-transparent border-b-transparent animate-spin"
/>
<SpinnerIcon className="hidden" />
</div>
<div className="max-w-sm space-y-1.5">
<p className="font-display text-lg font-semibold text-fg">{primary}</p>
<p className="text-sm text-fg-muted">{secondary}</p>
</div>
</div>
</div>
);
}
@@ -0,0 +1,315 @@
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
import {
type AttachmentHandle,
type DecryptedMessage,
downloadAndDecryptAttachment,
encryptAndUploadAttachment,
insertAttachmentRow,
parseMessagePayload,
sendEncryptedMessage,
} from '@chat-app/shared/chat';
import { extractErrorCode } from '@chat-app/shared/i18n';
import { bytesToPgHex } from '@chat-app/shared/supabase';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import { useConversationsContext } from '../context/ConversationsContext';
import { devLocalSecretStore } from '../lib/secretStore';
import { supabase } from '../lib/supabase';
import { Avatar } from './Avatar';
import { ForwardIcon, SpinnerIcon, UsersIcon, XIcon } from './icons';
interface Props {
open: boolean;
message: DecryptedMessage | null;
currentConversationId: string | null;
onClose: () => void;
}
// Forwards a message's plaintext to one or more conversations. Attachments are
// NOT carried over yet (would require re-uploading + re-encrypting under the
// new conversation key); only the text payload is forwarded for now and the
// preview hints at the dropped attachment.
export function ForwardDialog({ open, message, currentConversationId, onClose }: Props) {
const { t } = useTranslation(['app', 'errors']);
const { session, device } = useAuth();
const { conversations } = useConversationsContext();
const [selected, setSelected] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState(false);
useEffect(() => {
if (!open) return;
setSelected(new Set());
setError(null);
setDone(false);
}, [open, message?.id]);
const targets = useMemo(() => {
return conversations
.filter((c) => c.id !== currentConversationId && c.acceptedByMe)
.sort((a, b) => {
const ta = a.lastMessageAt ?? a.createdAt;
const tb = b.lastMessageAt ?? b.createdAt;
return tb.localeCompare(ta);
});
}, [conversations, currentConversationId]);
const preview = useMemo(() => {
if (!message?.plaintext) return '';
const p = parseMessagePayload(message.plaintext);
if (p.kind !== 'text') return '';
return p.text.length > 140 ? p.text.slice(0, 140) + '…' : p.text;
}, [message]);
const sourceAttachments = useMemo<AttachmentHandle[]>(() => {
if (!message?.plaintext) return [];
const p = parseMessagePayload(message.plaintext);
return p.kind === 'text' ? p.attachments : [];
}, [message]);
if (!open || !message) return null;
function toggle(id: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
async function handleSend() {
if (!session?.user.id || !device?.id || !message) return;
if (selected.size === 0) return;
setBusy(true);
setError(null);
try {
const priv = await loadDevicePrivateKey(devLocalSecretStore, session.user.id, device.id);
if (!priv) throw new Error('private key not loaded');
const hasAttachments = sourceAttachments.length > 0;
const text = preview || (hasAttachments ? '' : '');
if (!text && !hasAttachments) throw new Error('nothing to forward');
// Download+decrypt source attachments ONCE (same plaintext goes to every
// target). For each target conv we re-encrypt under fresh per-attachment
// keys and re-upload under the target conv's storage folder — source and
// target conv-keys differ, so the bytes must actually move.
const decryptedBlobs: { mime: string; size: number; width?: number; height?: number; blob: Blob }[] =
[];
for (const h of sourceAttachments) {
const blob = await downloadAndDecryptAttachment({ client: supabase, handle: h });
const entry: {
mime: string;
size: number;
width?: number;
height?: number;
blob: Blob;
} = { mime: h.mimeType, size: h.sizeBytes, blob };
if (h.width !== undefined) entry.width = h.width;
if (h.height !== undefined) entry.height = h.height;
decryptedBlobs.push(entry);
}
for (const convId of selected) {
const newHandles: AttachmentHandle[] = [];
const blobNonceHex = new Map<string, string>();
for (const src of decryptedBlobs) {
const res = await encryptAndUploadAttachment({
client: supabase,
conversationId: convId,
file: src.blob,
mimeType: src.mime,
sizeBytes: src.size,
...(src.width !== undefined ? { width: src.width } : {}),
...(src.height !== undefined ? { height: src.height } : {}),
});
newHandles.push(res.handle);
blobNonceHex.set(res.handle.id, bytesToPgHex(res.nonce));
}
const msg = await sendEncryptedMessage({
client: supabase,
conversationId: convId,
plaintext: text,
senderUserId: session.user.id,
senderDeviceId: device.id,
senderPrivateKey: priv,
...(newHandles.length > 0 ? { attachmentHandles: newHandles } : {}),
});
for (const h of newHandles) {
const bn = blobNonceHex.get(h.id) ?? '\\x';
await insertAttachmentRow(supabase, msg.id, h, bn);
}
}
setDone(true);
window.setTimeout(onClose, 700);
} 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
role="dialog"
aria-modal="true"
aria-label={t('app:chats.forward', { defaultValue: 'Weiterleiten' })}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={onClose}
>
<div
onClick={(e) => e.stopPropagation()}
className="flex max-h-[80vh] w-full max-w-md flex-col overflow-hidden rounded-2xl border border-line bg-surface-3 shadow-xl"
>
<header className="flex items-center justify-between border-b border-line px-5 py-3">
<div className="flex items-center gap-2">
<ForwardIcon className="h-4 w-4 text-accent" />
<h3 className="font-display text-sm font-semibold text-fg">
{t('app:chats.forward', { defaultValue: 'Weiterleiten' })}
</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-fg-muted transition hover:bg-surface-2 hover:text-fg"
>
<XIcon className="h-4 w-4" />
</button>
</header>
<div className="border-b border-line bg-surface-2 px-5 py-3">
<p className="text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
{t('app:chats.forward_preview', { defaultValue: 'Vorschau' })}
</p>
<p className="mt-1 line-clamp-3 break-words text-sm text-fg">
{preview || (sourceAttachments.length > 0 ? '📎' : '…')}
</p>
{sourceAttachments.length > 0 && (
<p className="mt-1 text-[11px] text-fg-muted">
📎{' '}
{t('app:chats.forward_attachments_count', {
count: sourceAttachments.length,
defaultValue: '{{count}} Anhang wird mit weitergeleitet',
})}
</p>
)}
</div>
<div className="flex-1 overflow-y-auto p-2">
{targets.length === 0 ? (
<p className="px-4 py-6 text-center text-sm text-fg-muted">
{t('app:chats.forward_no_targets', {
defaultValue: 'Keine anderen Unterhaltungen verfügbar.',
})}
</p>
) : (
<ul className="space-y-0.5">
{targets.map((c) => {
const isGroup = c.type === 'group';
const title = isGroup ? c.name ?? '?' : c.peer?.displayName ?? '?';
const avatarUrl = isGroup ? c.avatarUrl ?? null : c.peer?.avatarUrl ?? null;
const checked = selected.has(c.id);
return (
<li key={c.id}>
<button
type="button"
onClick={() => toggle(c.id)}
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-accent/40 ' +
(checked ? 'bg-accent/15' : 'hover:bg-surface-2')
}
>
{avatarUrl ? (
<Avatar
url={avatarUrl}
displayName={title}
className="h-9 w-9 text-sm"
/>
) : isGroup ? (
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-accent/20 text-accent">
<UsersIcon className="h-4 w-4" />
</div>
) : (
<Avatar
url={null}
displayName={title}
className="h-9 w-9 text-sm"
/>
)}
<span className="min-w-0 flex-1 truncate text-sm font-medium text-fg">
{title}
</span>
<span
aria-hidden="true"
className={
'flex h-5 w-5 shrink-0 items-center justify-center rounded border ' +
(checked ? 'border-accent bg-accent text-accent-fg' : 'border-line bg-surface-2')
}
>
{checked && (
<svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
)}
</span>
</button>
</li>
);
})}
</ul>
)}
</div>
{error && (
<p
role="alert"
className="border-t border-rose-500/30 bg-rose-500/10 px-5 py-2 text-xs text-rose-700 dark:text-rose-200"
>
{error}
</p>
)}
<footer className="flex items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3">
<button
type="button"
onClick={onClose}
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg transition hover:bg-surface-2"
>
{t('app:friends.action_cancel', { defaultValue: 'Abbrechen' })}
</button>
<button
type="button"
disabled={busy || selected.size === 0 || done}
onClick={() => void handleSend()}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-60"
>
{busy && <SpinnerIcon className="h-4 w-4" />}
<span>
{done
? t('app:chats.forward_done', { defaultValue: 'Gesendet' })
: t('app:chats.forward_send', {
count: selected.size,
defaultValue: 'An {{count}} senden',
})}
</span>
</button>
</footer>
</div>
</div>
);
}
@@ -123,15 +123,25 @@ export function GroupInfoPanel({ open, onClose, conversation }: Props) {
{conversation.members.map((m) => {
const name = m.profile?.displayName ?? '?';
const handle = m.profile?.username ? '@' + m.profile.username : '';
const avatarUrl = m.profile?.avatarUrl ?? null;
const letter = name.trim().charAt(0).toUpperCase() || '?';
return (
<li
key={m.userId}
className="flex items-center gap-3 rounded-lg px-2 py-1.5"
>
{avatarUrl ? (
<img
src={avatarUrl}
alt=""
className="h-8 w-8 shrink-0 rounded-full object-cover"
draggable={false}
/>
) : (
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-xs font-semibold text-white ring-1 ring-brand-400/30">
{letter}
</div>
)}
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-white">
{name}
@@ -187,9 +197,18 @@ export function GroupInfoPanel({ open, onClose, conversation }: Props) {
onClick={() => void handleAdd(f.userId)}
className="flex w-full cursor-pointer items-center gap-3 rounded-lg px-2 py-1.5 text-left transition hover:bg-white/5 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 disabled:opacity-60"
>
{f.avatarUrl ? (
<img
src={f.avatarUrl}
alt=""
className="h-8 w-8 shrink-0 rounded-full object-cover"
draggable={false}
/>
) : (
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-xs font-semibold text-white ring-1 ring-brand-400/30">
{letter}
</div>
)}
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-white">{f.displayName}</p>
<p className="truncate text-xs text-neutral-500">@{f.username}</p>
+488 -349
View File
@@ -1,46 +1,60 @@
import type { ConversationSummary } from '@chat-app/shared/chat';
import type { RemoteTrack } from 'livekit-client';
import { useEffect, useRef, useState } from 'react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import { type RemoteScreenShare, useCall } from '../context/CallContext';
import { type CallMode, useCall } from '../context/CallContext';
import {
getPttSettings,
type PttSettings,
subscribePttSettings,
} from '../lib/pttSettings';
import {
LockIcon,
MicIcon,
MicOffIcon,
MonitorShareIcon,
MonitorStopIcon,
PhoneOffIcon,
SpinnerIcon,
} from './icons';
import { useActiveSpeakers } from '../lib/useActiveSpeakers';
import { CallControls } from './CallControls';
import { CallParticipantTile } from './CallParticipantTile';
import { FocusIcon, GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } from './icons';
import { ScreenShareViewer } from './ScreenShareViewer';
// Discord-style in-call dock rendered above the message list. Renders three
// visual modes driven by CallContext.callMode: grid, focus, fullscreen.
// The fullscreen variant absolute-positions itself over the conversation
// container so the rail+chat-list remain visible on the left.
interface Props {
conversation: ConversationSummary;
}
// Discord-style call widget rendered above the message list when the user is
// in the current conversation's call. Shows participant avatars + controls.
interface Tile {
userId: string;
displayName: string;
avatarUrl: string | null;
self: boolean;
muted: boolean;
video: boolean;
sharing: boolean;
remoteSharing: boolean;
}
export function InCallPanel({ conversation }: Props) {
const { t } = useTranslation(['app']);
const {
state,
room,
remoteParticipants,
isMuted,
isE2EEActive,
toggleMute,
hangup,
isScreenSharing,
remoteScreenShares,
callMode,
focusedId,
toggleMute,
toggleScreenShare,
hangup,
setCallMode,
setFocusedId,
} = useCall();
const { session } = useAuth();
const myId = session?.user.id ?? null;
const activeSpeakers = useActiveSpeakers(room);
const active =
(state.kind === 'connected' ||
@@ -49,33 +63,19 @@ export function InCallPanel({ conversation }: Props) {
state.conversationId === conversation.id;
if (!active) return null;
const remoteIds = new Set<string>(
remoteParticipants.map((p) => p.identity).filter((s): s is string => Boolean(s)),
);
const tiles: ParticipantTileData[] = [];
if (myId) {
const me = conversation.members.find((m) => m.userId === myId) ?? null;
tiles.push({
userId: myId,
displayName: me?.profile?.displayName ?? '?',
avatarUrl: me?.profile?.avatarUrl ?? null,
self: true,
speaking: false,
muted: isMuted,
const tiles = buildTiles({
conversation,
myId,
remoteIdentities: remoteParticipants.map((p) => p.identity),
isMuted,
isScreenSharing,
remoteSharerIds: new Set(remoteScreenShares.map((s) => s.participantId)),
});
}
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 duration =
state.kind === 'connected'
? <LiveDuration startedAt={state.startedAt} />
: null;
const statusLabel =
state.kind === 'outgoing'
@@ -86,268 +86,491 @@ export function InCallPanel({ conversation }: Props) {
? t('app:call.waiting_for_peers', { defaultValue: 'Warte auf andere…' })
: t('app:call.connected');
const sharingTile = tiles.find((p) => p.sharing);
const effectiveFocusedId = focusedId ?? sharingTile?.userId ?? tiles[0]?.userId ?? null;
const speaker = tiles.find((p) => p.userId === effectiveFocusedId) ?? tiles[0];
const controls = (
<CallControls
muted={isMuted}
sharing={isScreenSharing}
video={false}
onToggleMute={toggleMute}
onToggleShare={() => void toggleScreenShare()}
onHangup={() => void hangup()}
compact={callMode !== 'fullscreen'}
glass={callMode === 'fullscreen'}
disabledMedia={state.kind !== 'connected'}
/>
);
if (callMode === 'fullscreen') {
return (
<FullscreenCall
tiles={tiles}
speaker={speaker}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversation.members}
activeSpeakers={activeSpeakers}
e2ee={isE2EEActive}
onExit={() => setCallMode('grid')}
onFocusTile={(id) => {
setFocusedId(id);
}}
controls={controls}
/>
);
}
const title =
conversation.type === 'group'
? conversation.name ?? t('app:chats.new_group')
: conversation.peer?.displayName ?? '—';
return (
<section
aria-label={t('app:call.in_call', { defaultValue: 'Im Anruf' })}
className="border-b border-white/5 bg-gradient-to-b from-ink-900/80 to-ink-950/40 px-6 py-5"
className="flex min-h-[280px] max-h-[420px] shrink-0 flex-col overflow-hidden border-b border-line bg-surface-2"
style={{ height: '50%' }}
>
<div className="mb-4 flex items-center gap-2 text-xs font-medium text-emerald-300">
{state.kind === 'connecting' ? (
<SpinnerIcon className="h-3.5 w-3.5" />
) : (
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400/60" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-400" />
</span>
<div className="flex items-center justify-between border-b border-line bg-surface-3 px-4 py-2">
<div className="flex min-w-0 flex-col gap-0.5">
<div className="flex items-center gap-2 text-sm font-semibold text-fg">
<UsersIcon className="h-4 w-4 shrink-0 text-fg-muted" />
<span className="truncate">{title}</span>
<span className="text-fg-muted/50" aria-hidden="true">·</span>
<span className="tabular-nums text-fg-muted">
{duration ?? statusLabel}
{state.kind !== 'connected' && (
<SpinnerIcon className="ml-1 inline h-3 w-3" />
)}
<span className="uppercase tracking-wide">{statusLabel}</span>
</span>
</div>
{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',
})}
>
<div className="flex items-center gap-1 text-[11px] font-medium text-emerald-600 dark:text-emerald-400">
<LockIcon className="h-3 w-3" />
E2EE
</span>
<span>{t('app:call.e2ee_active', { defaultValue: 'E2E verschlüsselt' })}</span>
</div>
)}
</div>
<div className="flex flex-wrap justify-center gap-4">
{tiles.map((p) => (
<ParticipantTile key={p.userId} {...p} />
))}
<ModeToggles mode={callMode} onChange={setCallMode} />
</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}
<CallStage
tiles={tiles}
speaker={speaker}
mode={callMode}
activeSpeakers={activeSpeakers}
e2ee={isE2EEActive}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversation.members}
onFocusTile={(id) => {
setFocusedId(id);
if (callMode === 'grid') setCallMode('focus');
}}
compact
/>
);
})}
</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>
{controls}
<PttHint />
</section>
);
}
interface ScreenShareViewerProps {
share: RemoteScreenShare;
avatarUrl: string | null;
displayName: string;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
interface BuildArgs {
conversation: ConversationSummary;
myId: string | null;
remoteIdentities: string[];
isMuted: boolean;
isScreenSharing: boolean;
remoteSharerIds: Set<string>;
}
function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShareViewerProps) {
const { t } = useTranslation(['app']);
const videoRef = useRef<HTMLVideoElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const [watching, setWatching] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const letter = displayName.trim().charAt(0).toUpperCase() || '?';
function buildTiles({
conversation,
myId,
remoteIdentities,
isMuted,
isScreenSharing,
remoteSharerIds,
}: BuildArgs): Tile[] {
const remoteSet = new Set(remoteIdentities);
const out: Tile[] = [];
if (myId) {
const me = conversation.members.find((m) => m.userId === myId) ?? null;
out.push({
userId: myId,
displayName: me?.profile?.displayName ?? '?',
avatarUrl: me?.profile?.avatarUrl ?? null,
self: true,
muted: isMuted,
video: false,
sharing: isScreenSharing,
remoteSharing: false,
});
}
for (const m of conversation.members) {
if (m.userId === myId) continue;
if (!remoteSet.has(m.userId)) continue;
const sharing = remoteSharerIds.has(m.userId);
out.push({
userId: m.userId,
displayName: m.profile?.displayName ?? '?',
avatarUrl: m.profile?.avatarUrl ?? null,
self: false,
muted: false,
video: false,
sharing,
remoteSharing: sharing,
});
}
return out;
}
function LiveDuration({ startedAt }: { startedAt: string }) {
const [, tick] = useState(0);
useEffect(() => {
if (!watching) return;
const el = videoRef.current;
if (!el) return;
const track: RemoteTrack = share.track;
track.attach(el);
return () => {
track.detach(el);
};
}, [share.track, watching]);
const id = window.setInterval(() => tick((v) => v + 1), 1000);
return () => window.clearInterval(id);
}, []);
return <>{formatElapsed(Date.now() - new Date(startedAt).getTime())}</>;
}
function formatElapsed(ms: number): string {
const total = Math.max(0, Math.floor(ms / 1000));
const hh = Math.floor(total / 3600);
const mm = Math.floor((total % 3600) / 60);
const ss = total % 60;
const pad = (n: number) => n.toString().padStart(2, '0');
return `${pad(hh)}:${pad(mm)}:${pad(ss)}`;
}
function ModeToggles({
mode,
onChange,
}: {
mode: CallMode;
onChange: (mode: CallMode) => void;
}) {
return (
<div className="flex items-center gap-1">
<ModeButton active={mode === 'grid'} onClick={() => onChange('grid')} label="Grid">
<GridIcon className="h-4 w-4" />
</ModeButton>
<ModeButton active={mode === 'focus'} onClick={() => onChange('focus')} label="Fokus">
<FocusIcon className="h-4 w-4" />
</ModeButton>
<ModeButton
active={mode === 'fullscreen'}
onClick={() => onChange('fullscreen')}
label="Vollbild"
>
<MaximizeIcon className="h-4 w-4" />
</ModeButton>
</div>
);
}
function ModeButton({
active,
onClick,
label,
children,
}: {
active: boolean;
onClick: () => void;
label: string;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
aria-label={label}
aria-pressed={active}
title={label}
className={
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 ' +
(active
? 'border-accent bg-accent text-accent-fg'
: 'border-line bg-surface-2 text-fg-muted hover:bg-surface-3 hover:text-fg')
}
>
{children}
</button>
);
}
interface StageProps {
tiles: Tile[];
speaker: Tile | undefined;
mode: CallMode;
activeSpeakers: Set<string>;
e2ee: boolean;
remoteScreenShares: {
track: import('livekit-client').RemoteTrack;
participantId: string;
participantName: string;
}[];
conversationMembers: ConversationSummary['members'];
onFocusTile: (id: string) => void;
compact?: boolean;
}
function CallStage({
tiles,
speaker,
mode,
activeSpeakers,
e2ee,
remoteScreenShares,
conversationMembers,
onFocusTile,
compact = false,
}: StageProps) {
if (mode === 'focus' && speaker) {
const others = tiles.filter((p) => p.userId !== speaker.userId);
return (
<div className="flex min-h-0 flex-1 flex-col gap-2.5 p-3">
<div className="min-h-0 flex-1">
<FocusedTile
tile={speaker}
e2ee={e2ee}
speaking={activeSpeakers.has(speaker.userId)}
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
/>
</div>
{others.length > 0 && (
<div className="flex h-[110px] gap-2.5 overflow-x-auto">
{others.map((p) => (
<div key={p.userId} className="h-full min-w-[160px]">
<CallParticipantTile
userId={p.userId}
displayName={p.displayName}
avatarUrl={p.avatarUrl}
me={p.self}
muted={p.muted}
speaking={activeSpeakers.has(p.userId)}
sharing={p.sharing}
video={p.video}
e2ee={e2ee}
size="small"
onClick={() => onFocusTile(p.userId)}
/>
</div>
))}
</div>
)}
</div>
);
}
// Grid
const gridClass = gridColsFor(tiles.length);
return (
<div className={'min-h-0 flex-1 p-3 ' + (compact ? '' : 'p-4')}>
<div className={'grid h-full gap-2 ' + gridClass}>
{tiles.map((p) => (
<CallParticipantTile
key={p.userId}
userId={p.userId}
displayName={p.displayName}
avatarUrl={p.avatarUrl}
me={p.self}
muted={p.muted}
speaking={activeSpeakers.has(p.userId)}
sharing={p.sharing}
video={p.video}
e2ee={e2ee}
onClick={() => onFocusTile(p.userId)}
/>
))}
</div>
</div>
);
}
function FocusedTile({
tile,
e2ee,
speaking,
remoteScreenShares,
conversationMembers,
}: {
tile: Tile;
e2ee: boolean;
speaking: boolean;
remoteScreenShares: StageProps['remoteScreenShares'];
conversationMembers: StageProps['conversationMembers'];
}) {
// When the focused participant is remotely sharing their screen, embed the
// real video stream rather than the fake-window placeholder.
if (tile.remoteSharing) {
const share = remoteScreenShares.find((s) => s.participantId === tile.userId);
const member = conversationMembers.find((m) => m.userId === tile.userId);
if (share) {
return (
<div className="h-full">
<ScreenShareViewer
share={share}
avatarUrl={member?.profile?.avatarUrl ?? tile.avatarUrl}
displayName={member?.profile?.displayName ?? tile.displayName}
/>
</div>
);
}
}
return (
<div className="h-full">
<CallParticipantTile
userId={tile.userId}
displayName={tile.displayName}
avatarUrl={tile.avatarUrl}
me={tile.self}
muted={tile.muted}
speaking={speaking}
sharing={tile.sharing}
video={tile.video}
e2ee={e2ee}
focused
/>
</div>
);
}
function gridColsFor(n: number): string {
if (n <= 1) return 'grid-cols-1';
if (n === 2) return 'grid-cols-2';
if (n === 3) return 'grid-cols-3';
if (n === 4) return 'grid-cols-2 grid-rows-2';
return 'grid-cols-3 grid-rows-2';
}
// ---------------------------------------------------------------------------
// Fullscreen cinema mode
// ---------------------------------------------------------------------------
interface FullscreenProps {
tiles: Tile[];
speaker: Tile | undefined;
remoteScreenShares: StageProps['remoteScreenShares'];
conversationMembers: StageProps['conversationMembers'];
activeSpeakers: Set<string>;
e2ee: boolean;
onExit: () => void;
onFocusTile: (id: string) => void;
controls: React.ReactNode;
}
function FullscreenCall({
tiles,
speaker,
remoteScreenShares,
conversationMembers,
activeSpeakers,
e2ee,
onExit: _onExit,
onFocusTile,
controls,
}: FullscreenProps) {
const others = speaker ? tiles.filter((p) => p.userId !== speaker.userId) : tiles;
const [hintGone, setHintGone] = useState(false);
useEffect(() => {
const onChange = () => {
setIsFullscreen(document.fullscreenElement === containerRef.current);
};
document.addEventListener('fullscreenchange', onChange);
return () => document.removeEventListener('fullscreenchange', onChange);
const id = window.setTimeout(() => setHintGone(true), 3500);
return () => window.clearTimeout(id);
}, []);
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')
}
/>
<div className="fixed inset-0 z-[60] flex flex-col overflow-hidden bg-surface">
<div className="relative min-h-0 flex-1">
{speaker && (
<div className="absolute inset-0">
{speaker.remoteSharing ? (
<FullscreenShare speaker={speaker} remoteScreenShares={remoteScreenShares} conversationMembers={conversationMembers} />
) : (
<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 className="h-full w-full [&>div]:rounded-none [&>div]:border-0">
<CallParticipantTile
userId={speaker.userId}
displayName={speaker.displayName}
avatarUrl={speaker.avatarUrl}
me={speaker.self}
muted={speaker.muted}
speaking={activeSpeakers.has(speaker.userId)}
sharing={speaker.sharing}
video={speaker.video}
e2ee={e2ee}
focused
/>
</div>
</div>
</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"
{others.length > 0 && (
<div className="absolute bottom-24 right-4 flex w-[180px] flex-col gap-2">
{others.slice(0, 4).map((p) => (
<div key={p.userId} className="h-[100px] backdrop-blur-xl">
<CallParticipantTile
userId={p.userId}
displayName={p.displayName}
avatarUrl={p.avatarUrl}
me={p.self}
muted={p.muted}
speaking={activeSpeakers.has(p.userId)}
sharing={p.sharing}
video={p.video}
e2ee={e2ee}
size="small"
onClick={() => onFocusTile(p.userId)}
/>
<div 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
</div>
))}
</div>
)}
{!hintGone && (
<div
aria-hidden="true"
className="text-[120px] font-display font-bold text-white/20 blur-[2px]"
className="pointer-events-none absolute left-1/2 top-5 z-10 animate-fs-hint rounded-lg border border-white/10 bg-black/60 px-3.5 py-1.5 text-[11px] font-medium tracking-wide text-white/70 backdrop-blur-md"
>
{letter}
</span>
Esc zum Verlassen
</div>
)}
<div className="pointer-events-auto absolute bottom-4 left-1/2 -translate-x-1/2">
{controls}
</div>
</div>
</div>
);
}
function PlayIcon(props: React.SVGProps<SVGSVGElement>) {
function FullscreenShare({
speaker,
remoteScreenShares,
conversationMembers,
}: {
speaker: Tile;
remoteScreenShares: StageProps['remoteScreenShares'];
conversationMembers: StageProps['conversationMembers'];
}) {
const share = remoteScreenShares.find((s) => s.participantId === speaker.userId);
const member = conversationMembers.find((m) => m.userId === speaker.userId);
if (!share) return null;
return (
<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>
<div className="h-full w-full">
<ScreenShareViewer
share={share}
avatarUrl={member?.profile?.avatarUrl ?? speaker.avatarUrl}
displayName={member?.profile?.displayName ?? speaker.displayName}
/>
</div>
);
}
@@ -356,95 +579,11 @@ function PttHint() {
useEffect(() => subscribePttSettings(setPtt), []);
if (!ptt.enabled) return null;
return (
<p className="mt-3 text-center text-[11px] text-neutral-500">
<p className="border-t border-line bg-surface-3 py-2 text-center text-[11px] text-fg-muted">
Push-to-Talk:&nbsp;
<kbd className="rounded border border-white/10 bg-white/5 px-1.5 py-0.5 font-mono text-[10px] text-neutral-300">
<kbd className="rounded border border-line bg-surface-2 px-1.5 py-0.5 font-mono text-[10px] text-fg">
{ptt.keyLabel}
</kbd>
</p>
);
}
interface ParticipantTileData {
userId: string;
displayName: string;
avatarUrl: string | null;
self: boolean;
speaking: boolean;
muted: boolean;
}
function ParticipantTile(p: ParticipantTileData) {
const letter = p.displayName.trim().charAt(0).toUpperCase() || '?';
return (
<div className="flex w-28 flex-col items-center gap-2">
<div
className={
'relative flex h-20 w-20 items-center justify-center rounded-2xl bg-gradient-to-br from-brand-500/40 to-brand-700/40 text-2xl font-semibold text-white ring-2 transition ' +
(p.speaking
? 'ring-emerald-400 shadow-[0_0_24px_rgba(52,211,153,0.35)]'
: 'ring-white/10')
}
>
{p.avatarUrl ? (
<img
src={p.avatarUrl}
alt=""
className="h-full w-full rounded-2xl object-cover"
/>
) : (
<span aria-hidden="true">{letter}</span>
)}
{p.muted && (
<span
aria-hidden="true"
className="absolute -bottom-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full bg-amber-500 ring-2 ring-ink-950"
>
<MicOffIcon className="h-3 w-3 text-ink-950" />
</span>
)}
</div>
<p className="max-w-full truncate text-xs font-medium text-neutral-200">
{p.displayName}
{p.self && (
<span className="ml-1 text-neutral-500">·&nbsp;Du</span>
)}
</p>
</div>
);
}
interface ControlButtonProps {
onClick: () => void;
label: string;
tone: 'neutral' | 'amber' | 'rose' | 'emerald';
active?: boolean;
disabled?: boolean;
children: React.ReactNode;
}
function ControlButton({ onClick, label, tone, disabled, children }: ControlButtonProps) {
const toneClass =
tone === 'rose'
? 'bg-rose-500 text-white hover:bg-rose-400 focus-visible:ring-rose-400/50'
: tone === 'amber'
? 'bg-amber-500/90 text-ink-950 hover:bg-amber-400 focus-visible:ring-amber-400/50'
: tone === 'emerald'
? 'bg-emerald-500/85 text-white hover:bg-emerald-400 focus-visible:ring-emerald-400/50'
: 'bg-white/10 text-neutral-100 hover:bg-white/20 focus-visible:ring-brand-400/40';
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={label}
title={label}
className={
'inline-flex h-11 w-11 cursor-pointer items-center justify-center rounded-full transition focus:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-50 ' +
toneClass
}
>
{children}
</button>
);
}
@@ -0,0 +1,123 @@
import type { ConversationSummary } from '@chat-app/shared/chat';
import { useTranslation } from 'react-i18next';
import { useCall } from '../context/CallContext';
import { useFriendshipsContext } from '../context/FriendshipsContext';
import { AvatarColorKey, colorKeyFor } from './CallParticipantTile';
import { LockIcon, PhoneIcon, PhoneOffIcon } from './icons';
interface Props {
conversation: ConversationSummary;
}
const PULSE_BG: Record<AvatarColorKey, string> = {
violet: 'bg-violet-200 text-violet-800 dark:bg-violet-900/60 dark:text-violet-200',
amber: 'bg-amber-200 text-amber-900 dark:bg-amber-900/60 dark:text-amber-200',
rose: 'bg-rose-200 text-rose-900 dark:bg-rose-900/60 dark:text-rose-200',
teal: 'bg-teal-200 text-teal-900 dark:bg-teal-900/60 dark:text-teal-200',
};
// Docked incoming-call panel: replaces the chat header, chat + composer stay
// visible and usable below (per user decision). Full-bleed overlay lives in
// the shell-level toast for cross-conversation ring notifications.
export function IncomingCallPanel({ conversation }: Props) {
const { t } = useTranslation(['app']);
const { state, acceptIncoming, rejectIncoming } = useCall();
const { friendships } = useFriendshipsContext();
if (state.kind !== 'incoming' || state.conversationId !== conversation.id) return null;
const isGroup = conversation.type === 'group';
const callerProfile = conversation.members.find((m) => m.userId === state.fromUserId)?.profile ?? null;
const callerName =
callerProfile?.displayName ??
friendships.find((f) => f.peer.userId === state.fromUserId)?.peer.displayName ??
'?';
const groupName = isGroup ? (conversation.name ?? t('app:chats.new_group')) : null;
const title = isGroup ? groupName ?? callerName : callerName;
const letter = title.trim().charAt(0).toUpperCase() || '?';
const avatarUrl = isGroup
? (conversation.avatarUrl ?? callerProfile?.avatarUrl ?? null)
: callerProfile?.avatarUrl ?? null;
const colorKey = colorKeyFor(state.fromUserId);
const avatarTone = PULSE_BG[colorKey];
const mediaLabel =
state.mediaKind === 'video' ? 'Video Call' : 'Voice Call';
return (
<section
role="region"
aria-label={t('app:call.incoming_title')}
className="relative overflow-hidden border-b border-line bg-surface-3"
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_30%_20%,rgba(79,70,229,0.10),transparent_60%),radial-gradient(circle_at_70%_80%,rgba(109,115,255,0.08),transparent_55%)]" />
<div className="relative mx-auto flex max-w-[480px] flex-col items-center px-6 py-8 text-center">
<p className="text-[12px] font-semibold uppercase tracking-[0.12em] text-fg-muted">
{t('app:call.incoming_title')}
</p>
<div className="relative my-4 flex h-[112px] w-[112px] items-center justify-center">
<span
aria-hidden="true"
className="pointer-events-none absolute inset-0 rounded-full border-2 border-accent animate-pulse-ring"
/>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-0 rounded-full border-2 border-accent animate-pulse-ring"
style={{ animationDelay: '1s' }}
/>
{avatarUrl ? (
<img
src={avatarUrl}
alt={title}
className="h-24 w-24 rounded-full border-[3px] border-surface-3 object-cover"
draggable={false}
/>
) : (
<div
className={
'flex h-24 w-24 items-center justify-center rounded-full border-[3px] border-surface-3 font-display text-4xl font-bold ' +
avatarTone
}
>
{letter}
</div>
)}
</div>
<p className="font-display text-2xl font-bold tracking-tight text-fg">{title}</p>
<p className="mt-1.5 flex items-center gap-1.5 text-xs text-fg-muted">
<LockIcon className="h-3.5 w-3.5" />
<span>E2E verschlüsselt · {mediaLabel}</span>
{isGroup && (
<>
<span className="text-fg-muted/50" aria-hidden="true">·</span>
<span>
{t('app:call.incoming_group_from', {
name: callerName,
defaultValue: callerName + ' ruft an',
})}
</span>
</>
)}
</p>
<div className="mt-5 flex w-full max-w-[360px] gap-3">
<button
type="button"
onClick={rejectIncoming}
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-[14px] border border-rose-500/40 bg-transparent px-5 py-3.5 text-sm font-semibold text-rose-600 transition hover:bg-rose-500/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-500/40 dark:text-rose-400"
>
<PhoneOffIcon className="h-4 w-4" />
<span>{t('app:call.decline')}</span>
</button>
<button
type="button"
onClick={() => void acceptIncoming()}
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-[14px] bg-emerald-600 px-5 py-3.5 text-sm font-semibold text-white shadow-accept-btn transition hover:bg-emerald-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50"
>
<PhoneIcon className="h-4 w-4" />
<span>{t('app:call.accept')}</span>
</button>
</div>
</div>
</section>
);
}
+139 -28
View File
@@ -14,29 +14,60 @@ 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';
import { Avatar } from './Avatar';
import { ForwardIcon, PencilIcon, PhoneIcon, PhoneOffIcon, ReplyIcon, SmileIcon, SpinnerIcon, TrashIcon, XIcon } from './icons';
const EMOJI_CHOICES = ['👍', '❤️', '😂', '🎉', '🔥', '😮', '😢', '🙏'];
const EDIT_WINDOW_MS = 24 * 60 * 60 * 1000;
export interface QuotedRef {
id: string;
senderName: string;
snippet: string;
isAttachment: boolean;
deleted: boolean;
}
interface Props {
message: DecryptedMessage;
mine: boolean;
groupedWithPrev: boolean;
/** Last message of a run from this sender — anchor avatar on this row. */
isLastOfRun?: boolean;
senderDisplayName?: string | null | undefined;
senderAvatarUrl?: string | null | undefined;
conversationId: string;
reactions: AggregatedReaction[];
onToggleReaction: (emoji: string) => Promise<void>;
showSeen?: boolean;
/** Resolved quoted message info (parent does the lookup). */
quoted?: QuotedRef | null;
/** Tap-to-jump on quote bubble. Receives the quoted message's id. */
onJumpToMessage?: (id: string) => void;
/** Hover action: parent receives current message to start a reply. */
onReply?: (m: DecryptedMessage) => void;
/** Hover action: parent opens forward dialog for current message. */
onForward?: (m: DecryptedMessage) => void;
/** Highlighted state — set briefly after a jump. */
highlighted?: boolean;
}
export function MessageBubble({
message,
mine,
groupedWithPrev,
isLastOfRun = false,
senderDisplayName,
senderAvatarUrl,
conversationId,
reactions,
onToggleReaction,
showSeen = false,
quoted = null,
onJumpToMessage,
onReply,
onForward,
highlighted = false,
}: Props) {
const { t } = useTranslation(['app']);
const { session, device } = useAuth();
@@ -98,6 +129,8 @@ export function MessageBubble({
messageId: message.id,
conversationId,
newPlaintext: trimmed,
senderUserId: session.user.id,
senderDeviceId: device.id,
senderPrivateKey: priv,
});
setEditing(false);
@@ -141,8 +174,13 @@ export function MessageBubble({
if (message.deletedAt) {
return (
<div className={'flex ' + (mine ? 'justify-end' : 'justify-start')}>
<div className="max-w-[70%] rounded-2xl border border-white/5 bg-white/5 px-3.5 py-1.5 text-xs italic text-neutral-500">
<div className={'flex items-end gap-2 ' + (mine ? 'flex-row-reverse' : '')}>
<AvatarSlot
show={isLastOfRun}
url={senderAvatarUrl ?? null}
displayName={senderDisplayName ?? null}
/>
<div className="max-w-[calc(70%-2.5rem)] rounded-2xl border border-line bg-surface-2 px-3.5 py-1.5 text-xs italic text-fg-muted">
{t('app:chats.deleted')}
</div>
</div>
@@ -154,14 +192,19 @@ export function MessageBubble({
}
return (
<div className={'flex ' + (mine ? 'justify-end' : 'justify-start')}>
<div className={'flex items-end gap-2 ' + (mine ? 'flex-row-reverse' : '')}>
<AvatarSlot
show={isLastOfRun}
url={senderAvatarUrl ?? null}
displayName={senderDisplayName ?? null}
/>
<div
className={
'group relative max-w-[70%] ' + (groupedWithPrev ? 'mt-0.5' : 'mt-2')
'group relative max-w-[calc(70%-2.5rem)] ' + (groupedWithPrev ? 'mt-0.5' : 'mt-2')
}
>
{editing ? (
<div className="rounded-2xl border border-brand-400/40 bg-ink-900/80 p-2 backdrop-blur-xl">
<div className="rounded-2xl border border-accent/40 bg-surface-2 p-2 shadow-sm">
<textarea
value={editText}
onChange={(e) => setEditText(e.target.value)}
@@ -177,10 +220,10 @@ export function MessageBubble({
}}
rows={2}
autoFocus
className="w-full resize-none rounded-md bg-ink-800 px-3 py-2 text-sm text-white outline-none focus:ring-2 focus:ring-brand-400/60"
className="w-full resize-none rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg outline-none focus:ring-2 focus:ring-accent/50"
/>
{editError && (
<p className="mt-1.5 break-words rounded-md border border-rose-500/20 bg-rose-500/10 px-2 py-1 text-[11px] text-rose-200">
<p className="mt-1.5 break-words rounded-md border border-rose-500/30 bg-rose-500/10 px-2 py-1 text-[11px] text-rose-600 dark:text-rose-200">
{editError}
</p>
)}
@@ -191,7 +234,7 @@ export function MessageBubble({
setEditing(false);
setEditError(null);
}}
className="cursor-pointer rounded-md border border-white/10 bg-white/5 px-3 py-1 text-xs text-neutral-200 hover:bg-white/10"
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-1 text-xs text-fg hover:bg-surface-2"
>
{t('app:friends.action_cancel')}
</button>
@@ -199,7 +242,7 @@ export function MessageBubble({
type="button"
disabled={busy}
onClick={() => void handleEditSave()}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-brand-500/90 px-3 py-1 text-xs font-semibold text-white hover:bg-brand-400 disabled:opacity-60"
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-1 text-xs font-semibold text-accent-fg hover:brightness-110 disabled:opacity-60"
>
{busy && <SpinnerIcon className="h-3.5 w-3.5" />}
<span>{t('common:save', { defaultValue: 'Save' })}</span>
@@ -208,15 +251,48 @@ export function MessageBubble({
</div>
) : (
<div
data-message-id={message.id}
className={
'break-words rounded-2xl px-3.5 py-2 text-sm ' +
'break-words px-3.5 py-2 text-sm transition ' +
(highlighted ? 'ring-2 ring-amber-400 ring-offset-2 ring-offset-surface-3 ' : '') +
(mine
? 'bg-brand-500/85 text-white'
: 'border border-white/5 bg-ink-900/70 text-neutral-100')
? 'rounded-[18px_18px_4px_18px] bg-accent text-accent-fg'
: 'rounded-[18px_18px_18px_4px] border border-line text-fg')
}
>
{quoted && (
<button
type="button"
onClick={() => onJumpToMessage?.(quoted.id)}
className={
'mb-1.5 flex w-full cursor-pointer items-stretch gap-2 rounded-md px-2 py-1.5 text-left text-xs transition hover:opacity-90 ' +
(mine
? 'bg-white/15 text-accent-fg/90'
: 'bg-surface-2 text-fg-muted')
}
>
<span
aria-hidden="true"
className={
'w-0.5 shrink-0 rounded-full ' + (mine ? 'bg-white/50' : 'bg-accent')
}
/>
<span className="min-w-0 flex-1">
<span className={'block truncate font-semibold ' + (mine ? '' : 'text-fg')}>
{quoted.senderName}
</span>
<span className="block truncate italic opacity-90">
{quoted.deleted
? t('app:chats.deleted')
: quoted.isAttachment && !quoted.snippet
? '📎 ' + t('app:chats.attachment', { defaultValue: 'Anhang' })
: quoted.snippet}
</span>
</span>
</button>
)}
{message.plaintext === null ? (
<span className="italic text-neutral-400">cannot decrypt</span>
<span className="italic opacity-70">cannot decrypt</span>
) : (
<>
{bodyText.length > 0 && <div>{bodyText}</div>}
@@ -228,7 +304,7 @@ export function MessageBubble({
<div
className={
'mt-1 flex items-center gap-1.5 text-[10px] ' +
(mine ? 'text-brand-100/70' : 'text-neutral-500')
(mine ? 'text-accent-fg/75' : 'text-fg-muted')
}
>
<span>{time}</span>
@@ -240,7 +316,7 @@ export function MessageBubble({
)}
{showSeen && mine && !editing && !message.deletedAt && (
<p className="mt-0.5 text-right text-[10px] text-neutral-500">
<p className="mt-0.5 text-right text-[10px] text-fg-muted">
{t('app:chats.seen')}
</p>
)}
@@ -253,10 +329,10 @@ export function MessageBubble({
type="button"
onClick={() => void onToggleReaction(r.emoji)}
className={
'inline-flex cursor-pointer items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' +
'inline-flex cursor-pointer items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(r.mine
? 'border-brand-400/40 bg-brand-500/20 text-brand-100'
: 'border-white/10 bg-white/5 text-neutral-200 hover:bg-white/10')
? 'border-accent/40 bg-accent/20 text-accent'
: 'border-line bg-surface-2 text-fg hover:bg-surface-3')
}
>
<span>{r.emoji}</span>
@@ -273,12 +349,26 @@ export function MessageBubble({
(mine ? 'right-full pr-2' : 'left-full pl-2')
}
>
<div className="flex items-center gap-0.5 rounded-lg border border-white/10 bg-ink-900/90 p-1 shadow-lg backdrop-blur-xl">
<div className="flex items-center gap-0.5 rounded-lg border border-line bg-surface-3 p-1 shadow-lg">
<ActionButton
label={t('app:friends.action_accept', { defaultValue: 'React' })}
onClick={() => setPickerOpen((v) => !v)}
icon={<SmileIcon className="h-4 w-4" />}
/>
{onReply && !message.deletedAt && (
<ActionButton
label={t('app:chats.reply', { defaultValue: 'Antworten' })}
onClick={() => onReply(message)}
icon={<ReplyIcon className="h-4 w-4" />}
/>
)}
{onForward && !message.deletedAt && (
<ActionButton
label={t('app:chats.forward', { defaultValue: 'Weiterleiten' })}
onClick={() => onForward(message)}
icon={<ForwardIcon className="h-4 w-4" />}
/>
)}
{canEdit && (
<ActionButton
label="Edit"
@@ -304,7 +394,7 @@ export function MessageBubble({
ref={pickerRef}
role="menu"
className={
'absolute z-30 mt-1 flex gap-1 rounded-lg border border-white/10 bg-ink-900/95 p-1.5 shadow-xl backdrop-blur-xl ' +
'absolute z-30 mt-1 flex gap-1 rounded-lg border border-line bg-surface-3 p-1.5 shadow-xl ' +
(mine ? 'right-0' : 'left-0')
}
>
@@ -313,7 +403,7 @@ export function MessageBubble({
key={e}
type="button"
onClick={() => void handlePickEmoji(e)}
className="cursor-pointer rounded-md px-2 py-1 text-lg transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
className="cursor-pointer rounded-md px-2 py-1 text-lg transition hover:bg-surface-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
{e}
</button>
@@ -321,7 +411,7 @@ export function MessageBubble({
<button
type="button"
onClick={() => setPickerOpen(false)}
className="cursor-pointer rounded-md px-1.5 py-1 text-neutral-500 transition hover:bg-white/10"
className="cursor-pointer rounded-md px-1.5 py-1 text-fg-muted transition hover:bg-surface-2"
>
<XIcon className="h-4 w-4" />
</button>
@@ -334,6 +424,27 @@ export function MessageBubble({
);
}
function AvatarSlot({
show,
url,
displayName,
}: {
show: boolean;
url: string | null;
displayName: string | null;
}) {
if (!show) {
return <div aria-hidden="true" className="h-8 w-8 shrink-0" />;
}
return (
<Avatar
url={url}
displayName={displayName ?? ''}
className="h-8 w-8 text-xs"
/>
);
}
function CallEventRow({
parsed,
mine,
@@ -349,8 +460,8 @@ function CallEventRow({
const Icon = isMissed ? PhoneOffIcon : PhoneIcon;
const tone = isMissed
? 'border-rose-500/20 bg-rose-500/10 text-rose-200'
: 'border-emerald-500/20 bg-emerald-500/10 text-emerald-200';
? 'border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-200'
: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-200';
const label =
status === 'ended'
@@ -406,10 +517,10 @@ function ActionButton({
title={label}
onClick={onClick}
className={
'flex h-7 w-7 cursor-pointer items-center justify-center rounded-md transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' +
'flex h-7 w-7 cursor-pointer items-center justify-center rounded-md transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(tone === 'danger'
? 'text-neutral-400 hover:bg-rose-500/20 hover:text-rose-200'
: 'text-neutral-400 hover:bg-white/10 hover:text-neutral-100')
? 'text-fg-muted hover:bg-rose-500/20 hover:text-rose-500 dark:hover:text-rose-200'
: 'text-fg-muted hover:bg-surface-2 hover:text-fg')
}
>
{icon}
+5 -5
View File
@@ -35,22 +35,22 @@ export function Modal({ open, title, onClose, children, size = 'md' }: Props) {
aria-modal="true"
aria-label={title}
onClick={onClose}
className="fixed inset-0 z-50 flex items-center justify-center bg-ink-950/80 p-6 backdrop-blur-sm animate-fade-in"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-6 backdrop-blur-sm animate-fade-in"
>
<div
onClick={(e) => e.stopPropagation()}
className={
'relative w-full animate-slide-up rounded-2xl border border-white/10 bg-ink-900/95 shadow-xl backdrop-blur-xl ' +
'relative w-full animate-slide-up rounded-2xl border border-line bg-surface-3 shadow-xl ' +
width
}
>
<header className="flex items-center justify-between border-b border-white/5 px-6 py-4">
<h2 className="font-display text-lg font-semibold text-white">{title}</h2>
<header className="flex items-center justify-between border-b border-line px-6 py-4">
<h2 className="font-display text-lg font-semibold text-fg">{title}</h2>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-neutral-400 transition hover:bg-white/10 hover:text-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-2 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
<XIcon className="h-4 w-4" />
</button>
@@ -0,0 +1,192 @@
import type { RemoteTrack } from 'livekit-client';
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import type { RemoteScreenShare } from '../context/CallContext';
import { MonitorShareIcon } from './icons';
interface ScreenShareViewerProps {
share: RemoteScreenShare;
avatarUrl: string | null;
displayName: string;
}
// Click-to-watch gate, live <video> attach/detach, native fullscreen toggle.
// Lifted out of the old InCallPanel so the new CallDock stays lean.
export function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShareViewerProps) {
const { t } = useTranslation(['app']);
const videoRef = useRef<HTMLVideoElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const [watching, setWatching] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const letter = displayName.trim().charAt(0).toUpperCase() || '?';
useEffect(() => {
if (!watching) return;
const el = videoRef.current;
if (!el) return;
const track: RemoteTrack = share.track;
track.attach(el);
return () => {
track.detach(el);
};
}, [share.track, watching]);
// Esc exits CSS fullscreen.
useEffect(() => {
if (!isFullscreen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setIsFullscreen(false);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [isFullscreen]);
// CSS-only "app fullscreen" — Discord-style: overlay the whole window
// including the left sidebar + chat list. Native Fullscreen API is
// unreliable in Tauri's WKWebView and doesn't add useful chrome-hiding
// beyond what `fixed inset-0 z-[60]` already gives us.
const toggleFullscreen = () => {
setIsFullscreen((v) => !v);
};
const viewerNode = (
<div
ref={containerRef}
className={
isFullscreen
? 'fixed inset-0 z-[60] flex h-screen w-screen flex-col overflow-hidden border-0 bg-black'
: 'overflow-hidden rounded-xl border border-emerald-500/30 bg-black'
}
>
<div className="flex shrink-0 items-center gap-2 border-b border-emerald-500/20 bg-emerald-500/15 px-3 py-1.5 text-xs text-emerald-700 dark:text-emerald-200">
<MonitorShareIcon className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate">
{t('app:call.is_sharing_screen', {
name: displayName,
defaultValue: displayName + ' teilt den Bildschirm',
})}
</span>
{watching && (
<>
<button
type="button"
onClick={toggleFullscreen}
aria-label={t('app:call.fullscreen', { defaultValue: 'Vollbild' })}
title={t('app:call.fullscreen', { defaultValue: 'Vollbild' })}
className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-emerald-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40"
>
<FullscreenIcon className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => {
if (document.fullscreenElement === containerRef.current) {
void document.exitFullscreen();
}
setWatching(false);
}}
aria-label={t('app:call.stop_watching', { defaultValue: 'Nicht mehr anschauen' })}
title={t('app:call.stop_watching', { defaultValue: 'Nicht mehr anschauen' })}
className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-emerald-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40"
>
<span aria-hidden="true" className="text-[14px] leading-none">×</span>
</button>
</>
)}
</div>
{watching ? (
<video
ref={videoRef}
autoPlay
playsInline
muted
onDoubleClick={toggleFullscreen}
className={
'block cursor-zoom-in bg-black ' +
(isFullscreen ? 'h-full w-full flex-1 object-contain' : 'w-full')
}
/>
) : (
<button
type="button"
onClick={() => setWatching(true)}
aria-label={t('app:call.watch_screen', { defaultValue: 'Bildschirm anschauen' })}
className="group relative block w-full cursor-pointer overflow-hidden bg-ink-900 focus:outline-none"
style={{ aspectRatio: '16 / 9' }}
>
<BlurredTile avatarUrl={avatarUrl} letter={letter} />
<div className="absolute inset-0 flex items-center justify-center bg-black/30 transition group-hover:bg-black/40">
<div className="flex flex-col items-center gap-2">
<span className="flex h-14 w-14 items-center justify-center rounded-full bg-emerald-500 text-white shadow-lg transition group-hover:scale-105">
<PlayIcon className="ml-0.5 h-6 w-6" />
</span>
<span className="text-xs font-medium text-white/90">
{t('app:call.watch_screen', { defaultValue: 'Bildschirm anschauen' })}
</span>
</div>
</div>
</button>
)}
</div>
);
// When in fullscreen, portal out of the call-panel subtree into <body> so
// no ancestor can clip or stack below us. Sidebar/chat-list are siblings of
// AppShell's root — portalled node sits above them via z-[60].
if (isFullscreen) {
return createPortal(viewerNode, document.body);
}
return viewerNode;
}
function BlurredTile({ avatarUrl, letter }: { avatarUrl: string | null; letter: string }) {
if (avatarUrl) {
return (
<>
<img
src={avatarUrl}
alt=""
className="absolute inset-0 h-full w-full scale-110 object-cover blur-2xl"
/>
<div className="absolute inset-0 bg-gradient-to-br from-accent/20 to-emerald-500/20" />
</>
);
}
return (
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-accent/40 via-fuchsia-500/20 to-emerald-500/30">
<span
aria-hidden="true"
className="text-[120px] font-display font-bold text-white/20 blur-[2px]"
>
{letter}
</span>
</div>
);
}
function PlayIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>
<path d="M8 5v14l11-7z" />
</svg>
);
}
function FullscreenIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5" />
</svg>
);
}
+114 -48
View File
@@ -4,110 +4,176 @@ import { NavLink } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { useConversationsContext } from '../context/ConversationsContext';
import { useFriendshipsContext } from '../context/FriendshipsContext';
import { CallBar } from './CallUI';
import { useTheme } from '../context/ThemeContext';
import {
ChatBubbleIcon,
GearIcon,
LogoMark,
MoonIcon,
ShieldIcon,
SignOutIcon,
SunIcon,
UsersIcon,
} from './icons';
import { UserBar } from './UserBar';
interface NavItem {
to: string;
labelKey: string;
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
badge?: 'friends' | 'chats';
}
const BASE_NAV_ITEMS: NavItem[] = [
{ to: '/chats', labelKey: 'app:nav.chats', icon: ChatBubbleIcon },
{ to: '/friends', labelKey: 'app:nav.friends', icon: UsersIcon },
{ to: '/settings', labelKey: 'app:nav.settings', icon: GearIcon },
const PRIMARY_NAV: NavItem[] = [
{ to: '/chats', labelKey: 'app:nav.chats', icon: ChatBubbleIcon, badge: 'chats' },
{ to: '/friends', labelKey: 'app:nav.friends', icon: UsersIcon, badge: 'friends' },
];
const ADMIN_NAV_ITEM: NavItem = {
const ADMIN_NAV: NavItem = {
to: '/admin',
labelKey: 'app:nav.admin',
icon: ShieldIcon,
};
// Clean-Rail 72px icon rail. Brand logo top → primary tabs → spacer →
// settings + theme toggle + sign-out at bottom. Active tab shows a pill
// indicator anchored to the left edge of the rail.
export function Sidebar() {
const { t } = useTranslation(['app', 'common']);
const { t } = useTranslation(['app']);
const { signOut, profile } = useAuth();
const { incomingCount } = useFriendshipsContext();
const { totalUnread } = useConversationsContext();
const { theme, toggle } = useTheme();
const navItems = profile?.isAdmin ? [...BASE_NAV_ITEMS, ADMIN_NAV_ITEM] : BASE_NAV_ITEMS;
const navItems: NavItem[] = profile?.isAdmin ? [...PRIMARY_NAV, ADMIN_NAV] : PRIMARY_NAV;
return (
<aside
aria-label="Primary navigation"
className="flex h-screen w-72 shrink-0 flex-col border-r border-white/5 bg-ink-900/70 backdrop-blur-xl"
className="flex h-screen w-[72px] shrink-0 flex-col items-center border-r border-line bg-surface-2/80 py-4 backdrop-blur-xl"
>
<div className="flex items-center gap-2.5 px-5 pb-3 pt-5">
<LogoMark className="h-7 w-7" />
<span className="font-display text-base font-semibold tracking-tight text-white">
{t('common:app_name')}
</span>
<div className="flex h-10 w-10 items-center justify-center" title="Netralax">
<LogoMark className="h-8 w-8" />
</div>
<nav className="mt-2 flex flex-col gap-0.5 px-3">
<div className="my-3 h-px w-8 bg-line" aria-hidden="true" />
<nav className="flex flex-col items-center gap-2">
{navItems.map((item) => {
const badge =
item.to === '/friends'
const badgeCount =
item.badge === 'friends'
? incomingCount
: item.to === '/chats'
: item.badge === 'chats'
? totalUnread
: 0;
const ariaLabel = badge > 0 ? `${t(item.labelKey)} (${badge})` : undefined;
return (
<NavLink
<RailNavLink
key={item.to}
to={item.to}
aria-label={ariaLabel}
className={({ isActive }) =>
[
'group flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/50',
isActive
? 'bg-brand-500/15 text-white ring-1 ring-brand-400/30'
: 'text-neutral-400 hover:bg-white/5 hover:text-neutral-100',
].join(' ')
}
>
<item.icon style={{ width: '18px', height: '18px' }} className="transition" />
<span className="flex-1">{t(item.labelKey)}</span>
{badge > 0 && <NavBadge count={badge} />}
</NavLink>
label={t(item.labelKey)}
icon={item.icon}
badge={badgeCount}
/>
);
})}
</nav>
<div className="flex-1" />
<div className="border-t border-white/5 p-3">
<CallBar />
<UserBar />
<button
type="button"
<div className="flex flex-col items-center gap-2">
<RailNavLink
to="/settings"
label={t('app:nav.settings')}
icon={GearIcon}
/>
<RailIconButton
label={theme === 'dark' ? t('app:theme.light', { defaultValue: 'Light mode' }) : t('app:theme.dark', { defaultValue: 'Dark mode' })}
onClick={toggle}
icon={theme === 'dark' ? SunIcon : MoonIcon}
/>
<RailIconButton
label={t('app:sidebar.sign_out')}
onClick={() => void signOut()}
className="mt-2 flex w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-xs font-medium text-neutral-400 transition hover:bg-rose-500/10 hover:text-rose-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/40"
>
<SignOutIcon className="h-4 w-4" />
<span>{t('app:sidebar.sign_out')}</span>
</button>
icon={SignOutIcon}
tone="danger"
/>
</div>
</aside>
);
}
function NavBadge({ count }: { count: number }) {
interface RailNavLinkProps {
to: string;
label: string;
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
badge?: number;
}
function RailNavLink({ to, label, icon: Icon, badge = 0 }: RailNavLinkProps) {
const ariaLabel = badge > 0 ? `${label} (${badge})` : label;
return (
<NavLink
to={to}
aria-label={ariaLabel}
title={label}
className={({ isActive }) =>
[
'group relative flex h-11 w-11 cursor-pointer items-center justify-center rounded-xl transition',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50',
isActive
? 'bg-accent/15 text-accent'
: 'text-fg-muted hover:bg-surface-3/60 hover:text-fg',
].join(' ')
}
>
{({ isActive }) => (
<>
{isActive && (
<span
aria-hidden="true"
className="absolute -left-4 h-6 w-1 rounded-r-full bg-accent"
/>
)}
<Icon style={{ width: '20px', height: '20px' }} />
{badge > 0 && <RailBadge count={badge} />}
</>
)}
</NavLink>
);
}
interface RailIconButtonProps {
label: string;
onClick: () => void;
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
tone?: 'default' | 'danger';
}
function RailIconButton({ label, onClick, icon: Icon, tone = 'default' }: RailIconButtonProps) {
const toneClass =
tone === 'danger'
? 'text-fg-muted hover:bg-rose-500/10 hover:text-rose-400 focus-visible:ring-rose-400/40'
: 'text-fg-muted hover:bg-surface-3/60 hover:text-fg focus-visible:ring-accent/50';
return (
<button
type="button"
aria-label={label}
title={label}
onClick={onClick}
className={
'flex h-11 w-11 cursor-pointer items-center justify-center rounded-xl transition focus:outline-none focus-visible:ring-2 ' +
toneClass
}
>
<Icon style={{ width: '20px', height: '20px' }} />
</button>
);
}
function RailBadge({ count }: { count: number }) {
const display = count > 99 ? '99+' : String(count);
return (
<span
aria-hidden="true"
className="inline-flex min-w-[20px] items-center justify-center rounded-full bg-rose-500 px-1.5 text-[10px] font-bold leading-tight text-white shadow-[0_0_0_2px_rgba(15,15,24,1)]"
className="absolute -right-1 -top-1 inline-flex min-w-[18px] items-center justify-center rounded-full bg-rose-500 px-1 text-[10px] font-bold leading-tight text-white ring-2 ring-surface-2"
>
{display}
</span>
@@ -20,7 +20,7 @@ export function TypingIndicator({ typingUserIds, members }: Props) {
: t('app:chats.typing_many', { count: typingUserIds.length });
return (
<div className="px-6 pb-1 pt-0 text-xs text-neutral-400">
<div className="bg-surface-3 px-6 pb-1 pt-0 text-xs text-fg-muted">
<span className="inline-flex items-center gap-2">
<TypingDots />
<span>{text}</span>
@@ -32,9 +32,9 @@ export function TypingIndicator({ typingUserIds, members }: Props) {
function TypingDots() {
return (
<span aria-hidden="true" className="inline-flex items-center gap-0.5">
<span className="h-1 w-1 rounded-full bg-neutral-500 [animation:pulse_1.2s_ease-in-out_infinite]" />
<span className="h-1 w-1 rounded-full bg-neutral-500 [animation:pulse_1.2s_ease-in-out_infinite] [animation-delay:0.15s]" />
<span className="h-1 w-1 rounded-full bg-neutral-500 [animation:pulse_1.2s_ease-in-out_infinite] [animation-delay:0.3s]" />
<span className="h-1 w-1 rounded-full bg-fg-muted [animation:pulse_1.2s_ease-in-out_infinite]" />
<span className="h-1 w-1 rounded-full bg-fg-muted [animation:pulse_1.2s_ease-in-out_infinite] [animation-delay:0.15s]" />
<span className="h-1 w-1 rounded-full bg-fg-muted [animation:pulse_1.2s_ease-in-out_infinite] [animation-delay:0.3s]" />
</span>
);
}
+7 -7
View File
@@ -57,22 +57,22 @@ export function UpdateToast() {
<div
role="status"
aria-live="polite"
className="fixed bottom-6 left-6 z-50 w-80 overflow-hidden rounded-2xl border border-white/10 bg-ink-900/95 p-4 shadow-2xl backdrop-blur-xl"
className="fixed bottom-6 left-6 z-50 w-80 overflow-hidden rounded-2xl border border-line bg-surface-3 p-4 shadow-2xl"
>
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-500/25 text-brand-200 ring-1 ring-brand-400/30">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-accent/20 text-accent">
<SparklesIcon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-white">
<p className="text-sm font-semibold text-fg">
{t('app:update.available', { defaultValue: 'Update verfügbar' })}
{state.version ? ' · v' + state.version : ''}
</p>
{state.notes && (
<p className="mt-0.5 line-clamp-3 text-xs text-neutral-400">{state.notes}</p>
<p className="mt-0.5 line-clamp-3 text-xs text-fg-muted">{state.notes}</p>
)}
{state.error && (
<p className="mt-1 text-xs text-rose-300">{state.error}</p>
<p className="mt-1 text-xs text-rose-500 dark:text-rose-300">{state.error}</p>
)}
</div>
{!installing && (
@@ -80,7 +80,7 @@ export function UpdateToast() {
type="button"
onClick={() => setDismissed(true)}
aria-label={t('common:close', { defaultValue: 'Schließen' })}
className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-neutral-400 transition hover:bg-white/10 hover:text-neutral-100"
className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-fg-muted transition hover:bg-surface-2 hover:text-fg"
>
<XIcon className="h-3.5 w-3.5" />
</button>
@@ -90,7 +90,7 @@ export function UpdateToast() {
type="button"
onClick={() => void handleInstall()}
disabled={installing}
className="mt-3 inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-gradient-to-br from-brand-400 to-brand-600 px-3 py-2 text-sm font-semibold text-white transition hover:from-brand-300 hover:to-brand-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/60 disabled:cursor-not-allowed disabled:opacity-60"
className="mt-3 inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 disabled:cursor-not-allowed disabled:opacity-60"
>
{installing ? (
<>
+18 -17
View File
@@ -5,22 +5,19 @@ import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import { supabase } from '../lib/supabase';
import { Avatar } from './Avatar';
import { ChevronDownIcon } from './icons';
const PRESENCE_OPTIONS: PresenceState[] = ['online', 'idle', 'dnd', 'invisible', 'offline'];
const PRESENCE_DOT: Record<PresenceState, string> = {
online: 'bg-emerald-400',
online: 'bg-emerald-500',
idle: 'bg-amber-400',
dnd: 'bg-rose-500',
invisible: 'bg-neutral-500',
offline: 'bg-neutral-600',
offline: 'bg-neutral-400 dark:bg-neutral-600',
};
function avatarLetter(input: string | undefined): string {
return (input ?? '?').trim().charAt(0).toUpperCase() || '?';
}
export function UserBar() {
const { t } = useTranslation(['app']);
const { profile, refreshProfile } = useAuth();
@@ -53,34 +50,38 @@ export function UserBar() {
onClick={() => setOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={open}
className="flex w-full cursor-pointer items-center gap-3 rounded-lg px-2 py-2 text-left transition hover:bg-white/5 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
className="flex w-full cursor-pointer items-center gap-3 rounded-lg px-2 py-2 text-left transition hover:bg-surface-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
<div className="relative flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-sm font-semibold text-white ring-1 ring-brand-400/30">
{avatarLetter(profile?.displayName ?? profile?.username)}
<div className="relative">
<Avatar
url={profile?.avatarUrl ?? null}
displayName={profile?.displayName ?? profile?.username}
className="h-9 w-9 text-sm"
/>
<span
className={
'absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full ring-2 ring-ink-900 ' +
'absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full ring-2 ring-surface-2 ' +
PRESENCE_DOT[presence]
}
/>
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-white">
<p className="truncate text-sm font-medium text-fg">
{profile?.displayName ?? '—'}
</p>
<p className="truncate text-xs text-neutral-400">
{profile?.username ? '@' + profile.username : '—'}
<p className="truncate text-xs text-fg-muted">
{t('app:presence.' + presence)}
</p>
</div>
<ChevronDownIcon
className={'h-4 w-4 text-neutral-500 transition ' + (open ? 'rotate-180' : '')}
className={'h-4 w-4 text-fg-muted transition ' + (open ? 'rotate-180' : '')}
/>
</button>
{open && (
<div
role="menu"
className="absolute bottom-full left-0 right-0 mb-2 overflow-hidden rounded-lg border border-white/10 bg-ink-900/95 shadow-xl backdrop-blur-xl"
className="absolute bottom-full left-0 right-0 mb-2 overflow-hidden rounded-lg border border-line bg-surface-3 shadow-xl"
>
{PRESENCE_OPTIONS.map((opt) => (
<button
@@ -90,12 +91,12 @@ export function UserBar() {
aria-checked={opt === presence}
onClick={() => void changePresence(opt)}
disabled={busy}
className="flex w-full cursor-pointer items-center gap-3 px-3 py-2 text-left text-sm text-neutral-200 transition hover:bg-white/5 disabled:opacity-50"
className="flex w-full cursor-pointer items-center gap-3 px-3 py-2 text-left text-sm text-fg transition hover:bg-surface-2 disabled:opacity-50"
>
<span className={'h-2.5 w-2.5 rounded-full ' + PRESENCE_DOT[opt]} />
<span className="flex-1">{t('app:presence.' + opt)}</span>
{opt === presence && (
<span className="text-xs text-brand-300"></span>
<span className="text-xs text-accent"></span>
)}
</button>
))}
+229 -12
View File
@@ -336,32 +336,249 @@ export function MonitorStopIcon(props: IconProps) {
);
}
export function SunIcon(props: IconProps) {
return (
<Base {...props}>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
</Base>
);
}
export function MoonIcon(props: IconProps) {
return (
<Base {...props}>
<path d="M21 12.79A9 9 0 1 1 11.21 3a7 7 0 0 0 9.79 9.79Z" />
</Base>
);
}
export function GridIcon(props: IconProps) {
return (
<Base {...props}>
<rect x="3" y="3" width="7" height="7" rx="1.5" />
<rect x="14" y="3" width="7" height="7" rx="1.5" />
<rect x="3" y="14" width="7" height="7" rx="1.5" />
<rect x="14" y="14" width="7" height="7" rx="1.5" />
</Base>
);
}
export function FocusIcon(props: IconProps) {
return (
<Base {...props}>
<rect x="3" y="3" width="18" height="18" rx="2" />
<rect x="8" y="8" width="8" height="8" rx="1" />
</Base>
);
}
export function MaximizeIcon(props: IconProps) {
return (
<Base {...props}>
<path d="M4 9V5a1 1 0 0 1 1-1h4" />
<path d="M20 9V5a1 1 0 0 0-1-1h-4" />
<path d="M4 15v4a1 1 0 0 0 1 1h4" />
<path d="M20 15v4a1 1 0 0 1-1 1h-4" />
</Base>
);
}
export function VideoIcon(props: IconProps) {
return (
<Base {...props}>
<rect x="2" y="6" width="15" height="12" rx="2" />
<path d="m22 8-5 4 5 4V8Z" />
</Base>
);
}
export function CrownIcon(props: IconProps) {
return (
<Base {...props}>
<path d="m3 7 4 4 5-6 5 6 4-4v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" />
</Base>
);
}
export function SendIcon(props: IconProps) {
return (
<Base {...props}>
<path d="m3 11 18-8-8 18-2-8-8-2Z" />
</Base>
);
}
export function ArchiveIcon(props: IconProps) {
return (
<Base {...props}>
<rect x="3" y="4" width="18" height="5" rx="1" />
<path d="M5 9v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V9" />
<path d="M10 13h4" />
</Base>
);
}
export function BellIcon(props: IconProps) {
return (
<Base {...props}>
<path d="M6 8a6 6 0 1 1 12 0c0 4 1.5 5 2 6H4c.5-1 2-2 2-6Z" />
<path d="M9.5 18a2.5 2.5 0 0 0 5 0" />
</Base>
);
}
export function BellOffIcon(props: IconProps) {
return (
<Base {...props}>
<path d="M9.5 18a2.5 2.5 0 0 0 5 0" />
<path d="M13.73 4A2 2 0 0 0 10 4" />
<path d="M18 8c0 1-.1 1.9-.2 2.7" />
<path d="M4 4 20 20" />
<path d="M6 8a6 6 0 0 1 .2-1.7" />
<path d="M4 18h13.5L18 17.4c.5-1 2-2 2-6" />
</Base>
);
}
export function MoreVerticalIcon(props: IconProps) {
return (
<Base {...props}>
<circle cx="12" cy="5" r="1.5" />
<circle cx="12" cy="12" r="1.5" />
<circle cx="12" cy="19" r="1.5" />
</Base>
);
}
export function ReplyIcon(props: IconProps) {
return (
<Base {...props}>
<polyline points="9 17 4 12 9 7" />
<path d="M20 18v-2a4 4 0 0 0-4-4H4" />
</Base>
);
}
export function ForwardIcon(props: IconProps) {
return (
<Base {...props}>
<polyline points="15 17 20 12 15 7" />
<path d="M4 18v-2a4 4 0 0 1 4-4h12" />
</Base>
);
}
export function ChevronUpIcon(props: IconProps) {
return (
<Base {...props}>
<polyline points="18 15 12 9 6 15" />
</Base>
);
}
export function AddUserIcon(props: IconProps) {
return (
<Base {...props}>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M19 8v6M22 11h-6" />
</Base>
);
}
export function LogoMark(props: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 32 32"
viewBox="0 0 64 64"
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>
<clipPath id="logo-hex-clip">
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19" />
</clipPath>
</defs>
<polygon
points="32,5 57,19 57,45 32,59 7,45 7,19"
fill="#2e1065"
/>
<g clipPath="url(#logo-hex-clip)">
<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)"
d="M-4 32 Q 16 18 32 32 T 68 32 L 68 64 L -4 64 Z"
fill="#7c4dff"
/>
<path
d="M12 14h8M12 10h8"
stroke="#0A0A0F"
strokeWidth="1.75"
strokeLinecap="round"
strokeOpacity="0.5"
d="M-4 32 Q 16 18 32 32 T 68 32"
stroke="#a78bfa"
strokeWidth="2"
fill="none"
/>
</g>
<polygon
points="32,5 57,19 57,45 32,59 7,45 7,19"
fill="none"
stroke="#a78bfa"
strokeWidth="1.5"
opacity="0.4"
/>
</svg>
);
}
// Full lockup: hex icon + "Netralax" wordmark. `tone` decides text colour:
// "dark" = white text (use on dark background), "light" = black text.
export function LogoLockup({
tone = 'dark',
...props
}: IconProps & { tone?: 'dark' | 'light' }) {
const textFill = tone === 'dark' ? '#ffffff' : '#0F172A';
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 260 64"
fill="none"
aria-hidden="true"
{...props}
>
<defs>
<clipPath id="logo-lockup-hex-clip">
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19" />
</clipPath>
</defs>
<polygon points="32,5 57,19 57,45 32,59 7,45 7,19" fill="#2e1065" />
<g clipPath="url(#logo-lockup-hex-clip)">
<path
d="M-4 32 Q 16 18 32 32 T 68 32 L 68 64 L -4 64 Z"
fill="#7c4dff"
/>
<path
d="M-4 32 Q 16 18 32 32 T 68 32"
stroke="#a78bfa"
strokeWidth="2"
fill="none"
/>
</g>
<polygon
points="32,5 57,19 57,45 32,59 7,45 7,19"
fill="none"
stroke="#a78bfa"
strokeWidth="1.5"
opacity="0.4"
/>
<text
x="78"
y="42"
fontFamily="'Space Grotesk', system-ui, sans-serif"
fontSize="30"
fontWeight="600"
letterSpacing="-0.6"
fill={textFill}
>
Netralax
</text>
</svg>
);
}
+20 -14
View File
@@ -18,6 +18,7 @@ import {
import { useTranslation } from 'react-i18next';
import { findExistingDevice } from '../lib/device';
import { setSecretStoreUser } from '../lib/secretStore';
import { supabase } from '../lib/supabase';
interface AuthContextValue {
@@ -53,34 +54,39 @@ export function AuthProvider({ children }: { children: ReactNode }) {
(async () => {
const { data: sessionRes } = await supabase.auth.getSession();
if (cancelled) return;
// Flip `ready` immediately on cached session read so the UI unblocks even
// if the network is slow/down. Validate the token in the background and
// only wipe on an unambiguous 401/403 — a stalled getUser (Tauri WebView
// with no network, server unreachable) must not keep the app on the
// loading spinner forever.
setSession(sessionRes.session ?? null);
setReady(true);
if (sessionRes.session) {
const { error } = await supabase.auth.getUser();
if (cancelled) return;
if (error) {
supabase.auth
.getUser()
.then(({ error }) => {
if (cancelled || !error) return;
const status = (error as { status?: number }).status;
if (status === 401 || status === 403) {
// Token genuinely invalid — wipe.
await supabase.auth.signOut().catch(() => {
void supabase.auth.signOut({ scope: 'local' }).catch(() => {
/* ignore */
});
setSession(null);
} else {
// Network / server unreachable — keep cached session, let reads
// fail gracefully and recover when the stack is back.
// Network / server unreachable — keep cached session.
console.warn('auth.getUser failed, keeping cached session:', error);
setSession(sessionRes.session);
}
} else {
setSession(sessionRes.session);
})
.catch((err: unknown) => {
console.warn('auth.getUser rejected, keeping cached session:', err);
});
}
} else {
setSession(null);
}
setReady(true);
})();
const { data: sub } = supabase.auth.onAuthStateChange((_event, s) => {
setSession(s);
setReady(true);
void setSecretStoreUser(s?.user.id ?? null);
});
return () => {
cancelled = true;
+113
View File
@@ -40,6 +40,7 @@ import { getPttSettings, subscribePttSettings } from '../lib/pttSettings';
import {
getAudioQualityParams,
getAudioSettings,
updateAudioSettings,
} from '../lib/audioSettings';
import {
createCallE2EE,
@@ -90,6 +91,10 @@ export interface RemoteScreenShare {
participantName: string;
}
// Visual call modes (Discord-style): grid shows all tiles equally, focus pins
// one speaker with others in a strip, fullscreen is cinema mode.
export type CallMode = 'grid' | 'focus' | 'fullscreen';
interface CallContextValue {
state: CallState;
room: Room | null;
@@ -102,6 +107,9 @@ interface CallContextValue {
// Remembers the conversation of the last call we left so a sidebar widget
// can show "still live — rejoin" while peers stay in the room.
lastCallConversationId: string | null;
// Clean-Rail UI state — display mode + focused participant id.
callMode: CallMode;
focusedId: string | null;
// Actions:
startCall: (conversationId: string, mediaKind?: CallKind) => Promise<void>;
joinActiveCall: (conversationId: string, mediaKind?: CallKind) => Promise<void>;
@@ -111,6 +119,14 @@ interface CallContextValue {
toggleMute: () => void;
toggleScreenShare: () => Promise<void>;
dismissLastCall: () => void;
setCallMode: (mode: CallMode) => void;
setFocusedId: (id: string | null) => void;
// Runtime mic switch. Persists choice so subsequent calls reuse it, and
// hot-swaps the input on an active call without a reconnect.
setAudioInputDevice: (deviceId: string | null) => Promise<void>;
// Runtime speaker/headphone switch. Persists + applies setSinkId to all
// currently-attached remote-audio elements.
setAudioOutputDevice: (deviceId: string | null) => Promise<void>;
}
const CallContext = createContext<CallContextValue | null>(null);
@@ -135,6 +151,8 @@ export function CallProvider({ children }: { children: ReactNode }) {
const [isScreenSharing, setIsScreenSharing] = useState(false);
const [remoteScreenShares, setRemoteScreenShares] = useState<RemoteScreenShare[]>([]);
const [lastCallConversationId, setLastCallConversationId] = useState<string | null>(null);
const [callMode, setCallModeState] = useState<CallMode>('grid');
const [focusedId, setFocusedIdState] = useState<string | null>(null);
const signalChannelRef = useRef<RealtimeChannel | null>(null);
const presenceChannelRef = useRef<RealtimeChannel | null>(null);
@@ -471,12 +489,16 @@ export function CallProvider({ children }: { children: ReactNode }) {
setIsE2EEActive(false);
}
try {
const inputId = getAudioSettings().inputDeviceId;
await r.localParticipant.setMicrophoneEnabled(true, {
echoCancellation: aParams.echoCancellation,
noiseSuppression: aParams.noiseSuppression,
autoGainControl: aParams.autoGainControl,
channelCount: aParams.stereo ? 2 : 1,
sampleRate: aParams.sampleRateHz,
// Plain string maps to `ideal` — if the device is gone we fall back
// to OS default instead of throwing NotFoundError.
...(inputId ? { deviceId: inputId } : {}),
});
} catch (micErr: unknown) {
console.error('setMicrophoneEnabled failed', micErr);
@@ -954,6 +976,76 @@ export function CallProvider({ children }: { children: ReactNode }) {
}
}, [myId, clearRingTimer, clearSoloTimer, disconnectRoom, sendSignal, emitCallEvent]);
const setCallMode = useCallback((mode: CallMode) => {
setCallModeState(mode);
}, []);
const setFocusedId = useCallback((id: string | null) => {
setFocusedIdState(id);
}, []);
const setAudioInputDevice = useCallback(async (deviceId: string | null) => {
updateAudioSettings({ inputDeviceId: deviceId });
const r = roomRef.current;
if (!r) return;
try {
// LiveKit API: `switchActiveDevice(kind, deviceId)` hot-swaps without a
// reconnect. Pass empty string or `default` to revert to OS default.
await r.switchActiveDevice('audioinput', deviceId ?? 'default');
} catch (err: unknown) {
console.error('switchActiveDevice(audioinput) failed', err);
}
}, []);
const setAudioOutputDevice = useCallback(async (deviceId: string | null) => {
updateAudioSettings({ outputDeviceId: deviceId });
const sinkId = deviceId ?? '';
// Apply to every <audio> element we've attached to the body. LiveKit's
// switchActiveDevice only tracks elements it attached itself; our custom
// appendChild path bypasses that, so we iterate and setSinkId manually.
const els = document.querySelectorAll<HTMLAudioElement>(
'audio[data-livekit-track]',
);
for (const el of Array.from(els)) {
if (typeof el.setSinkId !== 'function') continue;
try {
await el.setSinkId(sinkId);
} catch (err: unknown) {
console.warn('setSinkId on audio element failed', err);
}
}
const r = roomRef.current;
if (r) {
try {
await r.switchActiveDevice('audiooutput', sinkId || 'default');
} catch (err: unknown) {
console.warn('switchActiveDevice(audiooutput) failed', err);
}
}
}, []);
// Reset UI call-mode state when the call leaves any active phase so the next
// call starts fresh at grid/unfocused.
useEffect(() => {
if (state.kind === 'idle' || state.kind === 'error') {
setCallModeState('grid');
setFocusedIdState(null);
}
}, [state.kind]);
// Global Esc: drop out of fullscreen cinema back to grid while in an active
// call. Doesn't hangup and doesn't fire when other dialogs would consume it.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
if (callMode !== 'fullscreen') return;
if (state.kind !== 'connected' && state.kind !== 'connecting') return;
setCallModeState('grid');
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [callMode, state.kind]);
const value = useMemo<CallContextValue>(
() => ({
state,
@@ -964,6 +1056,8 @@ export function CallProvider({ children }: { children: ReactNode }) {
isScreenSharing,
remoteScreenShares,
lastCallConversationId,
callMode,
focusedId,
startCall,
joinActiveCall,
acceptIncoming,
@@ -972,6 +1066,10 @@ export function CallProvider({ children }: { children: ReactNode }) {
toggleMute,
toggleScreenShare,
dismissLastCall,
setCallMode,
setFocusedId,
setAudioInputDevice,
setAudioOutputDevice,
}),
[
state,
@@ -982,6 +1080,8 @@ export function CallProvider({ children }: { children: ReactNode }) {
isScreenSharing,
remoteScreenShares,
lastCallConversationId,
callMode,
focusedId,
startCall,
joinActiveCall,
acceptIncoming,
@@ -990,6 +1090,10 @@ export function CallProvider({ children }: { children: ReactNode }) {
toggleMute,
toggleScreenShare,
dismissLastCall,
setCallMode,
setFocusedId,
setAudioInputDevice,
setAudioOutputDevice,
],
);
@@ -1014,6 +1118,15 @@ function attachTrack(
audio.setAttribute('playsinline', 'true');
audio.setAttribute('data-livekit-track', track.sid ?? '');
document.body.appendChild(audio);
// Apply persisted sinkId so the element routes to the user's chosen
// speaker/headphone from the start (LiveKit's own `switchActiveDevice`
// doesn't track custom-appended elements).
const sinkId = getAudioSettings().outputDeviceId;
if (sinkId && typeof audio.setSinkId === 'function') {
void audio.setSinkId(sinkId).catch((err: unknown) => {
console.warn('setSinkId on attach failed', err);
});
}
}
}
// Video is handled later in M2.6/M3 by a dedicated <video> element.
@@ -1,4 +1,8 @@
import { type ConversationSummary, listConversations } from '@chat-app/shared/chat';
import {
type ConversationSummary,
isConversationMuted,
listConversations,
} from '@chat-app/shared/chat';
import {
createContext,
type ReactNode,
@@ -151,6 +155,15 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
.on('postgres_changes', { event: '*', schema: 'public', table: 'conversations' }, () => {
void refresh();
})
.on(
'postgres_changes',
{ event: 'UPDATE', schema: 'public', table: 'profiles' },
() => {
// Peer updated their profile (e.g. uploaded avatar / changed name).
// Re-pull conversations so members[].profile picks up the new data.
void refresh();
},
)
.on(
'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'messages' },
@@ -168,10 +181,15 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
...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') {
// Notification sound + OS notification — respect DND and
// per-conversation mute. Body stays empty because message
// content is E2E-encrypted and only decryptable in the
// conversation view (not at this hook level).
const convForMute = conversationsRef.current.find(
(c) => c.id === row.conversation_id,
);
const muted = isConversationMuted(convForMute?.mutedUntil ?? null);
if (presenceRef.current !== 'dnd' && !muted) {
playNotificationTone();
const conv = conversationsRef.current.find(
(c) => c.id === row.conversation_id,
@@ -195,7 +213,31 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
)
.subscribe();
// Windows WebView2 aggressively throttles background WebSockets and
// sometimes drops events entirely while the window is minimised. Force a
// refresh + realtime reconnect on visibility/focus regain so we never
// leave stale conversation lists on a Windows client after the user
// returns to the app.
const onAwake = () => {
if (document.visibilityState !== 'visible') return;
void refresh();
try {
// If the socket got wedged during background throttle, a no-op
// unsubscribe+resubscribe brings it back. `subscribe()` on an already
// joined channel is a no-op so this is safe.
channel.subscribe();
} catch {
/* ignore — already live */
}
};
document.addEventListener('visibilitychange', onAwake);
window.addEventListener('focus', onAwake);
window.addEventListener('online', onAwake);
return () => {
document.removeEventListener('visibilitychange', onAwake);
window.removeEventListener('focus', onAwake);
window.removeEventListener('online', onAwake);
void supabase.removeChannel(channel);
};
}, [myId, refresh, markRead]);
+53
View File
@@ -0,0 +1,53 @@
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import { applyTheme, getInitialTheme, setThemePersisted, type Theme } from '../lib/theme';
interface ThemeContextValue {
theme: Theme;
toggle: () => void;
setTheme: (t: Theme) => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<Theme>(() => getInitialTheme());
useEffect(() => {
applyTheme(theme);
}, [theme]);
const setTheme = useCallback((next: Theme) => {
setThemePersisted(next);
setThemeState(next);
}, []);
const toggle = useCallback(() => {
setThemeState((prev) => {
const next: Theme = prev === 'dark' ? 'light' : 'dark';
setThemePersisted(next);
return next;
});
}, []);
const value = useMemo<ThemeContextValue>(
() => ({ theme, toggle, setTheme }),
[theme, toggle, setTheme],
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
return ctx;
}
+13 -1
View File
@@ -44,10 +44,22 @@ export async function checkForUpdate(): Promise<UpdateState> {
error: null,
};
} catch (err: unknown) {
// Swallow "no release on GitHub yet" / network-unreachable cases silently.
// The updater endpoint serves `latest.json` from GitHub releases; a fresh
// repo or offline machine produces a generic "Could not fetch a valid
// release JSON" error that has no actionable information for the user —
// logging it on every launch just pollutes the console.
const msg = err instanceof Error ? err.message : String(err);
const benign =
/could not fetch a valid release json|network|timed? out|failed to fetch|connection/i.test(
msg,
);
if (!benign) {
console.warn('checkForUpdate failed', err);
}
return {
...IDLE_UPDATE_STATE,
error: err instanceof Error ? err.message : 'update check failed',
error: benign ? null : msg,
};
}
}
+17
View File
@@ -9,10 +9,19 @@ export type AudioQuality = 'voice' | 'hifi';
export interface AudioSettings {
quality: AudioQuality;
// Preferred input deviceId from enumerateDevices. null = use browser default
// (whatever the OS points at). Persisted across sessions, so "grandma's
// mic is default" stays even after browser picks the wrong device.
inputDeviceId: string | null;
// Preferred output (speaker/headphone) deviceId. null = system default.
// Applied to every <audio> element we attach via HTMLMediaElement.setSinkId.
outputDeviceId: string | null;
}
const DEFAULTS: AudioSettings = {
quality: 'voice',
inputDeviceId: null,
outputDeviceId: null,
};
export interface AudioQualityParams {
@@ -73,6 +82,14 @@ function read(): AudioSettings {
const parsed = JSON.parse(raw) as Partial<AudioSettings>;
cached = {
quality: isQuality(parsed.quality) ? parsed.quality : DEFAULTS.quality,
inputDeviceId:
typeof parsed.inputDeviceId === 'string' && parsed.inputDeviceId.length > 0
? parsed.inputDeviceId
: DEFAULTS.inputDeviceId,
outputDeviceId:
typeof parsed.outputDeviceId === 'string' && parsed.outputDeviceId.length > 0
? parsed.outputDeviceId
: DEFAULTS.outputDeviceId,
};
return cached;
} catch {
+77
View File
@@ -0,0 +1,77 @@
import { supabase } from './supabase';
const BUCKET = 'profile-avatars';
const MAX_DIM = 512;
const QUALITY = 0.85;
// Resizes the source image to a centred-cropped square ≤ MAX_DIM and
// re-encodes as WebP. Falls back to JPEG if WebP isn't supported (rare).
async function resizeToSquare(file: File): Promise<Blob> {
const url = URL.createObjectURL(file);
try {
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
const i = new Image();
i.onload = () => resolve(i);
i.onerror = () => reject(new Error('image load failed'));
i.src = url;
});
const minSide = Math.min(img.naturalWidth, img.naturalHeight);
const sx = (img.naturalWidth - minSide) / 2;
const sy = (img.naturalHeight - minSide) / 2;
const target = Math.min(MAX_DIM, minSide);
const canvas = document.createElement('canvas');
canvas.width = target;
canvas.height = target;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('canvas context unavailable');
ctx.drawImage(img, sx, sy, minSide, minSide, 0, 0, target, target);
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, 'image/webp', QUALITY),
);
if (blob) return blob;
const jpeg = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, 'image/jpeg', QUALITY),
);
if (!jpeg) throw new Error('canvas toBlob returned null');
return jpeg;
} finally {
URL.revokeObjectURL(url);
}
}
export async function uploadAvatar(userId: string, file: File): Promise<string> {
if (!file.type.startsWith('image/')) {
throw new Error('only image files are accepted');
}
const blob = await resizeToSquare(file);
const ext = blob.type === 'image/webp' ? 'webp' : 'jpg';
// Random filename so old uploads don't get overwritten before we update
// the profile row — Supabase Storage CDN caches by URL, so a fresh path
// also forces clients to fetch the new image.
const name =
userId + '/' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8) + '.' + ext;
const { error: upErr } = await supabase.storage.from(BUCKET).upload(name, blob, {
contentType: blob.type,
cacheControl: '604800',
upsert: false,
});
if (upErr) throw upErr;
const { data: pub } = supabase.storage.from(BUCKET).getPublicUrl(name);
return pub.publicUrl;
}
export async function deleteAvatarObject(publicUrl: string): Promise<void> {
// Public URLs look like
// https://<host>/storage/v1/object/public/profile-avatars/<path>
// Extract <path> and remove.
const marker = '/object/public/' + BUCKET + '/';
const idx = publicUrl.indexOf(marker);
if (idx === -1) return;
const path = publicUrl.slice(idx + marker.length);
const { error } = await supabase.storage.from(BUCKET).remove([path]);
if (error) throw error;
}
+236
View File
@@ -0,0 +1,236 @@
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
import { type OwnDeviceCtx, shareConvKeyToDevice } from '@chat-app/shared/chat';
import { pgHexToBytes } from '@chat-app/shared/supabase';
import { devLocalSecretStore } from './secretStore';
import { supabase } from './supabase';
// Watches the `devices` table for new entries AND, on mount, scans every
// conversation we participate in for missing key bundles. Fills gaps by
// re-wrapping our active conv-key for the missing recipient devices.
//
// This fixes the "cannot decrypt" cliff for devices that registered while
// no other participant device was online to share the key with them.
interface SyncCtx {
myUserId: string;
myDeviceId: string;
priv: Uint8Array;
}
// db-types is stale for `conversation_keys`/`active_key_version`; bypass.
function rawFrom(table: string) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (supabase as unknown as { from: (t: string) => any }).from(table);
}
// Module-level flag — gap-fill runs once per (user, device) combo per
// process lifetime. Page reloads / route changes don't re-trigger it.
const backfilledKey = new Set<string>();
export function startConversationKeySync(
ownUserId: string,
ownDeviceId: string,
): () => void {
let cancelled = false;
let priv: Uint8Array | null = null;
const dedupeKey = ownUserId + ':' + ownDeviceId;
void loadDevicePrivateKey(devLocalSecretStore, ownUserId, ownDeviceId).then(async (pk) => {
if (cancelled) return;
priv = pk;
if (!priv) return;
if (backfilledKey.has(dedupeKey)) return;
backfilledKey.add(dedupeKey);
await syncAllExistingGaps({ myUserId: ownUserId, myDeviceId: ownDeviceId, priv });
});
const channel = supabase
.channel('device-key-sync:' + ownDeviceId)
.on(
'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'devices' },
(payload: { new: { id?: string; user_id?: string; public_key?: string } }) => {
if (cancelled) return;
const row = payload.new;
if (!row?.id || !row.user_id || !row.public_key) return;
if (row.user_id === ownUserId && row.id === ownDeviceId) return;
if (!priv) return; // backfill on mount will catch it later
void wrapForOneDevice(
{ myUserId: ownUserId, myDeviceId: ownDeviceId, priv },
row.id,
row.user_id,
row.public_key,
);
},
)
.subscribe();
return () => {
cancelled = true;
void supabase.removeChannel(channel);
};
}
async function listMyConversationIds(myUserId: string): Promise<string[]> {
const { data, error } = await supabase
.from('conversation_members')
.select('conversation_id')
.eq('user_id', myUserId)
.eq('accepted', true);
if (error) {
console.warn('keySync: own-member lookup failed', error);
return [];
}
return (data ?? []).map((r) => r.conversation_id as string);
}
async function listConversationDevices(
conversationId: string,
): Promise<{ id: string; user_id: string; public_key: string }[]> {
const { data: members, error: mErr } = await supabase
.from('conversation_members')
.select('user_id')
.eq('conversation_id', conversationId)
.eq('accepted', true);
if (mErr) {
console.warn('keySync: members lookup failed', mErr);
return [];
}
const userIds = (members ?? []).map((m) => m.user_id as string);
if (userIds.length === 0) return [];
const { data: devices, error: dErr } = await supabase
.from('devices')
.select('id, user_id, public_key')
.in('user_id', userIds);
if (dErr) {
console.warn('keySync: devices lookup failed', dErr);
return [];
}
return (devices ?? []) as { id: string; user_id: string; public_key: string }[];
}
async function listExistingKeyRecipients(
conversationId: string,
keyVersion: number,
): Promise<Set<string>> {
const { data, error } = await rawFrom('conversation_keys')
.select('recipient_device_id')
.eq('conversation_id', conversationId)
.eq('key_version', keyVersion);
if (error) {
console.warn('keySync: existing keys lookup failed', error);
return new Set();
}
return new Set((data ?? []).map((r: { recipient_device_id: string }) => r.recipient_device_id));
}
async function getActiveKeyVersion(conversationId: string): Promise<number> {
const { data, error } = await rawFrom('conversations')
.select('active_key_version')
.eq('id', conversationId)
.single();
if (error) {
console.warn('keySync: active key version lookup failed', error);
return 1;
}
return (data as { active_key_version: number }).active_key_version;
}
async function syncAllExistingGaps(ctx: SyncCtx): Promise<void> {
const convs = await listMyConversationIds(ctx.myUserId);
for (const convId of convs) {
try {
await syncOneConversationGaps(ctx, convId);
} catch (err: unknown) {
console.warn('keySync: conv gap sync failed', { convId, err });
}
}
}
async function syncOneConversationGaps(ctx: SyncCtx, convId: string): Promise<void> {
const version = await getActiveKeyVersion(convId);
const devices = await listConversationDevices(convId);
if (devices.length === 0) return;
const recipients = await listExistingKeyRecipients(convId, version);
const ownCtx: OwnDeviceCtx = {
userId: ctx.myUserId,
deviceId: ctx.myDeviceId,
privateKey: ctx.priv,
};
for (const dev of devices) {
if (recipients.has(dev.id)) continue;
// Skip our own device — we already have the bundle if we're capable of
// sharing (or don't need it if we ourselves haven't been wrapped yet).
if (dev.id === ctx.myDeviceId) continue;
try {
await shareConvKeyToDevice(supabase, convId, dev.id, pgHexToBytes(dev.public_key), ownCtx);
} catch (err: unknown) {
// Backfill is best-effort. Most common silent failures:
// - tryGetConvKey couldn't unwrap (another peer will fill the gap).
// - RLS rejects because the recipient's owner is a pending (not-yet
// accepted) DM member, or was removed from the conv.
// Both are recoverable / expected, swallow without spam.
if (isExpectedShareFailure(err)) continue;
console.warn('keySync: shareConvKeyToDevice gap-fill failed', {
convId,
recipient: dev.id,
err,
});
}
}
}
function isExpectedShareFailure(err: unknown): boolean {
if (!err || typeof err !== 'object') return false;
const e = err as { message?: string; code?: string; details?: string; status?: number };
const code = (e.code ?? '').toString();
const status = e.status;
const haystack = (e.message ?? '') + ' ' + (e.details ?? '');
return (
status === 403 ||
code === '42501' || // postgres: insufficient_privilege (RLS)
code === '23505' || // unique_violation
haystack.includes('row-level security') ||
haystack.includes('does not have it yet') ||
haystack.includes('Forbidden')
);
}
async function wrapForOneDevice(
ctx: SyncCtx,
newDeviceId: string,
newDeviceUserId: string,
newDevicePubHex: string,
): Promise<void> {
const myConvs = new Set(await listMyConversationIds(ctx.myUserId));
const { data: peerMember, error: pErr } = await supabase
.from('conversation_members')
.select('conversation_id')
.eq('user_id', newDeviceUserId);
if (pErr) {
console.warn('keySync: peer-member lookup failed', pErr);
return;
}
const sharedConvs = (peerMember ?? [])
.map((r) => r.conversation_id as string)
.filter((id) => myConvs.has(id));
if (sharedConvs.length === 0) return;
const newPub = pgHexToBytes(newDevicePubHex);
const ownCtx: OwnDeviceCtx = {
userId: ctx.myUserId,
deviceId: ctx.myDeviceId,
privateKey: ctx.priv,
};
for (const convId of sharedConvs) {
try {
await shareConvKeyToDevice(supabase, convId, newDeviceId, newPub, ownCtx);
} catch (err: unknown) {
console.warn('keySync: shareConvKeyToDevice failed', { convId, err });
}
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
import type { CryptoBackend } from '@chat-app/shared/crypto';
import _sodium from 'libsodium-wrappers';
import _sodium from 'libsodium-wrappers-sumo';
// Build the libsodium-backed CryptoBackend. Awaits the WASM ready-gate once,
// then returns a synchronous implementation of the CryptoBackend contract.
+1 -1
View File
@@ -25,7 +25,7 @@ export function readLocalDeviceId(userId: string): string | null {
return window.localStorage.getItem(deviceIdStorageKey(userId));
}
function writeLocalDeviceId(userId: string, deviceId: string): void {
export function writeLocalDeviceId(userId: string, deviceId: string): void {
window.localStorage.setItem(deviceIdStorageKey(userId), deviceId);
}
+142
View File
@@ -0,0 +1,142 @@
import { getCryptoBackend } from '@chat-app/shared/crypto';
import sodium from 'libsodium-wrappers-sumo';
// Encrypts/decrypts the device private key with a user-provided passphrase
// so the backup string can be safely written down or stored in a password
// manager. Uses Argon2id (libsodium crypto_pwhash) for the KDF and
// XSalsa20-Poly1305 (crypto_secretbox) for the AEAD.
//
// Backup format (base64url-encoded blob, prefixed with a magic string so we
// can version it):
//
// chatapp-backup-v1.<base64url(salt(16) | nonce(24) | ciphertext)>
const MAGIC = 'chatapp-backup-v1.';
const SALT_LEN = 16; // crypto_pwhash_SALTBYTES
const NONCE_LEN = 24; // crypto_secretbox_NONCEBYTES
const KEY_LEN = 32; // crypto_secretbox_KEYBYTES
async function ensureSodium(): Promise<typeof sodium> {
await sodium.ready;
return sodium;
}
function b64url(bytes: Uint8Array): string {
let s = '';
for (const b of bytes) s += String.fromCharCode(b);
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function unb64url(s: string): Uint8Array {
let str = s.replace(/-/g, '+').replace(/_/g, '/');
while (str.length % 4) str += '=';
const bin = atob(str);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
async function deriveKey(passphrase: string, salt: Uint8Array, sodiumLib: typeof sodium): Promise<Uint8Array> {
return sodiumLib.crypto_pwhash(
KEY_LEN,
passphrase,
salt,
sodiumLib.crypto_pwhash_OPSLIMIT_MODERATE,
sodiumLib.crypto_pwhash_MEMLIMIT_MODERATE,
sodiumLib.crypto_pwhash_ALG_ARGON2ID13,
);
}
export async function exportDeviceKey(
privateKey: Uint8Array,
passphrase: string,
): Promise<string> {
if (passphrase.length < 8) throw new Error('Passphrase must be at least 8 characters.');
const s = await ensureSodium();
const salt = s.randombytes_buf(SALT_LEN);
const nonce = s.randombytes_buf(NONCE_LEN);
const key = await deriveKey(passphrase, salt, s);
const backend = getCryptoBackend();
const ciphertext = backend.secretbox(privateKey, nonce, key);
s.memzero(key);
const blob = new Uint8Array(SALT_LEN + NONCE_LEN + ciphertext.length);
blob.set(salt, 0);
blob.set(nonce, SALT_LEN);
blob.set(ciphertext, SALT_LEN + NONCE_LEN);
return MAGIC + b64url(blob);
}
export async function importDeviceKey(
backup: string,
passphrase: string,
): Promise<Uint8Array> {
if (!backup.startsWith(MAGIC)) {
throw new Error('Invalid backup format');
}
const blob = unb64url(backup.slice(MAGIC.length));
if (blob.length < SALT_LEN + NONCE_LEN + 1) {
throw new Error('Backup too short');
}
const salt = blob.slice(0, SALT_LEN);
const nonce = blob.slice(SALT_LEN, SALT_LEN + NONCE_LEN);
const ciphertext = blob.slice(SALT_LEN + NONCE_LEN);
const s = await ensureSodium();
const key = await deriveKey(passphrase, salt, s);
const backend = getCryptoBackend();
try {
return backend.secretboxOpen(ciphertext, nonce, key);
} catch {
throw new Error('Wrong passphrase or corrupt backup');
} finally {
s.memzero(key);
}
}
// Full-device backup. Wraps userId + deviceId + privateKey in a JSON payload
// before encrypting, so a restore flow can re-seed localStorage + vault + server
// device row without requiring the user to remember IDs.
export interface DeviceBackupPayload {
v: 2;
userId: string;
deviceId: string;
privateKeyB64: string;
}
export async function exportDeviceBackup(
params: {
userId: string;
deviceId: string;
privateKey: Uint8Array;
passphrase: string;
},
): Promise<string> {
const payload: DeviceBackupPayload = {
v: 2,
userId: params.userId,
deviceId: params.deviceId,
privateKeyB64: b64url(params.privateKey),
};
const bytes = new TextEncoder().encode(JSON.stringify(payload));
return exportDeviceKey(bytes, params.passphrase);
}
export async function importDeviceBackup(
backup: string,
passphrase: string,
): Promise<DeviceBackupPayload> {
const plain = await importDeviceKey(backup, passphrase);
const text = new TextDecoder().decode(plain);
try {
const obj = JSON.parse(text) as DeviceBackupPayload;
if (obj && obj.v === 2 && obj.userId && obj.deviceId && obj.privateKeyB64) {
return obj;
}
} catch {
/* fall through */
}
throw new Error('Backup format not supported — v2 expected');
}
export function decodePrivateKeyFromBackup(payload: DeviceBackupPayload): Uint8Array {
return unb64url(payload.privateKeyB64);
}
+35 -3
View File
@@ -4,23 +4,54 @@ import {
sendNotification,
} from '@tauri-apps/plugin-notification';
import { isTauriRuntime } from './globalShortcut';
// Tracks whether permission has already been requested this session so we
// don't spam the OS prompt. Actual permission state lives in the OS.
// don't spam the OS prompt. Actual permission state lives in the OS, but we
// also persist a "we've asked" marker in localStorage so reloads don't
// re-request (OS would block anyway after denial, but calling it every reload
// triggers noisy plugin warnings on some platforms).
let permissionChecked = false;
let permissionGranted = false;
const ASKED_KEY = 'chatapp.notif.asked';
function readAskedMarker(): boolean {
try {
return window.localStorage.getItem(ASKED_KEY) === '1';
} catch {
return false;
}
}
function writeAskedMarker(): void {
try {
window.localStorage.setItem(ASKED_KEY, '1');
} catch {
/* quota / private mode */
}
}
export async function ensureNotificationPermission(): Promise<boolean> {
if (permissionChecked) return permissionGranted;
permissionChecked = true;
if (!isTauriRuntime()) {
// Web preview / Chrome — Tauri notification plugin not available.
permissionGranted = false;
return false;
}
try {
let granted = await isPermissionGranted();
if (!granted) {
if (!granted && !readAskedMarker()) {
// First-install: prompt the user once. After this we remember via the
// marker and never re-prompt — the user can re-enable later via OS
// system settings if they change their mind.
const result = await requestPermission();
granted = result === 'granted';
writeAskedMarker();
}
permissionGranted = granted;
} catch (err: unknown) {
// Not running under Tauri (e.g. web preview) — fall back silently.
permissionGranted = false;
console.warn('notification permission check failed', err);
}
@@ -40,6 +71,7 @@ interface NotifyOpts {
export async function notify({ title, body, force = false }: NotifyOpts): Promise<void> {
if (!force && isAppFocused()) return;
if (!isTauriRuntime()) return;
const granted = await ensureNotificationPermission();
if (!granted) return;
try {
+58 -4
View File
@@ -1,12 +1,21 @@
import { base64FromBytes, bytesFromBase64, type SecretStore } from '@chat-app/shared/auth';
// M1 dev-only impl: persists secrets as base64 in localStorage.
// Swap this out for a tauri-plugin-stronghold implementation before release.
// The SecretStore interface stays identical so callers won't notice.
import { isTauriRuntime } from './globalShortcut';
import { makeSecureFileStore, migrateLocalStorageToVault } from './secureFileStore';
// Two-tier SecretStore:
// - Tauri runtime: encrypted single-file vault in `appLocalDataDir`
// (`secureFileStore` — XSalsa20-Poly1305 + Argon2id KDF). Survives app
// reinstalls when the OS preserves the data dir.
// - Web / pre-auth: plain localStorage (legacy fallback).
//
// Callers import `devLocalSecretStore` and call `setSecretStoreUser(userId)`
// once the session is known. The singleton object's identity is stable so
// existing imports keep working.
const PREFIX = 'chatapp.secret:';
export const devLocalSecretStore: SecretStore = {
const localStore: SecretStore = {
async getSecret(key: string): Promise<Uint8Array | null> {
const raw = window.localStorage.getItem(PREFIX + key);
if (!raw) return null;
@@ -20,3 +29,48 @@ export const devLocalSecretStore: SecretStore = {
window.localStorage.removeItem(PREFIX + key);
},
};
let activeBackend: SecretStore = localStore;
let activeUserId: string | null = null;
export async function setSecretStoreUser(userId: string | null): Promise<void> {
if (userId === activeUserId) return;
activeUserId = userId;
if (userId && isTauriRuntime()) {
const fileStore = makeSecureFileStore(userId);
try {
// Probe write/read to confirm the vault is usable on this machine.
// If anything throws (perm denied, disk full, KDF error), fall back to
// localStorage so the rest of the app keeps working.
await fileStore.getSecret('__probe');
activeBackend = fileStore;
try {
await migrateLocalStorageToVault(userId, PREFIX);
} catch (err: unknown) {
console.warn('vault migration failed', err);
}
} catch (err: unknown) {
console.warn('secure file vault init failed — falling back to localStorage', err);
activeBackend = localStore;
}
} else {
activeBackend = localStore;
}
}
export const devLocalSecretStore: SecretStore = {
async getSecret(key) {
return activeBackend.getSecret(key);
},
async setSecret(key, value) {
return activeBackend.setSecret(key, value);
},
async removeSecret(key) {
return activeBackend.removeSecret(key);
},
};
export function isEncryptedVaultActive(): boolean {
return activeBackend !== localStore;
}

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