Compare commits

...

5 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
52 changed files with 2921 additions and 318 deletions
+2 -1
View File
@@ -30,7 +30,7 @@
"@tauri-apps/plugin-stronghold": "^2.0.1", "@tauri-apps/plugin-stronghold": "^2.0.1",
"@tauri-apps/plugin-updater": "^2.10.1", "@tauri-apps/plugin-updater": "^2.10.1",
"i18next": "^23.16.4", "i18next": "^23.16.4",
"libsodium-wrappers": "0.7.15", "libsodium-wrappers-sumo": "0.7.15",
"livekit-client": "^2.7.0", "livekit-client": "^2.7.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
@@ -41,6 +41,7 @@
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2.1.0", "@tauri-apps/cli": "^2.1.0",
"@types/libsodium-wrappers": "^0.7.14", "@types/libsodium-wrappers": "^0.7.14",
"@types/libsodium-wrappers-sumo": "^0.8.2",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3", "@vitejs/plugin-react": "^4.3.3",
@@ -34,6 +34,7 @@
{ {
"identifier": "fs:scope", "identifier": "fs:scope",
"allow": [ "allow": [
{ "path": "$APPLOCALDATA" },
{ "path": "$APPLOCALDATA/**" } { "path": "$APPLOCALDATA/**" }
] ]
} }
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "ChatApp", "productName": "ChatApp",
"version": "0.6.0", "version": "0.7.1",
"identifier": "com.meinname.chatapp", "identifier": "com.meinname.chatapp",
"build": { "build": {
"beforeDevCommand": "pnpm vite:dev", "beforeDevCommand": "pnpm vite:dev",
+81 -37
View File
@@ -1,6 +1,7 @@
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 { AppShell } from './components/AppShell';
import { ErrorBoundary } from './components/ErrorBoundary';
import { UpdateToast } from './components/UpdateToast'; import { UpdateToast } from './components/UpdateToast';
import { RequireAdmin, RequireAuth, RequireDevice } from './components/guards'; import { RequireAdmin, RequireAuth, RequireDevice } from './components/guards';
import { AuthProvider } from './context/AuthContext'; import { AuthProvider } from './context/AuthContext';
@@ -17,42 +18,85 @@ import { DevicePage } from './pages/DevicePage';
import { FriendsPage } from './pages/FriendsPage'; import { FriendsPage } from './pages/FriendsPage';
import { SettingsPage } from './pages/SettingsPage'; import { SettingsPage } from './pages/SettingsPage';
export function App() { // 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 ( return (
<ThemeProvider> <ErrorBoundary scope={scope}>
<AuthProvider> <Outlet />
<FriendshipsProvider> </ErrorBoundary>
<ConversationsProvider> );
<CallProvider> }
<BrowserRouter>
<Routes> export function App() {
<Route path="/auth" element={<AuthPage />} /> return (
<Route path="/auth/callback" element={<AuthCallbackPage />} /> <ErrorBoundary scope="root">
<Route element={<RequireAuth />}> <ThemeProvider>
<Route path="/device" element={<DevicePage />} /> <AuthProvider>
<Route element={<RequireDevice />}> <FriendshipsProvider>
<Route element={<AppShell />}> <ConversationsProvider>
<Route index element={<Navigate to="/chats" replace />} /> <CallProvider>
<Route path="/chats" element={<ChatsPage />}> <BrowserRouter
<Route index element={<ChatsEmptyState />} /> future={{
<Route path=":id" element={<ConversationPage />} /> // Opt into v7 behaviour early so the upgrade is a no-op:
</Route> // - `v7_startTransition` wraps navigations in startTransition
<Route path="/friends" element={<FriendsPage />} /> // so Suspense / concurrent rendering deal with the new tree
<Route path="/settings" element={<SettingsPage />} /> // - `v7_relativeSplatPath` matches relative paths inside
<Route element={<RequireAdmin />}> // splat routes against the parent splat segment (not the
<Route path="/admin" element={<AdminPage />} /> // full matched path). Our tree has no splat routes today
</Route> // but this kills the runtime warning and future-proofs.
</Route> v7_startTransition: true,
</Route> v7_relativeSplatPath: true,
</Route> }}
<Route path="*" element={<Navigate to="/chats" replace />} /> >
</Routes> <Routes>
<UpdateToast /> <Route element={<RouteBoundary scope="auth" />}>
</BrowserRouter> <Route path="/auth" element={<AuthPage />} />
</CallProvider> <Route path="/auth/callback" element={<AuthCallbackPage />} />
</ConversationsProvider> </Route>
</FriendshipsProvider> <Route element={<RequireAuth />}>
</AuthProvider> <Route element={<RouteBoundary scope="device" />}>
</ThemeProvider> <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={
<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>
); );
} }
+2
View File
@@ -4,6 +4,7 @@ import { Outlet } from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { startConversationKeySync } from '../lib/conversationKeySync'; import { startConversationKeySync } from '../lib/conversationKeySync';
import { ensureNotificationPermission } from '../lib/osNotify'; import { ensureNotificationPermission } from '../lib/osNotify';
import { BackupPromptBanner } from './BackupPromptBanner';
import { CallUI } from './CallUI'; import { CallUI } from './CallUI';
import { Sidebar } from './Sidebar'; import { Sidebar } from './Sidebar';
@@ -32,6 +33,7 @@ export function AppShell() {
</main> </main>
</div> </div>
<CallUI /> <CallUI />
<BackupPromptBanner />
</div> </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}
/>
)}
</>
);
}
@@ -28,9 +28,10 @@ interface Props {
conversation: ConversationSummary | null; conversation: ConversationSummary | null;
peerPresence: PresenceState | null; peerPresence: PresenceState | null;
onInfoClick?: () => void; onInfoClick?: () => void;
onSearchClick?: () => void;
} }
export function ConversationHeader({ conversation, peerPresence, onInfoClick }: Props) { export function ConversationHeader({ conversation, peerPresence, onInfoClick, onSearchClick }: Props) {
if (!conversation) { if (!conversation) {
return <header className="h-[57px] border-b border-line px-6 py-3" aria-busy="true" />; return <header className="h-[57px] border-b border-line px-6 py-3" aria-busy="true" />;
} }
@@ -41,6 +42,7 @@ export function ConversationHeader({ conversation, peerPresence, onInfoClick }:
conversation={conversation} conversation={conversation}
peerPresence={peerPresence} peerPresence={peerPresence}
{...(onInfoClick ? { onInfoClick } : {})} {...(onInfoClick ? { onInfoClick } : {})}
{...(onSearchClick ? { onSearchClick } : {})}
/> />
<ActiveCallBanner conversationId={conversation.id} /> <ActiveCallBanner conversationId={conversation.id} />
</> </>
@@ -51,9 +53,10 @@ interface HeaderBarProps {
conversation: ConversationSummary; conversation: ConversationSummary;
peerPresence: PresenceState | null; peerPresence: PresenceState | null;
onInfoClick?: () => void; onInfoClick?: () => void;
onSearchClick?: () => void;
} }
function HeaderBar({ conversation, peerPresence, onInfoClick }: HeaderBarProps) { function HeaderBar({ conversation, peerPresence, onInfoClick, onSearchClick }: HeaderBarProps) {
const { t } = useTranslation(['app']); const { t } = useTranslation(['app']);
const isDm = conversation.type === 'dm'; const isDm = conversation.type === 'dm';
@@ -108,7 +111,11 @@ function HeaderBar({ conversation, peerPresence, onInfoClick }: HeaderBarProps)
</p> </p>
</div> </div>
<HeaderActionButton label={t('app:chats.search', { defaultValue: 'Suche' })} icon={SearchIcon} /> <HeaderActionButton
label={t('app:chats.search', { defaultValue: 'Suche' })}
icon={SearchIcon}
{...(onSearchClick ? { onClick: onSearchClick } : {})}
/>
<CallHeaderButton conversationId={conversation.id} kind="audio" /> <CallHeaderButton conversationId={conversation.id} kind="audio" />
<CallHeaderButton conversationId={conversation.id} kind="video" /> <CallHeaderButton conversationId={conversation.id} kind="video" />
{!isDm && onInfoClick && ( {!isDm && onInfoClick && (
@@ -203,17 +210,14 @@ function ActiveCallBanner({ conversationId }: { conversationId: string }) {
const justLeft = state.kind === 'idle' && lastCallConversationId === conversationId; const justLeft = state.kind === 'idle' && lastCallConversationId === conversationId;
// Once presence confirms the room is empty, drop the "just left" hint so the // Once presence confirms the room is empty, drop the "just left" hint so the
// banner hides cleanly instead of sticking forever. // 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(() => { useEffect(() => {
if (!justLeft) return; if (!justLeft) return;
if (active.length === 0) return; if (othersIn.length > 0) return; // still live — keep banner
if (othersIn.length === 0) { const id = window.setTimeout(() => dismissLastCall(), 3000);
// Active reports only us (or something stale) — wait, then dismiss. return () => window.clearTimeout(id);
const id = window.setTimeout(() => dismissLastCall(), 3000); }, [justLeft, othersIn.length, dismissLastCall]);
return () => window.clearTimeout(id);
}
return undefined;
}, [justLeft, active.length, othersIn.length, dismissLastCall]);
if (iAmIn) return null; if (iAmIn) return null;
if (othersIn.length === 0 && !justLeft) return null; if (othersIn.length === 0 && !justLeft) return null;
@@ -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>
);
}
+1 -1
View File
@@ -487,7 +487,7 @@ function FullscreenCall({
}, []); }, []);
return ( return (
<div className="absolute inset-0 z-40 flex flex-col overflow-hidden bg-surface"> <div className="fixed inset-0 z-[60] flex flex-col overflow-hidden bg-surface">
<div className="relative min-h-0 flex-1"> <div className="relative min-h-0 flex-1">
{speaker && ( {speaker && (
<div className="absolute inset-0"> <div className="absolute inset-0">
+72 -2
View File
@@ -15,11 +15,19 @@ import { supabase } from '../lib/supabase';
import type { AggregatedReaction } from '../lib/useMessageReactions'; import type { AggregatedReaction } from '../lib/useMessageReactions';
import { AttachmentImage } from './AttachmentImage'; import { AttachmentImage } from './AttachmentImage';
import { Avatar } from './Avatar'; import { Avatar } from './Avatar';
import { PencilIcon, PhoneIcon, PhoneOffIcon, SmileIcon, SpinnerIcon, TrashIcon, XIcon } from './icons'; import { ForwardIcon, PencilIcon, PhoneIcon, PhoneOffIcon, ReplyIcon, SmileIcon, SpinnerIcon, TrashIcon, XIcon } from './icons';
const EMOJI_CHOICES = ['👍', '❤️', '😂', '🎉', '🔥', '😮', '😢', '🙏']; const EMOJI_CHOICES = ['👍', '❤️', '😂', '🎉', '🔥', '😮', '😢', '🙏'];
const EDIT_WINDOW_MS = 24 * 60 * 60 * 1000; const EDIT_WINDOW_MS = 24 * 60 * 60 * 1000;
export interface QuotedRef {
id: string;
senderName: string;
snippet: string;
isAttachment: boolean;
deleted: boolean;
}
interface Props { interface Props {
message: DecryptedMessage; message: DecryptedMessage;
mine: boolean; mine: boolean;
@@ -32,6 +40,16 @@ interface Props {
reactions: AggregatedReaction[]; reactions: AggregatedReaction[];
onToggleReaction: (emoji: string) => Promise<void>; onToggleReaction: (emoji: string) => Promise<void>;
showSeen?: boolean; 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({ export function MessageBubble({
@@ -45,6 +63,11 @@ export function MessageBubble({
reactions, reactions,
onToggleReaction, onToggleReaction,
showSeen = false, showSeen = false,
quoted = null,
onJumpToMessage,
onReply,
onForward,
highlighted = false,
}: Props) { }: Props) {
const { t } = useTranslation(['app']); const { t } = useTranslation(['app']);
const { session, device } = useAuth(); const { session, device } = useAuth();
@@ -228,13 +251,46 @@ export function MessageBubble({
</div> </div>
) : ( ) : (
<div <div
data-message-id={message.id}
className={ className={
'break-words 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 (mine
? 'rounded-[18px_18px_4px_18px] bg-accent text-accent-fg' ? 'rounded-[18px_18px_4px_18px] bg-accent text-accent-fg'
: 'rounded-[18px_18px_18px_4px] border border-line text-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 ? ( {message.plaintext === null ? (
<span className="italic opacity-70">cannot decrypt</span> <span className="italic opacity-70">cannot decrypt</span>
) : ( ) : (
@@ -299,6 +355,20 @@ export function MessageBubble({
onClick={() => setPickerOpen((v) => !v)} onClick={() => setPickerOpen((v) => !v)}
icon={<SmileIcon className="h-4 w-4" />} 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 && ( {canEdit && (
<ActionButton <ActionButton
label="Edit" label="Edit"
@@ -1,5 +1,6 @@
import type { RemoteTrack } from 'livekit-client'; import type { RemoteTrack } from 'livekit-client';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { RemoteScreenShare } from '../context/CallContext'; import type { RemoteScreenShare } from '../context/CallContext';
@@ -32,30 +33,31 @@ export function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShare
}; };
}, [share.track, watching]); }, [share.track, watching]);
// Esc exits CSS fullscreen.
useEffect(() => { useEffect(() => {
const onChange = () => { if (!isFullscreen) return;
setIsFullscreen(document.fullscreenElement === containerRef.current); const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setIsFullscreen(false);
}; };
document.addEventListener('fullscreenchange', onChange); window.addEventListener('keydown', onKey);
return () => document.removeEventListener('fullscreenchange', onChange); 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 = () => { const toggleFullscreen = () => {
const el = containerRef.current; setIsFullscreen((v) => !v);
if (!el) return;
if (document.fullscreenElement === el) {
void document.exitFullscreen();
} else {
void el.requestFullscreen();
}
}; };
return ( const viewerNode = (
<div <div
ref={containerRef} ref={containerRef}
className={ className={
'overflow-hidden rounded-xl border border-emerald-500/30 bg-black ' + isFullscreen
(isFullscreen ? 'flex h-screen w-screen flex-col rounded-none' : '') ? '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"> <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">
@@ -130,6 +132,14 @@ export function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShare
)} )}
</div> </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 }) { function BlurredTile({ avatarUrl, letter }: { avatarUrl: string | null; letter: string }) {
+68
View File
@@ -409,6 +409,74 @@ export function SendIcon(props: IconProps) {
); );
} }
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) { export function AddUserIcon(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
+27 -23
View File
@@ -54,30 +54,34 @@ export function AuthProvider({ children }: { children: ReactNode }) {
(async () => { (async () => {
const { data: sessionRes } = await supabase.auth.getSession(); const { data: sessionRes } = await supabase.auth.getSession();
if (cancelled) return; if (cancelled) return;
if (sessionRes.session) { // Flip `ready` immediately on cached session read so the UI unblocks even
const { error } = await supabase.auth.getUser(); // if the network is slow/down. Validate the token in the background and
if (cancelled) return; // only wipe on an unambiguous 401/403 — a stalled getUser (Tauri WebView
if (error) { // with no network, server unreachable) must not keep the app on the
const status = (error as { status?: number }).status; // loading spinner forever.
if (status === 401 || status === 403) { setSession(sessionRes.session ?? null);
// Token genuinely invalid — wipe.
await 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.
console.warn('auth.getUser failed, keeping cached session:', error);
setSession(sessionRes.session);
}
} else {
setSession(sessionRes.session);
}
} else {
setSession(null);
}
setReady(true); setReady(true);
if (sessionRes.session) {
supabase.auth
.getUser()
.then(({ error }) => {
if (cancelled || !error) return;
const status = (error as { status?: number }).status;
if (status === 401 || status === 403) {
void supabase.auth.signOut({ scope: 'local' }).catch(() => {
/* ignore */
});
setSession(null);
} else {
// Network / server unreachable — keep cached session.
console.warn('auth.getUser failed, keeping cached session:', error);
}
})
.catch((err: unknown) => {
console.warn('auth.getUser rejected, keeping cached session:', err);
});
}
})(); })();
const { data: sub } = supabase.auth.onAuthStateChange((_event, s) => { const { data: sub } = supabase.auth.onAuthStateChange((_event, s) => {
setSession(s); setSession(s);
+64
View File
@@ -40,6 +40,7 @@ import { getPttSettings, subscribePttSettings } from '../lib/pttSettings';
import { import {
getAudioQualityParams, getAudioQualityParams,
getAudioSettings, getAudioSettings,
updateAudioSettings,
} from '../lib/audioSettings'; } from '../lib/audioSettings';
import { import {
createCallE2EE, createCallE2EE,
@@ -120,6 +121,12 @@ interface CallContextValue {
dismissLastCall: () => void; dismissLastCall: () => void;
setCallMode: (mode: CallMode) => void; setCallMode: (mode: CallMode) => void;
setFocusedId: (id: string | null) => 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); const CallContext = createContext<CallContextValue | null>(null);
@@ -482,12 +489,16 @@ export function CallProvider({ children }: { children: ReactNode }) {
setIsE2EEActive(false); setIsE2EEActive(false);
} }
try { try {
const inputId = getAudioSettings().inputDeviceId;
await r.localParticipant.setMicrophoneEnabled(true, { await r.localParticipant.setMicrophoneEnabled(true, {
echoCancellation: aParams.echoCancellation, echoCancellation: aParams.echoCancellation,
noiseSuppression: aParams.noiseSuppression, noiseSuppression: aParams.noiseSuppression,
autoGainControl: aParams.autoGainControl, autoGainControl: aParams.autoGainControl,
channelCount: aParams.stereo ? 2 : 1, channelCount: aParams.stereo ? 2 : 1,
sampleRate: aParams.sampleRateHz, 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) { } catch (micErr: unknown) {
console.error('setMicrophoneEnabled failed', micErr); console.error('setMicrophoneEnabled failed', micErr);
@@ -973,6 +984,46 @@ export function CallProvider({ children }: { children: ReactNode }) {
setFocusedIdState(id); 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 // Reset UI call-mode state when the call leaves any active phase so the next
// call starts fresh at grid/unfocused. // call starts fresh at grid/unfocused.
useEffect(() => { useEffect(() => {
@@ -1017,6 +1068,8 @@ export function CallProvider({ children }: { children: ReactNode }) {
dismissLastCall, dismissLastCall,
setCallMode, setCallMode,
setFocusedId, setFocusedId,
setAudioInputDevice,
setAudioOutputDevice,
}), }),
[ [
state, state,
@@ -1039,6 +1092,8 @@ export function CallProvider({ children }: { children: ReactNode }) {
dismissLastCall, dismissLastCall,
setCallMode, setCallMode,
setFocusedId, setFocusedId,
setAudioInputDevice,
setAudioOutputDevice,
], ],
); );
@@ -1063,6 +1118,15 @@ function attachTrack(
audio.setAttribute('playsinline', 'true'); audio.setAttribute('playsinline', 'true');
audio.setAttribute('data-livekit-track', track.sid ?? ''); audio.setAttribute('data-livekit-track', track.sid ?? '');
document.body.appendChild(audio); 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. // 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 { import {
createContext, createContext,
type ReactNode, type ReactNode,
@@ -151,6 +155,15 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
.on('postgres_changes', { event: '*', schema: 'public', table: 'conversations' }, () => { .on('postgres_changes', { event: '*', schema: 'public', table: 'conversations' }, () => {
void refresh(); 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( .on(
'postgres_changes', 'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'messages' }, { event: 'INSERT', schema: 'public', table: 'messages' },
@@ -168,10 +181,15 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
...prev, ...prev,
[row.conversation_id]: (prev[row.conversation_id] ?? 0) + 1, [row.conversation_id]: (prev[row.conversation_id] ?? 0) + 1,
})); }));
// Notification sound + OS notification — respect DND. Body stays // Notification sound + OS notification — respect DND and
// empty because message content is E2E-encrypted and only // per-conversation mute. Body stays empty because message
// decryptable in the conversation view (not at this hook level). // content is E2E-encrypted and only decryptable in the
if (presenceRef.current !== 'dnd') { // 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(); playNotificationTone();
const conv = conversationsRef.current.find( const conv = conversationsRef.current.find(
(c) => c.id === row.conversation_id, (c) => c.id === row.conversation_id,
@@ -195,7 +213,31 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
) )
.subscribe(); .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 () => { return () => {
document.removeEventListener('visibilitychange', onAwake);
window.removeEventListener('focus', onAwake);
window.removeEventListener('online', onAwake);
void supabase.removeChannel(channel); void supabase.removeChannel(channel);
}; };
}, [myId, refresh, markRead]); }, [myId, refresh, markRead]);
+14 -2
View File
@@ -44,10 +44,22 @@ export async function checkForUpdate(): Promise<UpdateState> {
error: null, error: null,
}; };
} catch (err: unknown) { } catch (err: unknown) {
console.warn('checkForUpdate failed', err); // 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 { return {
...IDLE_UPDATE_STATE, ...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 { export interface AudioSettings {
quality: AudioQuality; 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 = { const DEFAULTS: AudioSettings = {
quality: 'voice', quality: 'voice',
inputDeviceId: null,
outputDeviceId: null,
}; };
export interface AudioQualityParams { export interface AudioQualityParams {
@@ -73,6 +82,14 @@ function read(): AudioSettings {
const parsed = JSON.parse(raw) as Partial<AudioSettings>; const parsed = JSON.parse(raw) as Partial<AudioSettings>;
cached = { cached = {
quality: isQuality(parsed.quality) ? parsed.quality : DEFAULTS.quality, 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; return cached;
} catch { } catch {
+1 -1
View File
@@ -1,5 +1,5 @@
import type { CryptoBackend } from '@chat-app/shared/crypto'; 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, // Build the libsodium-backed CryptoBackend. Awaits the WASM ready-gate once,
// then returns a synchronous implementation of the CryptoBackend contract. // 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)); 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); window.localStorage.setItem(deviceIdStorageKey(userId), deviceId);
} }
+50 -1
View File
@@ -1,5 +1,5 @@
import { getCryptoBackend } from '@chat-app/shared/crypto'; import { getCryptoBackend } from '@chat-app/shared/crypto';
import sodium from 'libsodium-wrappers'; import sodium from 'libsodium-wrappers-sumo';
// Encrypts/decrypts the device private key with a user-provided passphrase // 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 // so the backup string can be safely written down or stored in a password
@@ -91,3 +91,52 @@ export async function importDeviceKey(
s.memzero(key); 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);
}
+27 -2
View File
@@ -7,10 +7,31 @@ import {
import { isTauriRuntime } from './globalShortcut'; import { isTauriRuntime } from './globalShortcut';
// Tracks whether permission has already been requested this session so we // Tracks whether permission has already been requested this session so we
// don't spam the OS prompt. Actual permission state lives in the OS. // don't spam the OS prompt. Actual permission state lives in the OS, 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 permissionChecked = false;
let permissionGranted = 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> { export async function ensureNotificationPermission(): Promise<boolean> {
if (permissionChecked) return permissionGranted; if (permissionChecked) return permissionGranted;
permissionChecked = true; permissionChecked = true;
@@ -21,9 +42,13 @@ export async function ensureNotificationPermission(): Promise<boolean> {
} }
try { try {
let granted = await isPermissionGranted(); 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(); const result = await requestPermission();
granted = result === 'granted'; granted = result === 'granted';
writeAskedMarker();
} }
permissionGranted = granted; permissionGranted = granted;
} catch (err: unknown) { } catch (err: unknown) {
+9 -4
View File
@@ -1,7 +1,9 @@
import type { SecretStore } from '@chat-app/shared/auth'; import type { SecretStore } from '@chat-app/shared/auth';
import { exists, mkdir, readFile, rename, writeFile } from '@tauri-apps/plugin-fs'; import { exists, mkdir, readFile, rename, writeFile } from '@tauri-apps/plugin-fs';
import { appLocalDataDir } from '@tauri-apps/api/path'; import { appLocalDataDir } from '@tauri-apps/api/path';
import sodium from 'libsodium-wrappers'; // sumo variant ships crypto_pwhash (Argon2id). Standard `libsodium-wrappers`
// is the compact build without Argon2 — vault KDF would error otherwise.
import sodium from 'libsodium-wrappers-sumo';
// Encrypted single-file SecretStore for Tauri. Replaces the flaky // Encrypted single-file SecretStore for Tauri. Replaces the flaky
// tauri-plugin-stronghold implementation. // tauri-plugin-stronghold implementation.
@@ -93,10 +95,13 @@ async function loadOrCreateVault(userId: string): Promise<VaultState> {
const path = joinPath(dir, fileName); const path = joinPath(dir, fileName);
const tmpPath = path + '.tmp'; const tmpPath = path + '.tmp';
try { // First-run: AppLocalData dir may not exist yet. `mkdir(recursive)` is
// idempotent on macOS/Linux, but we need to surface genuine permission
// errors (silent catch masked a previous bug where the dir was never
// created and every subsequent writeFile failed with ENOENT).
const dirExists = await exists(dir).catch(() => false);
if (!dirExists) {
await mkdir(dir, { recursive: true }); await mkdir(dir, { recursive: true });
} catch {
/* parent likely already exists */
} }
const fileExists = await exists(path).catch(() => false); const fileExists = await exists(path).catch(() => false);
@@ -51,7 +51,7 @@ function rowToMessage(row: Record<string, unknown>): MessageWithCipher {
} }
export function useConversationMessages({ conversationId, userId, deviceId }: Args): State & { export function useConversationMessages({ conversationId, userId, deviceId }: Args): State & {
send: (text: string, images?: File[]) => Promise<void>; send: (text: string, images?: File[], replyToId?: string | null) => Promise<void>;
refresh: () => Promise<void>; refresh: () => Promise<void>;
} { } {
const [state, setState] = useState<State>({ messages: [], loading: true, error: null }); const [state, setState] = useState<State>({ messages: [], loading: true, error: null });
@@ -192,18 +192,71 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
return { ...prev, messages: next }; return { ...prev, messages: next };
}); });
if (partial.editedAt && !partial.deletedAt) { if (partial.editedAt && !partial.deletedAt) {
const [decrypted] = await decryptBatch([partial]); // Realtime bytea encoding varies (base64 vs hex, even null for
// unchanged columns on some configs). Refetch via REST to get the
// canonical `\x…` hex then decrypt — same pattern as handleInsert.
if (!conversationId || !deviceId) return;
let decrypted: DecryptedMessage | null = null;
for (let attempt = 0; attempt < 6; attempt++) {
const { data, error } = await supabase
.from('messages')
.select(
'id, conversation_id, sender_id, sender_device_id, reply_to_id, edited_at, deleted_at, created_at, ciphertext, nonce, key_version',
)
.eq('id', partial.id)
.maybeSingle();
if (error) {
console.warn('handleUpdate refetch failed', error);
return;
}
if (!data) {
await new Promise((r) => window.setTimeout(r, 120 * (attempt + 1)));
continue;
}
const r = data as unknown as {
id: string;
conversation_id: string;
sender_id: string;
sender_device_id: string | null;
reply_to_id: string | null;
edited_at: string | null;
deleted_at: string | null;
created_at: string;
ciphertext: string;
nonce: string;
key_version: number;
};
const msg: MessageWithCipher = {
id: r.id,
conversationId: r.conversation_id,
senderId: r.sender_id,
senderDeviceId: r.sender_device_id,
replyToId: r.reply_to_id,
editedAt: r.edited_at,
deletedAt: r.deleted_at,
createdAt: r.created_at,
ciphertext: pgBytesToBytes(String(r.ciphertext)),
nonce: pgBytesToBytes(String(r.nonce)),
keyVersion: r.key_version,
};
const [d] = await decryptBatch([msg]);
if (d) {
decrypted = d;
if (d.plaintext !== null) break;
}
await new Promise((r) => window.setTimeout(r, 200 * (attempt + 1)));
}
if (!decrypted) return; if (!decrypted) return;
setState((prev) => { setState((prev) => {
const idx = prev.messages.findIndex((m) => m.id === decrypted.id); const idx = prev.messages.findIndex((m) => m.id === decrypted!.id);
if (idx === -1) return prev; if (idx === -1) return prev;
const next = [...prev.messages]; const next = [...prev.messages];
next[idx] = decrypted; next[idx] = decrypted!;
return { ...prev, messages: next }; return { ...prev, messages: next };
}); });
} }
}, },
[decryptBatch], [conversationId, deviceId, decryptBatch],
); );
const handleDelete = useCallback((row: Record<string, unknown>) => { const handleDelete = useCallback((row: Record<string, unknown>) => {
@@ -256,13 +309,32 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
) )
.subscribe(); .subscribe();
// Refresh + reconnect on wake from background throttle (mostly Windows
// WebView2). Without this, messages inserted while the window is
// minimised never arrive until the user explicitly reloads.
const onAwake = () => {
if (document.visibilityState !== 'visible') return;
void refresh();
try {
channel.subscribe();
} catch {
/* already live */
}
};
document.addEventListener('visibilitychange', onAwake);
window.addEventListener('focus', onAwake);
window.addEventListener('online', onAwake);
return () => { return () => {
document.removeEventListener('visibilitychange', onAwake);
window.removeEventListener('focus', onAwake);
window.removeEventListener('online', onAwake);
void supabase.removeChannel(channel); void supabase.removeChannel(channel);
}; };
}, [conversationId, userId, deviceId, refresh, handleInsert, handleUpdate, handleDelete]); }, [conversationId, userId, deviceId, refresh, handleInsert, handleUpdate, handleDelete]);
const send = useCallback( const send = useCallback(
async (text: string, images: File[] = []) => { async (text: string, images: File[] = [], replyToId: string | null = null) => {
const trimmed = text.trim(); const trimmed = text.trim();
if ((!trimmed && images.length === 0) || !conversationId || !userId || !deviceId) return; if ((!trimmed && images.length === 0) || !conversationId || !userId || !deviceId) return;
const priv = privateKeyRef.current; const priv = privateKeyRef.current;
@@ -299,6 +371,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
senderDeviceId: deviceId, senderDeviceId: deviceId,
senderPrivateKey: priv, senderPrivateKey: priv,
...(handles.length > 0 ? { attachmentHandles: handles } : {}), ...(handles.length > 0 ? { attachmentHandles: handles } : {}),
...(replyToId ? { replyToId } : {}),
}); });
// 3. Optimistic insert — we already have the plaintext in hand and the // 3. Optimistic insert — we already have the plaintext in hand and the
+17
View File
@@ -44,7 +44,24 @@ export function useFriendships(userId: string | undefined): FriendshipsState & {
}) })
.subscribe(); .subscribe();
// Windows WebView2 throttles background sockets — refresh on wake.
const onAwake = () => {
if (document.visibilityState !== 'visible') return;
void refresh();
try {
channel.subscribe();
} catch {
/* already live */
}
};
document.addEventListener('visibilitychange', onAwake);
window.addEventListener('focus', onAwake);
window.addEventListener('online', onAwake);
return () => { return () => {
document.removeEventListener('visibilitychange', onAwake);
window.removeEventListener('focus', onAwake);
window.removeEventListener('online', onAwake);
void supabase.removeChannel(channel); void supabase.removeChannel(channel);
}; };
}, [userId, refresh]); }, [userId, refresh]);
+1 -1
View File
@@ -65,7 +65,7 @@ export function AdminPage() {
}, [refresh]); }, [refresh]);
return ( return (
<div className="h-full bg-surface-3 text-fg"> <div className="min-h-full bg-surface-3 text-fg">
<div className="mx-auto flex max-w-4xl flex-col gap-6 px-6 py-8"> <div className="mx-auto flex max-w-4xl flex-col gap-6 px-6 py-8">
<header> <header>
<h1 className="font-display text-2xl font-semibold tracking-tight text-fg"> <h1 className="font-display text-2xl font-semibold tracking-tight text-fg">
+2 -2
View File
@@ -38,7 +38,7 @@ type UiState =
| { kind: 'sent'; email: string } | { kind: 'sent'; email: string }
| { kind: 'error'; message: string }; | { kind: 'error'; message: string };
const USERNAME_PATTERN = /^[a-z0-9_]{3,32}$/; const USERNAME_PATTERN = /^[A-Za-z0-9_]{3,32}$/;
export function AuthPage() { export function AuthPage() {
const { session } = useAuth(); const { session } = useAuth();
@@ -314,7 +314,7 @@ function FormCard({
required required
placeholder={t('auth:fields.username_placeholder')} placeholder={t('auth:fields.username_placeholder')}
value={username} value={username}
onChange={(e) => onUsernameChange(e.target.value.toLowerCase())} onChange={(e) => onUsernameChange(e.target.value)}
pattern={USERNAME_PATTERN.source} pattern={USERNAME_PATTERN.source}
className="w-full rounded-lg border border-white/10 bg-ink-800 py-2.5 pl-10 pr-3 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40" className="w-full rounded-lg border border-white/10 bg-ink-800 py-2.5 pl-10 pr-3 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
/> />
+126 -26
View File
@@ -1,12 +1,19 @@
import { acceptDm, type ConversationSummary } from '@chat-app/shared/chat'; import {
acceptDm,
type ConversationSummary,
isConversationMuted,
} from '@chat-app/shared/chat';
import { extractErrorCode } from '@chat-app/shared/i18n'; import { extractErrorCode } from '@chat-app/shared/i18n';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { NavLink, Outlet, useParams } from 'react-router-dom'; import { NavLink, Outlet, useParams } from 'react-router-dom';
import { ConversationRowMenu } from '../components/ConversationRowMenu';
import { CreateGroupDialog } from '../components/CreateGroupDialog'; import { CreateGroupDialog } from '../components/CreateGroupDialog';
import { import {
AddUserIcon, AddUserIcon,
ArchiveIcon,
BellOffIcon,
ChatBubbleIcon, ChatBubbleIcon,
SearchIcon, SearchIcon,
SpinnerIcon, SpinnerIcon,
@@ -21,6 +28,7 @@ export function ChatsPage() {
const { id: activeId } = useParams<{ id: string }>(); const { id: activeId } = useParams<{ id: string }>();
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [showArchived, setShowArchived] = useState(false);
const sorted = useMemo(() => { const sorted = useMemo(() => {
return [...conversations].sort((a, b) => { return [...conversations].sort((a, b) => {
@@ -30,7 +38,7 @@ export function ChatsPage() {
}); });
}, [conversations]); }, [conversations]);
const filtered = useMemo(() => { const queryFiltered = useMemo(() => {
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
if (!q) return sorted; if (!q) return sorted;
return sorted.filter((c) => { return sorted.filter((c) => {
@@ -40,10 +48,24 @@ export function ChatsPage() {
}); });
}, [sorted, query]); }, [sorted, query]);
// Split into active vs archived — the user toggles which bucket shows in the
// main list. Archived conversations with unread messages still surface so
// the user can't accidentally silence an ongoing conversation permanently.
const activeItems = useMemo(
() => queryFiltered.filter((c) => !c.archived),
[queryFiltered],
);
const archivedItems = useMemo(
() => queryFiltered.filter((c) => c.archived),
[queryFiltered],
);
const archivedUnread = archivedItems.reduce((s, c) => s + (unread[c.id] ?? 0), 0);
return ( return (
<div className="flex h-full"> <div className="flex h-full">
<ConversationList <ConversationList
items={filtered} items={showArchived ? archivedItems : activeItems}
loading={loading} loading={loading}
error={error} error={error}
activeId={activeId} activeId={activeId}
@@ -60,6 +82,9 @@ export function ChatsPage() {
} }
}} }}
onNewGroup={() => setCreateOpen(true)} onNewGroup={() => setCreateOpen(true)}
showArchived={showArchived}
onToggleArchived={() => setShowArchived((v) => !v)}
archivedUnread={archivedUnread}
/> />
<div className="flex-1 border-l border-line bg-surface-3"> <div className="flex-1 border-l border-line bg-surface-3">
<Outlet /> <Outlet />
@@ -79,6 +104,9 @@ interface ConversationListProps {
onQueryChange: (q: string) => void; onQueryChange: (q: string) => void;
onAccept: (id: string) => void; onAccept: (id: string) => void;
onNewGroup: () => void; onNewGroup: () => void;
showArchived: boolean;
onToggleArchived: () => void;
archivedUnread: number;
} }
function ConversationList({ function ConversationList({
@@ -91,6 +119,9 @@ function ConversationList({
onQueryChange, onQueryChange,
onAccept, onAccept,
onNewGroup, onNewGroup,
showArchived,
onToggleArchived,
archivedUnread,
}: ConversationListProps) { }: ConversationListProps) {
const { t } = useTranslation(['app']); const { t } = useTranslation(['app']);
@@ -101,17 +132,53 @@ function ConversationList({
> >
<header className="flex items-center justify-between px-4 pb-3 pt-5"> <header className="flex items-center justify-between px-4 pb-3 pt-5">
<h2 className="font-display text-base font-semibold tracking-tight text-fg"> <h2 className="font-display text-base font-semibold tracking-tight text-fg">
{t('app:nav.chats')} {showArchived
? t('app:chats.archived_title', { defaultValue: 'Archiv' })
: t('app:nav.chats')}
</h2> </h2>
<button <div className="flex items-center gap-1">
type="button" <button
onClick={onNewGroup} type="button"
aria-label={t('app:chats.new_group')} onClick={onToggleArchived}
title={t('app:chats.new_group')} aria-label={
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40" showArchived
> ? t('app:chats.show_active', { defaultValue: 'Aktive anzeigen' })
<AddUserIcon className="h-4 w-4" /> : t('app:chats.show_archived', { defaultValue: 'Archiv anzeigen' })
</button> }
title={
showArchived
? t('app:chats.show_active', { defaultValue: 'Aktive anzeigen' })
: t('app:chats.show_archived', { defaultValue: 'Archiv anzeigen' })
}
className={
'relative flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(showArchived
? 'bg-accent/15 text-accent'
: 'text-fg-muted hover:bg-surface-3 hover:text-fg')
}
>
<ArchiveIcon className="h-4 w-4" />
{!showArchived && archivedUnread > 0 && (
<span
aria-hidden="true"
className="absolute -right-0.5 -top-0.5 flex min-w-[16px] items-center justify-center rounded-full bg-accent px-1 text-[9px] font-bold leading-tight text-accent-fg"
>
{archivedUnread > 9 ? '9+' : archivedUnread}
</span>
)}
</button>
{!showArchived && (
<button
type="button"
onClick={onNewGroup}
aria-label={t('app:chats.new_group')}
title={t('app:chats.new_group')}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
<AddUserIcon className="h-4 w-4" />
</button>
)}
</div>
</header> </header>
<div className="px-3 pb-2"> <div className="px-3 pb-2">
@@ -140,10 +207,24 @@ function ConversationList({
) : items.length === 0 ? ( ) : items.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center"> <div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-line bg-surface-3 text-accent"> <div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-line bg-surface-3 text-accent">
<ChatBubbleIcon className="h-5 w-5" /> {showArchived ? (
<ArchiveIcon className="h-5 w-5" />
) : (
<ChatBubbleIcon className="h-5 w-5" />
)}
</div> </div>
<p className="text-sm text-fg-muted">{t('app:chats.empty_title')}</p> <p className="text-sm text-fg-muted">
<p className="text-xs text-fg-muted/80">{t('app:chats.empty_subtitle')}</p> {showArchived
? t('app:chats.archived_empty_title', { defaultValue: 'Nichts archiviert' })
: t('app:chats.empty_title')}
</p>
<p className="text-xs text-fg-muted/80">
{showArchived
? t('app:chats.archived_empty_subtitle', {
defaultValue: 'Archivierte Unterhaltungen erscheinen hier.',
})
: t('app:chats.empty_subtitle')}
</p>
</div> </div>
) : ( ) : (
<ul className="flex-1 overflow-y-auto px-2 pb-2"> <ul className="flex-1 overflow-y-auto px-2 pb-2">
@@ -213,11 +294,12 @@ function ConversationRow({
); );
} }
const muted = isConversationMuted(item.mutedUntil);
return ( return (
<NavLink <NavLink
to={'/chats/' + item.id} to={'/chats/' + item.id}
className={ className={
'my-1 flex cursor-pointer items-center gap-3 rounded-xl px-3 py-2.5 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' + 'group relative my-1 flex cursor-pointer items-center gap-3 rounded-xl px-3 py-2.5 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(active (active
? 'bg-accent/15 text-fg' ? 'bg-accent/15 text-fg'
: 'text-fg hover:bg-surface-3/70') : 'text-fg hover:bg-surface-3/70')
@@ -225,24 +307,42 @@ function ConversationRow({
> >
<ConvAvatar url={avatarUrl} title={title} isGroup={item.type === 'group'} /> <ConvAvatar url={avatarUrl} title={title} isGroup={item.type === 'group'} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p <div className="flex items-center gap-1.5">
className={ <p
'truncate text-sm ' + className={
(unreadCount > 0 ? 'font-bold' : 'font-semibold') 'truncate text-sm ' +
} (unreadCount > 0 && !muted ? 'font-bold' : 'font-semibold')
> }
{title} >
</p> {title}
</p>
{muted && (
<BellOffIcon
aria-hidden="true"
className="h-3 w-3 shrink-0 text-fg-muted"
/>
)}
</div>
<p className="truncate text-xs text-fg-muted">{preview}</p> <p className="truncate text-xs text-fg-muted">{preview}</p>
</div> </div>
{unreadCount > 0 && ( {unreadCount > 0 && (
<span <span
aria-label={'Unread: ' + unreadCount} aria-label={'Unread: ' + unreadCount}
className="inline-flex min-w-[20px] items-center justify-center rounded-full bg-accent px-1.5 text-[10px] font-bold leading-tight text-accent-fg" className={
'inline-flex min-w-[20px] items-center justify-center rounded-full px-1.5 text-[10px] font-bold leading-tight ' +
(muted
? 'bg-fg-muted/30 text-fg-muted'
: 'bg-accent text-accent-fg')
}
> >
{unreadCount > 99 ? '99+' : unreadCount} {unreadCount > 99 ? '99+' : unreadCount}
</span> </span>
)} )}
<ConversationRowMenu
conversationId={item.id}
archived={item.archived}
mutedUntil={item.mutedUntil}
/>
</NavLink> </NavLink>
); );
} }
+286 -8
View File
@@ -1,15 +1,28 @@
import { parseMessagePayload } from '@chat-app/shared/chat';
import { extractErrorCode } from '@chat-app/shared/i18n'; import { extractErrorCode } from '@chat-app/shared/i18n';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { ConversationHeader } from '../components/ConversationHeader'; import { ConversationHeader } from '../components/ConversationHeader';
import { ForwardDialog } from '../components/ForwardDialog';
import { GroupInfoPanel } from '../components/GroupInfoPanel'; import { GroupInfoPanel } from '../components/GroupInfoPanel';
import { AlertIcon, ArrowRightIcon, PlusIcon, SpinnerIcon, XIcon } from '../components/icons'; import {
AlertIcon,
ArrowRightIcon,
ChevronDownIcon,
ChevronUpIcon,
PlusIcon,
ReplyIcon,
SearchIcon,
SpinnerIcon,
XIcon,
} from '../components/icons';
import { InCallPanel } from '../components/InCallPanel'; import { InCallPanel } from '../components/InCallPanel';
import { IncomingCallPanel } from '../components/IncomingCallPanel'; import { IncomingCallPanel } from '../components/IncomingCallPanel';
import { MessageBubble } from '../components/MessageBubble'; import { MessageBubble, type QuotedRef } from '../components/MessageBubble';
import { TypingIndicator } from '../components/TypingIndicator'; import { TypingIndicator } from '../components/TypingIndicator';
import type { DecryptedMessage } from '@chat-app/shared/chat';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useCall } from '../context/CallContext'; import { useCall } from '../context/CallContext';
import { useConversationsContext } from '../context/ConversationsContext'; import { useConversationsContext } from '../context/ConversationsContext';
@@ -82,8 +95,114 @@ export function ConversationPage() {
const [stickToBottom, setStickToBottom] = useState(true); const [stickToBottom, setStickToBottom] = useState(true);
const [attachments, setAttachments] = useState<File[]>([]); const [attachments, setAttachments] = useState<File[]>([]);
const [infoPanelOpen, setInfoPanelOpen] = useState(false); const [infoPanelOpen, setInfoPanelOpen] = useState(false);
const [replyTo, setReplyTo] = useState<DecryptedMessage | null>(null);
const [forwardTarget, setForwardTarget] = useState<DecryptedMessage | null>(null);
const [searchOpen, setSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [searchIdx, setSearchIdx] = useState(0);
const [highlightedId, setHighlightedId] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const composerRef = useRef<HTMLTextAreaElement>(null);
// Drop reply-to / clear search state when switching conversation.
useEffect(() => {
setReplyTo(null);
setForwardTarget(null);
setSearchOpen(false);
setSearchQuery('');
}, [id]);
const messageById = useMemo(() => {
const m = new Map<string, DecryptedMessage>();
for (const msg of messages) m.set(msg.id, msg);
return m;
}, [messages]);
const senderNameFor = useCallback(
(senderId: string): string => {
if (senderId === myId) return t('app:chats.you', { defaultValue: 'Du' });
const profile =
conversation?.members.find((mm) => mm.userId === senderId)?.profile ??
(senderId !== myId ? conversation?.peer ?? null : null);
return profile?.displayName ?? '?';
},
[conversation, myId, t],
);
const buildQuoted = useCallback(
(replyToId: string | null): QuotedRef | null => {
if (!replyToId) return null;
const target = messageById.get(replyToId);
if (!target) {
return {
id: replyToId,
senderName: '…',
snippet: t('app:chats.quote_unavailable', { defaultValue: 'Nachricht nicht verfügbar' }),
isAttachment: false,
deleted: true,
};
}
const parsed = parseMessagePayload(target.plaintext);
const text = parsed.kind === 'text' ? parsed.text : '';
const hasAttachment = parsed.kind === 'text' && parsed.attachments.length > 0;
return {
id: target.id,
senderName: senderNameFor(target.senderId),
snippet: text.length > 120 ? text.slice(0, 120) + '…' : text,
isAttachment: hasAttachment,
deleted: !!target.deletedAt,
};
},
[messageById, senderNameFor, t],
);
const jumpToMessage = useCallback((targetId: string) => {
const el = scrollRef.current?.querySelector<HTMLElement>(
'[data-message-id="' + CSS.escape(targetId) + '"]',
);
if (!el) return;
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
setHighlightedId(targetId);
window.setTimeout(() => setHighlightedId((cur) => (cur === targetId ? null : cur)), 1600);
}, []);
const handleReply = useCallback((m: DecryptedMessage) => {
setReplyTo(m);
composerRef.current?.focus();
}, []);
const handleForward = useCallback((m: DecryptedMessage) => {
setForwardTarget(m);
}, []);
// Search matches: messages whose decrypted text includes the query.
const searchMatches = useMemo(() => {
const q = searchQuery.trim().toLowerCase();
if (!q) return [] as DecryptedMessage[];
return messages.filter((m) => {
if (!m.plaintext) return false;
const parsed = parseMessagePayload(m.plaintext);
if (parsed.kind !== 'text') return false;
return parsed.text.toLowerCase().includes(q);
});
}, [messages, searchQuery]);
// Reset/clamp the active match index when the match set changes.
useEffect(() => {
if (searchMatches.length === 0) {
setSearchIdx(0);
return;
}
setSearchIdx((cur) => Math.min(cur, searchMatches.length - 1));
}, [searchMatches.length]);
// Auto-jump to current match.
useEffect(() => {
if (!searchOpen || searchMatches.length === 0) return;
const target = searchMatches[searchIdx];
if (target) jumpToMessage(target.id);
}, [searchOpen, searchMatches, searchIdx, jumpToMessage]);
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
@@ -122,9 +241,10 @@ export function ConversationPage() {
setSending(true); setSending(true);
setSendError(null); setSendError(null);
try { try {
await send(text, attachments); await send(text, attachments, replyTo?.id ?? null);
setText(''); setText('');
setAttachments([]); setAttachments([]);
setReplyTo(null);
if (fileInputRef.current) fileInputRef.current.value = ''; if (fileInputRef.current) fileInputRef.current.value = '';
setStickToBottom(true); setStickToBottom(true);
notifyStopTyping(); notifyStopTyping();
@@ -177,10 +297,32 @@ export function ConversationPage() {
<ConversationHeader <ConversationHeader
conversation={conversation} conversation={conversation}
peerPresence={peerPresence} peerPresence={peerPresence}
onSearchClick={() => setSearchOpen((v) => !v)}
{...(isGroup ? { onInfoClick: () => setInfoPanelOpen(true) } : {})} {...(isGroup ? { onInfoClick: () => setInfoPanelOpen(true) } : {})}
/> />
)} )}
{!callHereActive && searchOpen && (
<SearchBar
query={searchQuery}
onQueryChange={setSearchQuery}
matches={searchMatches.length}
activeIdx={searchIdx}
onPrev={() =>
setSearchIdx((cur) =>
searchMatches.length === 0 ? 0 : (cur - 1 + searchMatches.length) % searchMatches.length,
)
}
onNext={() =>
setSearchIdx((cur) => (searchMatches.length === 0 ? 0 : (cur + 1) % searchMatches.length))
}
onClose={() => {
setSearchOpen(false);
setSearchQuery('');
}}
/>
)}
{isGroup && conversation && ( {isGroup && conversation && (
<GroupInfoPanel <GroupInfoPanel
open={infoPanelOpen} open={infoPanelOpen}
@@ -204,12 +346,31 @@ export function ConversationPage() {
) : ( ) : (
<ul className="space-y-0.5"> <ul className="space-y-0.5">
{messages.map((m, idx) => { {messages.map((m, idx) => {
const prev = messages[idx - 1]; // A "run" is consecutive bubbles from the same sender with
const next = messages[idx + 1]; // nothing between them. Call-event separators break the run —
const grouped = idx > 0 && prev?.senderId === m.senderId; // a bubble whose immediate next neighbour is a call_event must
const isLastOfRun = !next || next.senderId !== m.senderId; // anchor the avatar, even if another bubble from the same
const senderProfile = // sender appears after the separator.
const prevRaw = messages[idx - 1];
const nextRaw = messages[idx + 1];
const prevIsCallEvent =
!!prevRaw && parseMessagePayload(prevRaw.plaintext).kind === 'call_event';
const nextIsCallEvent =
!!nextRaw && parseMessagePayload(nextRaw.plaintext).kind === 'call_event';
const grouped =
!!prevRaw && prevRaw.senderId === m.senderId && !prevIsCallEvent;
// Anchor avatar on the LAST message of a run so it aligns with
// the bubble's tail (bottom corner). Tail is bottom-left for
// mine, bottom-right for peer — see rounded-[…_4px_…] above.
const isLastOfRun =
!nextRaw || nextRaw.senderId !== m.senderId || nextIsCallEvent;
// DM fallback: if member lookup fails (e.g. transient sync), fall
// back to conversation.peer so the peer's avatar still resolves.
const memberProfile =
conversation?.members.find((mm) => mm.userId === m.senderId)?.profile ?? null; conversation?.members.find((mm) => mm.userId === m.senderId)?.profile ?? null;
const senderProfile =
memberProfile ??
(m.senderId !== myId ? (conversation?.peer ?? null) : null);
return ( return (
<li key={m.id}> <li key={m.id}>
<MessageBubble <MessageBubble
@@ -223,6 +384,11 @@ export function ConversationPage() {
reactions={reactionsByMessage.get(m.id) ?? []} reactions={reactionsByMessage.get(m.id) ?? []}
onToggleReaction={(emoji) => toggleReaction(m.id, emoji)} onToggleReaction={(emoji) => toggleReaction(m.id, emoji)}
showSeen={m.id === lastSeenMessageId} showSeen={m.id === lastSeenMessageId}
quoted={buildQuoted(m.replyToId)}
onJumpToMessage={jumpToMessage}
onReply={handleReply}
onForward={handleForward}
highlighted={highlightedId === m.id}
/> />
</li> </li>
); );
@@ -243,6 +409,38 @@ export function ConversationPage() {
</div> </div>
)} )}
{replyTo && (
<div className="mb-2 flex items-stretch gap-2 rounded-lg border border-line bg-surface-2 p-2 text-sm">
<span aria-hidden="true" className="w-1 shrink-0 rounded-full bg-accent" />
<ReplyIcon className="mt-0.5 h-4 w-4 shrink-0 text-accent" />
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-semibold text-fg">
{t('app:chats.replying_to', {
name: senderNameFor(replyTo.senderId),
defaultValue: 'Antwort an ' + senderNameFor(replyTo.senderId),
})}
</p>
<p className="truncate text-xs italic text-fg-muted">
{(() => {
if (!replyTo.plaintext) return '…';
const p = parseMessagePayload(replyTo.plaintext);
if (p.kind !== 'text') return '';
if (!p.text && p.attachments.length > 0) return '📎';
return p.text;
})()}
</p>
</div>
<button
type="button"
onClick={() => setReplyTo(null)}
aria-label={t('app:friends.action_cancel', { defaultValue: 'Abbrechen' })}
className="shrink-0 cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg"
>
<XIcon className="h-4 w-4" />
</button>
</div>
)}
{attachments.length > 0 && ( {attachments.length > 0 && (
<div className="mb-2 flex flex-wrap gap-2"> <div className="mb-2 flex flex-wrap gap-2">
{attachments.map((file, idx) => ( {attachments.map((file, idx) => (
@@ -276,6 +474,7 @@ export function ConversationPage() {
<PlusIcon className="h-4 w-4" /> <PlusIcon className="h-4 w-4" />
</button> </button>
<textarea <textarea
ref={composerRef}
value={text} value={text}
onChange={(e) => { onChange={(e) => {
setText(e.target.value); setText(e.target.value);
@@ -301,10 +500,89 @@ export function ConversationPage() {
</button> </button>
</div> </div>
</form> </form>
<ForwardDialog
open={forwardTarget !== null}
message={forwardTarget}
currentConversationId={id ?? null}
onClose={() => setForwardTarget(null)}
/>
</div> </div>
); );
} }
interface SearchBarProps {
query: string;
onQueryChange: (q: string) => void;
matches: number;
activeIdx: number;
onPrev: () => void;
onNext: () => void;
onClose: () => void;
}
function SearchBar({ query, onQueryChange, matches, activeIdx, onPrev, onNext, onClose }: SearchBarProps) {
const { t } = useTranslation(['app']);
return (
<div className="flex items-center gap-2 border-b border-line bg-surface-2 px-4 py-2">
<SearchIcon className="h-4 w-4 shrink-0 text-fg-muted" />
<input
type="search"
autoFocus
value={query}
onChange={(e) => onQueryChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') onClose();
if (e.key === 'Enter') {
e.preventDefault();
if (e.shiftKey) onPrev();
else onNext();
}
}}
placeholder={t('app:chats.search_in_conv', { defaultValue: 'In Unterhaltung suchen…' })}
className="min-w-0 flex-1 bg-transparent text-sm text-fg placeholder-fg-muted outline-none"
/>
<span className="shrink-0 text-xs tabular-nums text-fg-muted">
{matches === 0
? query.trim().length > 0
? t('app:chats.search_none', { defaultValue: 'Keine Treffer' })
: ''
: activeIdx + 1 + ' / ' + matches}
</span>
<button
type="button"
onClick={onPrev}
disabled={matches === 0}
aria-label="Previous"
className="cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
>
<ChevronUpIcon className="h-4 w-4" />
</button>
<button
type="button"
onClick={onNext}
disabled={matches === 0}
aria-label="Next"
className="cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
>
<ChevronDownIcon className="h-4 w-4" />
</button>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="cursor-pointer rounded-md p-1 text-fg-muted transition hover:bg-surface-3 hover:text-fg"
>
<XIcon className="h-4 w-4" />
</button>
</div>
);
}
// Walks `messages` from `idx + step` skipping call_event entries until a
// regular bubble is found or the array boundary is reached. Used to decide
// run-grouping for avatar placement so call separators don't bleed into
// sender continuity.
function Banner({ children }: { children: React.ReactNode }) { function Banner({ children }: { children: React.ReactNode }) {
return ( return (
<div <div
+77 -9
View File
@@ -1,11 +1,18 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { DeviceRegistration } from '../components/DeviceRegistration'; import { DeviceRegistration } from '../components/DeviceRegistration';
import { DeviceRestore } from '../components/DeviceRestore';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
type Mode = 'register' | 'restore';
export function DevicePage() { export function DevicePage() {
const { t } = useTranslation(['app']);
const { session, profile, setDevice } = useAuth(); const { session, profile, setDevice } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const [mode, setMode] = useState<Mode>('register');
if (!session) return null; // Guarded by RequireAuth, but be defensive. if (!session) return null; // Guarded by RequireAuth, but be defensive.
@@ -15,20 +22,81 @@ export function DevicePage() {
return ( return (
<main className="relative flex min-h-screen items-center justify-center overflow-hidden bg-ink-950 px-5 py-10 text-neutral-100 sm:px-8"> <main className="relative flex min-h-screen items-center justify-center overflow-hidden bg-ink-950 px-5 py-10 text-neutral-100 sm:px-8">
<BackgroundStage /> <BackgroundStage />
<div className="relative z-10 w-full max-w-md"> <div className="relative z-10 w-full max-w-md space-y-3">
<DeviceRegistration <div
userId={session.user.id} role="tablist"
defaultName={defaultName} aria-label={t('app:backup.mode_label', { defaultValue: 'Gerätemodus' })}
onRegistered={(device) => { className="flex gap-1 rounded-xl border border-white/10 bg-ink-900/60 p-1 backdrop-blur-xl"
setDevice(device); >
navigate('/chats', { replace: true }); <TabButton
}} active={mode === 'register'}
/> onClick={() => setMode('register')}
label={t('app:backup.mode_register', { defaultValue: 'Neu einrichten' })}
/>
<TabButton
active={mode === 'restore'}
onClick={() => setMode('restore')}
label={t('app:backup.mode_restore', { defaultValue: 'Backup wiederherstellen' })}
/>
</div>
{mode === 'register' ? (
<DeviceRegistration
userId={session.user.id}
defaultName={defaultName}
onRegistered={(device) => {
setDevice(device);
// Signal the chats page to open the post-registration backup
// prompt (AppShell reads this on mount).
try {
window.sessionStorage.setItem('chatapp.backup.prompt', '1');
} catch {
/* storage disabled — skip hint */
}
navigate('/chats', { replace: true });
}}
/>
) : (
<DeviceRestore
userId={session.user.id}
onRestored={(device) => {
setDevice(device);
navigate('/chats', { replace: true });
}}
/>
)}
</div> </div>
</main> </main>
); );
} }
function TabButton({
active,
onClick,
label,
}: {
active: boolean;
onClick: () => void;
label: string;
}) {
return (
<button
type="button"
role="tab"
aria-selected={active}
onClick={onClick}
className={
'flex-1 cursor-pointer rounded-lg px-3 py-2 text-xs font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' +
(active
? 'bg-brand-500/20 text-brand-100 ring-1 ring-brand-400/40'
: 'text-neutral-400 hover:bg-white/5 hover:text-neutral-100')
}
>
{label}
</button>
);
}
function BackgroundStage() { function BackgroundStage() {
return ( return (
<div aria-hidden="true" className="pointer-events-none absolute inset-0"> <div aria-hidden="true" className="pointer-events-none absolute inset-0">
+3 -3
View File
@@ -107,8 +107,8 @@ export function FriendsPage() {
); );
return ( return (
<div className="h-full bg-surface-3 text-fg"> <div className="min-h-full bg-surface-3 text-fg">
<div className="mx-auto flex h-full max-w-3xl flex-col gap-5 px-6 py-8"> <div className="mx-auto flex min-h-full max-w-3xl flex-col gap-5 px-6 py-8">
<header className="flex items-center justify-between gap-4"> <header className="flex items-center justify-between gap-4">
<h1 className="font-display text-2xl font-semibold tracking-tight text-fg"> <h1 className="font-display text-2xl font-semibold tracking-tight text-fg">
{t('app:friends.title')} {t('app:friends.title')}
@@ -121,7 +121,7 @@ export function FriendsPage() {
<input <input
type="search" type="search"
value={query} value={query}
onChange={(e) => setQuery(e.target.value.toLowerCase())} onChange={(e) => setQuery(e.target.value)}
placeholder={t('app:friends.search_placeholder')} placeholder={t('app:friends.search_placeholder')}
autoComplete="off" autoComplete="off"
autoCapitalize="none" autoCapitalize="none"
+239 -131
View File
@@ -4,15 +4,16 @@ import {
SUPPORTED_LOCALES, SUPPORTED_LOCALES,
type SupportedLocale, type SupportedLocale,
} from '@chat-app/shared/i18n'; } from '@chat-app/shared/i18n';
import { useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Avatar } from '../components/Avatar'; import { Avatar } from '../components/Avatar';
import { BackupExportDialog } from '../components/BackupExportDialog';
import { LockIcon } from '../components/icons'; import { LockIcon } from '../components/icons';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { loadDevicePrivateKey, saveDevicePrivateKey } from '@chat-app/shared/auth'; import { useCall } from '../context/CallContext';
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
import { deleteAvatarObject, uploadAvatar } from '../lib/avatarUpload'; import { deleteAvatarObject, uploadAvatar } from '../lib/avatarUpload';
import { exportDeviceKey, importDeviceKey } from '../lib/deviceBackup';
import { devLocalSecretStore } from '../lib/secretStore'; import { devLocalSecretStore } from '../lib/secretStore';
import { import {
getPttSettings, getPttSettings,
@@ -76,7 +77,7 @@ export function SettingsPage() {
} }
return ( return (
<div className="h-full bg-surface-3 text-fg"> <div className="min-h-full bg-surface-3 text-fg">
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8"> <div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
<header className="mb-2"> <header className="mb-2">
<h1 className="font-display text-2xl font-semibold tracking-tight text-fg"> <h1 className="font-display text-2xl font-semibold tracking-tight text-fg">
@@ -142,7 +143,10 @@ export function SettingsPage() {
{/* Voice / Push-to-Talk + Audio Quality + E2EE */} {/* Voice / Push-to-Talk + Audio Quality + E2EE */}
<Section title={t('app:settings.section_voice', { defaultValue: 'Sprache' })}> <Section title={t('app:settings.section_voice', { defaultValue: 'Sprache' })}>
<AudioQualityControls /> <AudioDeviceControls />
<div className="mt-3 border-t border-line pt-3">
<AudioQualityControls />
</div>
<div className="mt-3 border-t border-line pt-3"> <div className="mt-3 border-t border-line pt-3">
<PttControls /> <PttControls />
</div> </div>
@@ -447,65 +451,32 @@ function AvatarControls({ patchProfile, busy }: AvatarControlsProps) {
function DeviceKeyBackupControls() { function DeviceKeyBackupControls() {
const { t } = useTranslation(['app']); const { t } = useTranslation(['app']);
const { profile, device } = useAuth(); const { profile, device } = useAuth();
const [busy, setBusy] = useState(false); const [open, setOpen] = useState(false);
const [backupOut, setBackupOut] = useState<string | null>(null); const [privateKey, setPrivateKey] = useState<Uint8Array | null>(null);
const [exportPass, setExportPass] = useState(''); const [err, setErr] = useState<string | null>(null);
const [importPass, setImportPass] = useState('');
const [importBlob, setImportBlob] = useState('');
const [msg, setMsg] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
const canRun = !!profile?.userId && !!device?.id; const canRun = !!profile?.userId && !!device?.id;
async function handleExport() { async function handleOpen() {
if (!canRun) return; if (!canRun) return;
setMsg(null); setErr(null);
setBusy(true);
try { try {
const priv = await loadDevicePrivateKey(devLocalSecretStore, profile.userId, device.id); const priv = await loadDevicePrivateKey(devLocalSecretStore, profile.userId, device.id);
if (!priv) throw new Error('No device key on this install'); if (!priv) throw new Error(t('app:backup.no_key_here', {
const out = await exportDeviceKey(priv, exportPass); defaultValue: 'Kein Geräteschlüssel auf dieser Installation.',
setBackupOut(out); }));
setExportPass(''); setPrivateKey(priv);
setMsg({ setOpen(true);
kind: 'ok', } catch (e: unknown) {
text: t('app:settings.backup_export_ok', { setErr(e instanceof Error ? e.message : 'failed to load key');
defaultValue: 'Backup erstellt — kopiere und bewahre es sicher auf.',
}),
});
} catch (err: unknown) {
setMsg({
kind: 'err',
text: err instanceof Error ? err.message : 'export failed',
});
} finally {
setBusy(false);
} }
} }
async function handleImport() { function handleClose() {
if (!canRun) return; setOpen(false);
setMsg(null); if (privateKey) {
setBusy(true); for (let i = 0; i < privateKey.length; i++) privateKey[i] = 0;
try {
const priv = await importDeviceKey(importBlob.trim(), importPass);
await saveDevicePrivateKey(devLocalSecretStore, profile.userId, device.id, priv);
setImportBlob('');
setImportPass('');
setMsg({
kind: 'ok',
text: t('app:settings.backup_import_ok', {
defaultValue:
'Schlüssel importiert. Beim nächsten Reload sollten alte Nachrichten lesbar sein.',
}),
});
} catch (err: unknown) {
setMsg({
kind: 'err',
text: err instanceof Error ? err.message : 'import failed',
});
} finally {
setBusy(false);
} }
setPrivateKey(null);
} }
return ( return (
@@ -514,87 +485,31 @@ function DeviceKeyBackupControls() {
{t('app:settings.device_key_backup', { defaultValue: 'Geräteschlüssel-Backup' })} {t('app:settings.device_key_backup', { defaultValue: 'Geräteschlüssel-Backup' })}
</div> </div>
<p className="mt-1 text-xs text-fg-muted"> <p className="mt-1 text-xs text-fg-muted">
{t('app:settings.device_key_backup_hint', { {t('app:settings.device_key_backup_hint_v2', {
defaultValue: defaultValue:
'Sichere deinen privaten Schlüssel passwortgeschützt, damit du auf neuen Geräten alte Nachrichten weiter lesen kannst.', 'Backup deines Geräteschlüssels. Ohne Backup kein Zugriff auf alte Nachrichten wenn Gerät oder Browser-Storage verloren geht. Wiederherstellung passiert im Gerät-Einrichtungsbildschirm.',
})} })}
</p> </p>
<button
type="button"
disabled={!canRun}
onClick={() => void handleOpen()}
className="mt-3 inline-flex cursor-pointer items-center gap-1.5 rounded-lg bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
>
{t('app:backup.export_open', { defaultValue: 'Backup erstellen' })}
</button>
{err && (
<p className="mt-2 text-xs text-rose-600 dark:text-rose-300">{err}</p>
)}
<div className="mt-4 space-y-2"> {open && profile && device && privateKey && (
<div className="text-xs font-semibold uppercase tracking-[0.1em] text-fg-muted"> <BackupExportDialog
{t('app:settings.backup_export', { defaultValue: 'Export' })} open={open}
</div> userId={profile.userId}
<div className="flex gap-2"> deviceId={device.id}
<input privateKey={privateKey}
type="password" onClose={handleClose}
value={exportPass}
onChange={(e) => setExportPass(e.target.value)}
placeholder={t('app:settings.backup_passphrase', { defaultValue: 'Passphrase (min 8)' })}
className="flex-1 rounded-lg border border-line bg-surface-3 px-3 py-2 text-sm text-fg placeholder-fg-muted focus:border-accent focus:outline-none"
/>
<button
type="button"
disabled={busy || exportPass.length < 8 || !canRun}
onClick={() => void handleExport()}
className="cursor-pointer rounded-lg bg-accent px-4 text-sm font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
>
{t('app:settings.backup_create', { defaultValue: 'Erstellen' })}
</button>
</div>
{backupOut && (
<textarea
readOnly
value={backupOut}
onClick={(e) => (e.target as HTMLTextAreaElement).select()}
rows={3}
className="w-full rounded-lg border border-emerald-500/30 bg-emerald-500/5 p-2 font-mono text-[10px] text-emerald-700 dark:text-emerald-200"
/>
)}
</div>
<div className="mt-4 space-y-2 border-t border-line pt-4">
<div className="text-xs font-semibold uppercase tracking-[0.1em] text-fg-muted">
{t('app:settings.backup_import', { defaultValue: 'Import' })}
</div>
<textarea
value={importBlob}
onChange={(e) => setImportBlob(e.target.value)}
rows={3}
placeholder={t('app:settings.backup_blob_placeholder', {
defaultValue: 'chatapp-backup-v1.…',
})}
className="w-full rounded-lg border border-line bg-surface-3 p-2 font-mono text-[11px] text-fg placeholder-fg-muted focus:border-accent focus:outline-none"
/> />
<div className="flex gap-2">
<input
type="password"
value={importPass}
onChange={(e) => setImportPass(e.target.value)}
placeholder={t('app:settings.backup_passphrase', { defaultValue: 'Passphrase' })}
className="flex-1 rounded-lg border border-line bg-surface-3 px-3 py-2 text-sm text-fg placeholder-fg-muted focus:border-accent focus:outline-none"
/>
<button
type="button"
disabled={busy || !importBlob || !importPass || !canRun}
onClick={() => void handleImport()}
className="cursor-pointer rounded-lg bg-emerald-600 px-4 text-sm font-semibold text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50"
>
{t('app:settings.backup_restore', { defaultValue: 'Wiederherstellen' })}
</button>
</div>
</div>
{msg && (
<p
className={
'mt-3 text-xs ' +
(msg.kind === 'ok'
? 'text-emerald-600 dark:text-emerald-300'
: 'text-rose-600 dark:text-rose-300')
}
>
{msg.text}
</p>
)} )}
</div> </div>
); );
@@ -731,3 +646,196 @@ function Toggle({
</label> </label>
); );
} }
// ---------------------------------------------------------------------------
// Audio device selection — persisted input + output deviceIds, hot-swap on
// active calls. Output swap uses HTMLMediaElement.setSinkId on our attached
// <audio> elements (LiveKit's switchActiveDevice only tracks elements it
// attached itself).
// ---------------------------------------------------------------------------
function AudioDeviceControls() {
const { t } = useTranslation(['app']);
const { setAudioInputDevice, setAudioOutputDevice } = useCall();
const [inputs, setInputs] = useState<MediaDeviceInfo[]>([]);
const [outputs, setOutputs] = useState<MediaDeviceInfo[]>([]);
const [inputId, setInputId] = useState<string | null>(
() => getAudioSettings().inputDeviceId,
);
const [outputId, setOutputId] = useState<string | null>(
() => getAudioSettings().outputDeviceId,
);
const [error, setError] = useState<string | null>(null);
const [permission, setPermission] = useState<'unknown' | 'granted' | 'denied'>(
'unknown',
);
const refresh = useCallback(async () => {
try {
const list = await navigator.mediaDevices.enumerateDevices();
setInputs(list.filter((d) => d.kind === 'audioinput'));
setOutputs(list.filter((d) => d.kind === 'audiooutput'));
// If labels are empty, permission hasn't been granted yet — browsers
// mask device names until a getUserMedia call succeeds at least once.
const hasLabels = list.some(
(d) => (d.kind === 'audioinput' || d.kind === 'audiooutput') && d.label.length > 0,
);
setPermission(hasLabels ? 'granted' : 'unknown');
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'enumerateDevices failed');
}
}, []);
useEffect(() => {
void refresh();
const onChange = () => void refresh();
try {
navigator.mediaDevices.addEventListener('devicechange', onChange);
} catch {
/* some browsers omit devicechange */
}
const unsubSettings = subscribeAudioSettings((s) => {
setInputId(s.inputDeviceId);
setOutputId(s.outputDeviceId);
});
return () => {
try {
navigator.mediaDevices.removeEventListener('devicechange', onChange);
} catch {
/* ignore */
}
unsubSettings();
};
}, [refresh]);
const requestPermission = useCallback(async () => {
setError(null);
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
stream.getTracks().forEach((t) => t.stop());
setPermission('granted');
await refresh();
} catch (err: unknown) {
setPermission('denied');
setError(err instanceof Error ? err.message : 'permission denied');
}
}, [refresh]);
const handleInput = useCallback(
async (id: string) => {
const next = id === '' ? null : id;
setInputId(next);
await setAudioInputDevice(next);
},
[setAudioInputDevice],
);
const handleOutput = useCallback(
async (id: string) => {
const next = id === '' ? null : id;
setOutputId(next);
await setAudioOutputDevice(next);
},
[setAudioOutputDevice],
);
const outputSupported =
typeof HTMLAudioElement !== 'undefined' &&
typeof HTMLAudioElement.prototype.setSinkId === 'function';
return (
<div className="space-y-4">
<div>
<div className="text-sm font-semibold text-fg">
{t('app:settings.mic_title', { defaultValue: 'Mikrofon' })}
</div>
<p className="mt-1 text-xs text-fg-muted">
{t('app:settings.mic_hint', {
defaultValue: 'Eingabegerät. Bei aktivem Anruf wird live umgeschaltet.',
})}
</p>
<div className="mt-2 flex items-center gap-2">
<select
value={inputId ?? ''}
onChange={(e) => void handleInput(e.target.value)}
className="flex-1 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
>
<option value="">
{t('app:settings.mic_default', { defaultValue: 'Systemstandard' })}
</option>
{inputs.map((d) => (
<option key={d.deviceId} value={d.deviceId}>
{d.label || t('app:settings.mic_unnamed', { defaultValue: 'Unbenannt' })}
</option>
))}
</select>
<button
type="button"
onClick={() => void refresh()}
className="cursor-pointer rounded-lg border border-line bg-surface-2 px-3 py-2 text-xs font-medium text-fg transition hover:bg-surface-3"
>
{t('app:settings.mic_refresh', { defaultValue: 'Neu laden' })}
</button>
</div>
</div>
<div>
<div className="text-sm font-semibold text-fg">
{t('app:settings.speaker_title', { defaultValue: 'Ausgabegerät' })}
</div>
<p className="mt-1 text-xs text-fg-muted">
{t('app:settings.speaker_hint', {
defaultValue:
'Lautsprecher oder Kopfhörer. Wird auf alle aktiven Audio-Streams angewendet.',
})}
</p>
{!outputSupported && (
<p className="mt-1 text-xs text-amber-600 dark:text-amber-300">
{t('app:settings.speaker_unsupported', {
defaultValue: 'Browser unterstützt setSinkId nicht — Ausgabe folgt System-Default.',
})}
</p>
)}
<div className="mt-2 flex items-center gap-2">
<select
value={outputId ?? ''}
disabled={!outputSupported}
onChange={(e) => void handleOutput(e.target.value)}
className="flex-1 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30 disabled:opacity-50"
>
<option value="">
{t('app:settings.mic_default', { defaultValue: 'Systemstandard' })}
</option>
{outputs.map((d) => (
<option key={d.deviceId} value={d.deviceId}>
{d.label || t('app:settings.mic_unnamed', { defaultValue: 'Unbenannt' })}
</option>
))}
</select>
</div>
</div>
{permission !== 'granted' && (
<div className="flex items-center justify-between gap-3 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-700 dark:text-amber-200">
<span>
{t('app:settings.mic_permission_hint', {
defaultValue:
'Ohne Mikrofon-Freigabe erscheinen Gerätenamen nicht. Einmal erlauben und Liste lädt neu.',
})}
</span>
<button
type="button"
onClick={() => void requestPermission()}
className="shrink-0 cursor-pointer rounded-md bg-amber-600 px-2.5 py-1 text-xs font-semibold text-white transition hover:bg-amber-500"
>
{t('app:settings.mic_grant', { defaultValue: 'Freigeben' })}
</button>
</div>
)}
{error && (
<p className="text-xs text-rose-600 dark:text-rose-300">{error}</p>
)}
</div>
);
}
+9
View File
@@ -0,0 +1,9 @@
// Shim sumo types to the non-sumo types package. sumo is an API superset of
// libsodium-wrappers; its runtime exports match the standard wrappers module
// and additionally include `crypto_pwhash` (Argon2id). Reuse the existing
// `@types/libsodium-wrappers` definitions rather than duplicating them.
declare module 'libsodium-wrappers-sumo' {
import sodium from 'libsodium-wrappers';
export default sodium;
export * from 'libsodium-wrappers';
}
+5 -5
View File
@@ -16,11 +16,11 @@ export default defineConfig({
}, },
}, },
optimizeDeps: { optimizeDeps: {
// libsodium-wrappers 0.7.16 ships a broken "import" condition in its // libsodium-wrappers-sumo (and the compact variant) ship broken "import"
// package exports (the ESM bundle references a sibling ./libsodium.mjs // conditions in package exports the ESM bundle references a sibling
// that isn't in the published artefact). Force esbuild to pick the // ./libsodium.mjs that isn't in the published artefact. Force esbuild to
// "require" condition so the self-contained CJS build is used. // pick the "require" condition so the self-contained CJS build is used.
include: ['libsodium-wrappers'], include: ['libsodium-wrappers-sumo'],
esbuildOptions: { esbuildOptions: {
conditions: ['require', 'node', 'default'], conditions: ['require', 'node', 'default'],
}, },
+50
View File
@@ -152,6 +152,56 @@ export async function saveDevicePrivateKey(
await secretStore.setSecret(privateKeySecretName(userId, deviceId), privateKey); await secretStore.setSecret(privateKeySecretName(userId, deviceId), privateKey);
} }
// Restores a device record from a backup by re-seeding the local private key
// store for an EXISTING server-side device row. Does NOT insert a new row —
// the original row is kept intact so conversation-key bundles stay valid.
// Throws when the server-side device was removed (the backup is then unusable;
// user must provision a fresh device and get conv-keys shared from another
// live device).
export async function restoreDeviceFromServerRecord(params: {
client: AppSupabaseClient;
secretStore: SecretStore;
userId: string;
deviceId: string;
privateKey: Uint8Array;
}): Promise<DeviceRecord> {
const { data: session } = await params.client.auth.getUser();
if (!session.user) throw new Error('not authenticated');
if (session.user.id !== params.userId) {
throw new Error(
'Backup is for a different account — sign in as the owner before restoring.',
);
}
const { data: row, error } = await params.client
.from('devices')
.select('id, name, platform, public_key, last_seen_at')
.eq('id', params.deviceId)
.eq('user_id', params.userId)
.maybeSingle();
if (error) throw error;
if (!row) {
throw new Error(
'Device record not found on server — it was removed. Backup is no longer valid; register a new device instead.',
);
}
await saveDevicePrivateKey(
params.secretStore,
params.userId,
params.deviceId,
params.privateKey,
);
return {
id: row.id,
name: row.name,
platform: row.platform,
publicKey: pgHexToBytes(row.public_key),
lastSeenAt: row.last_seen_at,
};
}
// Lightweight helpers for platforms that want to cache their current device id // Lightweight helpers for platforms that want to cache their current device id
// in JSON storage (separate from the secret store, which only holds raw bytes). // in JSON storage (separate from the secret store, which only holds raw bytes).
export const DEVICE_ID_STORAGE_KEY_PREFIX = 'chatapp.device_id'; export const DEVICE_ID_STORAGE_KEY_PREFIX = 'chatapp.device_id';
+5 -2
View File
@@ -3,7 +3,8 @@ import type { AppSupabaseClient } from '../supabase/client.js';
export interface SignupParams { export interface SignupParams {
email: string; email: string;
// Login handle. Lowercased server-side; must match ^[a-z0-9_]{3,32}$ once lowercased. // Login handle. Stored with original casing but uniqueness is case-insensitive
// (citext). Must match ^[A-Za-z0-9_]{3,32}$.
username: string; username: string;
// Optional human-readable name. Defaults to username server-side if omitted. // Optional human-readable name. Defaults to username server-side if omitted.
displayName?: string; displayName?: string;
@@ -29,7 +30,9 @@ export async function signUpWithMagicLink(
shouldCreateUser: true, shouldCreateUser: true,
data: { data: {
invite_code: params.inviteCode, invite_code: params.inviteCode,
username: params.username.toLowerCase(), // Preserve case. `profiles.username` is citext so uniqueness + lookups
// stay case-insensitive regardless of stored casing.
username: params.username.trim(),
display_name: params.displayName ?? params.username, display_name: params.displayName ?? params.username,
...(params.locale ? { locale: params.locale } : {}), ...(params.locale ? { locale: params.locale } : {}),
}, },
+4 -2
View File
@@ -72,7 +72,8 @@ export async function getProfileByUsername(
const { data, error } = await client const { data, error } = await client
.from('profiles') .from('profiles')
.select(PROFILE_COLS) .select(PROFILE_COLS)
.eq('username', username.toLowerCase()) // citext column compares CI server-side — send raw input, don't force case.
.eq('username', username.trim())
.maybeSingle(); .maybeSingle();
if (error) throw error; if (error) throw error;
return data ? mapProfile(data as unknown as ProfileRow) : null; return data ? mapProfile(data as unknown as ProfileRow) : null;
@@ -85,7 +86,8 @@ export async function isUsernameAvailable(
const { count, error } = await client const { count, error } = await client
.from('profiles') .from('profiles')
.select('user_id', { count: 'exact', head: true }) .select('user_id', { count: 'exact', head: true })
.eq('username', username.toLowerCase()); // citext compares CI — no manual normalization needed.
.eq('username', username.trim());
if (error) throw error; if (error) throw error;
return (count ?? 0) === 0; return (count ?? 0) === 0;
} }
+62 -5
View File
@@ -28,13 +28,21 @@ async function currentUserId(client: AppSupabaseClient): Promise<string> {
export async function listConversations(client: AppSupabaseClient): Promise<ConversationSummary[]> { export async function listConversations(client: AppSupabaseClient): Promise<ConversationSummary[]> {
const myId = await currentUserId(client); const myId = await currentUserId(client);
// 1. Caller's memberships // 1. Caller's memberships. `archived` / `muted_until` live on the members
// row (see migration 20260420000001). db-types snapshot predates them so
// cast the select to bypass typing.
const { data: myMembers, error: mErr } = await client const { data: myMembers, error: mErr } = await client
.from('conversation_members') .from('conversation_members')
.select('conversation_id, role, accepted') .select('conversation_id, role, accepted, archived, muted_until' as '*')
.eq('user_id', myId); .eq('user_id', myId);
if (mErr) throw mErr; if (mErr) throw mErr;
const myMembersList = myMembers ?? []; const myMembersList = (myMembers ?? []) as unknown as Array<{
conversation_id: string;
role: string;
accepted: boolean;
archived: boolean | null;
muted_until: string | null;
}>;
if (myMembersList.length === 0) return []; if (myMembersList.length === 0) return [];
const convIds = myMembersList.map((m) => m.conversation_id); const convIds = myMembersList.map((m) => m.conversation_id);
@@ -103,6 +111,9 @@ export async function listConversations(client: AppSupabaseClient): Promise<Conv
c.type === 'dm' c.type === 'dm'
? (members.find((m) => m.userId !== myId)?.profile ?? null) ? (members.find((m) => m.userId !== myId)?.profile ?? null)
: null; : null;
const mineRow = mine as
| { accepted: boolean; role: string; archived?: boolean; muted_until?: string | null }
| undefined;
return { return {
id: c.id, id: c.id,
type: c.type, type: c.type,
@@ -110,10 +121,56 @@ export async function listConversations(client: AppSupabaseClient): Promise<Conv
avatarUrl: c.avatar_url, avatarUrl: c.avatar_url,
createdAt: c.created_at, createdAt: c.created_at,
peer, peer,
acceptedByMe: mine?.accepted ?? false, acceptedByMe: mineRow?.accepted ?? false,
myRole: mine?.role ?? 'member', myRole: (mineRow?.role ?? 'member') as ConversationSummary['myRole'],
members, members,
lastMessageAt: lastSeen.get(c.id) ?? null, lastMessageAt: lastSeen.get(c.id) ?? null,
archived: mineRow?.archived ?? false,
mutedUntil: mineRow?.muted_until ?? null,
}; };
}); });
} }
// Toggle archive flag on the caller's own conversation_members row.
export async function setConversationArchived(
client: AppSupabaseClient,
conversationId: string,
archived: boolean,
): Promise<void> {
const myId = await currentUserId(client);
const { error } = await client
.from('conversation_members')
.update({ archived } as never)
.eq('conversation_id', conversationId)
.eq('user_id', myId);
if (error) throw error;
}
// Set mute until a specific ISO timestamp (null clears the mute). A
// far-future timestamp is the "muted forever" representation.
export async function setConversationMutedUntil(
client: AppSupabaseClient,
conversationId: string,
until: string | null,
): Promise<void> {
const myId = await currentUserId(client);
const { error } = await client
.from('conversation_members')
.update({ muted_until: until } as never)
.eq('conversation_id', conversationId)
.eq('user_id', myId);
if (error) throw error;
}
// Convenience: `null` unmutes, number means minutes from now. For "forever"
// pass a very large number (e.g. 100 years worth of minutes).
export function muteDurationToIso(minutes: number | null): string | null {
if (minutes === null) return null;
return new Date(Date.now() + minutes * 60 * 1000).toISOString();
}
// True iff the member is currently muted (mutedUntil present and > now).
export function isConversationMuted(mutedUntil: string | null): boolean {
if (!mutedUntil) return false;
return new Date(mutedUntil).getTime() > Date.now();
}
+5
View File
@@ -23,6 +23,11 @@ export interface ConversationSummary {
members: ConversationMember[]; members: ConversationMember[];
// Latest message timestamp (server can't see content, only metadata). // Latest message timestamp (server can't see content, only metadata).
lastMessageAt: string | null; lastMessageAt: string | null;
// Caller's per-member preferences.
archived: boolean;
// ISO timestamp. null = not muted. Past timestamp = expired mute (treat as
// not muted — the server row is kept for history until the next toggle).
mutedUntil: string | null;
} }
export interface ChatMessage { export interface ChatMessage {
+1 -1
View File
@@ -55,7 +55,7 @@ export async function searchProfiles(
query: string, query: string,
limit = 10, limit = 10,
): Promise<ProfileBrief[]> { ): Promise<ProfileBrief[]> {
const trimmed = query.trim().toLowerCase(); const trimmed = query.trim();
if (trimmed.length < 2) return []; if (trimmed.length < 2) return [];
const myId = await currentUserId(client); const myId = await currentUserId(client);
+31 -1
View File
@@ -33,7 +33,37 @@
"call_incoming": "Eingehender Anruf", "call_incoming": "Eingehender Anruf",
"call_missed": "Verpasster Anruf", "call_missed": "Verpasster Anruf",
"call_no_answer": "Keine Antwort", "call_no_answer": "Keine Antwort",
"call_declined": "Anruf abgelehnt" "call_declined": "Anruf abgelehnt",
"you": "Du",
"attachment": "Anhang",
"reply": "Antworten",
"forward": "Weiterleiten",
"replying_to": "Antwort an {{name}}",
"quote_unavailable": "Nachricht nicht verfügbar",
"search_in_conv": "In Unterhaltung suchen…",
"search_none": "Keine Treffer",
"forward_preview": "Vorschau",
"forward_attachments_dropped": "Anhänge werden nicht mit weitergeleitet.",
"forward_no_targets": "Keine anderen Unterhaltungen verfügbar.",
"forward_done": "Gesendet",
"forward_send": "An {{count}} senden",
"forward_attachments_count_one": "{{count}} Anhang wird mit weitergeleitet",
"forward_attachments_count_other": "{{count}} Anhänge werden mit weitergeleitet",
"archive": "Archivieren",
"unarchive": "Entarchivieren",
"archived_title": "Archiv",
"show_archived": "Archiv anzeigen",
"show_active": "Aktive anzeigen",
"archived_empty_title": "Nichts archiviert",
"archived_empty_subtitle": "Archivierte Unterhaltungen erscheinen hier.",
"mute": "Stummschalten",
"unmute": "Stummschaltung aufheben",
"mute_1h": "1 Stunde",
"mute_8h": "8 Stunden",
"mute_24h": "24 Stunden",
"mute_1w": "1 Woche",
"mute_forever": "Bis auf Weiteres",
"row_menu": "Aktionen"
}, },
"call": { "call": {
"start_audio": "Sprachanruf", "start_audio": "Sprachanruf",
@@ -30,8 +30,8 @@
"email_placeholder": "du@beispiel.de", "email_placeholder": "du@beispiel.de",
"username": "Benutzername", "username": "Benutzername",
"username_placeholder": "dennis", "username_placeholder": "dennis",
"username_hint": "Damit meldest du dich an. Nur Kleinbuchstaben.", "username_hint": "Damit meldest du dich an. Groß-/Kleinschreibung bleibt, ist aber nicht unterscheidbar (dennis = Dennis).",
"username_invalid": "Kleinbuchstaben az, Ziffern, Unterstrich · 332 Zeichen.", "username_invalid": "Buchstaben, Ziffern oder Unterstrich · 332 Zeichen.",
"invite_code": "Einladungscode", "invite_code": "Einladungscode",
"invite_hint": "Pflichtfeld · Zugang nur auf Einladung." "invite_hint": "Pflichtfeld · Zugang nur auf Einladung."
}, },
@@ -3,7 +3,7 @@
"network": "Netzwerkfehler. Prüfe deine Verbindung.", "network": "Netzwerkfehler. Prüfe deine Verbindung.",
"ERR_NOT_AUTH": "Du bist nicht angemeldet.", "ERR_NOT_AUTH": "Du bist nicht angemeldet.",
"ERR_INVITE_CODE_REQUIRED": "Einladungscode ist erforderlich.", "ERR_INVITE_CODE_REQUIRED": "Einladungscode ist erforderlich.",
"ERR_USERNAME_INVALID": "Benutzername muss aus Kleinbuchstaben az, Ziffern oder Unterstrich bestehen (332 Zeichen).", "ERR_USERNAME_INVALID": "Benutzername darf nur aus Buchstaben (AZ, az), Ziffern oder Unterstrich bestehen (332 Zeichen).",
"ERR_INVITES_DISABLED": "Registrierungen sind derzeit vom Admin deaktiviert.", "ERR_INVITES_DISABLED": "Registrierungen sind derzeit vom Admin deaktiviert.",
"ERR_INVITE_NOT_FOUND": "Einladungscode nicht gefunden.", "ERR_INVITE_NOT_FOUND": "Einladungscode nicht gefunden.",
"ERR_INVITE_DISABLED": "Diese Einladung wurde deaktiviert.", "ERR_INVITE_DISABLED": "Diese Einladung wurde deaktiviert.",
+31 -1
View File
@@ -33,7 +33,37 @@
"call_incoming": "Incoming call", "call_incoming": "Incoming call",
"call_missed": "Missed call", "call_missed": "Missed call",
"call_no_answer": "No answer", "call_no_answer": "No answer",
"call_declined": "Call declined" "call_declined": "Call declined",
"you": "You",
"attachment": "Attachment",
"reply": "Reply",
"forward": "Forward",
"replying_to": "Replying to {{name}}",
"quote_unavailable": "Message not available",
"search_in_conv": "Search in conversation…",
"search_none": "No matches",
"forward_preview": "Preview",
"forward_attachments_dropped": "Attachments are not forwarded.",
"forward_no_targets": "No other conversations available.",
"forward_done": "Sent",
"forward_send": "Send to {{count}}",
"forward_attachments_count_one": "{{count}} attachment forwarded",
"forward_attachments_count_other": "{{count}} attachments forwarded",
"archive": "Archive",
"unarchive": "Unarchive",
"archived_title": "Archive",
"show_archived": "Show archive",
"show_active": "Show active",
"archived_empty_title": "Nothing archived",
"archived_empty_subtitle": "Archived conversations appear here.",
"mute": "Mute",
"unmute": "Unmute",
"mute_1h": "1 hour",
"mute_8h": "8 hours",
"mute_24h": "24 hours",
"mute_1w": "1 week",
"mute_forever": "Until further notice",
"row_menu": "Actions"
}, },
"call": { "call": {
"start_audio": "Voice call", "start_audio": "Voice call",
@@ -30,8 +30,8 @@
"email_placeholder": "you@example.com", "email_placeholder": "you@example.com",
"username": "Username", "username": "Username",
"username_placeholder": "dennis", "username_placeholder": "dennis",
"username_hint": "You log in with this. Lowercase only.", "username_hint": "You log in with this. Case is preserved but not unique (dennis = Dennis).",
"username_invalid": "Lowercase az, digits, underscore · 332 chars.", "username_invalid": "Letters, digits, or underscore · 332 chars.",
"invite_code": "Invite code", "invite_code": "Invite code",
"invite_hint": "Required · invite-only access." "invite_hint": "Required · invite-only access."
}, },
@@ -3,7 +3,7 @@
"network": "Network error. Check your connection.", "network": "Network error. Check your connection.",
"ERR_NOT_AUTH": "You are not signed in.", "ERR_NOT_AUTH": "You are not signed in.",
"ERR_INVITE_CODE_REQUIRED": "Invite code is required.", "ERR_INVITE_CODE_REQUIRED": "Invite code is required.",
"ERR_USERNAME_INVALID": "Username must be lowercase az, digits, underscore, 332 chars.", "ERR_USERNAME_INVALID": "Username must be letters (AZ, az), digits, or underscore, 332 chars.",
"ERR_INVITES_DISABLED": "Signups are currently disabled by the admin.", "ERR_INVITES_DISABLED": "Signups are currently disabled by the admin.",
"ERR_INVITE_NOT_FOUND": "Invite code not found.", "ERR_INVITE_NOT_FOUND": "Invite code not found.",
"ERR_INVITE_DISABLED": "This invite has been disabled.", "ERR_INVITE_DISABLED": "This invite has been disabled.",
+24 -1
View File
@@ -86,7 +86,7 @@ importers:
i18next: i18next:
specifier: ^23.16.4 specifier: ^23.16.4
version: 23.16.8 version: 23.16.8
libsodium-wrappers: libsodium-wrappers-sumo:
specifier: 0.7.15 specifier: 0.7.15
version: 0.7.15 version: 0.7.15
livekit-client: livekit-client:
@@ -114,6 +114,9 @@ importers:
'@types/libsodium-wrappers': '@types/libsodium-wrappers':
specifier: ^0.7.14 specifier: ^0.7.14
version: 0.7.14 version: 0.7.14
'@types/libsodium-wrappers-sumo':
specifier: ^0.8.2
version: 0.8.2
'@types/react': '@types/react':
specifier: ^18.3.12 specifier: ^18.3.12
version: 18.3.28 version: 18.3.28
@@ -1853,6 +1856,10 @@ packages:
'@types/json5@0.0.29': '@types/json5@0.0.29':
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
'@types/libsodium-wrappers-sumo@0.8.2':
resolution: {integrity: sha512-uFOBpg/r21hExVlh2ty8YpDfSR+Yy3Jn8XS4+SSjitbhTxdYq+pBz/49XRxyUFe8SzqujHf/Wu0/O4d+FUtNfQ==}
deprecated: This is a stub types definition. libsodium-wrappers-sumo provides its own type definitions, so you do not need this installed.
'@types/libsodium-wrappers@0.7.14': '@types/libsodium-wrappers@0.7.14':
resolution: {integrity: sha512-5Kv68fXuXK0iDuUir1WPGw2R9fOZUlYlSAa0ztMcL0s0BfIDTqg9GXz8K30VJpPP3sxWhbolnQma2x+/TfkzDQ==} resolution: {integrity: sha512-5Kv68fXuXK0iDuUir1WPGw2R9fOZUlYlSAa0ztMcL0s0BfIDTqg9GXz8K30VJpPP3sxWhbolnQma2x+/TfkzDQ==}
@@ -3642,9 +3649,15 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
libsodium-sumo@0.7.16:
resolution: {integrity: sha512-x6atrz2AdXCJg6G709x9W9TTJRI6/0NcL5dD0l5GGVqNE48UJmDsjO4RUWYTeyXXUpg+NXZ2SHECaZnFRYzwGA==}
libsodium-sumo@0.8.3: libsodium-sumo@0.8.3:
resolution: {integrity: sha512-z5CLkGJqilCXpfYxrXWh8fHVv2C8lpnIVxsZAHkxbEFyS+zZtL8VyM8FjtAmuDYP/rHgw8ftdwxSV8Efhzb8GQ==} resolution: {integrity: sha512-z5CLkGJqilCXpfYxrXWh8fHVv2C8lpnIVxsZAHkxbEFyS+zZtL8VyM8FjtAmuDYP/rHgw8ftdwxSV8Efhzb8GQ==}
libsodium-wrappers-sumo@0.7.15:
resolution: {integrity: sha512-aSWY8wKDZh5TC7rMvEdTHoyppVq/1dTSAeAR7H6pzd6QRT3vQWcT5pGwCotLcpPEOLXX6VvqihSPkpEhYAjANA==}
libsodium-wrappers-sumo@0.8.3: libsodium-wrappers-sumo@0.8.3:
resolution: {integrity: sha512-EfLSlxKJ7RUGVospOlvbvse0suAAVPR+CkZfFcFjPzPtTEgVfvIaZUPsWndVjxYp/om2HDc0iLeR5wLF8YbHZg==} resolution: {integrity: sha512-EfLSlxKJ7RUGVospOlvbvse0suAAVPR+CkZfFcFjPzPtTEgVfvIaZUPsWndVjxYp/om2HDc0iLeR5wLF8YbHZg==}
@@ -7492,6 +7505,10 @@ snapshots:
'@types/json5@0.0.29': {} '@types/json5@0.0.29': {}
'@types/libsodium-wrappers-sumo@0.8.2':
dependencies:
libsodium-wrappers-sumo: 0.7.15
'@types/libsodium-wrappers@0.7.14': {} '@types/libsodium-wrappers@0.7.14': {}
'@types/node-forge@1.3.14': '@types/node-forge@1.3.14':
@@ -9591,8 +9608,14 @@ snapshots:
prelude-ls: 1.2.1 prelude-ls: 1.2.1
type-check: 0.4.0 type-check: 0.4.0
libsodium-sumo@0.7.16: {}
libsodium-sumo@0.8.3: {} libsodium-sumo@0.8.3: {}
libsodium-wrappers-sumo@0.7.15:
dependencies:
libsodium-sumo: 0.7.16
libsodium-wrappers-sumo@0.8.3: libsodium-wrappers-sumo@0.8.3:
dependencies: dependencies:
libsodium-sumo: 0.8.3 libsodium-sumo: 0.8.3
@@ -0,0 +1,16 @@
-- Per-member conversation preferences: archive + mute.
--
-- Both live on `conversation_members` because they are per-user state, not
-- shared across the conversation. Archive hides the conversation from the
-- main list until explicitly unarchived. Mute silences notifications until
-- `muted_until` (null = not muted, a far-future timestamp = muted forever).
alter table public.conversation_members
add column if not exists archived boolean not null default false,
add column if not exists muted_until timestamptz null;
-- Update policy on conversation_members already allows self-updates via
-- `members_update_self_or_admin`. No additional policy needed — users can
-- toggle their own archive / mute flags.
-- Realtime publication already includes `conversation_members`.
@@ -0,0 +1,71 @@
-- Keep raw casing on usernames at signup.
--
-- `profiles.username` is already `citext` so unique + lookup checks are
-- case-insensitive regardless of stored casing. Previously the signup
-- trigger force-lowercased via `lower(trim(...))`, losing the user's
-- preferred display case. Drop the lower(), widen the validation regex to
-- accept A-Z, keep trim() + uniqueness semantics.
create or replace function public.handle_new_user()
returns trigger language plpgsql security definer set search_path = public as $$
declare
v_invite_code text;
v_username text;
v_display_name text;
v_locale text;
v_invite public.invites%rowtype;
begin
v_invite_code := new.raw_user_meta_data->>'invite_code';
v_username := trim(new.raw_user_meta_data->>'username');
v_display_name := nullif(trim(new.raw_user_meta_data->>'display_name'), '');
v_locale := lower(trim(new.raw_user_meta_data->>'locale'));
if v_display_name is null then
v_display_name := v_username;
end if;
if v_locale is null or v_locale not in ('en', 'de') then
v_locale := 'en';
end if;
if v_invite_code is null or length(v_invite_code) = 0 then
raise exception 'ERR_INVITE_CODE_REQUIRED';
end if;
if v_username is null or v_username !~ '^[A-Za-z0-9_]{3,32}$' then
raise exception 'ERR_USERNAME_INVALID';
end if;
if not coalesce((select (value)::boolean from public.admin_settings where key = 'invites_enabled'), true) then
raise exception 'ERR_INVITES_DISABLED';
end if;
select * into v_invite from public.invites
where code = v_invite_code
for update;
if not found then
raise exception 'ERR_INVITE_NOT_FOUND';
end if;
if v_invite.disabled then
raise exception 'ERR_INVITE_DISABLED';
end if;
if v_invite.expires_at is not null and v_invite.expires_at < now() then
raise exception 'ERR_INVITE_EXPIRED';
end if;
if v_invite.uses_limit is not null and v_invite.uses_count >= v_invite.uses_limit then
raise exception 'ERR_INVITE_EXHAUSTED';
end if;
update public.invites
set uses_count = uses_count + 1
where code = v_invite.code;
insert into public.profiles (user_id, username, display_name, locale)
values (new.id, v_username, v_display_name, v_locale);
return new;
end;
$$;