Compare commits
170 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e8a5d6dc5e | |||
| c5851195c7 | |||
| 258ad3511d | |||
| 2583613d7f | |||
| 04d9909e92 | |||
| e05f5445e7 | |||
| a4f7a16c90 | |||
| f6e0e4dd09 | |||
| d449e128a8 | |||
| 5b0aa24a8d | |||
| 540b506f91 | |||
| ede0d00f5b | |||
| ef1f9f45d8 | |||
| 95156a65eb | |||
| 915569db39 | |||
| 526f9d7bcc | |||
| cd79b77e6f | |||
| 7726498218 | |||
| f1d9602a76 | |||
| 45bcbb449c | |||
| ef98efb938 | |||
| 7355b343c8 | |||
| 1a39c1bb33 | |||
| 70209be1de | |||
| 3dbeabd268 | |||
| 6bff163f09 | |||
| ddeedeb71d | |||
| 7e85ffe548 | |||
| 0d30a462b3 | |||
| 745b8cd69d | |||
| 5ace6735d8 | |||
| ffaa6ceb70 | |||
| ccac822cb9 | |||
| 74a0ef6c32 | |||
| 9de2c368bf | |||
| 88345420e0 | |||
| 0354676d2f | |||
| cb7bd99fb4 | |||
| 3b55fcaf2c | |||
| 7ae1d5ba8c | |||
| 50bfb5b137 | |||
| 41359816f1 | |||
| 8ad212291a | |||
| 18a6365586 | |||
| 898b7469bb | |||
| a5eadef663 | |||
| e8074e7da0 | |||
| 88409aff5d | |||
| b7d7a85253 | |||
| 502c6af1b8 | |||
| adc9686035 | |||
| 472665b980 | |||
| c52c65faa9 | |||
| 79f1786ac6 | |||
| ca77214e2d | |||
| 8c27e8eafa | |||
| 69733168ed | |||
| bf87add18d | |||
| c871687fd2 | |||
| 61a1eb37ea | |||
| 9b9802ff6e | |||
| 2463081949 | |||
| 1eea80c529 | |||
| 830dac4cdd | |||
| cb46483f5d | |||
| 87fed820dc | |||
| fca5211008 | |||
| c430a590fc | |||
| 3b884b0415 | |||
| affff0b433 | |||
| a5c0889b0a | |||
| 90fa0a7e95 | |||
| 767db72847 | |||
| 0288d7a476 | |||
| 34b972ec2a | |||
| c55173800b | |||
| fc9f1ec143 | |||
| 12e4b597a0 | |||
| 5c05afb009 | |||
| a636a3c1c1 | |||
| c9fe4879e0 | |||
| 61462516d2 | |||
| d39a0fb6dc | |||
| 9207f473cd | |||
| 5367544b59 | |||
| e6b698bf14 | |||
| 6caa674c19 | |||
| d9377fc52a | |||
| 1c18078b1f | |||
| 0e05a2cd85 | |||
| b55ccf899f | |||
| b2eb214d9f | |||
| 010a810485 | |||
| 6a9a0bb804 | |||
| f7c60945d0 | |||
| 15ef9ece66 | |||
| b789f4b10d | |||
| 02e1af4517 | |||
| e292df82f0 | |||
| b3804d805b | |||
| 02c4bb1c9b | |||
| 20216b37c6 | |||
| 2c586351fc | |||
| b89a7e2617 | |||
| 8d69329763 | |||
| 2fdbe9ee3a | |||
| 0d1d4496c3 | |||
| baf9c2e054 | |||
| b54fe0b56d | |||
| 89eb8d97c6 | |||
| 32bff77fd0 | |||
| d10b0fb5c3 | |||
| 1370f8794b | |||
| eef884c782 | |||
| d9f3c6c562 | |||
| b61f929cf7 | |||
| b0967dd2c5 | |||
| f8859b4601 | |||
| 4068e8c7b6 | |||
| cb8e729624 | |||
| 1e9395fdc2 | |||
| 9b3ef8c24f | |||
| ba8ba75490 | |||
| 122191276e | |||
| 5a3dc15704 | |||
| 10d3e91bbb | |||
| 04da0f211d | |||
| 4d787b68b4 | |||
| c2df741055 | |||
| b81c63a2d3 | |||
| 098e4f4c1d | |||
| e83c23d698 | |||
| 68cd4108dd | |||
| ed7cb72ebe | |||
| 177a4c059f | |||
| 1440b11740 | |||
| 286836f539 | |||
| 3b62fbc243 | |||
| 8d7ed592b8 | |||
| 153e40bdcb | |||
| 415fa10ed0 | |||
| 8b7b6a3b0f | |||
| 05a17b7c1c | |||
| dd6ae63491 | |||
| b1cdc4d184 | |||
| 263d2455a8 | |||
| 3755a927c3 | |||
| 0b3a6280fc | |||
| 3313dd84f3 | |||
| 2a5f836b86 | |||
| bf02380c19 | |||
| 375d28efa5 | |||
| 1e88941796 | |||
| 7ac93a95e6 | |||
| a275703ba6 | |||
| 035b5f078d | |||
| 13c6ad68f0 | |||
| 84a9f35ed8 | |||
| 7eb8224bb0 | |||
| 574436ab71 | |||
| 5e483368ec | |||
| b4457b74f5 | |||
| bb7aeec345 | |||
| 1bf4f4bb83 | |||
| 3049a0e39d | |||
| 2003a26c23 | |||
| 1e0cf67335 | |||
| 5f753412a6 | |||
| a560f24c45 | |||
| 87c2e45bb4 |
@@ -0,0 +1,11 @@
|
||||
# React Native / Expo expects a flat (npm/yarn-style) node_modules layout
|
||||
# where ALL transitive deps are reachable from any package. pnpm's strict
|
||||
# isolation breaks that — Metro hits a fresh missing-module error for every
|
||||
# transitive dep (invariant, nullthrows, @babel/runtime/helpers/*, ...).
|
||||
#
|
||||
# shamefully-hoist=true hoists everything to the workspace-root node_modules
|
||||
# (like npm/yarn) so all transitive deps are reachable via standard Node
|
||||
# resolution. This is the official Expo monorepo workaround and is the
|
||||
# only sustainable answer for Expo + RN + pnpm. Desktop (Electron+Vite)
|
||||
# bundles via Vite, so the flatter layout doesn't affect it.
|
||||
shamefully-hoist=true
|
||||
@@ -106,6 +106,19 @@ export const CHANNELS = {
|
||||
// Used by the call cinema mode to flip the host BrowserWindow into real
|
||||
// OS fullscreen so the Windows taskbar / macOS menubar gets covered.
|
||||
WINDOW_SET_FULLSCREEN: 'window:set-fullscreen',
|
||||
|
||||
// Wipe-on-close — main process pushes this to the renderer right before
|
||||
// exiting if the user has enabled the Settings → Sicherheit toggle. The
|
||||
// renderer clears its sensitive caches (memoryWipe.ts) and acks via
|
||||
// `app:wipe-before-quit:done`; main quits after the ack (or a 2s safety
|
||||
// timeout, whichever comes first).
|
||||
/** Main → renderer: about to quit. Renderer wipes, then resolves. */
|
||||
APP_WIPE_BEFORE_QUIT: 'app:wipe-before-quit',
|
||||
|
||||
// OS hostname — returns the machine hostname via Node's `os.hostname()`.
|
||||
// Used by AuthContext on first launch to populate the `devices` row with
|
||||
// a human-readable default device name. Returns null on failure.
|
||||
APP_HOSTNAME: 'app:hostname',
|
||||
} as const;
|
||||
|
||||
// ---- Screen sources ------------------------------------------------------
|
||||
|
||||
@@ -6,11 +6,13 @@
|
||||
// the old Tauri devUrl so nothing in the renderer code needs to change)
|
||||
// and from the built dist in packaged mode.
|
||||
|
||||
import { app, BrowserWindow, desktopCapturer, Menu, session } from 'electron';
|
||||
import { app, BrowserWindow, desktopCapturer, ipcMain, Menu, session } from 'electron';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { CHANNELS } from './ipc-types';
|
||||
import { register as registerAudioLoopback } from './modules/audio-loopback';
|
||||
import { register as registerAppHostname } from './modules/app-hostname';
|
||||
import { register as registerAutostart } from './modules/autostart';
|
||||
import { register as registerFsScoped } from './modules/fs-scoped';
|
||||
import { register as registerNotifications } from './modules/notifications';
|
||||
@@ -253,6 +255,7 @@ if (!gotLock) {
|
||||
registerFsScoped();
|
||||
registerSql();
|
||||
registerAutostart();
|
||||
registerAppHostname();
|
||||
|
||||
// Window-dependent registrars — only call once mainWindow exists so
|
||||
// event emitters have somewhere to send.
|
||||
@@ -263,6 +266,32 @@ if (!gotLock) {
|
||||
registerAudioLoopback(mainWindow);
|
||||
});
|
||||
|
||||
// Wipe-on-close: when the user enables it in Settings, the renderer is given
|
||||
// a chance to clear all sensitive caches before the app process exits. If
|
||||
// the renderer doesn't ack within 2 seconds we force-quit anyway — better
|
||||
// to lose the wipe than to hang the app shutdown.
|
||||
//
|
||||
// Note: there's a separate `before-quit` listener in modules/tray.ts that
|
||||
// tears down the Tray instance. Electron fires both; the tray listener is
|
||||
// synchronous and doesn't touch event.preventDefault, so it doesn't fight
|
||||
// our deferred-quit dance here. The `wipeRequested` flag guards re-entry
|
||||
// when our own `app.quit()` below fires `before-quit` a second time.
|
||||
let wipeRequested = false;
|
||||
app.on('before-quit', (event) => {
|
||||
if (wipeRequested) return;
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
wipeRequested = true;
|
||||
event.preventDefault();
|
||||
|
||||
mainWindow.webContents.send(CHANNELS.APP_WIPE_BEFORE_QUIT);
|
||||
const done = new Promise<void>((resolve) => {
|
||||
ipcMain.once(CHANNELS.APP_WIPE_BEFORE_QUIT + ':done', () => resolve());
|
||||
});
|
||||
void Promise.race([done, new Promise<void>((r) => setTimeout(r, 2000))]).finally(() => {
|
||||
app.quit();
|
||||
});
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') app.quit();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// app:hostname adapter — returns the OS hostname via Node's `os.hostname()`.
|
||||
// The renderer has no Node access (contextIsolation is ON), so it must ask
|
||||
// main for this value. Used by AuthContext on first launch to populate the
|
||||
// `devices` row with a human-readable default device name.
|
||||
|
||||
import os from 'node:os';
|
||||
|
||||
import { ipcMain } from 'electron';
|
||||
|
||||
import { CHANNELS } from '../ipc-types';
|
||||
|
||||
export function register(): void {
|
||||
ipcMain.handle(CHANNELS.APP_HOSTNAME, (): string | null => {
|
||||
try {
|
||||
const h = os.hostname();
|
||||
return typeof h === 'string' && h.length > 0 ? h : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -30,6 +30,17 @@ function resolveIconPathDev(): string {
|
||||
return path.join(app.getAppPath(), 'resources', 'icon.ico');
|
||||
}
|
||||
|
||||
// 16×16 grey square — last-ditch fallback when neither the packaged
|
||||
// nor dev icon file resolves. Tray constructor on Windows throws when
|
||||
// handed an empty NativeImage, which would tear down the whole
|
||||
// registrar before `ipcMain.handle(TRAY_UNREAD)` runs — leaving the
|
||||
// taskbar overlay badge wired but the renderer's invoke rejecting
|
||||
// with "No handler registered". A non-empty placeholder keeps the
|
||||
// constructor happy so the IPC handler always gets registered.
|
||||
const FALLBACK_TRAY_PNG_BASE64 =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAH0lEQVR42mNk' +
|
||||
'YGD4z0ABYBxVOKpwVOGowlGFwwoBAEnYAR9XlIldAAAAAElFTkSuQmCC';
|
||||
|
||||
function loadTrayIcon(): NativeImage {
|
||||
for (const p of [resolveIconPath(), resolveIconPathDev()]) {
|
||||
try {
|
||||
@@ -39,7 +50,7 @@ function loadTrayIcon(): NativeImage {
|
||||
/* try next */
|
||||
}
|
||||
}
|
||||
return nativeImage.createEmpty();
|
||||
return nativeImage.createFromBuffer(Buffer.from(FALLBACK_TRAY_PNG_BASE64, 'base64'));
|
||||
}
|
||||
|
||||
function buildOverlay(): NativeImage {
|
||||
@@ -53,35 +64,49 @@ function buildOverlay(): NativeImage {
|
||||
}
|
||||
|
||||
export function register(mainWindow: BrowserWindow): void {
|
||||
const icon = loadTrayIcon();
|
||||
trayRef = new Tray(icon.isEmpty() ? nativeImage.createEmpty() : icon);
|
||||
trayRef.setToolTip('Netralax');
|
||||
// Tray icon is best-effort: if neither the packaged resource nor the
|
||||
// dev path resolves (e.g. icon.ico isn't shipped under resourcesPath
|
||||
// in packaged builds — only the app icon goes into the .exe metadata),
|
||||
// we still want the taskbar overlay badge to work. setOverlayIcon is
|
||||
// a BrowserWindow method, so it functions even when the systray icon
|
||||
// creation fails.
|
||||
try {
|
||||
const icon = loadTrayIcon();
|
||||
if (!icon.isEmpty()) {
|
||||
trayRef = new Tray(icon);
|
||||
trayRef.setToolTip('Netralax');
|
||||
|
||||
const menu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: 'Open',
|
||||
click: (): void => {
|
||||
const menu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: 'Open',
|
||||
click: (): void => {
|
||||
if (mainWindow.isDestroyed()) return;
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Quit',
|
||||
click: (): void => {
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
]);
|
||||
trayRef.setContextMenu(menu);
|
||||
trayRef.on('click', (): void => {
|
||||
if (mainWindow.isDestroyed()) return;
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Quit',
|
||||
click: (): void => {
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
]);
|
||||
trayRef.setContextMenu(menu);
|
||||
trayRef.on('click', (): void => {
|
||||
if (mainWindow.isDestroyed()) return;
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
if (mainWindow.isVisible()) mainWindow.focus();
|
||||
else mainWindow.show();
|
||||
});
|
||||
if (mainWindow.isVisible()) mainWindow.focus();
|
||||
else mainWindow.show();
|
||||
});
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
// Swallow: the overlay badge below is the user-visible bit. A missing
|
||||
// systray icon is cosmetic and shouldn't take the unread handler with it.
|
||||
console.warn('[tray] systray init failed, overlay badge still active', err);
|
||||
}
|
||||
|
||||
ipcMain.handle(
|
||||
CHANNELS.TRAY_UNREAD,
|
||||
|
||||
+11
@@ -95,6 +95,17 @@ export interface ElectronAPI {
|
||||
setAutoStart: (enabled: boolean) => Promise<void>;
|
||||
|
||||
setFullscreen: (enabled: boolean) => Promise<void>;
|
||||
|
||||
/** Subscribe to the main-process pre-quit notification. Used by the
|
||||
* "Cache beim Schließen leeren" Settings toggle. */
|
||||
onWipeBeforeQuit: (cb: () => Promise<void>) => () => void;
|
||||
|
||||
/** Returns the OS hostname (via Node's `os.hostname()`). Used by
|
||||
* AuthContext on first launch to populate the `devices` row with a
|
||||
* human-readable default device name. Optional: always feature-check
|
||||
* via `typeof window.electronAPI?.getHostname === 'function'` because
|
||||
* the web build has no preload bridge. */
|
||||
getHostname?: () => Promise<string | null>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -28,6 +28,13 @@ import {
|
||||
type UpdateCheckResult,
|
||||
type UpdateProgress,
|
||||
} from './ipc-types';
|
||||
// Static import of the desktop package.json so the bundler inlines the
|
||||
// version string at build time. The previous `process.env.npm_package_-
|
||||
// version` approach worked in dev (pnpm sets it as a script env var) but
|
||||
// fell back to '0.0.0' in packaged builds — every installed user got
|
||||
// flagged as "Update verfügbar" against their own actually-current
|
||||
// version. resolveJsonModule + esModuleInterop are on in tsconfig.node.
|
||||
import pkg from '../package.json';
|
||||
|
||||
type Unsubscribe = () => void;
|
||||
|
||||
@@ -40,7 +47,7 @@ function on<T>(channel: string, cb: (payload: T) => void): Unsubscribe {
|
||||
const api = {
|
||||
platform: ELECTRON_RUNTIME_MARKER,
|
||||
osPlatform: process.platform as NodeJS.Platform,
|
||||
appVersion: process.env.npm_package_version ?? '0.0.0',
|
||||
appVersion: pkg.version,
|
||||
|
||||
// Screen sources ---------------------------------------------------------
|
||||
getScreenSources: (): Promise<ScreenSource[]> => ipcRenderer.invoke(CHANNELS.SCREEN_GET_SOURCES),
|
||||
@@ -152,6 +159,27 @@ const api = {
|
||||
// Window fullscreen ------------------------------------------------------
|
||||
setFullscreen: (enabled: boolean): Promise<void> =>
|
||||
ipcRenderer.invoke(CHANNELS.WINDOW_SET_FULLSCREEN, enabled),
|
||||
|
||||
// OS hostname ------------------------------------------------------------
|
||||
getHostname: (): Promise<string | null> => ipcRenderer.invoke(CHANNELS.APP_HOSTNAME),
|
||||
|
||||
// Wipe-on-close ----------------------------------------------------------
|
||||
// Subscribe to the main-process pre-quit notification. The renderer's
|
||||
// callback does the actual wipe (memoryWipe.ts) and resolves; we ack
|
||||
// unconditionally so main can finish quitting — better to lose the wipe
|
||||
// than to hang the app shutdown if the callback throws.
|
||||
onWipeBeforeQuit: (cb: () => Promise<void>): Unsubscribe => {
|
||||
const handler = async (_evt: Electron.IpcRendererEvent): Promise<void> => {
|
||||
try {
|
||||
await cb();
|
||||
} catch (err) {
|
||||
console.warn('[wipe] renderer cb failed', err);
|
||||
}
|
||||
ipcRenderer.send(CHANNELS.APP_WIPE_BEFORE_QUIT + ':done');
|
||||
};
|
||||
ipcRenderer.on(CHANNELS.APP_WIPE_BEFORE_QUIT, handler);
|
||||
return () => ipcRenderer.removeListener(CHANNELS.APP_WIPE_BEFORE_QUIT, handler);
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type ElectronAPI = typeof api;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@chat-app/desktop",
|
||||
"version": "0.17.4",
|
||||
"version": "0.19.0",
|
||||
"private": true,
|
||||
"description": "Electron desktop client (Windows / macOS / Linux)",
|
||||
"type": "module",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { HashRouter, Navigate, Outlet, Route, Routes } from 'react-router-dom';
|
||||
import { AppShell } from './components/AppShell';
|
||||
import { CrashToast } from './components/CrashToast';
|
||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||
import { RemoteRevokedScreen } from './components/RemoteRevokedScreen';
|
||||
import { SpinnerIcon } from './components/icons';
|
||||
import { UpdateToast } from './components/UpdateToast';
|
||||
import { RequireAdmin, RequireAuth, RequireDevice } from './components/guards';
|
||||
@@ -168,6 +169,7 @@ export function App() {
|
||||
</Routes>
|
||||
<UpdateToast />
|
||||
<CrashToast />
|
||||
<RemoteRevokedScreen />
|
||||
</HashRouter>
|
||||
</CallProvider>
|
||||
</ConversationsProvider>
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
|
||||
import { useEffect } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { startConversationKeySync } from '../lib/conversationKeySync';
|
||||
import { startDeviceApprovalListener } from '../lib/deviceApproval';
|
||||
import { ensureInstallId } from '../lib/installId';
|
||||
import { ensureNotificationPermission } from '../lib/osNotify';
|
||||
import { devLocalSecretStore } from '../lib/secretStore';
|
||||
import { BackupPromptBanner } from './BackupPromptBanner';
|
||||
import { useMentionNotifications } from '../lib/useMentionNotifications';
|
||||
import { cachedUserKey } from '../lib/userIdentity';
|
||||
import { CallUI } from './CallUI';
|
||||
import { DeviceApprovalBanner } from './DeviceApprovalBanner';
|
||||
import { Sidebar } from './Sidebar';
|
||||
|
||||
export function AppShell() {
|
||||
const { session, device } = useAuth();
|
||||
const { session } = useAuth();
|
||||
useMentionNotifications(session?.user.id);
|
||||
useEffect(() => {
|
||||
// Prompt once per authenticated shell mount. Module-level guard prevents
|
||||
// re-asking if the user already responded this session.
|
||||
@@ -21,20 +22,25 @@ export function AppShell() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session?.user.id || !device?.id) return;
|
||||
if (!session?.user.id) return;
|
||||
const userId = session.user.id;
|
||||
const deviceId = device.id;
|
||||
const stopKeySync = startConversationKeySync(userId, deviceId);
|
||||
// Per-install id replaces the per-device record now that crypto is
|
||||
// user-keyed. The legacy conv-key-sync and device-approval subsystems
|
||||
// still take a "deviceId" argument for telemetry / row identity; passing
|
||||
// the install id keeps those wires intact until they are themselves
|
||||
// reworked in later tasks (Task 16/17/19 territory).
|
||||
const installId = ensureInstallId();
|
||||
const stopKeySync = startConversationKeySync(userId, installId);
|
||||
const stopApproval = startDeviceApprovalListener({
|
||||
ownUserId: userId,
|
||||
ownDeviceId: deviceId,
|
||||
getPriv: () => loadDevicePrivateKey(devLocalSecretStore, userId, deviceId),
|
||||
ownDeviceId: installId,
|
||||
getPriv: () => cachedUserKey(userId),
|
||||
});
|
||||
return () => {
|
||||
stopKeySync();
|
||||
stopApproval();
|
||||
};
|
||||
}, [session?.user.id, device?.id]);
|
||||
}, [session?.user.id]);
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen overflow-hidden bg-surface text-fg">
|
||||
@@ -48,7 +54,6 @@ export function AppShell() {
|
||||
</main>
|
||||
</div>
|
||||
<CallUI />
|
||||
<BackupPromptBanner />
|
||||
<DeviceApprovalBanner />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,9 +5,15 @@ import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { AlertIcon, SpinnerIcon } from './icons';
|
||||
import { Lightbox } from './Lightbox';
|
||||
import { ViewOnceImage } from './ViewOnceImage';
|
||||
|
||||
interface Props {
|
||||
handle: AttachmentHandle;
|
||||
/** True iff the local user sent this message. View-once images use this
|
||||
* to suppress the burn (senders never burn their own attachment) and to
|
||||
* pick the right preview chrome. Defaults to false so existing callers
|
||||
* (e.g. MediaFilesDrawer) keep working without modification. */
|
||||
mine?: boolean;
|
||||
}
|
||||
|
||||
// Max inline-preview dimension. Full-resolution stays available for the
|
||||
@@ -44,7 +50,7 @@ async function makeThumbnail(blob: Blob): Promise<Blob | null> {
|
||||
}
|
||||
}
|
||||
|
||||
export function AttachmentImage({ handle }: Props) {
|
||||
export function AttachmentImage({ handle, mine = false }: Props) {
|
||||
const [fullUrl, setFullUrl] = useState<string | null>(null);
|
||||
const [thumbUrl, setThumbUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -116,6 +122,25 @@ export function AttachmentImage({ handle }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
// View-once attachments swap the standard image preview for the ViewOnceImage
|
||||
// chrome (locked card → fullscreen lightbox + burn for recipient, normal
|
||||
// image with badge for sender). We still kick off the decrypt above so the
|
||||
// fullscreen lightbox has the decoded blob ready the moment the recipient
|
||||
// taps. `handle.viewedAt` is undefined in the current renderer (it lives on
|
||||
// the public attachment row, not in the encrypted payload) — the component
|
||||
// treats undefined as "not yet burned" and uses the RPC response to flip
|
||||
// the state locally after the recipient opens.
|
||||
if (handle.viewOnce) {
|
||||
return (
|
||||
<ViewOnceImage
|
||||
attachmentId={handle.id}
|
||||
viewedAt={handle.viewedAt ?? null}
|
||||
isSender={mine}
|
||||
src={blobUrl}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
|
||||
@@ -1,306 +0,0 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { type BackupBundle, exportDeviceBackupWithRecovery } 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 [bundle, setBundle] = useState<BackupBundle | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [copiedRecovery, setCopiedRecovery] = useState(false);
|
||||
|
||||
const canGenerate = useMemo(() => {
|
||||
return passphrase.length >= 8 && passphrase === confirm && !busy;
|
||||
}, [passphrase, confirm, busy]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setPassphrase('');
|
||||
setConfirm('');
|
||||
setBundle(null);
|
||||
setError(null);
|
||||
setCopied(false);
|
||||
setCopiedRecovery(false);
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
reset();
|
||||
onClose();
|
||||
}, [reset, onClose]);
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
if (!canGenerate) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const b = await exportDeviceBackupWithRecovery({
|
||||
userId,
|
||||
deviceId,
|
||||
privateKey,
|
||||
passphrase,
|
||||
});
|
||||
setBundle(b);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [canGenerate, userId, deviceId, privateKey, passphrase]);
|
||||
|
||||
const copyText = async (text: string, marker: 'main' | 'recovery'): Promise<void> => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
if (marker === 'main') {
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
} else {
|
||||
setCopiedRecovery(true);
|
||||
window.setTimeout(() => setCopiedRecovery(false), 1500);
|
||||
}
|
||||
} catch {
|
||||
/* fall back — user can select manually */
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
if (!bundle) return;
|
||||
const text =
|
||||
'=== Passphrase backup ===\n' +
|
||||
bundle.passphraseBackup +
|
||||
'\n\n=== Recovery code ===\n' +
|
||||
bundle.recoveryCode +
|
||||
'\n\n=== Recovery backup (use with the recovery code) ===\n' +
|
||||
bundle.recoveryBackup +
|
||||
'\n';
|
||||
const blob = new Blob([text], { 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);
|
||||
}, [bundle, 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">
|
||||
{!bundle ? (
|
||||
<>
|
||||
<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>
|
||||
<label className="mt-3 block text-xs font-medium uppercase tracking-wide text-fg-muted">
|
||||
Backup-String
|
||||
</label>
|
||||
<textarea
|
||||
readOnly
|
||||
value={bundle.passphraseBackup}
|
||||
rows={6}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
className="mt-1 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-2 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyText(bundle.passphraseBackup, 'main')}
|
||||
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 ? 'Kopiert!' : '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"
|
||||
>
|
||||
Als Datei speichern (alles)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<ShieldIcon className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-300" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-semibold text-amber-700 dark:text-amber-200">
|
||||
Recovery-Code (Passphrase vergessen?)
|
||||
</p>
|
||||
<p className="mt-0.5 text-[11px] text-amber-700/80 dark:text-amber-200/80">
|
||||
Code separat aufbewahren. Mit dem Recovery-Backup unten lässt sich der Schlüssel
|
||||
ohne Passphrase wiederherstellen.
|
||||
</p>
|
||||
<p className="mt-2 select-all rounded bg-surface-3 px-2 py-1.5 font-mono text-sm tracking-widest text-fg">
|
||||
{bundle.recoveryCode}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="mt-3 block text-xs font-medium uppercase tracking-wide text-fg-muted">
|
||||
Recovery-Backup-String
|
||||
</label>
|
||||
<textarea
|
||||
readOnly
|
||||
value={bundle.recoveryBackup}
|
||||
rows={6}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
className="mt-1 w-full resize-none rounded-lg border border-line bg-surface-2 p-3 font-mono text-[11px] leading-relaxed text-fg"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyText(bundle.recoveryBackup, 'recovery')}
|
||||
className="mt-2 inline-flex w-full 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>{copiedRecovery ? 'Kopiert!' : 'Recovery-Backup kopieren'}</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="flex items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3">
|
||||
{!bundle ? (
|
||||
<>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { DeviceRestore } from './DeviceRestore';
|
||||
import { AlertIcon, ShieldIcon, XIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
userId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// Modal wrapper around DeviceRestore for the already-signed-in case. A
|
||||
// successful restore swaps the local device-identity for the one embedded
|
||||
// in the backup string — the app then hard-reloads so every hook
|
||||
// re-initialises against the restored keys (simpler than invalidating
|
||||
// supabase-realtime subscriptions, stronghold caches, livekit rooms, etc.
|
||||
// individually).
|
||||
export function BackupRestoreDialog({ open, userId, onClose }: Props) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey);
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Backup wiederherstellen"
|
||||
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/60 p-6 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex w-full max-w-md flex-col gap-4"
|
||||
>
|
||||
<div className="flex items-center justify-between rounded-lg border border-white/10 bg-ink-900/70 p-3 backdrop-blur-xl">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldIcon className="h-4 w-4 text-brand-300" />
|
||||
<h3 className="text-sm font-semibold text-white">
|
||||
Gerät aus Backup wiederherstellen
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Schließen"
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-neutral-400 transition hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-100">
|
||||
<AlertIcon className="mt-0.5 h-4 w-4 shrink-0 text-amber-300" />
|
||||
<p className="min-w-0 flex-1">
|
||||
Restore ersetzt das aktuelle Gerät durch das aus dem Backup.
|
||||
Die App lädt danach neu. Nachrichten, die auf diesem Gerät seit
|
||||
dem Backup eingegangen sind, sind erst wieder lesbar, nachdem
|
||||
Peer-Geräte den Conversation-Key erneut für die wiederhergestellte
|
||||
Device-ID wrappen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DeviceRestore
|
||||
userId={userId}
|
||||
onRestored={() => {
|
||||
// Hard reload — cleanest way to reset every hook, supabase
|
||||
// realtime channel, stronghold handle, and cached state.
|
||||
window.location.reload();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { useNickname } from '../lib/friendNicknames';
|
||||
import {
|
||||
CrownIcon,
|
||||
HeadphonesOffIcon,
|
||||
@@ -89,6 +90,7 @@ export interface ParticipantTileProps {
|
||||
|
||||
export function CallParticipantTile(props: ParticipantTileProps) {
|
||||
const {
|
||||
userId,
|
||||
displayName,
|
||||
me,
|
||||
muted,
|
||||
@@ -107,6 +109,13 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
||||
onContextMenu,
|
||||
} = props;
|
||||
|
||||
// Apply the per-viewer nickname override once at the top — each tile is
|
||||
// already per-participant, so a single hook call is fine. The resolved
|
||||
// name is forwarded into the avatar sub-components below so their initial
|
||||
// letter respects the nickname too.
|
||||
const resolvedName = useNickname(userId, displayName);
|
||||
const tileProps = { ...props, displayName: resolvedName };
|
||||
|
||||
const small = size === 'small';
|
||||
// Discord-style: full tile border switches to emerald the whole time the
|
||||
// user is speaking. Same treatment for audio + video tiles so the visual
|
||||
@@ -132,9 +141,9 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
||||
}
|
||||
>
|
||||
{video ? (
|
||||
<VideoStub {...props} small={small} fit={focused ? 'contain' : 'cover'} />
|
||||
<VideoStub {...tileProps} small={small} fit={focused ? 'contain' : 'cover'} />
|
||||
) : (
|
||||
<AudioContent {...props} small={small} />
|
||||
<AudioContent {...tileProps} small={small} />
|
||||
)}
|
||||
|
||||
{speaking && (
|
||||
@@ -187,7 +196,7 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
||||
/>
|
||||
)}
|
||||
<span className="truncate">
|
||||
{displayName}
|
||||
{resolvedName}
|
||||
{me ? ' (du)' : ''}
|
||||
</span>
|
||||
{e2ee && (
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useCall } from '../context/CallContext';
|
||||
import { useCallPresence } from '../lib/useCallPresence';
|
||||
import { Avatar } from './Avatar';
|
||||
import { PhoneIcon, SpinnerIcon, XIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
conversation: ConversationSummary;
|
||||
}
|
||||
|
||||
const MAX_TILES = 7;
|
||||
|
||||
/**
|
||||
* Discord-DM-style call preview panel. Renders only while peers are in the
|
||||
* conversation's active call and the local user is NOT in it. Provides large
|
||||
* avatar tiles plus a single "Beitreten" call-to-action. Calls are still
|
||||
* STARTED via the topbar phone icon (`ConversationHeader.startCall`); this
|
||||
* component never initiates — only joins.
|
||||
*/
|
||||
export function CallPreviewPanel({ conversation }: Props) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { session } = useAuth();
|
||||
const { state, joinActiveCall } = useCall();
|
||||
const presentIds = useCallPresence(conversation.id);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
const myId = session?.user.id ?? null;
|
||||
const iAmIn =
|
||||
(state.kind === 'connected' ||
|
||||
state.kind === 'connecting' ||
|
||||
state.kind === 'reconnecting') &&
|
||||
state.conversationId === conversation.id;
|
||||
|
||||
const others = presentIds.filter((u) => u !== myId);
|
||||
if (iAmIn || others.length === 0) return null;
|
||||
|
||||
const visibleTiles = others.slice(0, MAX_TILES);
|
||||
const overflow = Math.max(0, others.length - MAX_TILES);
|
||||
const busy = state.kind !== 'idle';
|
||||
|
||||
const handleJoin = () => {
|
||||
if (busy) return;
|
||||
void joinActiveCall(conversation.id, 'audio');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-b border-line bg-surface-3/70">
|
||||
<div className="flex items-center gap-2 px-5 py-2 text-xs">
|
||||
<PhoneIcon className="h-3.5 w-3.5 text-emerald-500" />
|
||||
<span className="font-semibold uppercase tracking-[0.12em] text-fg-muted">
|
||||
{t('app:call.active_in_conv', {
|
||||
defaultValue: 'Laufender Anruf · {{count}} im Raum',
|
||||
count: others.length,
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
aria-label={collapsed ? 'Anrufvorschau ausklappen' : 'Anrufvorschau einklappen'}
|
||||
className="ml-auto inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-2 hover:text-fg"
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!collapsed && (
|
||||
<div className="flex flex-col items-center gap-4 px-5 pb-4 pt-1">
|
||||
<div className="grid w-full max-w-2xl grid-cols-2 gap-3 md:grid-cols-3">
|
||||
{visibleTiles.map((id) => {
|
||||
const member = conversation.members.find((m) => m.userId === id);
|
||||
const name = member?.profile?.displayName ?? '?';
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
title={name}
|
||||
className="flex aspect-square flex-col items-center justify-center gap-2 rounded-lg border border-line bg-surface-2 p-3"
|
||||
>
|
||||
<div className="h-16 w-16 overflow-hidden rounded-full">
|
||||
<Avatar
|
||||
url={member?.profile?.avatarUrl ?? null}
|
||||
displayName={name}
|
||||
className="h-full w-full text-base"
|
||||
/>
|
||||
</div>
|
||||
<span className="line-clamp-1 text-xs font-medium text-fg">{name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{overflow > 0 && (
|
||||
<div className="flex aspect-square flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-line bg-surface-2 p-3 text-fg-muted">
|
||||
<span className="text-xl font-semibold">+{overflow}</span>
|
||||
<span className="text-xs">weitere</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleJoin}
|
||||
disabled={busy}
|
||||
className="inline-flex w-full max-w-xs cursor-pointer items-center justify-center gap-2 rounded-lg bg-emerald-600 px-4 py-3 text-sm font-semibold text-white transition hover:bg-emerald-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{busy ? (
|
||||
<SpinnerIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<PhoneIcon className="h-4 w-4" />
|
||||
)}
|
||||
<span>{t('app:call.join', { defaultValue: 'Beitreten' })}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { PresenceState } from '@chat-app/shared/supabase';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useCall } from '../context/CallContext';
|
||||
import { useNickname } from '../lib/friendNicknames';
|
||||
import type { PeerPresence } from '../lib/usePeerPresence';
|
||||
import { Avatar } from './Avatar';
|
||||
import {
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
UsersIcon,
|
||||
VideoIcon,
|
||||
} from './icons';
|
||||
import { PinnedMessagesPill } from './PinnedMessagesPill';
|
||||
|
||||
const PRESENCE_DOT: Record<PresenceState, string> = {
|
||||
online: 'bg-emerald-500',
|
||||
@@ -29,6 +31,8 @@ interface Props {
|
||||
onMediaClick?: () => void;
|
||||
onProfileClick?: (ev: React.MouseEvent) => void;
|
||||
onSearchClick?: () => void;
|
||||
pinnedCount?: number;
|
||||
onOpenPinned?: () => void;
|
||||
}
|
||||
|
||||
export function ConversationHeader({
|
||||
@@ -38,6 +42,8 @@ export function ConversationHeader({
|
||||
onMediaClick,
|
||||
onProfileClick,
|
||||
onSearchClick,
|
||||
pinnedCount = 0,
|
||||
onOpenPinned,
|
||||
}: Props) {
|
||||
if (!conversation) {
|
||||
return <header className="h-[65px] border-b border-line px-6 py-3" aria-busy="true" />;
|
||||
@@ -48,10 +54,12 @@ export function ConversationHeader({
|
||||
<HeaderBar
|
||||
conversation={conversation}
|
||||
peerPresence={peerPresence}
|
||||
pinnedCount={pinnedCount}
|
||||
{...(onInfoClick ? { onInfoClick } : {})}
|
||||
{...(onMediaClick ? { onMediaClick } : {})}
|
||||
{...(onProfileClick ? { onProfileClick } : {})}
|
||||
{...(onSearchClick ? { onSearchClick } : {})}
|
||||
{...(onOpenPinned ? { onOpenPinned } : {})}
|
||||
/>
|
||||
{/* Voice-channel rail rendered separately in ConversationPage so it
|
||||
can sit between the message surface and the in-call dock. */}
|
||||
@@ -66,6 +74,8 @@ interface HeaderBarProps {
|
||||
onMediaClick?: () => void;
|
||||
onProfileClick?: (ev: React.MouseEvent) => void;
|
||||
onSearchClick?: () => void;
|
||||
pinnedCount?: number;
|
||||
onOpenPinned?: () => void;
|
||||
}
|
||||
|
||||
function HeaderBar({
|
||||
@@ -75,11 +85,19 @@ function HeaderBar({
|
||||
onMediaClick,
|
||||
onProfileClick,
|
||||
onSearchClick,
|
||||
pinnedCount = 0,
|
||||
onOpenPinned,
|
||||
}: HeaderBarProps) {
|
||||
const { t } = useTranslation(['app']);
|
||||
|
||||
const isDm = conversation.type === 'dm';
|
||||
const title = isDm ? (conversation.peer?.displayName ?? '?') : (conversation.name ?? '?');
|
||||
// For DMs, route the peer's display name through the per-viewer nickname
|
||||
// override. Falls back to the real displayName when no nickname is set.
|
||||
const peerName = useNickname(
|
||||
conversation.peer?.userId,
|
||||
conversation.peer?.displayName ?? '?',
|
||||
);
|
||||
const title = isDm ? peerName : (conversation.name ?? '?');
|
||||
const handle = isDm ? '@' + (conversation.peer?.username ?? '?') : '';
|
||||
const peerAvatar = isDm
|
||||
? (conversation.peer?.avatarUrl ?? null)
|
||||
@@ -133,6 +151,7 @@ function HeaderBar({
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{!isDm && <UsersIcon className="h-4 w-4 shrink-0 text-fg-muted" />}
|
||||
<p className="truncate font-display text-base font-semibold text-fg">{title}</p>
|
||||
<PinnedMessagesPill count={pinnedCount} onClick={() => onOpenPinned?.()} />
|
||||
</div>
|
||||
<p className="truncate text-xs text-fg-muted">
|
||||
{isDm ? (
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import type { DeviceRecord } from '@chat-app/shared/auth';
|
||||
import { extractErrorCode } from '@chat-app/shared/i18n';
|
||||
import { useCallback, useId, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { detectDesktopPlatform, registerCurrentDevice } from '../lib/device';
|
||||
import { AlertIcon, ArrowRightIcon, LockIcon, ShieldIcon, SpinnerIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
userId: string;
|
||||
defaultName: string;
|
||||
onRegistered: (device: DeviceRecord) => void;
|
||||
}
|
||||
|
||||
export function DeviceRegistration({ userId, defaultName, onRegistered }: Props) {
|
||||
const { t } = useTranslation(['auth', 'errors']);
|
||||
const nameId = useId();
|
||||
const [name, setName] = useState(defaultName);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const device = await registerCurrentDevice({ userId, name: trimmed });
|
||||
onRegistered(device);
|
||||
} catch (err: unknown) {
|
||||
const code = extractErrorCode(err);
|
||||
if (code) {
|
||||
setError(t(`errors:${code}`, { defaultValue: t('errors:generic') }));
|
||||
} else if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError(t('errors:generic'));
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[name, userId, onRegistered, t],
|
||||
);
|
||||
|
||||
const platform = detectDesktopPlatform();
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="w-full max-w-md animate-slide-up rounded-2xl border border-white/10 bg-ink-900/70 p-7 shadow-glow backdrop-blur-xl sm:p-8"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-brand-500/20 ring-1 ring-brand-400/30">
|
||||
<LockIcon className="h-5 w-5 text-brand-300" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-display text-lg font-semibold text-white">{t('auth:device.title')}</h2>
|
||||
<p className="text-xs text-neutral-400">{t('auth:device.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-1.5">
|
||||
<label
|
||||
htmlFor={nameId}
|
||||
className="block text-xs font-medium uppercase tracking-wide text-neutral-400"
|
||||
>
|
||||
{t('auth:device.name_label')}
|
||||
</label>
|
||||
<input
|
||||
id={nameId}
|
||||
type="text"
|
||||
required
|
||||
autoFocus
|
||||
maxLength={64}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t('auth:device.name_placeholder')}
|
||||
className="w-full rounded-lg border border-white/10 bg-ink-800 px-3 py-2.5 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
|
||||
/>
|
||||
<p className="text-xs text-neutral-500">{t('auth:device.name_hint')}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 rounded-lg border border-amber-500/20 bg-amber-500/10 p-3 text-xs text-amber-200">
|
||||
<div className="flex items-start gap-2">
|
||||
<ShieldIcon className="mt-0.5 h-4 w-4 shrink-0 text-amber-300" />
|
||||
<span className="min-w-0 flex-1 break-words">{t('auth:device.security_note_dev')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || name.trim().length === 0}
|
||||
aria-busy={busy}
|
||||
className="group mt-6 inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-gradient-to-br from-brand-400 to-brand-600 px-4 py-3 text-sm font-semibold text-white shadow-glow transition hover:from-brand-300 hover:to-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-400/60 focus:ring-offset-2 focus:ring-offset-ink-900 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{busy ? (
|
||||
<>
|
||||
<SpinnerIcon className="h-4 w-4" />
|
||||
<span>{t('auth:device.cta_loading')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>{t('auth:device.cta')}</span>
|
||||
<ArrowRightIcon className="h-4 w-4 transition group-hover:translate-x-0.5" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-4 flex items-start gap-3 rounded-lg border border-rose-500/20 bg-rose-500/10 p-3 text-sm text-rose-100"
|
||||
>
|
||||
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" />
|
||||
<p className="min-w-0 flex-1 break-words">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-4 text-center text-xs text-neutral-500">
|
||||
{t('auth:device.device_platform', { defaultValue: 'Platform' })}: {platform}
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
import {
|
||||
type DeviceRecord,
|
||||
restoreDeviceFromServerRecord,
|
||||
} from '@chat-app/shared/auth';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
decodePrivateKeyFromBackup,
|
||||
importDeviceBackup,
|
||||
normalizeRecoveryCode,
|
||||
} 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 [mode, setMode] = useState<'passphrase' | 'recovery'>('passphrase');
|
||||
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 secret =
|
||||
mode === 'recovery' ? normalizeRecoveryCode(passphrase) : passphrase;
|
||||
const payload = await importDeviceBackup(backup.trim(), secret);
|
||||
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, mode, 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">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-xs font-medium uppercase tracking-wide text-neutral-400">
|
||||
{mode === 'passphrase' ? 'Passphrase' : 'Recovery-Code'}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMode((m) => (m === 'passphrase' ? 'recovery' : 'passphrase'));
|
||||
setPassphrase('');
|
||||
setError(null);
|
||||
}}
|
||||
className="cursor-pointer text-[11px] font-semibold text-brand-300 hover:underline"
|
||||
>
|
||||
{mode === 'passphrase'
|
||||
? 'Passphrase vergessen? Recovery-Code nutzen'
|
||||
: 'Stattdessen Passphrase eingeben'}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type={mode === 'passphrase' ? 'password' : 'text'}
|
||||
required
|
||||
value={passphrase}
|
||||
onChange={(e) => setPassphrase(e.target.value)}
|
||||
placeholder={mode === 'recovery' ? 'XXXXXX-XXXXXX-XXXXXX-XXXXXX' : ''}
|
||||
spellCheck={false}
|
||||
autoComplete={mode === 'recovery' ? 'off' : 'current-password'}
|
||||
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 ' +
|
||||
(mode === 'recovery' ? 'font-mono tracking-widest' : '')
|
||||
}
|
||||
/>
|
||||
{mode === 'recovery' && (
|
||||
<p className="text-[11px] text-neutral-500">
|
||||
Stattdessen den Recovery-Backup-String oben einfügen.
|
||||
</p>
|
||||
)}
|
||||
</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,32 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
action?: { label: string; onClick: () => void };
|
||||
}
|
||||
|
||||
// Reusable empty-state placeholder: large icon + heading + description +
|
||||
// optional primary CTA. Used everywhere there's a meaningfully-empty list
|
||||
// (no chats, no friends, no search results, fresh conversation).
|
||||
export function EmptyState({ icon, title, description, action }: Props) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center px-6 py-12 text-center">
|
||||
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-accent/10 text-accent">
|
||||
{icon}
|
||||
</div>
|
||||
<h3 className="font-display text-lg font-semibold text-fg">{title}</h3>
|
||||
<p className="mt-2 max-w-xs text-sm text-fg-muted">{description}</p>
|
||||
{action && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={action.onClick}
|
||||
className="mt-5 inline-flex cursor-pointer items-center justify-center rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
|
||||
import {
|
||||
type AttachmentHandle,
|
||||
type DecryptedMessage,
|
||||
@@ -15,8 +14,9 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useConversationsContext } from '../context/ConversationsContext';
|
||||
import { devLocalSecretStore } from '../lib/secretStore';
|
||||
import { ensureInstallId } from '../lib/installId';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { cachedUserKey } from '../lib/userIdentity';
|
||||
import { Avatar } from './Avatar';
|
||||
import { ForwardIcon, SpinnerIcon, UsersIcon, XIcon } from './icons';
|
||||
|
||||
@@ -33,7 +33,7 @@ interface Props {
|
||||
// preview hints at the dropped attachment.
|
||||
export function ForwardDialog({ open, message, currentConversationId, onClose }: Props) {
|
||||
const { t } = useTranslation(['app', 'errors']);
|
||||
const { session, device } = useAuth();
|
||||
const { session } = useAuth();
|
||||
const { conversations } = useConversationsContext();
|
||||
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
@@ -84,13 +84,13 @@ export function ForwardDialog({ open, message, currentConversationId, onClose }:
|
||||
}
|
||||
|
||||
async function handleSend() {
|
||||
if (!session?.user.id || !device?.id || !message) return;
|
||||
if (!session?.user.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 priv = await cachedUserKey(session.user.id);
|
||||
if (!priv) throw new Error('user key not unlocked');
|
||||
|
||||
const hasAttachments = sourceAttachments.length > 0;
|
||||
const text = preview || (hasAttachments ? '' : '');
|
||||
@@ -138,7 +138,7 @@ export function ForwardDialog({ open, message, currentConversationId, onClose }:
|
||||
conversationId: convId,
|
||||
plaintext: text,
|
||||
senderUserId: session.user.id,
|
||||
senderDeviceId: device.id,
|
||||
senderDeviceId: ensureInstallId(),
|
||||
senderPrivateKey: priv,
|
||||
...(newHandles.length > 0 ? { attachmentHandles: newHandles } : {}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
featuredGifs,
|
||||
getRecentGifs,
|
||||
type GifResult,
|
||||
rememberRecentGif,
|
||||
searchGifs,
|
||||
} from '../lib/tenor';
|
||||
import { SpinnerIcon, XIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onPick: (gif: GifResult) => void;
|
||||
}
|
||||
|
||||
type Tab = 'trending' | 'search' | 'recent';
|
||||
|
||||
export function GifPicker({ open, onClose, onPick }: Props) {
|
||||
const [tab, setTab] = useState<Tab>('trending');
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<GifResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const recent = useMemo(() => getRecentGifs(), [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const run = async () => {
|
||||
try {
|
||||
const gifs = tab === 'search' && query.trim().length > 0
|
||||
? await searchGifs(query)
|
||||
: tab === 'trending'
|
||||
? await featuredGifs()
|
||||
: [];
|
||||
if (!cancelled) setResults(gifs);
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : 'GIFs gerade nicht verfügbar');
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
void run();
|
||||
return () => { cancelled = true; };
|
||||
}, [open, tab, query]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const visible = tab === 'recent' ? recent : results;
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-full left-0 z-30 mb-2 w-[440px] rounded-2xl border border-line bg-surface-2 shadow-xl">
|
||||
<header className="flex items-center justify-between border-b border-line px-3 py-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{(['trending', 'search', 'recent'] as Tab[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setTab(t)}
|
||||
className={
|
||||
'cursor-pointer rounded-md px-2 py-1 text-xs font-medium transition ' +
|
||||
(t === tab ? 'bg-accent/15 text-accent' : 'text-fg-muted hover:text-fg')
|
||||
}
|
||||
>
|
||||
{t === 'trending' ? 'Trending' : t === 'search' ? 'Suche' : 'Zuletzt'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Schließen"
|
||||
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</header>
|
||||
{tab === 'search' && (
|
||||
<div className="border-b border-line px-3 py-2">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="GIFs suchen…"
|
||||
className="w-full rounded-md border border-line bg-surface-3 px-2 py-1.5 text-sm text-fg placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/40"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid max-h-[360px] grid-cols-3 gap-1.5 overflow-y-auto p-2">
|
||||
{loading && (
|
||||
<div className="col-span-3 flex justify-center py-6 text-fg-muted">
|
||||
<SpinnerIcon className="h-5 w-5" />
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<p className="col-span-3 px-2 py-4 text-center text-xs text-rose-300">
|
||||
{error === 'giphy_api_key_missing'
|
||||
? 'GIFs sind nicht konfiguriert (VITE_GIPHY_API_KEY fehlt).'
|
||||
: 'GIFs gerade nicht verfügbar.'}
|
||||
</p>
|
||||
)}
|
||||
{!loading && !error && visible.length === 0 && (
|
||||
<p className="col-span-3 px-2 py-6 text-center text-xs text-fg-muted">
|
||||
{tab === 'recent' ? 'Noch keine zuletzt verwendeten GIFs.' : 'Keine Treffer.'}
|
||||
</p>
|
||||
)}
|
||||
{!loading && !error && visible.map((g) => (
|
||||
<button
|
||||
key={g.id}
|
||||
type="button"
|
||||
onClick={() => { rememberRecentGif(g); onPick(g); onClose(); }}
|
||||
className="overflow-hidden rounded-md border border-line bg-surface-3 hover:border-accent/40"
|
||||
title={g.description}
|
||||
>
|
||||
<img src={g.previewUrl} alt={g.description || 'GIF'} className="h-24 w-full object-cover" loading="lazy" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1542,8 +1542,8 @@ function FullscreenCall({
|
||||
function StripToggleIcon({ hidden }: { hidden: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useNickname } from '../lib/friendNicknames';
|
||||
import { Avatar } from './Avatar';
|
||||
|
||||
type Member = ConversationSummary['members'][number];
|
||||
|
||||
interface Props {
|
||||
members: ConversationSummary['members'];
|
||||
query: string;
|
||||
@@ -63,37 +66,58 @@ export function MentionAutocomplete({ members, query, excludeUserId, onSelect, o
|
||||
aria-label="Mitglieder"
|
||||
className="absolute bottom-full left-0 right-0 z-20 mb-2 max-h-64 overflow-y-auto rounded-xl border border-line bg-surface-2 p-1 shadow-xl dark:bg-[#2b2d31]"
|
||||
>
|
||||
{matches.map((m, idx) => {
|
||||
const name = m.profile?.displayName ?? m.profile?.username ?? '?';
|
||||
const handle = m.profile?.username ?? '';
|
||||
const isActive = idx === active;
|
||||
return (
|
||||
<button
|
||||
key={m.userId}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isActive}
|
||||
onMouseEnter={() => setActive(idx)}
|
||||
onClick={() => {
|
||||
if (m.profile?.username) onSelect(m.profile.username);
|
||||
}}
|
||||
className={
|
||||
'flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ' +
|
||||
(isActive
|
||||
? 'bg-accent/20 text-fg'
|
||||
: 'text-fg-muted hover:bg-surface-3 dark:hover:bg-[#383a40]')
|
||||
}
|
||||
>
|
||||
<Avatar
|
||||
displayName={name}
|
||||
url={m.profile?.avatarUrl ?? null}
|
||||
className="h-6 w-6 text-[10px]"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-fg">{name}</span>
|
||||
<span className="shrink-0 text-[10px] text-fg-muted">@{handle}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{matches.map((m, idx) => (
|
||||
<MemberRow
|
||||
key={m.userId}
|
||||
member={m}
|
||||
isActive={idx === active}
|
||||
onActivate={() => setActive(idx)}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Extracted per-row component so we can call `useNickname` once per member
|
||||
// at the top of THIS component (hooks can't run inside .map callbacks).
|
||||
function MemberRow({
|
||||
member,
|
||||
isActive,
|
||||
onActivate,
|
||||
onSelect,
|
||||
}: {
|
||||
member: Member;
|
||||
isActive: boolean;
|
||||
onActivate: () => void;
|
||||
onSelect: (username: string) => void;
|
||||
}) {
|
||||
const fallback = member.profile?.displayName ?? member.profile?.username ?? '?';
|
||||
const name = useNickname(member.userId, fallback);
|
||||
const handle = member.profile?.username ?? '';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isActive}
|
||||
onMouseEnter={onActivate}
|
||||
onClick={() => {
|
||||
if (member.profile?.username) onSelect(member.profile.username);
|
||||
}}
|
||||
className={
|
||||
'flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ' +
|
||||
(isActive
|
||||
? 'bg-accent/20 text-fg'
|
||||
: 'text-fg-muted hover:bg-surface-3 dark:hover:bg-[#383a40]')
|
||||
}
|
||||
>
|
||||
<Avatar
|
||||
displayName={name}
|
||||
url={member.profile?.avatarUrl ?? null}
|
||||
className="h-6 w-6 text-[10px]"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-fg">{name}</span>
|
||||
<span className="shrink-0 text-[10px] text-fg-muted">@{handle}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
|
||||
import {
|
||||
type DecryptedMessage,
|
||||
editEncryptedMessage,
|
||||
@@ -11,8 +10,10 @@ import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { devLocalSecretStore } from '../lib/secretStore';
|
||||
import { useNickname } from '../lib/friendNicknames';
|
||||
import { ensureInstallId } from '../lib/installId';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { cachedUserKey } from '../lib/userIdentity';
|
||||
import type { AggregatedReaction } from '../lib/useMessageReactions';
|
||||
import { summarizePollVotes } from '../lib/conversationFeatures';
|
||||
import { extractFirstUrl } from '../lib/useLinkPreview';
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
PencilIcon,
|
||||
PhoneIcon,
|
||||
PhoneOffIcon,
|
||||
PinIcon,
|
||||
ReplyIcon,
|
||||
SmileIcon,
|
||||
SpinnerIcon,
|
||||
@@ -75,6 +77,10 @@ interface Props {
|
||||
onAvatarClick?: (userId: string, ev: React.MouseEvent) => void;
|
||||
/** Highlighted state — set briefly after a jump. */
|
||||
highlighted?: boolean;
|
||||
/** True iff this message is currently pinned (parent maintains the set). */
|
||||
isPinned?: boolean;
|
||||
/** Right-click menu → toggle pin/unpin for this message id. */
|
||||
onTogglePin?: (messageId: string) => void;
|
||||
}
|
||||
|
||||
export function MessageBubble({
|
||||
@@ -96,9 +102,17 @@ export function MessageBubble({
|
||||
onForward,
|
||||
onAvatarClick,
|
||||
highlighted = false,
|
||||
isPinned = false,
|
||||
onTogglePin,
|
||||
}: Props) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { session, device } = useAuth();
|
||||
const { session } = useAuth();
|
||||
|
||||
// Per-viewer nickname override for the message sender. Fallback chain
|
||||
// keeps the existing behavior when no nickname is set. Skipped for the
|
||||
// quoted-sender snippet — that's a different user.
|
||||
const senderName = useNickname(message.senderId, senderDisplayName ?? '');
|
||||
const resolvedSenderDisplayName = senderName.length > 0 ? senderName : null;
|
||||
|
||||
const parsed = parseMessagePayload(message.plaintext);
|
||||
const initialText = parsed.kind === 'text' ? parsed.text : '';
|
||||
@@ -191,7 +205,7 @@ export function MessageBubble({
|
||||
}, [pickerOpen]);
|
||||
|
||||
const handleEditSave = useCallback(async () => {
|
||||
if (!session || !device) return;
|
||||
if (!session) return;
|
||||
const trimmed = editText.trim();
|
||||
if (!trimmed || trimmed === bodyText) {
|
||||
setEditing(false);
|
||||
@@ -201,15 +215,15 @@ export function MessageBubble({
|
||||
setBusy(true);
|
||||
setEditError(null);
|
||||
try {
|
||||
const priv = await loadDevicePrivateKey(devLocalSecretStore, session.user.id, device.id);
|
||||
if (!priv) throw new Error('private key not loaded');
|
||||
const priv = await cachedUserKey(session.user.id);
|
||||
if (!priv) throw new Error('user key not unlocked');
|
||||
await editEncryptedMessage({
|
||||
client: supabase,
|
||||
messageId: message.id,
|
||||
conversationId,
|
||||
newPlaintext: trimmed,
|
||||
senderUserId: session.user.id,
|
||||
senderDeviceId: device.id,
|
||||
senderDeviceId: ensureInstallId(),
|
||||
senderPrivateKey: priv,
|
||||
});
|
||||
setEditing(false);
|
||||
@@ -225,7 +239,7 @@ export function MessageBubble({
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [editText, message.id, message.plaintext, conversationId, session, device, t]);
|
||||
}, [editText, message.id, message.plaintext, conversationId, session, t]);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (busy) return;
|
||||
@@ -281,7 +295,7 @@ export function MessageBubble({
|
||||
<AvatarSlot
|
||||
show={isLastOfRun}
|
||||
url={senderAvatarUrl ?? null}
|
||||
displayName={senderDisplayName ?? null}
|
||||
displayName={resolvedSenderDisplayName}
|
||||
{...(onAvatarClick
|
||||
? { onClick: (ev: React.MouseEvent) => onAvatarClick(message.senderId, ev) }
|
||||
: {})}
|
||||
@@ -299,7 +313,7 @@ export function MessageBubble({
|
||||
parsed={parsed}
|
||||
mine={mine}
|
||||
time={time}
|
||||
senderDisplayName={senderDisplayName ?? null}
|
||||
senderDisplayName={resolvedSenderDisplayName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -315,7 +329,7 @@ export function MessageBubble({
|
||||
<AvatarSlot
|
||||
show={isLastOfRun}
|
||||
url={senderAvatarUrl ?? null}
|
||||
displayName={senderDisplayName ?? null}
|
||||
displayName={resolvedSenderDisplayName}
|
||||
{...(onAvatarClick
|
||||
? { onClick: (ev: React.MouseEvent) => onAvatarClick(message.senderId, ev) }
|
||||
: {})}
|
||||
@@ -416,7 +430,18 @@ export function MessageBubble({
|
||||
</button>
|
||||
)}
|
||||
{message.plaintext === null ? (
|
||||
<span className="italic opacity-70">…cannot decrypt</span>
|
||||
<span
|
||||
className={
|
||||
'italic ' +
|
||||
// Mine = blue/accent bubble → use accent-fg with reduced opacity
|
||||
// (still meets 4.5:1). Peer = surface-2 grey → muted-fg works.
|
||||
(mine ? 'text-accent-fg/80' : 'text-fg-muted')
|
||||
}
|
||||
>
|
||||
{t('app:chats.unreadable', {
|
||||
defaultValue: 'Nachricht nicht lesbar',
|
||||
})}
|
||||
</span>
|
||||
) : parsed.kind === 'poll' ? (
|
||||
<PollCard
|
||||
question={parsed.question}
|
||||
@@ -440,7 +465,7 @@ export function MessageBubble({
|
||||
return <AttachmentAudio key={a.id} handle={a} />;
|
||||
}
|
||||
if (a.mimeType.startsWith('image/')) {
|
||||
return <AttachmentImage key={a.id} handle={a} />;
|
||||
return <AttachmentImage key={a.id} handle={a} mine={mine} />;
|
||||
}
|
||||
if (a.mimeType.startsWith('video/')) {
|
||||
return <AttachmentVideo key={a.id} handle={a} />;
|
||||
@@ -624,6 +649,15 @@ export function MessageBubble({
|
||||
setContextMenu(null);
|
||||
void handleDelete();
|
||||
}}
|
||||
isPinned={isPinned}
|
||||
{...(onTogglePin
|
||||
? {
|
||||
onTogglePin: () => {
|
||||
setContextMenu(null);
|
||||
onTogglePin(message.id);
|
||||
},
|
||||
}
|
||||
: {})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -721,6 +755,8 @@ function MessageContextMenu({
|
||||
onEdit,
|
||||
onCopy,
|
||||
onDelete,
|
||||
isPinned,
|
||||
onTogglePin,
|
||||
}: {
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -735,6 +771,8 @@ function MessageContextMenu({
|
||||
onEdit: () => void;
|
||||
onCopy: () => void;
|
||||
onDelete: () => void;
|
||||
isPinned?: boolean;
|
||||
onTogglePin?: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
@@ -801,6 +839,13 @@ function MessageContextMenu({
|
||||
tone="danger"
|
||||
/>
|
||||
)}
|
||||
{onTogglePin && (
|
||||
<MenuItem
|
||||
label={isPinned ? 'Anheftung entfernen' : 'Anpinnen'}
|
||||
icon={<PinIcon className="h-4 w-4" />}
|
||||
onClick={onTogglePin}
|
||||
/>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { getNickname, setNickname } from '../lib/friendNicknames';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
userId: string;
|
||||
displayName: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function NicknameDialog({ open, userId, displayName, onClose }: Props) {
|
||||
const [value, setValue] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setValue(getNickname(userId) ?? '');
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}, [open, userId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const submit = (): void => {
|
||||
setNickname(userId, value);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Spitzname setzen"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-full max-w-sm rounded-2xl border border-line bg-surface-2 p-5 shadow-xl"
|
||||
>
|
||||
<h3 className="font-display text-base font-semibold text-fg">Spitzname für {displayName}</h3>
|
||||
<p className="mt-1 text-xs text-fg-muted">
|
||||
Nur du siehst diesen Namen. Leer lassen = den richtigen Namen verwenden.
|
||||
</p>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') submit(); }}
|
||||
maxLength={32}
|
||||
placeholder={displayName}
|
||||
className="mt-4 w-full 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 focus:ring-2 focus:ring-accent/30"
|
||||
/>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg hover:bg-surface-2"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
className="cursor-pointer rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg hover:brightness-110"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
length?: number;
|
||||
autoFocus?: boolean;
|
||||
disabled?: boolean;
|
||||
ariaLabel: string;
|
||||
onSubmit?: () => void;
|
||||
}
|
||||
|
||||
export function PinInput({ value, onChange, length = 6, autoFocus, disabled, ariaLabel, onSubmit }: Props) {
|
||||
const ref = useRef<HTMLInputElement | null>(null);
|
||||
const [focused, setFocused] = useState(false);
|
||||
useEffect(() => { if (autoFocus) ref.current?.focus(); }, [autoFocus]);
|
||||
// Index of the next empty slot the next keystroke will fill. When the user
|
||||
// has typed all `length` digits, no slot is "active" — the form should
|
||||
// submit instead of pretending one is still focused.
|
||||
const activeIndex = value.length < length ? value.length : -1;
|
||||
return (
|
||||
<div className="relative flex justify-center" onClick={() => ref.current?.focus()}>
|
||||
<input
|
||||
ref={ref}
|
||||
aria-label={ariaLabel}
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
pattern="\d*"
|
||||
maxLength={length}
|
||||
disabled={disabled}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value.replace(/\D/g, '').slice(0, length))}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && value.length === length) onSubmit?.(); }}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
className="absolute h-px w-px overflow-hidden p-0 opacity-0"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
{Array.from({ length }).map((_, i) => {
|
||||
const filled = i < value.length;
|
||||
const active = focused && i === activeIndex;
|
||||
let classes =
|
||||
'flex h-12 w-10 items-center justify-center rounded-lg border text-lg font-semibold transition ';
|
||||
if (filled) {
|
||||
classes += 'border-brand-400 bg-brand-500/10 text-white';
|
||||
} else if (active) {
|
||||
// Brand-coloured ring + glow so the user immediately sees where
|
||||
// the next keystroke lands. The animated cursor inside reinforces
|
||||
// the "input is alive" feeling.
|
||||
classes += 'border-brand-400 bg-brand-500/10 text-brand-300 ring-2 ring-brand-400/40 shadow-[0_0_12px_-2px] shadow-brand-500/40';
|
||||
} else {
|
||||
classes += 'border-white/10 bg-ink-800 text-neutral-500';
|
||||
}
|
||||
return (
|
||||
<span key={i} className={classes}>
|
||||
{filled ? '•' : active ? <span className="animate-pulse">|</span> : ''}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { PinnedMessage } from '@chat-app/shared/chat';
|
||||
|
||||
import { PinIcon, XIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
pins: PinnedMessage[];
|
||||
onClose: () => void;
|
||||
onJump: (messageId: string) => void;
|
||||
onUnpin: (messageId: string) => void;
|
||||
// Optional preview-renderer: parent resolves messageId → short text/snippet
|
||||
// since the panel itself doesn't decrypt. If absent, the panel just shows
|
||||
// the message-id stub.
|
||||
renderPreview?: (messageId: string) => React.ReactNode;
|
||||
}
|
||||
|
||||
export function PinnedMessagesPanel({ open, pins, onClose, onJump, onUnpin, renderPreview }: Props) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<aside
|
||||
role="complementary"
|
||||
aria-label="Angepinnte Nachrichten"
|
||||
className="absolute right-0 top-0 z-30 flex h-full w-80 flex-col border-l border-line bg-surface-2 shadow-xl"
|
||||
>
|
||||
<header className="flex items-center justify-between border-b border-line px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<PinIcon className="h-4 w-4 text-accent" />
|
||||
<h3 className="text-sm font-semibold text-fg">Angepinnt · {pins.length}</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Schließen"
|
||||
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
{pins.length === 0 ? (
|
||||
<p className="p-6 text-center text-xs text-fg-muted">Noch nichts angepinnt.</p>
|
||||
) : (
|
||||
<ul className="flex-1 overflow-y-auto">
|
||||
{pins.map((p) => (
|
||||
<li key={p.messageId} className="border-b border-line/60 px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onJump(p.messageId)}
|
||||
className="block w-full cursor-pointer text-left text-sm text-fg hover:text-accent"
|
||||
>
|
||||
{renderPreview ? renderPreview(p.messageId) : <span className="font-mono text-xs">{p.messageId.slice(0, 8)}</span>}
|
||||
</button>
|
||||
<div className="mt-1 flex items-center justify-between text-[11px] text-fg-muted">
|
||||
<time dateTime={p.pinnedAt}>{new Date(p.pinnedAt).toLocaleString()}</time>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUnpin(p.messageId)}
|
||||
className="cursor-pointer text-rose-400 hover:underline"
|
||||
>
|
||||
Anheftung entfernen
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { PinIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
count: number;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
// Compact chip rendered in the conv header that opens the pinned panel.
|
||||
// Renders nothing when count is 0 so a fresh conv shows no clutter.
|
||||
export function PinnedMessagesPill({ count, onClick }: Props) {
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-full border border-line bg-surface-3 px-2.5 text-xs font-medium text-fg-muted transition hover:bg-surface-2 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
title="Angepinnte Nachrichten anzeigen"
|
||||
>
|
||||
<PinIcon className="h-3 w-3 text-accent" />
|
||||
<span>{count} angepinnt</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
export function RemoteRevokedScreen() {
|
||||
const { t } = useTranslation();
|
||||
const { revokedRemotely, acknowledgeRevocation } = useAuth();
|
||||
|
||||
if (!revokedRemotely) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="remote-revoked-title"
|
||||
className="fixed inset-0 z-[1000] flex items-center justify-center bg-ink-950/95 p-6"
|
||||
>
|
||||
<div className="max-w-sm rounded-2xl border border-line bg-surface-2 p-6 text-center shadow-xl">
|
||||
<h2
|
||||
id="remote-revoked-title"
|
||||
className="mb-2 font-display text-xl font-semibold text-fg"
|
||||
>
|
||||
{t('app:auth.revoked_title', { defaultValue: 'Du wurdest remote abgemeldet' })}
|
||||
</h2>
|
||||
<p className="mb-5 text-sm text-fg-muted">
|
||||
{t('app:auth.revoked_body', {
|
||||
defaultValue:
|
||||
'Ein anderes deiner Geräte hat diesen Login beendet. Aus Sicherheitsgründen wurden alle lokalen Daten gelöscht.',
|
||||
})}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={acknowledgeRevocation}
|
||||
className="inline-flex cursor-pointer items-center justify-center rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
{t('app:auth.revoked_acknowledge', { defaultValue: 'Verstanden' })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { isWipeOnCloseEnabled, setWipeOnClose } from '../lib/memoryWipeSettings';
|
||||
import {
|
||||
changePin,
|
||||
type LegacyMigrationReport,
|
||||
regenerateRecoveryCode,
|
||||
resetIdentity,
|
||||
retryLegacyMigration,
|
||||
} from '../lib/userIdentity';
|
||||
import { PinInput } from './PinInput';
|
||||
import { ShieldIcon, SpinnerIcon } from './icons';
|
||||
|
||||
interface Props { userId: string }
|
||||
|
||||
export function SecurityCenter({ userId }: Props) {
|
||||
const [pinOld, setPinOld] = useState('');
|
||||
const [pinNew, setPinNew] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [recovery, setRecovery] = useState<string | null>(null);
|
||||
const [migration, setMigration] = useState<LegacyMigrationReport | null>(null);
|
||||
const [wipeOnClose, setWipeOnCloseState] = useState<boolean>(() => isWipeOnCloseEnabled());
|
||||
|
||||
async function handleRetryMigration() {
|
||||
setBusy(true); setMsg(null); setMigration(null);
|
||||
try {
|
||||
const report = await retryLegacyMigration(userId);
|
||||
setMigration(report);
|
||||
} catch (err) {
|
||||
setMsg(err instanceof Error ? err.message : String(err));
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function handleChangePin() {
|
||||
setBusy(true); setMsg(null);
|
||||
try {
|
||||
await changePin({ userId, oldPin: pinOld, newPin: pinNew });
|
||||
setMsg('PIN geändert.'); setPinOld(''); setPinNew('');
|
||||
} catch (err) {
|
||||
setMsg(err instanceof Error ? err.message : String(err));
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function handleRegenerateRecovery() {
|
||||
setBusy(true); setMsg(null);
|
||||
try { setRecovery(await regenerateRecoveryCode({ userId })); }
|
||||
catch (err) { setMsg(err instanceof Error ? err.message : String(err)); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function handleReset() {
|
||||
if (!window.confirm('Identität wirklich zurücksetzen? Alle bisherigen Chats werden für dich unlesbar.')) return;
|
||||
setBusy(true); setMsg(null);
|
||||
try {
|
||||
const code = await resetIdentity({ userId, pin: pinNew || pinOld });
|
||||
setRecovery(code);
|
||||
setMsg('Identität zurückgesetzt.');
|
||||
} catch (err) {
|
||||
setMsg(err instanceof Error ? err.message : String(err));
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 rounded-xl border border-line bg-surface-2 p-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldIcon className="h-4 w-4 text-accent" />
|
||||
<h2 className="text-sm font-semibold">Sicherheit</h2>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-fg-muted">PIN ändern</h3>
|
||||
<div className="space-y-2">
|
||||
<PinInput ariaLabel="Aktuelle PIN" value={pinOld} onChange={setPinOld} />
|
||||
<PinInput ariaLabel="Neue PIN" value={pinNew} onChange={setPinNew} />
|
||||
<button type="button"
|
||||
disabled={busy || pinOld.length !== 6 || pinNew.length !== 6}
|
||||
onClick={() => void handleChangePin()}
|
||||
className="rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg disabled:opacity-60"
|
||||
>
|
||||
{busy && <SpinnerIcon className="mr-1 inline h-4 w-4" />}PIN ändern
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-fg-muted">Recovery-Code</h3>
|
||||
<button type="button" disabled={busy} onClick={() => void handleRegenerateRecovery()}
|
||||
className="rounded-md border border-line bg-surface-3 px-3 py-2 text-sm hover:bg-surface-2"
|
||||
>Neuen Recovery-Code erzeugen</button>
|
||||
{recovery && (
|
||||
<div className="mt-2 select-all rounded bg-surface-3 px-2 py-1.5 font-mono text-sm tracking-widest">
|
||||
{recovery}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-fg-muted">Schlüssel-Migration reparieren</h3>
|
||||
<p className="mb-2 text-xs text-fg-muted">
|
||||
Versucht, alte Conversation-Schlüssel erneut für deine neue Identität zu re-wrappen.
|
||||
Sicher zu klicken wenn Nachrichten verschlüsselt bleiben oder du nicht senden kannst.
|
||||
</p>
|
||||
<button type="button" disabled={busy} onClick={() => void handleRetryMigration()}
|
||||
className="rounded-md border border-line bg-surface-3 px-3 py-2 text-sm hover:bg-surface-2"
|
||||
>
|
||||
{busy && <SpinnerIcon className="mr-1 inline h-4 w-4" />}Migration erneut ausführen
|
||||
</button>
|
||||
{migration && (
|
||||
<div className="mt-2 rounded border border-line bg-surface-3 p-3 text-xs text-fg-muted">
|
||||
<div>Geräte (Server): {migration.serverDevices}</div>
|
||||
<div>Lokale Schlüssel im Vault: {migration.strongholdKeysFromServerDevices}
|
||||
{migration.strongholdKeysFromBundleScan > 0 && (
|
||||
<> (+{migration.strongholdKeysFromBundleScan} aus Bundle-Scan)</>
|
||||
)}
|
||||
</div>
|
||||
<div>Versucht: {migration.attempted}, Erfolgreich: <span className="text-emerald-500">{migration.migrated}</span></div>
|
||||
<div>Übersprungen (kein lokaler Schlüssel): {migration.noStrongholdKey}</div>
|
||||
<div>Entschlüsselung gescheitert: {migration.decryptFailed}</div>
|
||||
<div>Server-Fehler: {migration.rpcFailed}</div>
|
||||
{migration.attempted > 0 && migration.migrated === 0 && (
|
||||
<p className="mt-2 text-rose-300">
|
||||
Keine Bundles migriert. Vermutlich hast du den ursprünglichen Geräteschlüssel nicht mehr lokal.
|
||||
Nutze "Identität zurücksetzen" wenn du neu starten willst (alte Chats gehen verloren).
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-fg-muted">
|
||||
Cache beim Schließen leeren
|
||||
</h3>
|
||||
<p className="mb-2 text-xs text-fg-muted">
|
||||
Beim Beenden der App werden alle entschlüsselten Caches gelöscht. Beim nächsten Start
|
||||
musst du wieder deine PIN eingeben. Empfohlen für gemeinsam genutzte Rechner.
|
||||
</p>
|
||||
<label className="inline-flex cursor-pointer items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={wipeOnClose}
|
||||
onChange={(e) => {
|
||||
setWipeOnClose(e.target.checked);
|
||||
setWipeOnCloseState(e.target.checked);
|
||||
}}
|
||||
/>
|
||||
<span>Aktivieren</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-rose-400">Identität zurücksetzen</h3>
|
||||
<p className="mb-2 text-xs text-fg-muted">Erstellt einen neuen Schlüssel. Alle alten Chats werden unlesbar.</p>
|
||||
<button type="button" disabled={busy} onClick={() => void handleReset()}
|
||||
className="rounded-md border border-rose-500/40 bg-rose-500/10 px-3 py-2 text-sm text-rose-200 hover:bg-rose-500/20"
|
||||
>Identität zurücksetzen</button>
|
||||
</section>
|
||||
|
||||
{msg && <p role="status" className="text-sm text-fg-muted">{msg}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { setupNewUserIdentity } from '../lib/userIdentity';
|
||||
import { PinInput } from './PinInput';
|
||||
import { ShieldIcon, SpinnerIcon } from './icons';
|
||||
|
||||
interface Props { userId: string; onComplete: () => void }
|
||||
type Step = 'enter' | 'recovery';
|
||||
|
||||
export function UserKeySetup({ userId, onComplete }: Props) {
|
||||
const [pin, setPin] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [withRecovery, setWithRecovery] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [step, setStep] = useState<Step>('enter');
|
||||
const [recoveryCode, setRecoveryCode] = useState<string | null>(null);
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
if (pin.length !== 6 || confirm.length !== 6) {
|
||||
setError('PIN muss 6 Ziffern haben.'); return;
|
||||
}
|
||||
if (pin !== confirm) {
|
||||
setError('PINs stimmen nicht überein.'); return;
|
||||
}
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
const r = await setupNewUserIdentity({ userId, pin, withRecovery });
|
||||
setRecoveryCode(r.recoveryCode);
|
||||
if (withRecovery) setStep('recovery');
|
||||
else onComplete();
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally { setBusy(false); }
|
||||
}, [pin, confirm, withRecovery, userId, onComplete]);
|
||||
|
||||
if (step === 'recovery') {
|
||||
return (
|
||||
<div className="space-y-4 rounded-2xl border border-white/10 bg-ink-900/70 p-6 text-neutral-100">
|
||||
<div className="flex items-center gap-2 text-amber-300">
|
||||
<ShieldIcon className="h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">Recovery-Code</h2>
|
||||
</div>
|
||||
<p className="text-sm text-neutral-300">
|
||||
Speichere diesen Code an einem sicheren Ort (Passwortmanager, ausgedruckt).
|
||||
Mit ihm kannst du deinen Account auch ohne PIN entsperren.
|
||||
</p>
|
||||
<div className="select-all rounded-lg bg-ink-800 p-4 text-center font-mono text-lg tracking-widest">
|
||||
{recoveryCode}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="button"
|
||||
className="flex-1 rounded-lg border border-white/10 bg-ink-800 px-3 py-2 text-sm text-neutral-200 hover:bg-white/5"
|
||||
onClick={onComplete}
|
||||
>Überspringen</button>
|
||||
<button type="button"
|
||||
className="flex-1 rounded-lg bg-brand-500 px-3 py-2 text-sm font-semibold text-white hover:bg-brand-400"
|
||||
onClick={onComplete}
|
||||
>Habe ich gespeichert</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); void submit(); }}
|
||||
className="space-y-4 rounded-2xl border border-white/10 bg-ink-900/70 p-6 text-neutral-100"
|
||||
>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium uppercase tracking-wide text-neutral-400">PIN eingeben</label>
|
||||
<PinInput value={pin} onChange={setPin} ariaLabel="PIN eingeben" autoFocus />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium uppercase tracking-wide text-neutral-400">PIN bestätigen</label>
|
||||
<PinInput value={confirm} onChange={setConfirm} ariaLabel="PIN bestätigen" />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs text-neutral-300">
|
||||
<input type="checkbox" checked={withRecovery} onChange={(e) => setWithRecovery(e.target.checked)} />
|
||||
Recovery-Code erstellen (empfohlen)
|
||||
</label>
|
||||
{error && <p role="alert" className="text-sm text-rose-300">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="inline-flex w-full items-center justify-center gap-2 rounded-lg bg-brand-500 px-4 py-3 text-sm font-semibold text-white hover:bg-brand-400 disabled:opacity-60"
|
||||
>
|
||||
{busy && <SpinnerIcon className="h-4 w-4" />}
|
||||
Weiter
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { loadOrUnlockUserKey } from '../lib/userIdentity';
|
||||
import { PinInput } from './PinInput';
|
||||
import { LockIcon, SpinnerIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
userId: string;
|
||||
hasRecovery: boolean;
|
||||
lockedUntil?: string | null;
|
||||
onUnlocked: () => void;
|
||||
}
|
||||
|
||||
type Mode = 'pin' | 'recovery';
|
||||
|
||||
export function UserKeyUnlock({ userId, hasRecovery, lockedUntil, onUnlocked }: Props) {
|
||||
const [mode, setMode] = useState<Mode>('pin');
|
||||
const [value, setValue] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
const res = await loadOrUnlockUserKey({
|
||||
userId, pin: value, isRecoveryCode: mode === 'recovery',
|
||||
});
|
||||
if (res.kind === 'unlocked') onUnlocked();
|
||||
else if (res.kind === 'locked') setError('Konto gesperrt bis ' + res.lockedUntil);
|
||||
else setError('Schlüssel auf dem Server nicht gefunden.');
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally { setBusy(false); }
|
||||
}, [userId, value, mode, onUnlocked]);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); void submit(); }}
|
||||
className="space-y-4 rounded-2xl border border-white/10 bg-ink-900/70 p-6 text-neutral-100"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<LockIcon className="h-5 w-5 text-brand-300" />
|
||||
<h2 className="text-lg font-semibold">Account entsperren</h2>
|
||||
</div>
|
||||
{lockedUntil && (
|
||||
<p role="alert" className="rounded-lg border border-rose-500/30 bg-rose-500/10 p-3 text-xs text-rose-100">
|
||||
Zu viele Fehlversuche. Gesperrt bis {lockedUntil}.
|
||||
</p>
|
||||
)}
|
||||
{mode === 'pin' ? (
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium uppercase tracking-wide text-neutral-400">PIN eingeben</label>
|
||||
<PinInput value={value} onChange={setValue} ariaLabel="PIN eingeben" autoFocus onSubmit={() => void submit()} />
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium uppercase tracking-wide text-neutral-400" htmlFor="recovery-input">Recovery-Code</label>
|
||||
<input
|
||||
id="recovery-input"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
spellCheck={false}
|
||||
className="w-full rounded-lg border border-white/10 bg-ink-800 px-3 py-2 font-mono tracking-widest text-white"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{hasRecovery && (
|
||||
<button type="button"
|
||||
onClick={() => { setMode((m) => (m === 'pin' ? 'recovery' : 'pin')); setValue(''); setError(null); }}
|
||||
className="text-xs text-brand-300 hover:underline"
|
||||
>
|
||||
{mode === 'pin' ? 'PIN vergessen? Recovery-Code nutzen' : 'Stattdessen PIN verwenden'}
|
||||
</button>
|
||||
)}
|
||||
{error && <p role="alert" className="text-sm text-rose-300">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || (mode === 'pin' ? value.length !== 6 : value.trim().length === 0)}
|
||||
className="inline-flex w-full items-center justify-center gap-2 rounded-lg bg-brand-500 px-4 py-3 text-sm font-semibold text-white hover:bg-brand-400 disabled:opacity-60"
|
||||
>
|
||||
{busy && <SpinnerIcon className="h-4 w-4" />}
|
||||
Entsperren
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { markAttachmentViewed } from '@chat-app/shared/chat';
|
||||
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { EyeOffIcon, LockIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
attachmentId: string;
|
||||
/** Already-viewed timestamp from the DB row. Renders tombstone immediately. */
|
||||
viewedAt: string | null;
|
||||
/** True iff the local user is the sender — they don't burn the view. */
|
||||
isSender: boolean;
|
||||
/** Decrypted image source; only fetched/displayed inside the lightbox. */
|
||||
src: string;
|
||||
}
|
||||
|
||||
// Three states:
|
||||
// 1. viewedAt is null AND user is recipient → blurred lock card; tap opens
|
||||
// fullscreen lightbox AND fires the mark-viewed RPC.
|
||||
// 2. viewedAt is set → tombstone "Angesehen am …".
|
||||
// 3. user is sender → normal image, tombstone update appears once recipient burns it.
|
||||
export function ViewOnceImage({ attachmentId, viewedAt, isSender, src }: Props) {
|
||||
const [revealedAt, setRevealedAt] = useState<string | null>(viewedAt);
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
|
||||
const burned = revealedAt !== null;
|
||||
|
||||
if (burned && !isSender) {
|
||||
return (
|
||||
<div className="flex h-32 w-48 items-center justify-center rounded-lg border border-dashed border-line bg-surface-3 text-xs text-fg-muted">
|
||||
<EyeOffIcon className="mr-2 h-4 w-4" />
|
||||
Angesehen am {new Date(revealedAt).toLocaleString()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isSender) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<img src={src} alt="" className="max-h-72 rounded-lg" />
|
||||
<span className="absolute left-2 top-2 inline-flex items-center gap-1 rounded-full bg-black/60 px-2 py-0.5 text-[10px] font-semibold text-white">
|
||||
<EyeOffIcon className="h-3 w-3" /> Einmal ansehen
|
||||
</span>
|
||||
{burned && (
|
||||
<span className="absolute right-2 bottom-2 rounded-full bg-emerald-500/80 px-2 py-0.5 text-[10px] font-semibold text-white">
|
||||
Angesehen
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Recipient, not yet viewed.
|
||||
const handleOpen = async (): Promise<void> => {
|
||||
try {
|
||||
const res = await markAttachmentViewed(supabase, attachmentId);
|
||||
if (res.viewedAt) setRevealedAt(res.viewedAt);
|
||||
} catch (err) {
|
||||
console.warn('mark-viewed failed', err);
|
||||
}
|
||||
setFullscreen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleOpen()}
|
||||
className="relative flex h-48 w-64 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-line bg-surface-3 text-fg-muted hover:border-accent/40"
|
||||
>
|
||||
<LockIcon className="h-6 w-6 text-accent" />
|
||||
<span className="text-xs font-medium">Einmal ansehen — antippen</span>
|
||||
</button>
|
||||
{fullscreen && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 p-6"
|
||||
onClick={() => setFullscreen(false)}
|
||||
>
|
||||
<img src={src} alt="" className="max-h-full max-w-full rounded-lg" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useCall } from '../context/CallContext';
|
||||
import { useCallPresence } from '../lib/useCallPresence';
|
||||
import { Avatar } from './Avatar';
|
||||
import { PhoneIcon, SpinnerIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
conversation: ConversationSummary;
|
||||
}
|
||||
|
||||
const MAX_AVATARS = 5;
|
||||
|
||||
/**
|
||||
* Discord-style persistent voice-channel band rendered at the top of the
|
||||
* message surface. Always visible for groups so any member can pop in
|
||||
* without an invite-ring; for 1:1 conversations only when someone's already
|
||||
* in (a soft "rejoin" affordance). Joining uses the existing joinActiveCall
|
||||
* path which sends no invite signals.
|
||||
*/
|
||||
export function VoiceChannelRail({ conversation }: Props) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { session } = useAuth();
|
||||
const { state, joinActiveCall } = useCall();
|
||||
const presentIds = useCallPresence(conversation.id);
|
||||
|
||||
const myId = session?.user.id ?? null;
|
||||
const iAmIn =
|
||||
(state.kind === 'connected' ||
|
||||
state.kind === 'connecting' ||
|
||||
state.kind === 'reconnecting') &&
|
||||
state.conversationId === conversation.id;
|
||||
|
||||
const others = presentIds.filter((u) => u !== myId);
|
||||
const isGroup = conversation.type === 'group';
|
||||
|
||||
// For groups: persistent channel feel — show even when empty.
|
||||
// For DMs: only when somebody is already in (matches Discord-DM behaviour).
|
||||
const visible = !iAmIn && (isGroup || others.length > 0);
|
||||
if (!visible) return null;
|
||||
|
||||
const showAvatars = others.slice(0, MAX_AVATARS);
|
||||
const overflow = Math.max(0, others.length - MAX_AVATARS);
|
||||
const busy = state.kind !== 'idle';
|
||||
|
||||
const handleJoin = () => {
|
||||
if (busy) return;
|
||||
void joinActiveCall(conversation.id, 'audio');
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleJoin}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleJoin();
|
||||
}
|
||||
}}
|
||||
className={
|
||||
'flex cursor-pointer items-center gap-3 border-b border-line px-5 py-2.5 text-sm transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
(others.length > 0
|
||||
? 'bg-emerald-500/10 hover:bg-emerald-500/15'
|
||||
: 'bg-surface-3/70 hover:bg-surface-3')
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
'flex h-8 w-8 shrink-0 items-center justify-center rounded-md ' +
|
||||
(others.length > 0 ? 'bg-emerald-500/20 text-emerald-500' : 'bg-accent/15 text-accent')
|
||||
}
|
||||
>
|
||||
<PhoneIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-fg-muted">
|
||||
{t('app:call.voice_channel', { defaultValue: 'Sprach-Channel' })}
|
||||
</p>
|
||||
<p className="truncate text-xs text-fg">
|
||||
{others.length === 0
|
||||
? t('app:call.voice_empty', {
|
||||
defaultValue: 'Niemand drin — sei der Erste.',
|
||||
})
|
||||
: t('app:call.voice_count', {
|
||||
defaultValue: '{{count}} im Channel',
|
||||
count: others.length,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
{showAvatars.length > 0 && (
|
||||
<div className="flex -space-x-2">
|
||||
{showAvatars.map((id) => {
|
||||
const member = conversation.members.find((m) => m.userId === id);
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
title={member?.profile?.displayName ?? '?'}
|
||||
className="relative h-7 w-7 overflow-hidden rounded-full border-2 border-surface-3 bg-surface-2"
|
||||
>
|
||||
<Avatar
|
||||
url={member?.profile?.avatarUrl ?? null}
|
||||
displayName={member?.profile?.displayName ?? '?'}
|
||||
className="h-full w-full text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{overflow > 0 && (
|
||||
<span className="inline-flex h-7 min-w-[1.75rem] items-center justify-center rounded-full border-2 border-surface-3 bg-surface-2 px-1.5 text-[10px] font-semibold text-fg-muted">
|
||||
+{overflow}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleJoin();
|
||||
}}
|
||||
disabled={busy}
|
||||
className="inline-flex shrink-0 cursor-pointer items-center gap-1.5 rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-emerald-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{busy ? (
|
||||
<SpinnerIcon className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<PhoneIcon className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span>
|
||||
{others.length === 0
|
||||
? t('app:call.voice_open', { defaultValue: 'Channel öffnen' })
|
||||
: t('app:call.join', { defaultValue: 'Beitreten' })}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,11 +23,14 @@ export function RequireAuth() {
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
// Forces a registered device on this install. Sends to /device otherwise.
|
||||
// Forces a usable per-user encrypted key blob on this install. Sends to
|
||||
// /device (the setup/unlock page) when the blob is missing or locked.
|
||||
export function RequireDevice() {
|
||||
const { device, deviceLookupDone } = useAuth();
|
||||
if (!deviceLookupDone) return <FullScreenSpinner />;
|
||||
if (!device) return <Navigate to="/device" replace />;
|
||||
const { userKeyState } = useAuth();
|
||||
if (userKeyState.status === 'loading') return <FullScreenSpinner />;
|
||||
if (userKeyState.status === 'needs-setup' || userKeyState.status === 'needs-unlock') {
|
||||
return <Navigate to="/device" replace />;
|
||||
}
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useOwnDevices } from '../../hooks/useOwnDevices';
|
||||
import { getDeviceRowId } from '../../lib/deviceRowId';
|
||||
import { SpinnerIcon } from '../icons';
|
||||
|
||||
function formatLastSeen(iso: string, locale: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' });
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
export function DeviceListTab() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { devices, loading, error, revoke } = useOwnDevices();
|
||||
const ownId = getDeviceRowId();
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [opError, setOpError] = useState<string | null>(null);
|
||||
|
||||
const handleRevoke = async (id: string) => {
|
||||
setOpError(null);
|
||||
setBusy(id);
|
||||
try {
|
||||
await revoke(id);
|
||||
} catch (err) {
|
||||
setOpError(err instanceof Error ? err.message : 'revoke failed');
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-10 text-fg-muted">
|
||||
<SpinnerIcon className="h-5 w-5" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<p className="rounded-lg border border-rose-500/40 bg-rose-500/10 px-4 py-3 text-sm text-rose-600 dark:text-rose-300">
|
||||
{t('app:settings.devices.error', { defaultValue: 'Geräteliste konnte nicht geladen werden.' })}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (devices.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-fg-muted">
|
||||
{t('app:settings.devices.empty', { defaultValue: 'Noch keine Geräte angemeldet.' })}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{opError && (
|
||||
<p className="rounded-lg border border-rose-500/40 bg-rose-500/10 px-4 py-2 text-sm text-rose-600 dark:text-rose-300">
|
||||
{opError}
|
||||
</p>
|
||||
)}
|
||||
<ul className="space-y-2">
|
||||
{devices.map((d) => {
|
||||
const isOwn = d.id === ownId;
|
||||
const isRevoked = d.revokedAt !== null;
|
||||
return (
|
||||
<li
|
||||
key={d.id}
|
||||
className="flex items-center gap-3 rounded-lg border border-line bg-surface-2 px-4 py-3"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium text-fg">{d.name}</span>
|
||||
{isOwn && (
|
||||
<span className="rounded-md bg-accent/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-accent">
|
||||
{t('app:settings.devices.this_device', { defaultValue: 'Dieses Gerät' })}
|
||||
</span>
|
||||
)}
|
||||
{isRevoked && (
|
||||
<span className="rounded-md bg-rose-500/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-rose-600 dark:text-rose-300">
|
||||
{t('app:settings.devices.revoked', { defaultValue: 'Abgemeldet' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-fg-muted">
|
||||
{d.platform} · {t('app:settings.devices.last_seen', { defaultValue: 'zuletzt' })}{' '}
|
||||
{formatLastSeen(d.lastSeenAt, i18n.language)}
|
||||
</p>
|
||||
</div>
|
||||
{isOwn ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
title={t('app:settings.devices.use_sign_out', { defaultValue: 'Nutze Sign-out' })}
|
||||
className="cursor-not-allowed rounded-md border border-line bg-surface-3 px-3 py-1.5 text-xs font-medium text-fg-muted opacity-60"
|
||||
>
|
||||
{t('app:settings.devices.use_sign_out', { defaultValue: 'Nutze Sign-out' })}
|
||||
</button>
|
||||
) : isRevoked ? (
|
||||
<span className="text-xs text-fg-muted">
|
||||
{t('app:settings.devices.already_revoked', { defaultValue: '—' })}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRevoke(d.id)}
|
||||
disabled={busy === d.id}
|
||||
className="cursor-pointer rounded-md border border-rose-500/40 bg-rose-500/10 px-3 py-1.5 text-xs font-semibold text-rose-600 transition hover:bg-rose-500/20 disabled:cursor-wait disabled:opacity-60 dark:text-rose-300"
|
||||
>
|
||||
{busy === d.id
|
||||
? t('app:settings.devices.revoking', { defaultValue: 'Wird abgemeldet…' })
|
||||
: t('app:settings.devices.revoke', { defaultValue: 'Abmelden' })}
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
import {
|
||||
type DeviceRecord,
|
||||
fetchUserKeyBlob,
|
||||
getOwnProfile,
|
||||
listOwnDevices,
|
||||
registerDevice,
|
||||
signOut as supabaseSignOut,
|
||||
type Profile,
|
||||
touchDeviceLastSeen,
|
||||
type Profile,
|
||||
updateOwnProfile,
|
||||
} from '@chat-app/shared/auth';
|
||||
import { changeLocale, isSupportedLocale } from '@chat-app/shared/i18n';
|
||||
import type { DevicePlatform } from '@chat-app/shared/supabase';
|
||||
import type { Session } from '@supabase/supabase-js';
|
||||
import {
|
||||
createContext,
|
||||
@@ -20,24 +23,40 @@ import {
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { findExistingDevice } from '../lib/device';
|
||||
import { PRESENCE_HEARTBEAT_MS } from '../lib/presence';
|
||||
import { clearDeviceRowId, getDeviceRowId, setDeviceRowId } from '../lib/deviceRowId';
|
||||
import { ensureInstallId } from '../lib/installId';
|
||||
import { wipeLocalState } from '../lib/memoryWipe';
|
||||
import { isWipeOnCloseEnabled } from '../lib/memoryWipeSettings';
|
||||
import { setSecretStoreUser } from '../lib/secretStore';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { cachedUserKey, ensureLegacyMigrated } from '../lib/userIdentity';
|
||||
import { registerWebPush } from '../lib/webPush';
|
||||
|
||||
// Discriminated union describing the per-user encrypted key blob lifecycle:
|
||||
//
|
||||
// loading — initial state, or refresh in flight
|
||||
// needs-setup — no row exists on Supabase; user must pick a PIN
|
||||
// needs-unlock — row exists but local cache empty; PIN (or recovery code)
|
||||
// required. `lockedUntil` non-null means the server-side
|
||||
// rate limiter is currently rejecting attempts.
|
||||
// unlocked — private key is in the local secret store and ready to use
|
||||
export type UserKeyState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'needs-setup' }
|
||||
| { status: 'needs-unlock'; lockedUntil: string | null; hasRecovery: boolean }
|
||||
| { status: 'unlocked' };
|
||||
|
||||
interface AuthContextValue {
|
||||
session: Session | null;
|
||||
profile: Profile | null;
|
||||
device: DeviceRecord | null;
|
||||
userKeyState: UserKeyState;
|
||||
// null while we're still resolving the very first auth state.
|
||||
ready: boolean;
|
||||
// null until a device lookup has finished for the current session.
|
||||
deviceLookupDone: boolean;
|
||||
refreshProfile: () => Promise<void>;
|
||||
refreshDevice: () => Promise<void>;
|
||||
setDevice: (device: DeviceRecord | null) => void;
|
||||
refreshUserKeyState: () => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
revokedRemotely: boolean;
|
||||
acknowledgeRevocation: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
@@ -47,8 +66,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [profile, setProfile] = useState<Profile | null>(null);
|
||||
const [device, setDevice] = useState<DeviceRecord | null>(null);
|
||||
const [deviceLookupDone, setDeviceLookupDone] = useState(false);
|
||||
const [userKeyState, setUserKeyState] = useState<UserKeyState>({ status: 'loading' });
|
||||
const [revokedRemotely, setRevokedRemotely] = useState(false);
|
||||
const autoOnlineUserRef = useRef<string | null>(null);
|
||||
|
||||
// Initial session + auth subscription. We verify the cached JWT against the
|
||||
@@ -112,41 +131,142 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}, [session, i18n.resolvedLanguage]);
|
||||
|
||||
const refreshDevice = useCallback(async () => {
|
||||
// Resolves the current state of the per-user encrypted key blob:
|
||||
// 1. local cache hit → 'unlocked'
|
||||
// 2. no remote row → 'needs-setup'
|
||||
// 3. server rate-limit → 'needs-unlock' with lockedUntil set
|
||||
// 4. otherwise → 'needs-unlock'; hasRecovery reflects whether a
|
||||
// recovery-code blob is present so the UI can
|
||||
// conditionally offer the recovery affordance
|
||||
const refreshUserKeyState = useCallback(async () => {
|
||||
if (!session) {
|
||||
setDevice(null);
|
||||
setDeviceLookupDone(false);
|
||||
setUserKeyState({ status: 'loading' });
|
||||
return;
|
||||
}
|
||||
setDeviceLookupDone(false);
|
||||
const found = await findExistingDevice(session.user.id);
|
||||
setDevice(found);
|
||||
setDeviceLookupDone(true);
|
||||
setUserKeyState({ status: 'loading' });
|
||||
const cached = await cachedUserKey(session.user.id);
|
||||
if (cached) {
|
||||
setUserKeyState({ status: 'unlocked' });
|
||||
// Best-effort: re-wrap any unmigrated legacy bundles. Idempotent (RPC
|
||||
// uses ON CONFLICT DO NOTHING). Recovers users who set up under 0.18.0
|
||||
// where the migration query had a `.eq(null)` bug that made it a no-op.
|
||||
void ensureLegacyMigrated(session.user.id).catch((err) => {
|
||||
console.warn('legacy conv-key migration on auth-resume failed', err);
|
||||
});
|
||||
return;
|
||||
}
|
||||
const blob = await fetchUserKeyBlob(supabase, session.user.id);
|
||||
if (!blob || !blob.exists) {
|
||||
setUserKeyState({ status: 'needs-setup' });
|
||||
return;
|
||||
}
|
||||
if (blob.locked) {
|
||||
setUserKeyState({
|
||||
status: 'needs-unlock',
|
||||
lockedUntil: blob.lockedUntil,
|
||||
hasRecovery: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setUserKeyState({
|
||||
status: 'needs-unlock',
|
||||
lockedUntil: null,
|
||||
hasRecovery: blob.recoverySealedPrivateKey !== null,
|
||||
});
|
||||
}, [session]);
|
||||
|
||||
// Re-pull profile + device whenever session flips.
|
||||
// Re-pull profile + user-key state whenever session flips.
|
||||
useEffect(() => {
|
||||
if (!session) {
|
||||
setProfile(null);
|
||||
setDevice(null);
|
||||
setDeviceLookupDone(false);
|
||||
setUserKeyState({ status: 'loading' });
|
||||
return;
|
||||
}
|
||||
void refreshProfile().catch((err: unknown) => {
|
||||
console.error('refreshProfile failed', err);
|
||||
});
|
||||
void refreshDevice().catch((err: unknown) => {
|
||||
console.error('refreshDevice failed', err);
|
||||
setDeviceLookupDone(true);
|
||||
void refreshUserKeyState().catch((err: unknown) => {
|
||||
console.error('refreshUserKeyState failed', err);
|
||||
// Treat an unrecoverable lookup error as "needs-setup" so the UI at
|
||||
// least drives the user toward the setup/unlock page rather than
|
||||
// hanging forever on the spinner.
|
||||
setUserKeyState({ status: 'needs-setup' });
|
||||
});
|
||||
}, [session, refreshProfile, refreshDevice]);
|
||||
}, [session, refreshProfile, refreshUserKeyState]);
|
||||
|
||||
// Best-effort web-push registration once we know the device id. No-op on
|
||||
// Tauri (uses native notifications) or when VITE_VAPID_PUBLIC_KEY is unset.
|
||||
// Best-effort web-push registration once we have a session. Keyed by an
|
||||
// install-id (localStorage UUID) since there's no longer a per-device
|
||||
// crypto record to key by. No-op on Tauri (uses native notifications) or
|
||||
// when VITE_VAPID_PUBLIC_KEY is unset.
|
||||
useEffect(() => {
|
||||
if (!device?.id) return;
|
||||
void registerWebPush(device.id);
|
||||
}, [device?.id]);
|
||||
if (!session) return;
|
||||
const installId = ensureInstallId();
|
||||
void registerWebPush(installId);
|
||||
}, [session]);
|
||||
|
||||
// Phase 3: ensure this install owns exactly one devices row. The row is
|
||||
// pure session-list telemetry — it does not carry any cryptographic
|
||||
// material since the per-user-key refactor. We re-use the row across
|
||||
// restarts via localStorage (chatapp.deviceRowId.v1); a memory-wipe is
|
||||
// intentionally treated as "new install".
|
||||
useEffect(() => {
|
||||
if (!session) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const existing = getDeviceRowId();
|
||||
if (existing) {
|
||||
const rows = await listOwnDevices(supabase);
|
||||
const match = rows.find((r) => r.id === existing && r.revokedAt === null);
|
||||
if (match) {
|
||||
await touchDeviceLastSeen(supabase, existing).catch(() => {});
|
||||
return;
|
||||
}
|
||||
// Row gone / revoked: drop the stale id and fall through to
|
||||
// registering a fresh one.
|
||||
clearDeviceRowId();
|
||||
}
|
||||
if (cancelled) return;
|
||||
const hostname =
|
||||
(typeof window.electronAPI?.getHostname === 'function'
|
||||
? await window.electronAPI.getHostname().catch(() => null)
|
||||
: null) ?? 'Desktop';
|
||||
// Map Node's process.platform values to the schema's device_platform
|
||||
// enum ('windows' | 'macos' | 'linux' | 'ios' | 'android'). Default
|
||||
// to 'linux' for unknown/web-build cases — the field is only used
|
||||
// for the session-list UI icon.
|
||||
const osPlatform = window.electronAPI?.osPlatform;
|
||||
const platform: DevicePlatform =
|
||||
osPlatform === 'win32'
|
||||
? 'windows'
|
||||
: osPlatform === 'darwin'
|
||||
? 'macos'
|
||||
: 'linux';
|
||||
const created = await registerDevice(supabase, {
|
||||
name: hostname.slice(0, 64),
|
||||
platform,
|
||||
});
|
||||
if (!cancelled) setDeviceRowId(created.id);
|
||||
} catch (err) {
|
||||
console.warn('ensure device row failed', err);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [session]);
|
||||
|
||||
// Wipe-on-close: register a handler the main process pings on `before-quit`
|
||||
// when the user has enabled the Settings → Sicherheit toggle. No-op outside
|
||||
// Electron (web build has no preload bridge) or when the toggle is off.
|
||||
useEffect(() => {
|
||||
if (typeof window.electronAPI?.onWipeBeforeQuit !== 'function') return;
|
||||
const unsub = window.electronAPI.onWipeBeforeQuit(async () => {
|
||||
if (!isWipeOnCloseEnabled()) return;
|
||||
await wipeLocalState(session?.user.id ?? null);
|
||||
});
|
||||
return unsub;
|
||||
}, [session?.user.id]);
|
||||
|
||||
// Auto online/offline transition.
|
||||
//
|
||||
@@ -188,52 +308,78 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
};
|
||||
}, [session, profile, refreshProfile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session || !device?.id) return;
|
||||
|
||||
const touch = () => {
|
||||
void touchDeviceLastSeen(supabase, device.id).catch((err: unknown) => {
|
||||
console.warn('presence heartbeat failed', err);
|
||||
});
|
||||
};
|
||||
const touchWhenVisible = () => {
|
||||
if (document.visibilityState === 'visible') touch();
|
||||
};
|
||||
|
||||
touch();
|
||||
const heartbeat = window.setInterval(touch, PRESENCE_HEARTBEAT_MS);
|
||||
window.addEventListener('focus', touch);
|
||||
window.addEventListener('online', touch);
|
||||
document.addEventListener('visibilitychange', touchWhenVisible);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(heartbeat);
|
||||
window.removeEventListener('focus', touch);
|
||||
window.removeEventListener('online', touch);
|
||||
document.removeEventListener('visibilitychange', touchWhenVisible);
|
||||
};
|
||||
}, [session, device?.id]);
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
const uid = session?.user.id ?? null;
|
||||
await updateOwnProfile(supabase, { presenceState: 'offline' }).catch((err: unknown) => {
|
||||
console.warn('offline update before sign-out failed', err);
|
||||
});
|
||||
await supabaseSignOut(supabase);
|
||||
await wipeLocalState(uid);
|
||||
}, [session]);
|
||||
|
||||
const acknowledgeRevocation = useCallback(() => {
|
||||
setRevokedRemotely(false);
|
||||
}, []);
|
||||
|
||||
// Phase 3: listen for own-device revocations. The same channel also fires
|
||||
// when *another* of the user's installs is revoked — we ignore those (we
|
||||
// only force-sign-out when OUR row's revoked_at flips). The UI's device
|
||||
// list refetches independently via its own subscription in useOwnDevices.
|
||||
useEffect(() => {
|
||||
if (!session) return;
|
||||
const userId = session.user.id;
|
||||
const channel = supabase
|
||||
.channel('devices:self:' + userId)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'UPDATE',
|
||||
schema: 'public',
|
||||
table: 'devices',
|
||||
filter: 'user_id=eq.' + userId,
|
||||
},
|
||||
(payload) => {
|
||||
const ownId = getDeviceRowId();
|
||||
const row = payload.new as { id?: string; revoked_at?: string | null } | null;
|
||||
if (!row || !ownId) return;
|
||||
if (row.id !== ownId) return;
|
||||
if (row.revoked_at) {
|
||||
setRevokedRemotely(true);
|
||||
void signOut().catch((err) => {
|
||||
console.warn('forced signOut after revoke failed', err);
|
||||
});
|
||||
}
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
return () => {
|
||||
void supabase.removeChannel(channel);
|
||||
};
|
||||
}, [session, signOut]);
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({
|
||||
session,
|
||||
profile,
|
||||
device,
|
||||
userKeyState,
|
||||
ready,
|
||||
deviceLookupDone,
|
||||
refreshProfile,
|
||||
refreshDevice,
|
||||
setDevice,
|
||||
refreshUserKeyState,
|
||||
signOut,
|
||||
revokedRemotely,
|
||||
acknowledgeRevocation,
|
||||
}),
|
||||
[session, profile, device, ready, deviceLookupDone, refreshProfile, refreshDevice, signOut],
|
||||
[
|
||||
session,
|
||||
profile,
|
||||
userKeyState,
|
||||
ready,
|
||||
refreshProfile,
|
||||
refreshUserKeyState,
|
||||
signOut,
|
||||
revokedRemotely,
|
||||
acknowledgeRevocation,
|
||||
],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
|
||||
import { sendEncryptedMessage } from '@chat-app/shared/chat';
|
||||
import {
|
||||
type CallKind,
|
||||
@@ -114,8 +113,9 @@ import {
|
||||
type ScreenSharePreset,
|
||||
updateScreenShareSettings,
|
||||
} from '../lib/screenShareSettings';
|
||||
import { devLocalSecretStore } from '../lib/secretStore';
|
||||
import { ensureInstallId } from '../lib/installId';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { cachedUserKey } from '../lib/userIdentity';
|
||||
import { setWindowFullscreen } from '../lib/windowFullscreen';
|
||||
|
||||
export type CallState =
|
||||
@@ -325,7 +325,7 @@ function newCallId(): string {
|
||||
}
|
||||
|
||||
export function CallProvider({ children }: { children: ReactNode }) {
|
||||
const { session, device, profile } = useAuth();
|
||||
const { session, profile } = useAuth();
|
||||
const { conversations } = useConversationsContext();
|
||||
const myId = session?.user.id;
|
||||
|
||||
@@ -446,13 +446,9 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
mediaKind: CallKind,
|
||||
durationSec: number,
|
||||
) => {
|
||||
if (!session?.user.id || !device?.id) return;
|
||||
if (!session?.user.id) return;
|
||||
try {
|
||||
const priv = await loadDevicePrivateKey(
|
||||
devLocalSecretStore,
|
||||
session.user.id,
|
||||
device.id,
|
||||
);
|
||||
const priv = await cachedUserKey(session.user.id);
|
||||
if (!priv) return;
|
||||
const payload = JSON.stringify({
|
||||
v: 1,
|
||||
@@ -466,14 +462,14 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
conversationId,
|
||||
plaintext: payload,
|
||||
senderUserId: session.user.id,
|
||||
senderDeviceId: device.id,
|
||||
senderDeviceId: ensureInstallId(),
|
||||
senderPrivateKey: priv,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
console.error('emitCallEvent failed', err);
|
||||
}
|
||||
},
|
||||
[session?.user.id, device?.id],
|
||||
[session?.user.id],
|
||||
);
|
||||
|
||||
const clearRingTimer = useCallback(() => {
|
||||
@@ -1446,20 +1442,29 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
// the same system audio twice and Chromium's version would
|
||||
// include our own renderer playback.
|
||||
audio: preferNativeLoopback ? false : wantAudio,
|
||||
// Preset dims are passed as `ideal` constraints (not fixed width/height)
|
||||
// so Chromium picks the closest match preserving the source's aspect
|
||||
// ratio instead of CROPPING non-matching monitors. Without this, a 16:10
|
||||
// monitor (1920×1200, 2560×1600) streamed under a 16:9 preset loses its
|
||||
// bottom strip — including the Windows taskbar.
|
||||
//
|
||||
// LiveKit's VideoResolution types declare width/height as `number`, but
|
||||
// Chromium's getDisplayMedia accepts the full MediaTrackConstraints shape
|
||||
// including { ideal: N } objects — we cast through `unknown` to satisfy tsc.
|
||||
...(ssParams.dims
|
||||
? {
|
||||
resolution: {
|
||||
width: ssParams.dims.width,
|
||||
height: ssParams.dims.height,
|
||||
width: { ideal: ssParams.dims.width },
|
||||
height: { ideal: ssParams.dims.height },
|
||||
frameRate: fps,
|
||||
},
|
||||
} as unknown as { width: number; height: number; frameRate: number },
|
||||
}
|
||||
: {
|
||||
resolution: {
|
||||
width: 3840,
|
||||
height: 2160,
|
||||
frameRate: fps,
|
||||
},
|
||||
width: undefined,
|
||||
height: undefined,
|
||||
} as unknown as { width: number; height: number; frameRate: number },
|
||||
}),
|
||||
...(displaySurface
|
||||
? ({ displaySurface } as { displaySurface: DisplaySurfaceHint })
|
||||
@@ -2008,14 +2013,16 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const syncGlobalShortcuts = () => {
|
||||
if (!isTauriRuntime()) return;
|
||||
// Only register an OS-level shortcut if the user explicitly opted in.
|
||||
// Window-scoped firing happens via the `onKey` listener above and works
|
||||
// for every enabled binding regardless of the `global` flag.
|
||||
const enabledAndGlobal = (b: VoiceHotkeys[HotkeyKind]) => b.enabled && b.global;
|
||||
const desired: Record<HotkeyKind, string | null> = {
|
||||
mute: settings.mute.enabled ? bindingToTauriShortcut(settings.mute) : null,
|
||||
deafen: settings.deafen.enabled ? bindingToTauriShortcut(settings.deafen) : null,
|
||||
hangup: settings.hangup.enabled ? bindingToTauriShortcut(settings.hangup) : null,
|
||||
screenShare: settings.screenShare.enabled
|
||||
? bindingToTauriShortcut(settings.screenShare)
|
||||
: null,
|
||||
video: settings.video.enabled ? bindingToTauriShortcut(settings.video) : null,
|
||||
mute: enabledAndGlobal(settings.mute) ? bindingToTauriShortcut(settings.mute) : null,
|
||||
deafen: enabledAndGlobal(settings.deafen) ? bindingToTauriShortcut(settings.deafen) : null,
|
||||
hangup: enabledAndGlobal(settings.hangup) ? bindingToTauriShortcut(settings.hangup) : null,
|
||||
screenShare: enabledAndGlobal(settings.screenShare) ? bindingToTauriShortcut(settings.screenShare) : null,
|
||||
video: enabledAndGlobal(settings.video) ? bindingToTauriShortcut(settings.video) : null,
|
||||
};
|
||||
for (const kind of KINDS) {
|
||||
const want = desired[kind];
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { type DeviceRecord, listOwnDevices, revokeDevice } from '@chat-app/shared/auth';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
interface State {
|
||||
devices: DeviceRecord[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function useOwnDevices(): {
|
||||
devices: DeviceRecord[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
revoke: (deviceId: string) => Promise<void>;
|
||||
} {
|
||||
const { session } = useAuth();
|
||||
const userId = session?.user.id ?? null;
|
||||
const [state, setState] = useState<State>({ devices: [], loading: true, error: null });
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setState((s) => ({ ...s, loading: true, error: null }));
|
||||
const list = await listOwnDevices(supabase);
|
||||
setState({ devices: list, loading: false, error: null });
|
||||
} catch (err) {
|
||||
setState((s) => ({
|
||||
...s,
|
||||
loading: false,
|
||||
error: err instanceof Error ? err.message : 'failed to load devices',
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId) {
|
||||
setState({ devices: [], loading: false, error: null });
|
||||
return;
|
||||
}
|
||||
void refresh();
|
||||
const channel = supabase
|
||||
.channel('devices:list:' + userId)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: '*',
|
||||
schema: 'public',
|
||||
table: 'devices',
|
||||
filter: 'user_id=eq.' + userId,
|
||||
},
|
||||
() => {
|
||||
void refresh();
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
return () => {
|
||||
void supabase.removeChannel(channel);
|
||||
};
|
||||
}, [userId, refresh]);
|
||||
|
||||
const revoke = useCallback(
|
||||
async (deviceId: string) => {
|
||||
await revokeDevice(supabase, deviceId);
|
||||
await refresh();
|
||||
},
|
||||
[refresh],
|
||||
);
|
||||
|
||||
return { devices: state.devices, loading: state.loading, error: state.error, refresh, revoke };
|
||||
}
|
||||
@@ -1,16 +1,14 @@
|
||||
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
|
||||
import { type OwnDeviceCtx, shareConvKeyToDevice } from '@chat-app/shared/chat';
|
||||
import { type OwnUserCtx, shareConvKeyToUser } from '@chat-app/shared/chat';
|
||||
import { pgHexToBytes } from '@chat-app/shared/supabase';
|
||||
|
||||
import { devLocalSecretStore } from './secretStore';
|
||||
import { supabase } from './supabase';
|
||||
|
||||
// Watches the `devices` table for new entries AND, on mount, scans every
|
||||
// conversation we participate in for missing key bundles. Fills gaps by
|
||||
// re-wrapping our active conv-key for the missing recipient devices.
|
||||
// re-wrapping our active conv-key for the missing recipient users.
|
||||
//
|
||||
// This fixes the "cannot decrypt" cliff for devices that registered while
|
||||
// no other participant device was online to share the key with them.
|
||||
// This fixes the "cannot decrypt" cliff for users that joined while no
|
||||
// other participant was online to share the key with them.
|
||||
|
||||
export interface SyncCtx {
|
||||
myUserId: string;
|
||||
@@ -57,8 +55,6 @@ export function startConversationKeySync(
|
||||
void ownUserId;
|
||||
void ownDeviceId;
|
||||
void backfilledKey;
|
||||
void loadDevicePrivateKey;
|
||||
void devLocalSecretStore;
|
||||
void supabase;
|
||||
return () => {};
|
||||
}
|
||||
@@ -70,10 +66,10 @@ export function startConversationKeySync(
|
||||
// `wrapForOneDevice` and `syncOneConversationGaps` are exported below for
|
||||
// the device-approval module — once the user explicitly approves a new
|
||||
// device the approval flow re-uses these helpers to wrap conv-keys for
|
||||
// that specific deviceId.
|
||||
// that specific user.
|
||||
void (() => {
|
||||
void listMyConversationIds;
|
||||
void listConversationDevices;
|
||||
void listConversationMembers;
|
||||
void listExistingKeyRecipients;
|
||||
void getActiveKeyVersion;
|
||||
void syncAllExistingGaps;
|
||||
@@ -94,9 +90,9 @@ async function listMyConversationIds(myUserId: string): Promise<string[]> {
|
||||
return (data ?? []).map((r) => r.conversation_id as string);
|
||||
}
|
||||
|
||||
async function listConversationDevices(
|
||||
async function listConversationMembers(
|
||||
conversationId: string,
|
||||
): Promise<{ id: string; user_id: string; public_key: string }[]> {
|
||||
): Promise<{ user_id: string; public_key: string }[]> {
|
||||
const { data: members, error: mErr } = await supabase
|
||||
.from('conversation_members')
|
||||
.select('user_id')
|
||||
@@ -109,15 +105,14 @@ async function listConversationDevices(
|
||||
const userIds = (members ?? []).map((m) => m.user_id as string);
|
||||
if (userIds.length === 0) return [];
|
||||
|
||||
const { data: devices, error: dErr } = await supabase
|
||||
.from('devices')
|
||||
.select('id, user_id, public_key')
|
||||
const { data: keys, error: kErr } = await rawFrom('user_keys')
|
||||
.select('user_id, public_key')
|
||||
.in('user_id', userIds);
|
||||
if (dErr) {
|
||||
console.warn('keySync: devices lookup failed', dErr);
|
||||
if (kErr) {
|
||||
console.warn('keySync: user-keys lookup failed', kErr);
|
||||
return [];
|
||||
}
|
||||
return (devices ?? []) as { id: string; user_id: string; public_key: string }[];
|
||||
return (keys ?? []) as { user_id: string; public_key: string }[];
|
||||
}
|
||||
|
||||
async function listExistingKeyRecipients(
|
||||
@@ -125,14 +120,14 @@ async function listExistingKeyRecipients(
|
||||
keyVersion: number,
|
||||
): Promise<Set<string>> {
|
||||
const { data, error } = await rawFrom('conversation_keys')
|
||||
.select('recipient_device_id')
|
||||
.select('recipient_user_id')
|
||||
.eq('conversation_id', conversationId)
|
||||
.eq('key_version', keyVersion);
|
||||
if (error) {
|
||||
console.warn('keySync: existing keys lookup failed', error);
|
||||
return new Set();
|
||||
}
|
||||
return new Set((data ?? []).map((r: { recipient_device_id: string }) => r.recipient_device_id));
|
||||
return new Set((data ?? []).map((r: { recipient_user_id: string }) => r.recipient_user_id));
|
||||
}
|
||||
|
||||
async function getActiveKeyVersion(conversationId: string): Promise<number> {
|
||||
@@ -160,33 +155,32 @@ async function syncAllExistingGaps(ctx: SyncCtx): Promise<void> {
|
||||
|
||||
export async function syncOneConversationGaps(ctx: SyncCtx, convId: string): Promise<void> {
|
||||
const version = await getActiveKeyVersion(convId);
|
||||
const devices = await listConversationDevices(convId);
|
||||
if (devices.length === 0) return;
|
||||
const members = await listConversationMembers(convId);
|
||||
if (members.length === 0) return;
|
||||
|
||||
const recipients = await listExistingKeyRecipients(convId, version);
|
||||
const ownCtx: OwnDeviceCtx = {
|
||||
const ownCtx: OwnUserCtx = {
|
||||
userId: ctx.myUserId,
|
||||
deviceId: ctx.myDeviceId,
|
||||
privateKey: ctx.priv,
|
||||
};
|
||||
|
||||
for (const dev of devices) {
|
||||
if (recipients.has(dev.id)) continue;
|
||||
// Skip our own device — we already have the bundle if we're capable of
|
||||
for (const m of members) {
|
||||
if (recipients.has(m.user_id)) continue;
|
||||
// Skip our own user — we already have the bundle if we're capable of
|
||||
// sharing (or don't need it if we ourselves haven't been wrapped yet).
|
||||
if (dev.id === ctx.myDeviceId) continue;
|
||||
if (m.user_id === ctx.myUserId) continue;
|
||||
try {
|
||||
await shareConvKeyToDevice(supabase, convId, dev.id, pgHexToBytes(dev.public_key), ownCtx);
|
||||
await shareConvKeyToUser(supabase, convId, m.user_id, pgHexToBytes(m.public_key), ownCtx);
|
||||
} catch (err: unknown) {
|
||||
// Backfill is best-effort. Most common silent failures:
|
||||
// - tryGetConvKey couldn't unwrap (another peer will fill the gap).
|
||||
// - RLS rejects because the recipient's owner is a pending (not-yet
|
||||
// - RLS rejects because the recipient is a pending (not-yet
|
||||
// accepted) DM member, or was removed from the conv.
|
||||
// Both are recoverable / expected, swallow without spam.
|
||||
if (isExpectedShareFailure(err)) continue;
|
||||
console.warn('keySync: shareConvKeyToDevice gap-fill failed', {
|
||||
console.warn('keySync: shareConvKeyToUser gap-fill failed', {
|
||||
convId,
|
||||
recipient: dev.id,
|
||||
recipient: m.user_id,
|
||||
err,
|
||||
});
|
||||
}
|
||||
@@ -209,12 +203,20 @@ function isExpectedShareFailure(err: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
// Approval-flow helper. The legacy signature took (deviceId, userId,
|
||||
// devicePubHex) because conv-keys were wrapped per-device. In the per-user
|
||||
// model only the user dimension matters, so the device-id parameter is
|
||||
// ignored and the public key passed in MUST be the recipient user's
|
||||
// user_keys.public_key (callers will be updated alongside the broader
|
||||
// approval-flow rework).
|
||||
export async function wrapForOneDevice(
|
||||
ctx: SyncCtx,
|
||||
newDeviceId: string,
|
||||
newDeviceUserId: string,
|
||||
newDevicePubHex: string,
|
||||
): Promise<void> {
|
||||
void newDeviceId; // kept for API compat; no longer used
|
||||
|
||||
const myConvs = new Set(await listMyConversationIds(ctx.myUserId));
|
||||
const { data: peerMember, error: pErr } = await supabase
|
||||
.from('conversation_members')
|
||||
@@ -230,16 +232,15 @@ export async function wrapForOneDevice(
|
||||
if (sharedConvs.length === 0) return;
|
||||
|
||||
const newPub = pgHexToBytes(newDevicePubHex);
|
||||
const ownCtx: OwnDeviceCtx = {
|
||||
const ownCtx: OwnUserCtx = {
|
||||
userId: ctx.myUserId,
|
||||
deviceId: ctx.myDeviceId,
|
||||
privateKey: ctx.priv,
|
||||
};
|
||||
for (const convId of sharedConvs) {
|
||||
try {
|
||||
await shareConvKeyToDevice(supabase, convId, newDeviceId, newPub, ownCtx);
|
||||
await shareConvKeyToUser(supabase, convId, newDeviceUserId, newPub, ownCtx);
|
||||
} catch (err: unknown) {
|
||||
console.warn('keySync: shareConvKeyToDevice failed', { convId, err });
|
||||
console.warn('keySync: shareConvKeyToUser failed', { convId, err });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,5 +25,13 @@ export async function createLibsodiumBackend(): Promise<CryptoBackend> {
|
||||
s.crypto_box_open_easy(ciphertext, nonce, senderPublicKey, recipientPrivateKey),
|
||||
secretbox: (plaintext, nonce, key) => s.crypto_secretbox_easy(plaintext, nonce, key),
|
||||
secretboxOpen: (ciphertext, nonce, key) => s.crypto_secretbox_open_easy(ciphertext, nonce, key),
|
||||
pwhashConsts: {
|
||||
OPSLIMIT_MODERATE: s.crypto_pwhash_OPSLIMIT_MODERATE,
|
||||
MEMLIMIT_MODERATE: s.crypto_pwhash_MEMLIMIT_MODERATE,
|
||||
ALG_ARGON2ID13: s.crypto_pwhash_ALG_ARGON2ID13,
|
||||
},
|
||||
pwhash: (outLen, password, salt, opslimit, memlimit, alg) =>
|
||||
s.crypto_pwhash(outLen, password, salt, opslimit, memlimit, alg),
|
||||
scalarMultBase: (priv) => s.crypto_scalarmult_base(priv),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import {
|
||||
deviceIdStorageKey,
|
||||
listOwnDevices,
|
||||
loadDevicePrivateKey,
|
||||
provisionNewDevice,
|
||||
touchDeviceLastSeen,
|
||||
type DeviceRecord,
|
||||
} from '@chat-app/shared/auth';
|
||||
import type { DevicePlatform } from '@chat-app/shared/supabase';
|
||||
|
||||
import { devLocalSecretStore } from './secretStore';
|
||||
import { supabase } from './supabase';
|
||||
|
||||
export function detectDesktopPlatform(): DevicePlatform {
|
||||
const ua =
|
||||
typeof navigator !== 'undefined' && typeof navigator.userAgent === 'string'
|
||||
? navigator.userAgent.toLowerCase()
|
||||
: '';
|
||||
if (ua.includes('mac')) return 'macos';
|
||||
if (ua.includes('win')) return 'windows';
|
||||
return 'linux';
|
||||
}
|
||||
|
||||
export function readLocalDeviceId(userId: string): string | null {
|
||||
return window.localStorage.getItem(deviceIdStorageKey(userId));
|
||||
}
|
||||
|
||||
export function writeLocalDeviceId(userId: string, deviceId: string): void {
|
||||
window.localStorage.setItem(deviceIdStorageKey(userId), deviceId);
|
||||
}
|
||||
|
||||
export function clearLocalDeviceId(userId: string): void {
|
||||
window.localStorage.removeItem(deviceIdStorageKey(userId));
|
||||
}
|
||||
|
||||
// Look up the current install's device record. Returns null when either:
|
||||
// - no device id is cached locally, or
|
||||
// - the cached id was deleted server-side (e.g. wiped from Studio).
|
||||
// In both cases the UI should prompt the user to register a fresh device.
|
||||
export async function findExistingDevice(userId: string): Promise<DeviceRecord | null> {
|
||||
const cachedId = readLocalDeviceId(userId);
|
||||
if (!cachedId) return null;
|
||||
|
||||
const all = await listOwnDevices(supabase);
|
||||
const hit = all.find((d) => d.id === cachedId) ?? null;
|
||||
if (!hit) return null;
|
||||
|
||||
const priv = await loadDevicePrivateKey(devLocalSecretStore, userId, hit.id);
|
||||
if (!priv) {
|
||||
// Server row exists but we lost the private key locally — treat as fresh install.
|
||||
return null;
|
||||
}
|
||||
|
||||
void touchDeviceLastSeen(supabase, hit.id).catch(() => {
|
||||
/* non-fatal */
|
||||
});
|
||||
return hit;
|
||||
}
|
||||
|
||||
export async function registerCurrentDevice(params: {
|
||||
userId: string;
|
||||
name: string;
|
||||
}): Promise<DeviceRecord> {
|
||||
const device = await provisionNewDevice({
|
||||
client: supabase,
|
||||
secretStore: devLocalSecretStore,
|
||||
userId: params.userId,
|
||||
name: params.name,
|
||||
platform: detectDesktopPlatform(),
|
||||
});
|
||||
writeLocalDeviceId(params.userId, device.id);
|
||||
return device;
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
import { getCryptoBackend } from '@chat-app/shared/crypto';
|
||||
import sodium from 'libsodium-wrappers-sumo';
|
||||
|
||||
import { pwhashArgon2id } from './nativeCryptoOps';
|
||||
|
||||
// Encrypts/decrypts the device private key with a user-provided passphrase
|
||||
// so the backup string can be safely written down or stored in a password
|
||||
// manager. Uses Argon2id (libsodium crypto_pwhash) for the KDF and
|
||||
// XSalsa20-Poly1305 (crypto_secretbox) for the AEAD.
|
||||
//
|
||||
// Backup format (base64url-encoded blob, prefixed with a magic string so we
|
||||
// can version it):
|
||||
//
|
||||
// chatapp-backup-v1.<base64url(salt(16) | nonce(24) | ciphertext)>
|
||||
|
||||
const MAGIC = 'chatapp-backup-v1.';
|
||||
const SALT_LEN = 16; // crypto_pwhash_SALTBYTES
|
||||
const NONCE_LEN = 24; // crypto_secretbox_NONCEBYTES
|
||||
const KEY_LEN = 32; // crypto_secretbox_KEYBYTES
|
||||
|
||||
async function ensureSodium(): Promise<typeof sodium> {
|
||||
await sodium.ready;
|
||||
return sodium;
|
||||
}
|
||||
|
||||
function b64url(bytes: Uint8Array): string {
|
||||
let s = '';
|
||||
for (const b of bytes) s += String.fromCharCode(b);
|
||||
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
function unb64url(s: string): Uint8Array {
|
||||
let str = s.replace(/-/g, '+').replace(/_/g, '/');
|
||||
while (str.length % 4) str += '=';
|
||||
const bin = atob(str);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function deriveKey(passphrase: string, salt: Uint8Array, _sodiumLib: typeof sodium): Promise<Uint8Array> {
|
||||
return pwhashArgon2id({
|
||||
password: passphrase,
|
||||
salt,
|
||||
outLen: KEY_LEN,
|
||||
preset: 'moderate',
|
||||
});
|
||||
}
|
||||
|
||||
export async function exportDeviceKey(
|
||||
privateKey: Uint8Array,
|
||||
passphrase: string,
|
||||
): Promise<string> {
|
||||
if (passphrase.length < 8) throw new Error('Passphrase must be at least 8 characters.');
|
||||
const s = await ensureSodium();
|
||||
const salt = s.randombytes_buf(SALT_LEN);
|
||||
const nonce = s.randombytes_buf(NONCE_LEN);
|
||||
const key = await deriveKey(passphrase, salt, s);
|
||||
const backend = getCryptoBackend();
|
||||
const ciphertext = backend.secretbox(privateKey, nonce, key);
|
||||
s.memzero(key);
|
||||
const blob = new Uint8Array(SALT_LEN + NONCE_LEN + ciphertext.length);
|
||||
blob.set(salt, 0);
|
||||
blob.set(nonce, SALT_LEN);
|
||||
blob.set(ciphertext, SALT_LEN + NONCE_LEN);
|
||||
return MAGIC + b64url(blob);
|
||||
}
|
||||
|
||||
export async function importDeviceKey(
|
||||
backup: string,
|
||||
passphrase: string,
|
||||
): Promise<Uint8Array> {
|
||||
if (!backup.startsWith(MAGIC)) {
|
||||
throw new Error('Invalid backup format');
|
||||
}
|
||||
const blob = unb64url(backup.slice(MAGIC.length));
|
||||
if (blob.length < SALT_LEN + NONCE_LEN + 1) {
|
||||
throw new Error('Backup too short');
|
||||
}
|
||||
const salt = blob.slice(0, SALT_LEN);
|
||||
const nonce = blob.slice(SALT_LEN, SALT_LEN + NONCE_LEN);
|
||||
const ciphertext = blob.slice(SALT_LEN + NONCE_LEN);
|
||||
const s = await ensureSodium();
|
||||
const key = await deriveKey(passphrase, salt, s);
|
||||
const backend = getCryptoBackend();
|
||||
try {
|
||||
return backend.secretboxOpen(ciphertext, nonce, key);
|
||||
} catch {
|
||||
throw new Error('Wrong passphrase or corrupt backup');
|
||||
} finally {
|
||||
s.memzero(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Full-device backup. Wraps userId + deviceId + privateKey in a JSON payload
|
||||
// before encrypting, so a restore flow can re-seed localStorage + vault + server
|
||||
// device row without requiring the user to remember IDs.
|
||||
export interface DeviceBackupPayload {
|
||||
v: 2;
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
privateKeyB64: string;
|
||||
}
|
||||
|
||||
export async function exportDeviceBackup(
|
||||
params: {
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
privateKey: Uint8Array;
|
||||
passphrase: string;
|
||||
},
|
||||
): Promise<string> {
|
||||
const payload: DeviceBackupPayload = {
|
||||
v: 2,
|
||||
userId: params.userId,
|
||||
deviceId: params.deviceId,
|
||||
privateKeyB64: b64url(params.privateKey),
|
||||
};
|
||||
const bytes = new TextEncoder().encode(JSON.stringify(payload));
|
||||
return exportDeviceKey(bytes, params.passphrase);
|
||||
}
|
||||
|
||||
export async function importDeviceBackup(
|
||||
backup: string,
|
||||
passphrase: string,
|
||||
): Promise<DeviceBackupPayload> {
|
||||
const plain = await importDeviceKey(backup, passphrase);
|
||||
const text = new TextDecoder().decode(plain);
|
||||
try {
|
||||
const obj = JSON.parse(text) as DeviceBackupPayload;
|
||||
if (obj && obj.v === 2 && obj.userId && obj.deviceId && obj.privateKeyB64) {
|
||||
return obj;
|
||||
}
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
throw new Error('Backup format not supported — v2 expected');
|
||||
}
|
||||
|
||||
export function decodePrivateKeyFromBackup(payload: DeviceBackupPayload): Uint8Array {
|
||||
return unb64url(payload.privateKeyB64);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recovery code
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Generates a high-entropy code shown to the user once at backup time. The
|
||||
// same payload is encrypted twice — once with the user's passphrase, once
|
||||
// with the recovery code — so either string can decrypt the device key.
|
||||
//
|
||||
// The recovery code is 24 chars from a 32-symbol alphabet (no ambiguous
|
||||
// characters), grouped as 4×6. ~120 bits of entropy.
|
||||
|
||||
const RECOVERY_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
|
||||
export interface BackupBundle {
|
||||
passphraseBackup: string;
|
||||
recoveryBackup: string;
|
||||
recoveryCode: string;
|
||||
}
|
||||
|
||||
export async function exportDeviceBackupWithRecovery(params: {
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
privateKey: Uint8Array;
|
||||
passphrase: string;
|
||||
}): Promise<BackupBundle> {
|
||||
const recoveryCode = await generateRecoveryCode();
|
||||
const [passphraseBackup, recoveryBackup] = await Promise.all([
|
||||
exportDeviceBackup(params),
|
||||
exportDeviceBackup({ ...params, passphrase: recoveryCode }),
|
||||
]);
|
||||
return { passphraseBackup, recoveryBackup, recoveryCode };
|
||||
}
|
||||
|
||||
async function generateRecoveryCode(): Promise<string> {
|
||||
const s = await ensureSodium();
|
||||
const raw = s.randombytes_buf(24);
|
||||
let out = '';
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
out += RECOVERY_ALPHABET[raw[i]! % RECOVERY_ALPHABET.length];
|
||||
if ((i + 1) % 6 === 0 && i !== raw.length - 1) out += '-';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Normalizes a user-typed recovery code: strips dashes/spaces, uppercases,
|
||||
// maps look-alike characters. Lets users enter the code with imperfect
|
||||
// spacing without rejecting valid input.
|
||||
export function normalizeRecoveryCode(input: string): string {
|
||||
return input
|
||||
.toUpperCase()
|
||||
.replace(/[\s-]/g, '')
|
||||
.split('')
|
||||
.filter((c) => RECOVERY_ALPHABET.includes(c))
|
||||
.join('');
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// localStorage key for "the devices.id row that belongs to THIS install".
|
||||
// Reset on memory-wipe (NOT preserved) — a wiped install is conceptually a
|
||||
// fresh install, so registering a new row is correct.
|
||||
|
||||
const KEY = 'chatapp.deviceRowId.v1';
|
||||
|
||||
export function getDeviceRowId(): string | null {
|
||||
try {
|
||||
const v = window.localStorage.getItem(KEY);
|
||||
return v && v.length > 0 ? v : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setDeviceRowId(id: string): void {
|
||||
try {
|
||||
window.localStorage.setItem(KEY, id);
|
||||
} catch {
|
||||
/* quota */
|
||||
}
|
||||
}
|
||||
|
||||
export function clearDeviceRowId(): void {
|
||||
try {
|
||||
window.localStorage.removeItem(KEY);
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
// Local-only friend nickname overrides. Stored in localStorage keyed by the
|
||||
// peer's user-id. Empty/missing value = use the real display name.
|
||||
// Local-only by design — friends never see your nickname for them.
|
||||
|
||||
const STORAGE_KEY = 'chatapp.friendNicknames.v1';
|
||||
|
||||
let cache: Record<string, string> | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function load(): Record<string, string> {
|
||||
if (cache) return cache;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) { cache = {}; return cache; }
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
cache = {};
|
||||
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
|
||||
if (typeof v === 'string' && v.trim().length > 0) cache[k] = v;
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
} catch { /* corrupted; fall through */ }
|
||||
cache = {};
|
||||
return cache;
|
||||
}
|
||||
|
||||
function persist(): void {
|
||||
try { window.localStorage.setItem(STORAGE_KEY, JSON.stringify(cache ?? {})); }
|
||||
catch { /* quota / private mode */ }
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
|
||||
export function getNickname(userId: string): string | null {
|
||||
return load()[userId] ?? null;
|
||||
}
|
||||
|
||||
export function setNickname(userId: string, nickname: string | null): void {
|
||||
const store = load();
|
||||
const trimmed = nickname?.trim() ?? '';
|
||||
if (trimmed.length === 0) {
|
||||
if (!(userId in store)) return;
|
||||
delete store[userId];
|
||||
} else {
|
||||
if (store[userId] === trimmed) return;
|
||||
store[userId] = trimmed;
|
||||
}
|
||||
persist();
|
||||
}
|
||||
|
||||
// Reactive hook: returns the current nickname for a user, or `fallback`
|
||||
// when no nickname is set. Re-renders when ANY nickname changes (cheap,
|
||||
// the set is small).
|
||||
export function useNickname(userId: string | null | undefined, fallback: string): string {
|
||||
const subscribe = (cb: () => void) => {
|
||||
listeners.add(cb);
|
||||
return () => { listeners.delete(cb); };
|
||||
};
|
||||
const getSnapshot = () => (userId ? getNickname(userId) : null);
|
||||
const nickname = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
return nickname ?? fallback;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Per-install browser identifier. Replaces the legacy per-device crypto
|
||||
// identity for non-crypto bookkeeping (push tokens, message sender_device_id
|
||||
// metadata) where we just need a stable, opaque id for this browser/install.
|
||||
//
|
||||
// The user-key model has no device-bound crypto; the `devices` table and
|
||||
// `messages.sender_device_id` column still exist but they're now plain
|
||||
// telemetry. Using a localStorage UUID keeps the column populated without
|
||||
// any of the old key-management overhead.
|
||||
|
||||
const KEY = 'chatapp.installId';
|
||||
|
||||
export function ensureInstallId(): string {
|
||||
let id = window.localStorage.getItem(KEY);
|
||||
if (!id) {
|
||||
id = crypto.randomUUID();
|
||||
window.localStorage.setItem(KEY, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { clearConvKeyCache } from '@chat-app/shared/chat';
|
||||
|
||||
import { devLocalSecretStore } from './secretStore';
|
||||
|
||||
// Aggressively scrub local crypto + chat caches on sign-out (and on
|
||||
// app-close if the user opted in). Preserves things that aren't sensitive
|
||||
// and would be annoying to lose (theme, locale, install-id).
|
||||
//
|
||||
// We can't enumerate IndexedDB names without async + the indexedDB API,
|
||||
// so we list the ones we know about explicitly. Adding a new local store
|
||||
// later? Append to LOCAL_DBS.
|
||||
|
||||
const LOCAL_DBS = ['soundboard', 'message-cache', 'chatapp-attachments'];
|
||||
const PRESERVE_LOCAL_STORAGE = new Set([
|
||||
'chatapp.theme',
|
||||
'chatapp.locale',
|
||||
'chatapp.installId',
|
||||
'chatapp.wipeOnClose.v1',
|
||||
'i18nextLng',
|
||||
]);
|
||||
|
||||
export async function wipeLocalState(userId: string | null): Promise<void> {
|
||||
// 1. Per-conversation key cache (in-memory).
|
||||
try { clearConvKeyCache(); } catch { /* never throws but be defensive */ }
|
||||
|
||||
// 2. Stronghold / secret-store: drop the user-priv blob for this user.
|
||||
if (userId) {
|
||||
try { await devLocalSecretStore.removeSecret('chatapp.userpriv.' + userId); }
|
||||
catch (err) { console.warn('[wipe] userpriv remove failed', err); }
|
||||
}
|
||||
|
||||
// 3. localStorage — preserve only the explicit whitelist.
|
||||
try {
|
||||
const keysToDrop: string[] = [];
|
||||
for (let i = 0; i < window.localStorage.length; i++) {
|
||||
const k = window.localStorage.key(i);
|
||||
if (k && !PRESERVE_LOCAL_STORAGE.has(k)) keysToDrop.push(k);
|
||||
}
|
||||
for (const k of keysToDrop) window.localStorage.removeItem(k);
|
||||
} catch (err) { console.warn('[wipe] localStorage clear failed', err); }
|
||||
|
||||
// 4. sessionStorage — always full.
|
||||
try { window.sessionStorage.clear(); } catch { /* ignored */ }
|
||||
|
||||
// 5. IndexedDB — delete known databases. Resolves even when blocked so
|
||||
// we don't hang sign-out forever.
|
||||
await Promise.allSettled(LOCAL_DBS.map((name) => new Promise<void>((resolve) => {
|
||||
try {
|
||||
const req = window.indexedDB.deleteDatabase(name);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => resolve();
|
||||
req.onblocked = () => resolve();
|
||||
} catch { resolve(); }
|
||||
})));
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Persisted toggle for the "wipe local caches on app close" feature.
|
||||
// Read by SecurityCenter (UI) and AuthContext (renderer-side IPC handler).
|
||||
//
|
||||
// Stored in localStorage so the preference survives sessions without
|
||||
// touching the secret-store or SQLite. The key is on the preserve
|
||||
// whitelist in lib/memoryWipe.ts so a sign-out wipe (or a closing wipe)
|
||||
// doesn't blow away the user's own preference.
|
||||
const KEY = 'chatapp.wipeOnClose.v1';
|
||||
|
||||
export function isWipeOnCloseEnabled(): boolean {
|
||||
try {
|
||||
return window.localStorage.getItem(KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function setWipeOnClose(enabled: boolean): void {
|
||||
try {
|
||||
window.localStorage.setItem(KEY, enabled ? '1' : '0');
|
||||
} catch {
|
||||
/* ignored */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// GIPHY v1 client — Google closed Tenor to new API clients in Jan 2026, so
|
||||
// the picker uses GIPHY's Developer API instead. Provision a free key at
|
||||
// https://developers.giphy.com/dashboard/ and expose it via the renderer
|
||||
// env var VITE_GIPHY_API_KEY (e.g. in apps/desktop/.env.local). When unset,
|
||||
// every call throws — the picker degrades to a friendly
|
||||
// "GIFs sind nicht konfiguriert" message via the consumer's catch.
|
||||
//
|
||||
// The file is still named `tenor.ts` for import-path stability; rename later
|
||||
// if it bothers anyone.
|
||||
|
||||
const ENDPOINT = 'https://api.giphy.com/v1/gifs';
|
||||
const RECENT_KEY = 'chatapp.gifRecent.v1';
|
||||
const RECENT_MAX = 24;
|
||||
const API_KEY = (
|
||||
(import.meta as unknown as { env?: { VITE_GIPHY_API_KEY?: string } }).env
|
||||
?.VITE_GIPHY_API_KEY ?? ''
|
||||
).trim();
|
||||
|
||||
export interface GifResult {
|
||||
id: string;
|
||||
// Animated full-size URL (typically <2 MB).
|
||||
url: string;
|
||||
// Small preview shown in the picker grid.
|
||||
previewUrl: string;
|
||||
width: number;
|
||||
height: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface GiphyImage {
|
||||
url?: string;
|
||||
width?: string;
|
||||
height?: string;
|
||||
}
|
||||
|
||||
interface GiphyResult {
|
||||
id: string;
|
||||
title?: string;
|
||||
images?: {
|
||||
original?: GiphyImage;
|
||||
fixed_height?: GiphyImage;
|
||||
fixed_height_small?: GiphyImage;
|
||||
preview_gif?: GiphyImage;
|
||||
};
|
||||
}
|
||||
|
||||
function mapResult(r: GiphyResult): GifResult | null {
|
||||
const full = r.images?.original ?? r.images?.fixed_height;
|
||||
const preview =
|
||||
r.images?.fixed_height_small ?? r.images?.preview_gif ?? r.images?.fixed_height ?? full;
|
||||
if (!full?.url || !preview?.url) return null;
|
||||
return {
|
||||
id: r.id,
|
||||
url: full.url,
|
||||
previewUrl: preview.url,
|
||||
width: full.width ? Number(full.width) : 0,
|
||||
height: full.height ? Number(full.height) : 0,
|
||||
description: r.title ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchGiphy(path: string, params: Record<string, string>): Promise<GifResult[]> {
|
||||
if (!API_KEY) {
|
||||
throw new Error('giphy_api_key_missing');
|
||||
}
|
||||
const search = new URLSearchParams({ api_key: API_KEY, rating: 'pg-13', ...params });
|
||||
const res = await fetch(`${ENDPOINT}/${path}?${search.toString()}`);
|
||||
if (!res.ok) throw new Error('giphy http ' + res.status);
|
||||
const json = (await res.json()) as { data?: GiphyResult[] };
|
||||
return (json.data ?? []).map(mapResult).filter((x): x is GifResult => x !== null);
|
||||
}
|
||||
|
||||
export function isGifProviderConfigured(): boolean {
|
||||
return API_KEY.length > 0;
|
||||
}
|
||||
|
||||
// Back-compat alias for the original Tenor function name. New code should
|
||||
// use isGifProviderConfigured.
|
||||
export const isTenorConfigured = isGifProviderConfigured;
|
||||
|
||||
export async function searchGifs(query: string, locale: string = 'de'): Promise<GifResult[]> {
|
||||
if (!query.trim()) return featuredGifs(locale);
|
||||
return fetchGiphy('search', { q: query, limit: '30', lang: locale });
|
||||
}
|
||||
|
||||
export async function featuredGifs(_locale: string = 'de'): Promise<GifResult[]> {
|
||||
return fetchGiphy('trending', { limit: '30' });
|
||||
}
|
||||
|
||||
export function getRecentGifs(): GifResult[] {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(RECENT_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter(
|
||||
(x): x is GifResult =>
|
||||
x != null &&
|
||||
typeof x === 'object' &&
|
||||
typeof (x as GifResult).url === 'string',
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function rememberRecentGif(gif: GifResult): void {
|
||||
const current = getRecentGifs().filter((g) => g.id !== gif.id);
|
||||
const next = [gif, ...current].slice(0, RECENT_MAX);
|
||||
try {
|
||||
window.localStorage.setItem(RECENT_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
/* quota */
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
|
||||
import { fetchPeerPublicKeys } from '@chat-app/shared/auth';
|
||||
import {
|
||||
type AttachmentHandle,
|
||||
type DecryptedMessage,
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
MAX_ATTACHMENT_BYTES,
|
||||
type MessageWithCipher,
|
||||
sendEncryptedMessage,
|
||||
shareConvKeyToUser,
|
||||
tryGetConvKey,
|
||||
} from '@chat-app/shared/chat';
|
||||
import { bytesToPgHex, pgBytesToBytes } from '@chat-app/shared/supabase';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
@@ -30,8 +32,8 @@ import {
|
||||
shouldGiveUp,
|
||||
subscribeOutbox,
|
||||
} from './messageOutbox';
|
||||
import { devLocalSecretStore } from './secretStore';
|
||||
import { supabase } from './supabase';
|
||||
import { cachedUserKey } from './userIdentity';
|
||||
|
||||
interface State {
|
||||
messages: DecryptedMessage[];
|
||||
@@ -68,7 +70,12 @@ function rowToMessage(row: Record<string, unknown>): MessageWithCipher {
|
||||
}
|
||||
|
||||
export function useConversationMessages({ conversationId, userId, deviceId }: Args): State & {
|
||||
send: (text: string, images?: File[], replyToId?: string | null) => Promise<void>;
|
||||
send: (
|
||||
text: string,
|
||||
images?: File[],
|
||||
replyToId?: string | null,
|
||||
opts?: { viewOnce?: boolean },
|
||||
) => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
pending: OutboxItem[];
|
||||
retryPending: (id: string) => void;
|
||||
@@ -90,25 +97,115 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
});
|
||||
}, [conversationId]);
|
||||
|
||||
// Load own private key once per (user, device).
|
||||
// Load own user private key once per user.
|
||||
useEffect(() => {
|
||||
privateKeyRef.current = null;
|
||||
if (!userId || !deviceId) return;
|
||||
void loadDevicePrivateKey(devLocalSecretStore, userId, deviceId).then((pk) => {
|
||||
if (!userId) return;
|
||||
void cachedUserKey(userId).then((pk) => {
|
||||
privateKeyRef.current = pk;
|
||||
});
|
||||
}, [userId, deviceId]);
|
||||
}, [userId]);
|
||||
|
||||
// Proactive rewrap sweep: when a conversation opens, walk every accepted
|
||||
// member and ensure the active conv-key has a `recipient_user_id` bundle
|
||||
// for them. Members who are missing one (typically peers who haven't yet
|
||||
// migrated to the per-user key model) get a best-effort wrap from the
|
||||
// local conv-key handle. Closes the legacy migration gap so peer B can
|
||||
// read on first unlock without manual intervention from A.
|
||||
useEffect(() => {
|
||||
if (!conversationId || !userId) return;
|
||||
let cancelled = false;
|
||||
const run = async (): Promise<void> => {
|
||||
// Wait for the private key ref to be populated. Loop with backoff
|
||||
// because it's set asynchronously by another effect; bail if the
|
||||
// conversation switches.
|
||||
for (let i = 0; i < 20 && !privateKeyRef.current && !cancelled; i++) {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
const priv = privateKeyRef.current;
|
||||
if (!priv || cancelled) return;
|
||||
|
||||
try {
|
||||
const { data: convRow, error: convErr } = await supabase
|
||||
.from('conversations')
|
||||
.select('active_key_version')
|
||||
.eq('id', conversationId)
|
||||
.single();
|
||||
if (convErr || !convRow) return;
|
||||
// db-types snapshot predates the active_key_version column; cast via unknown.
|
||||
const version = (convRow as unknown as { active_key_version: number }).active_key_version;
|
||||
|
||||
const handle = await tryGetConvKey(supabase, conversationId, userId, priv, version);
|
||||
if (!handle || cancelled) return;
|
||||
|
||||
const { data: members, error: mErr } = await supabase
|
||||
.from('conversation_members')
|
||||
.select('user_id, accepted')
|
||||
.eq('conversation_id', conversationId);
|
||||
if (mErr || !members) return;
|
||||
const memberIds = (members as Array<{ user_id: string; accepted: boolean }>)
|
||||
.filter((m) => m.accepted && m.user_id !== userId)
|
||||
.map((m) => m.user_id);
|
||||
if (memberIds.length === 0) return;
|
||||
|
||||
const peers = await fetchPeerPublicKeys(supabase, memberIds);
|
||||
for (const peer of peers) {
|
||||
if (cancelled) return;
|
||||
const { count, error: cntErr } = await (
|
||||
supabase as unknown as {
|
||||
from: (t: string) => {
|
||||
select: (s: string, o?: object) => {
|
||||
eq: (...a: unknown[]) => {
|
||||
eq: (...a: unknown[]) => {
|
||||
eq: (
|
||||
...a: unknown[]
|
||||
) => Promise<{ count: number | null; error: unknown }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
)
|
||||
.from('conversation_keys')
|
||||
.select('recipient_user_id', { count: 'exact', head: true })
|
||||
.eq('conversation_id', conversationId)
|
||||
.eq('recipient_user_id', peer.userId)
|
||||
.eq('key_version', version);
|
||||
if (cntErr) continue;
|
||||
if ((count ?? 0) === 0) {
|
||||
try {
|
||||
await shareConvKeyToUser(
|
||||
supabase,
|
||||
conversationId,
|
||||
peer.userId,
|
||||
peer.publicKey,
|
||||
{ userId, privateKey: priv },
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn('proactive rewrap failed for', peer.userId, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('proactive rewrap sweep failed', err);
|
||||
}
|
||||
};
|
||||
void run();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [conversationId, userId]);
|
||||
|
||||
const decryptBatch = useCallback(
|
||||
async (messages: MessageWithCipher[]): Promise<DecryptedMessage[]> => {
|
||||
const priv = privateKeyRef.current;
|
||||
if (!priv || !deviceId || messages.length === 0) {
|
||||
if (!priv || !userId || messages.length === 0) {
|
||||
return messages.map((m) => ({ ...m, plaintext: null }));
|
||||
}
|
||||
return decryptMessages({
|
||||
client: supabase,
|
||||
messages,
|
||||
ownDeviceId: deviceId,
|
||||
ownUserId: userId,
|
||||
ownPrivateKey: priv,
|
||||
// Offload the symmetric decrypt + utf-8 decode to a Web Worker so
|
||||
// the main thread stays responsive during bulk operations (initial
|
||||
@@ -116,7 +213,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
aeadBatchDelegate: decryptBatchWorker,
|
||||
});
|
||||
},
|
||||
[deviceId],
|
||||
[userId],
|
||||
);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
@@ -467,7 +564,12 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
);
|
||||
|
||||
const send = useCallback(
|
||||
async (text: string, images: File[] = [], replyToId: string | null = null) => {
|
||||
async (
|
||||
text: string,
|
||||
images: File[] = [],
|
||||
replyToId: string | null = null,
|
||||
opts: { viewOnce?: boolean } = {},
|
||||
) => {
|
||||
const trimmed = text.trim();
|
||||
if ((!trimmed && images.length === 0) || !conversationId || !userId || !deviceId) return;
|
||||
const priv = privateKeyRef.current;
|
||||
@@ -539,6 +641,14 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
...(dims.width !== undefined ? { width: dims.width } : {}),
|
||||
...(dims.height !== undefined ? { height: dims.height } : {}),
|
||||
});
|
||||
// Stamp the view-once flag on each handle the caller requested it
|
||||
// for. The flag rides inside the encrypted payload (so peers can
|
||||
// render the locked card without leaking who-sent-what to the
|
||||
// server) AND lands on the public message_attachments row via
|
||||
// insertAttachmentRow below (where the mark-viewed RPC enforces it).
|
||||
if (opts.viewOnce) {
|
||||
res.handle.viewOnce = true;
|
||||
}
|
||||
handles.push(res.handle);
|
||||
blobNonceHexByHandleId.set(res.handle.id, bytesToPgHex(res.nonce));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { notify } from './osNotify';
|
||||
import { supabase } from './supabase';
|
||||
|
||||
interface MentionRow {
|
||||
message_id: string;
|
||||
mentioned_user_id: string;
|
||||
conversation_id: string;
|
||||
}
|
||||
|
||||
// Subscribes to my own message_mentions inserts and fires an OS notification
|
||||
// for each one. Bypasses per-conv mute (mentions override mute by design).
|
||||
//
|
||||
// We don't decrypt the body here — the notification just says "Du wurdest
|
||||
// erwähnt". The conv list highlight + the in-app navigation reveal context.
|
||||
export function useMentionNotifications(userId: string | undefined): void {
|
||||
useEffect(() => {
|
||||
if (!userId) return;
|
||||
const channel = supabase
|
||||
.channel('mentions:' + userId)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'INSERT',
|
||||
schema: 'public',
|
||||
table: 'message_mentions',
|
||||
filter: 'mentioned_user_id=eq.' + userId,
|
||||
},
|
||||
(payload) => {
|
||||
const row = payload.new as MentionRow | null;
|
||||
if (!row) return;
|
||||
void notify({
|
||||
title: 'Du wurdest erwähnt',
|
||||
body: 'Tippe um die Nachricht zu lesen.',
|
||||
force: true,
|
||||
});
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
return () => {
|
||||
void supabase.removeChannel(channel);
|
||||
};
|
||||
}, [userId]);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { listPinnedMessages, type PinnedMessage } from '@chat-app/shared/chat';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { supabase } from './supabase';
|
||||
|
||||
// Live list of pinned messages for one conversation. Subscribes to the
|
||||
// `pinned_messages` realtime channel for the conv so the header pill +
|
||||
// side-panel update without a refetch.
|
||||
export function usePinnedMessages(conversationId: string | undefined): PinnedMessage[] {
|
||||
const [pins, setPins] = useState<PinnedMessage[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!conversationId) {
|
||||
setPins([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
|
||||
void listPinnedMessages(supabase, conversationId).then((rows) => {
|
||||
if (!cancelled) setPins(rows);
|
||||
});
|
||||
|
||||
const channel = supabase
|
||||
.channel('pinned_messages:' + conversationId)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: '*',
|
||||
schema: 'public',
|
||||
table: 'pinned_messages',
|
||||
filter: 'conversation_id=eq.' + conversationId,
|
||||
},
|
||||
() => {
|
||||
void listPinnedMessages(supabase, conversationId).then((rows) => {
|
||||
if (!cancelled) setPins(rows);
|
||||
});
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
void supabase.removeChannel(channel);
|
||||
};
|
||||
}, [conversationId]);
|
||||
|
||||
return pins;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it, beforeEach, vi, beforeAll } from 'vitest';
|
||||
|
||||
import { setCryptoBackend } from '@chat-app/shared/crypto';
|
||||
import { makeWasmTestBackend } from '@chat-app/shared/crypto/testBackend';
|
||||
|
||||
beforeAll(async () => { setCryptoBackend(await makeWasmTestBackend()); });
|
||||
|
||||
const memStore: Record<string, Uint8Array> = {};
|
||||
const fakeStore = {
|
||||
getSecret: async (k: string) => memStore[k] ?? null,
|
||||
setSecret: async (k: string, v: Uint8Array) => { memStore[k] = v; },
|
||||
removeSecret: async (k: string) => { delete memStore[k]; },
|
||||
};
|
||||
|
||||
vi.mock('./secretStore', () => ({
|
||||
devLocalSecretStore: fakeStore,
|
||||
setSecretStoreUser: vi.fn(),
|
||||
isEncryptedVaultActive: () => false,
|
||||
}));
|
||||
|
||||
vi.mock('./supabase', () => {
|
||||
const rpcResponses = new Map<string, { data: unknown; error: unknown }>();
|
||||
const rpc = vi.fn((name: string, _params: unknown) =>
|
||||
Promise.resolve(rpcResponses.get(name) ?? { data: null, error: null }),
|
||||
);
|
||||
const supabase = {
|
||||
auth: { getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'me' } }, error: null }) },
|
||||
rpc,
|
||||
from: vi.fn(() => ({
|
||||
select: () => ({ in: () => Promise.resolve({ data: [], error: null }) }),
|
||||
})),
|
||||
};
|
||||
return { supabase, __setRpcResponse: (n: string, r: { data: unknown; error: unknown }) => rpcResponses.set(n, r) };
|
||||
});
|
||||
|
||||
describe('userIdentity', () => {
|
||||
beforeEach(() => { for (const k of Object.keys(memStore)) delete memStore[k]; });
|
||||
|
||||
it('setupNewUserIdentity uploads blob, caches private key in store', async () => {
|
||||
const supabaseMod = await import('./supabase');
|
||||
(supabaseMod as unknown as { __setRpcResponse: (n: string, r: { data: unknown; error: unknown }) => void })
|
||||
.__setRpcResponse('reset_user_key', { data: 0, error: null });
|
||||
const { setupNewUserIdentity, cachedUserKey } = await import('./userIdentity');
|
||||
const result = await setupNewUserIdentity({ userId: 'me', pin: '123456', withRecovery: true });
|
||||
expect(result.publicKey).toHaveLength(32);
|
||||
expect(result.recoveryCode).toMatch(/^[A-Z2-9]{6}-[A-Z2-9]{6}-[A-Z2-9]{6}-[A-Z2-9]{6}$/);
|
||||
const cached = await cachedUserKey('me');
|
||||
expect(cached).toBeInstanceOf(Uint8Array);
|
||||
expect(cached?.length).toBe(32);
|
||||
});
|
||||
|
||||
it('loadOrUnlockUserKey unlocks with correct PIN, throws on wrong PIN', async () => {
|
||||
const { setupNewUserIdentity, clearUserKeyCache, loadOrUnlockUserKey } =
|
||||
await import('./userIdentity');
|
||||
const supabaseMod = await import('./supabase');
|
||||
const setRpc = (supabaseMod as unknown as { __setRpcResponse: (n: string, r: { data: unknown; error: unknown }) => void }).__setRpcResponse;
|
||||
setRpc('reset_user_key', { data: 0, error: null });
|
||||
|
||||
await setupNewUserIdentity({ userId: 'me', pin: '123456', withRecovery: false });
|
||||
const rpc = (supabaseMod.supabase as unknown as { rpc: ReturnType<typeof vi.fn> }).rpc;
|
||||
const lastCall = rpc.mock.calls[rpc.mock.calls.length - 1]!;
|
||||
const params = lastCall[1] as Record<string, string>;
|
||||
|
||||
setRpc('try_unlock_user_key', {
|
||||
data: {
|
||||
exists: true, locked: false,
|
||||
sealed_private_key: params.p_sealed_private_b64,
|
||||
salt: params.p_salt_b64,
|
||||
kdf_params: params.p_kdf_params,
|
||||
recovery_sealed_private_key: null, recovery_salt: null,
|
||||
failed_attempts: 0, failed_recovery_attempts: 0,
|
||||
recovery_locked_until: null, key_version: 1,
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
setRpc('record_pin_attempt', { data: { failed_attempts: 0, locked_until: null }, error: null });
|
||||
|
||||
await clearUserKeyCache('me');
|
||||
const ok = await loadOrUnlockUserKey({ userId: 'me', pin: '123456' });
|
||||
expect(ok.kind).toBe('unlocked');
|
||||
|
||||
await clearUserKeyCache('me');
|
||||
await expect(loadOrUnlockUserKey({ userId: 'me', pin: '654321' })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
import {
|
||||
fetchUserKeyBlob, listOwnDevices, recordPinAttempt,
|
||||
resetUserKey, tryUnlockUserKey, uploadUserKeyBlob,
|
||||
} from '@chat-app/shared/auth';
|
||||
import {
|
||||
generateRecoveryCode, generateUserKeyPair, getCryptoBackend, normalizeRecoveryCode,
|
||||
openUserKey, sealUserKey,
|
||||
} from '@chat-app/shared/crypto';
|
||||
import { migrateOwnLegacyBundles } from '@chat-app/shared/chat';
|
||||
|
||||
import { devLocalSecretStore } from './secretStore';
|
||||
import { supabase } from './supabase';
|
||||
|
||||
const cacheKey = (userId: string) => `chatapp.userpriv.${userId}`;
|
||||
|
||||
export interface SetupParams { userId: string; pin: string; withRecovery: boolean }
|
||||
export interface SetupResult { publicKey: Uint8Array; recoveryCode: string | null }
|
||||
|
||||
export async function setupNewUserIdentity(p: SetupParams): Promise<SetupResult> {
|
||||
const kp = await generateUserKeyPair();
|
||||
const sealed = await sealUserKey({ privateKey: kp.privateKey, pin: p.pin });
|
||||
let recoveryCode: string | null = null;
|
||||
let recoverySealed: { sealedPrivateKey: Uint8Array; salt: Uint8Array } | null = null;
|
||||
if (p.withRecovery) {
|
||||
recoveryCode = await generateRecoveryCode();
|
||||
const r = await sealUserKey({ privateKey: kp.privateKey, pin: normalizeRecoveryCode(recoveryCode) });
|
||||
recoverySealed = { sealedPrivateKey: r.sealedPrivateKey, salt: r.salt };
|
||||
}
|
||||
await uploadUserKeyBlob(supabase, {
|
||||
userId: p.userId,
|
||||
publicKey: kp.publicKey,
|
||||
sealedPrivateKey: sealed.sealedPrivateKey,
|
||||
salt: sealed.salt,
|
||||
kdfParams: sealed.kdfParams,
|
||||
recoverySealedPrivateKey: recoverySealed?.sealedPrivateKey ?? null,
|
||||
recoverySalt: recoverySealed?.salt ?? null,
|
||||
});
|
||||
await devLocalSecretStore.setSecret(cacheKey(p.userId), kp.privateKey);
|
||||
void ensureLegacyMigrated(p.userId).catch((err) => {
|
||||
console.warn('legacy conv-key migration failed', err);
|
||||
});
|
||||
return { publicKey: kp.publicKey, recoveryCode };
|
||||
}
|
||||
|
||||
// Background, idempotent re-wrap of own legacy conv-key bundles for the new
|
||||
// per-user identity. Safe to call repeatedly: the underlying RPC uses
|
||||
// ON CONFLICT DO NOTHING. Triggered on every successful unlock so users who
|
||||
// upgraded to 0.18.0 (where the .eq(null) bug made setup-time migration a
|
||||
// no-op) auto-recover on the next launch.
|
||||
export async function ensureLegacyMigrated(userId: string): Promise<void> {
|
||||
const priv = await devLocalSecretStore.getSecret(cacheKey(userId));
|
||||
if (!priv) return;
|
||||
const pub = derivePublicKey(priv);
|
||||
await runLegacyMigration(userId, priv, pub);
|
||||
}
|
||||
|
||||
export interface UnlockParams { userId: string; pin: string; isRecoveryCode?: boolean }
|
||||
|
||||
export type UnlockOutcome =
|
||||
| { kind: 'unlocked' }
|
||||
| { kind: 'locked'; lockedUntil: string }
|
||||
| { kind: 'missing' };
|
||||
|
||||
export async function loadOrUnlockUserKey(p: UnlockParams): Promise<UnlockOutcome> {
|
||||
const remote = await tryUnlockUserKey(supabase, p.userId);
|
||||
if (!remote.exists) return { kind: 'missing' };
|
||||
if (remote.locked) return { kind: 'locked', lockedUntil: remote.lockedUntil };
|
||||
const secret = p.isRecoveryCode ? normalizeRecoveryCode(p.pin) : p.pin;
|
||||
const sealed = p.isRecoveryCode ? remote.recoverySealedPrivateKey : remote.sealedPrivateKey;
|
||||
const salt = p.isRecoveryCode ? remote.recoverySalt : remote.salt;
|
||||
if (!sealed || !salt) throw new Error('no recovery blob configured');
|
||||
let priv: Uint8Array;
|
||||
try {
|
||||
priv = await openUserKey({ sealed, pin: secret, salt, kdfParams: remote.kdfParams });
|
||||
} catch (err) {
|
||||
await recordPinAttempt(supabase, p.userId, false, p.isRecoveryCode === true).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
await recordPinAttempt(supabase, p.userId, true, p.isRecoveryCode === true).catch(() => {});
|
||||
await devLocalSecretStore.setSecret(cacheKey(p.userId), priv);
|
||||
void ensureLegacyMigrated(p.userId).catch((err) => {
|
||||
console.warn('legacy conv-key migration failed', err);
|
||||
});
|
||||
return { kind: 'unlocked' };
|
||||
}
|
||||
|
||||
export async function cachedUserKey(userId: string): Promise<Uint8Array | null> {
|
||||
return devLocalSecretStore.getSecret(cacheKey(userId));
|
||||
}
|
||||
|
||||
export async function clearUserKeyCache(userId: string): Promise<void> {
|
||||
await devLocalSecretStore.removeSecret(cacheKey(userId));
|
||||
}
|
||||
|
||||
export async function userKeyExistsRemotely(userId: string): Promise<boolean> {
|
||||
const blob = await fetchUserKeyBlob(supabase, userId);
|
||||
return blob !== null;
|
||||
}
|
||||
|
||||
export async function changePin(params: {
|
||||
userId: string; oldPin: string; newPin: string;
|
||||
}): Promise<void> {
|
||||
const cached = await cachedUserKey(params.userId);
|
||||
if (!cached) throw new Error('user key not cached locally — re-login required');
|
||||
const fresh = await sealUserKey({ privateKey: cached, pin: params.newPin });
|
||||
await uploadUserKeyBlob(supabase, {
|
||||
userId: params.userId,
|
||||
publicKey: derivePublicKey(cached),
|
||||
sealedPrivateKey: fresh.sealedPrivateKey,
|
||||
salt: fresh.salt,
|
||||
kdfParams: fresh.kdfParams,
|
||||
});
|
||||
void params.oldPin; // unused: cached key already proves old PIN was correct
|
||||
}
|
||||
|
||||
export async function regenerateRecoveryCode(params: { userId: string }): Promise<string> {
|
||||
const cached = await cachedUserKey(params.userId);
|
||||
if (!cached) throw new Error('user key not cached locally');
|
||||
const blob = await fetchUserKeyBlob(supabase, params.userId);
|
||||
if (!blob || !blob.exists || blob.locked) throw new Error('cannot regenerate recovery while locked');
|
||||
const recoveryCode = await generateRecoveryCode();
|
||||
const sealed = await sealUserKey({ privateKey: cached, pin: normalizeRecoveryCode(recoveryCode) });
|
||||
await uploadUserKeyBlob(supabase, {
|
||||
userId: params.userId,
|
||||
publicKey: derivePublicKey(cached),
|
||||
sealedPrivateKey: blob.sealedPrivateKey,
|
||||
salt: blob.salt,
|
||||
kdfParams: blob.kdfParams,
|
||||
recoverySealedPrivateKey: sealed.sealedPrivateKey,
|
||||
recoverySalt: sealed.salt,
|
||||
});
|
||||
return recoveryCode;
|
||||
}
|
||||
|
||||
export async function resetIdentity(params: { userId: string; pin: string }): Promise<string> {
|
||||
await clearUserKeyCache(params.userId);
|
||||
// resetUserKey already deletes legacy bundles; setupNewUserIdentity uploads
|
||||
// the brand-new blob via the same RPC (UPSERT). After this, no migration
|
||||
// pass runs because all legacy conv-keys are gone.
|
||||
await resetUserKey(supabase, {
|
||||
userId: params.userId,
|
||||
publicKey: new Uint8Array(32), // overwritten by next upload
|
||||
sealedPrivateKey: new Uint8Array(40),
|
||||
salt: new Uint8Array(16),
|
||||
kdfParams: { algo: 'argon2id', preset: 'moderate', opslimit: 1, memlimit: 1 },
|
||||
});
|
||||
const setup = await setupNewUserIdentity({ userId: params.userId, pin: params.pin, withRecovery: true });
|
||||
return setup.recoveryCode ?? '';
|
||||
}
|
||||
|
||||
// Returned by ensureLegacyMigrated and SecurityCenter's manual retry. Lets
|
||||
// the UI surface "X conv-keys re-wrapped, Y stuck because no key in vault."
|
||||
export interface LegacyMigrationReport {
|
||||
serverDevices: number;
|
||||
strongholdKeysFromServerDevices: number;
|
||||
strongholdKeysFromBundleScan: number;
|
||||
attempted: number;
|
||||
migrated: number;
|
||||
noStrongholdKey: number;
|
||||
decryptFailed: number;
|
||||
rpcFailed: number;
|
||||
}
|
||||
|
||||
async function runLegacyMigration(
|
||||
userId: string,
|
||||
ownNewPriv: Uint8Array,
|
||||
ownNewPub: Uint8Array,
|
||||
): Promise<LegacyMigrationReport> {
|
||||
const report: LegacyMigrationReport = {
|
||||
serverDevices: 0,
|
||||
strongholdKeysFromServerDevices: 0,
|
||||
strongholdKeysFromBundleScan: 0,
|
||||
attempted: 0,
|
||||
migrated: 0,
|
||||
noStrongholdKey: 0,
|
||||
decryptFailed: 0,
|
||||
rpcFailed: 0,
|
||||
};
|
||||
|
||||
const devices = await listOwnDevices(supabase);
|
||||
report.serverDevices = devices.length;
|
||||
const ownLegacyDevicePrivateKeys: Record<string, Uint8Array> = {};
|
||||
|
||||
// 1) Try every server-listed device first.
|
||||
for (const d of devices) {
|
||||
const k = await devLocalSecretStore.getSecret(`chatapp.priv.${userId}.${d.id}`);
|
||||
if (k) ownLegacyDevicePrivateKeys[d.id] = k;
|
||||
}
|
||||
report.strongholdKeysFromServerDevices = Object.keys(ownLegacyDevicePrivateKeys).length;
|
||||
|
||||
// 2) Scan our visible un-migrated conversation_keys for distinct
|
||||
// recipient_device_ids and probe stronghold for each. This catches the case
|
||||
// where a device row was deleted server-side but its key remains locally,
|
||||
// OR where listOwnDevices missed a device because of an RLS edge.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { data: scanRowsRaw } = await (supabase as any)
|
||||
.from('conversation_keys')
|
||||
.select('recipient_device_id')
|
||||
.is('recipient_user_id', null)
|
||||
.not('recipient_device_id', 'is', null);
|
||||
const scanIds = Array.from(new Set(
|
||||
((scanRowsRaw ?? []) as { recipient_device_id: string }[])
|
||||
.map((r) => r.recipient_device_id)
|
||||
.filter((id): id is string => !!id),
|
||||
));
|
||||
for (const id of scanIds) {
|
||||
if (ownLegacyDevicePrivateKeys[id]) continue;
|
||||
const k = await devLocalSecretStore.getSecret(`chatapp.priv.${userId}.${id}`);
|
||||
if (k) {
|
||||
ownLegacyDevicePrivateKeys[id] = k;
|
||||
report.strongholdKeysFromBundleScan += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.info(
|
||||
'[crypto-migration] vault scan:',
|
||||
'serverDevices=' + report.serverDevices,
|
||||
'keysFromServerList=' + report.strongholdKeysFromServerDevices,
|
||||
'extraKeysFromBundleScan=' + report.strongholdKeysFromBundleScan,
|
||||
);
|
||||
|
||||
const ids = Object.keys(ownLegacyDevicePrivateKeys);
|
||||
if (ids.length === 0) {
|
||||
console.warn('[crypto-migration] no legacy private keys in vault — nothing to migrate');
|
||||
return report;
|
||||
}
|
||||
|
||||
const result = await migrateOwnLegacyBundles({
|
||||
client: supabase,
|
||||
ownUserId: userId,
|
||||
ownNewPublicKey: ownNewPub,
|
||||
ownNewPrivateKey: ownNewPriv,
|
||||
ownLegacyDeviceIds: ids,
|
||||
ownLegacyDevicePrivateKeys,
|
||||
});
|
||||
|
||||
report.attempted = result.attempted;
|
||||
report.migrated = result.migratedConversations;
|
||||
report.noStrongholdKey = result.noStrongholdKey;
|
||||
report.decryptFailed = result.decryptFailed;
|
||||
report.rpcFailed = result.rpcFailed;
|
||||
return report;
|
||||
}
|
||||
|
||||
// Public wrapper for SecurityCenter's "Migration erneut versuchen" button.
|
||||
// Returns a structured report so the UI can render numbers and reasons.
|
||||
export async function retryLegacyMigration(userId: string): Promise<LegacyMigrationReport> {
|
||||
const priv = await devLocalSecretStore.getSecret(cacheKey(userId));
|
||||
if (!priv) throw new Error('user key not cached locally — re-login required');
|
||||
const pub = derivePublicKey(priv);
|
||||
return runLegacyMigration(userId, priv, pub);
|
||||
}
|
||||
|
||||
function derivePublicKey(privateKey: Uint8Array): Uint8Array {
|
||||
return getCryptoBackend().scalarMultBase(privateKey);
|
||||
}
|
||||
@@ -20,6 +20,13 @@ export interface VoiceHotkeyBinding {
|
||||
shift: boolean;
|
||||
alt: boolean;
|
||||
enabled: boolean;
|
||||
/**
|
||||
* When true, the hotkey is registered as an OS-level shortcut and fires
|
||||
* even when the app isn't focused. When false (default) the binding only
|
||||
* fires from the window's keydown listener — so e.g. setting "M" as mute
|
||||
* doesn't break typing "m" everywhere else on the system.
|
||||
*/
|
||||
global: boolean;
|
||||
}
|
||||
|
||||
export interface VoiceHotkeys {
|
||||
@@ -36,46 +43,11 @@ export interface VoiceHotkeys {
|
||||
export type VoiceHotkeyKind = keyof VoiceHotkeys;
|
||||
|
||||
const DEFAULTS: VoiceHotkeys = {
|
||||
mute: {
|
||||
key: 'KeyM',
|
||||
keyLabel: 'Ctrl+Shift+M',
|
||||
ctrl: true,
|
||||
shift: true,
|
||||
alt: false,
|
||||
enabled: false,
|
||||
},
|
||||
deafen: {
|
||||
key: 'KeyD',
|
||||
keyLabel: 'Ctrl+Shift+D',
|
||||
ctrl: true,
|
||||
shift: true,
|
||||
alt: false,
|
||||
enabled: false,
|
||||
},
|
||||
hangup: {
|
||||
key: 'KeyH',
|
||||
keyLabel: 'Ctrl+Shift+H',
|
||||
ctrl: true,
|
||||
shift: true,
|
||||
alt: false,
|
||||
enabled: false,
|
||||
},
|
||||
screenShare: {
|
||||
key: 'KeyE',
|
||||
keyLabel: 'Ctrl+Shift+E',
|
||||
ctrl: true,
|
||||
shift: true,
|
||||
alt: false,
|
||||
enabled: false,
|
||||
},
|
||||
video: {
|
||||
key: 'KeyV',
|
||||
keyLabel: 'Ctrl+Shift+V',
|
||||
ctrl: true,
|
||||
shift: true,
|
||||
alt: false,
|
||||
enabled: false,
|
||||
},
|
||||
mute: { key: 'KeyM', keyLabel: 'Ctrl+Shift+M', ctrl: true, shift: true, alt: false, enabled: false, global: false },
|
||||
deafen: { key: 'KeyD', keyLabel: 'Ctrl+Shift+D', ctrl: true, shift: true, alt: false, enabled: false, global: false },
|
||||
hangup: { key: 'KeyH', keyLabel: 'Ctrl+Shift+H', ctrl: true, shift: true, alt: false, enabled: false, global: false },
|
||||
screenShare: { key: 'KeyE', keyLabel: 'Ctrl+Shift+E', ctrl: true, shift: true, alt: false, enabled: false, global: false },
|
||||
video: { key: 'KeyV', keyLabel: 'Ctrl+Shift+V', ctrl: true, shift: true, alt: false, enabled: false, global: false },
|
||||
};
|
||||
|
||||
type Listener = (s: VoiceHotkeys) => void;
|
||||
@@ -93,6 +65,7 @@ function validateBinding(raw: unknown, fallback: VoiceHotkeyBinding): VoiceHotke
|
||||
shift: typeof b.shift === 'boolean' ? b.shift : fallback.shift,
|
||||
alt: typeof b.alt === 'boolean' ? b.alt : fallback.alt,
|
||||
enabled: typeof b.enabled === 'boolean' ? b.enabled : fallback.enabled,
|
||||
global: typeof b.global === 'boolean' ? b.global : fallback.global,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,11 @@ import { acceptDm, type ConversationSummary, isConversationMuted } from '@chat-a
|
||||
import { extractErrorCode } from '@chat-app/shared/i18n';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { NavLink, Outlet, useParams } from 'react-router-dom';
|
||||
import { NavLink, Outlet, useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
import { ConversationRowMenu } from '../components/ConversationRowMenu';
|
||||
import { CreateGroupDialog } from '../components/CreateGroupDialog';
|
||||
import { EmptyState } from '../components/EmptyState';
|
||||
import {
|
||||
AddUserIcon,
|
||||
ArchiveIcon,
|
||||
@@ -119,6 +120,7 @@ function ConversationList({
|
||||
archivedCount,
|
||||
}: ConversationListProps) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<aside
|
||||
@@ -223,27 +225,27 @@ function ConversationList({
|
||||
) : error ? (
|
||||
<p className="px-4 py-2 text-xs text-rose-500 dark:text-rose-300">{error}</p>
|
||||
) : items.length === 0 ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-line bg-surface-3 text-accent">
|
||||
{showArchived ? (
|
||||
<ArchiveIcon className="h-5 w-5" />
|
||||
) : (
|
||||
<ChatBubbleIcon className="h-5 w-5" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-fg-muted">
|
||||
{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>
|
||||
showArchived ? (
|
||||
<EmptyState
|
||||
icon={<ArchiveIcon className="h-8 w-8" />}
|
||||
title={t('app:chats.archived_empty_title', { defaultValue: 'Nichts archiviert' })}
|
||||
description={t('app:chats.archived_empty_subtitle', {
|
||||
defaultValue: 'Archivierte Unterhaltungen erscheinen hier.',
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={<ChatBubbleIcon className="h-8 w-8" />}
|
||||
title={t('app:chats.empty_title', { defaultValue: 'Noch keine Chats' })}
|
||||
description={t('app:chats.empty_desc', {
|
||||
defaultValue: 'Lade einen Freund ein und schreibe die erste Nachricht.',
|
||||
})}
|
||||
action={{
|
||||
label: t('app:chats.empty_cta', { defaultValue: 'Freunde verwalten' }),
|
||||
onClick: () => navigate('/friends'),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<VirtualConversationList
|
||||
items={items}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { parseMessagePayload } from '@chat-app/shared/chat';
|
||||
import { parseMessagePayload, pinMessage, unpinMessage } from '@chat-app/shared/chat';
|
||||
import { extractErrorCode } from '@chat-app/shared/i18n';
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -6,27 +6,32 @@ import { useParams } from 'react-router-dom';
|
||||
|
||||
import { ConversationHeader } from '../components/ConversationHeader';
|
||||
import { EmojiPicker } from '../components/EmojiPicker';
|
||||
import { EmptyState } from '../components/EmptyState';
|
||||
import { ForwardDialog } from '../components/ForwardDialog';
|
||||
import { GifPicker } from '../components/GifPicker';
|
||||
import { GroupInfoPanel } from '../components/GroupInfoPanel';
|
||||
import {
|
||||
AlertIcon,
|
||||
ArrowRightIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
EyeOffIcon,
|
||||
PlusIcon,
|
||||
PollIcon,
|
||||
ReplyIcon,
|
||||
SearchIcon,
|
||||
SendIcon,
|
||||
SmileIcon,
|
||||
SpinnerIcon,
|
||||
XIcon,
|
||||
} from '../components/icons';
|
||||
import { InCallPanel } from '../components/InCallPanel';
|
||||
import { IncomingCallPanel } from '../components/IncomingCallPanel';
|
||||
import { VoiceChannelRail } from '../components/VoiceChannelRail';
|
||||
import { CallPreviewPanel } from '../components/CallPreviewPanel';
|
||||
import { MediaFilesDrawer } from '../components/MediaFilesDrawer';
|
||||
import { MentionAutocomplete } from '../components/MentionAutocomplete';
|
||||
import { MessageBubble, type QuotedRef } from '../components/MessageBubble';
|
||||
import { PinnedMessagesPanel } from '../components/PinnedMessagesPanel';
|
||||
import { PollComposerDialog } from '../components/PollComposerDialog';
|
||||
import { UserProfilePopover } from '../components/UserProfilePopover';
|
||||
import { TypingIndicator } from '../components/TypingIndicator';
|
||||
@@ -37,7 +42,10 @@ import { useCall } from '../context/CallContext';
|
||||
import { useConversationsContext } from '../context/ConversationsContext';
|
||||
import { collectConversationAttachments, createPollPayload } from '../lib/conversationFeatures';
|
||||
import { compressImages } from '../lib/imageCompress';
|
||||
import { ensureInstallId } from '../lib/installId';
|
||||
import { searchCachedMessages } from '../lib/messageCache';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { type GifResult } from '../lib/tenor';
|
||||
import type { OutboxItem } from '../lib/messageOutbox';
|
||||
import { useConversationMessages } from '../lib/useConversationMessages';
|
||||
import { useMessageReactions } from '../lib/useMessageReactions';
|
||||
@@ -45,6 +53,7 @@ import { useGroupReceipts } from '../lib/useGroupReceipts';
|
||||
import { markDelivered, useMessageDeliveries } from '../lib/useMessageDeliveries';
|
||||
import { markRead as markMessagesReadRemote, useMessageReads } from '../lib/useMessageReads';
|
||||
import { usePeerPresence } from '../lib/usePeerPresence';
|
||||
import { usePinnedMessages } from '../lib/usePinnedMessages';
|
||||
import { useTypingChannel } from '../lib/useTypingChannel';
|
||||
|
||||
const STICK_THRESHOLD = 80;
|
||||
@@ -61,7 +70,7 @@ const scrollPositions = new Map<string, { scrollTop: number; stickToBottom: bool
|
||||
export function ConversationPage() {
|
||||
const { t } = useTranslation(['app', 'errors']);
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { session, device } = useAuth();
|
||||
const { session } = useAuth();
|
||||
const { conversations, setActiveConversation, markRead, unread } = useConversationsContext();
|
||||
|
||||
const conversation = useMemo(
|
||||
@@ -75,7 +84,7 @@ export function ConversationPage() {
|
||||
useConversationMessages({
|
||||
conversationId: id,
|
||||
userId: session?.user.id,
|
||||
deviceId: device?.id,
|
||||
deviceId: ensureInstallId(),
|
||||
});
|
||||
const messageIds = useMemo(() => messages.map((m) => m.id), [messages]);
|
||||
const {
|
||||
@@ -174,6 +183,47 @@ export function ConversationPage() {
|
||||
} | null>(null);
|
||||
const [mentionState, setMentionState] = useState<{ query: string; start: number } | null>(null);
|
||||
const [emojiOpen, setEmojiOpen] = useState(false);
|
||||
const [gifPickerOpen, setGifPickerOpen] = useState(false);
|
||||
// Sticky toggle: when on, the next image(s) sent are marked view-once.
|
||||
// Auto-clears on a successful send so the composer doesn't accidentally
|
||||
// burn the message-after-next.
|
||||
const [viewOnceNext, setViewOnceNext] = useState(false);
|
||||
const pins = usePinnedMessages(id);
|
||||
const [pinnedPanelOpen, setPinnedPanelOpen] = useState(false);
|
||||
const pinnedIds = useMemo(() => new Set(pins.map((p) => p.messageId)), [pins]);
|
||||
|
||||
const handleTogglePin = useCallback(
|
||||
async (messageId: string) => {
|
||||
if (!id || !myId) return;
|
||||
try {
|
||||
if (pinnedIds.has(messageId)) {
|
||||
await unpinMessage(supabase, id, messageId);
|
||||
} else {
|
||||
await pinMessage(supabase, id, messageId, myId);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('pin toggle failed', err);
|
||||
}
|
||||
},
|
||||
[id, myId, pinnedIds],
|
||||
);
|
||||
|
||||
const handleGifPick = useCallback(
|
||||
async (gif: GifResult) => {
|
||||
try {
|
||||
// Download the GIF bytes once and feed them into the existing
|
||||
// image-attachment pipeline so the result is end-to-end-encrypted
|
||||
// like any normal image.
|
||||
const res = await fetch(gif.url);
|
||||
const blob = await res.blob();
|
||||
const file = new File([blob], `tenor-${gif.id}.gif`, { type: 'image/gif' });
|
||||
await send('', [file], null);
|
||||
} catch (err) {
|
||||
console.warn('GIF send failed', err);
|
||||
}
|
||||
},
|
||||
[send],
|
||||
);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const loadMoreSentinelRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -491,10 +541,13 @@ export function ConversationPage() {
|
||||
setSending(true);
|
||||
setSendError(null);
|
||||
try {
|
||||
await send(text, attachments, replyTo?.id ?? null);
|
||||
await send(text, attachments, replyTo?.id ?? null, { viewOnce: viewOnceNext });
|
||||
setText('');
|
||||
setAttachments([]);
|
||||
setReplyTo(null);
|
||||
// Reset the sticky view-once flag so it only applies to the message
|
||||
// the user explicitly armed it for — Snapchat / WhatsApp parity.
|
||||
setViewOnceNext(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
setStickToBottom(true);
|
||||
notifyStopTyping();
|
||||
@@ -602,6 +655,8 @@ export function ConversationPage() {
|
||||
: {})}
|
||||
onSearchClick={() => setSearchOpen((v) => !v)}
|
||||
{...(isGroup ? { onInfoClick: () => setInfoPanelOpen(true) } : {})}
|
||||
pinnedCount={pins.length}
|
||||
onOpenPinned={() => setPinnedPanelOpen(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -653,7 +708,7 @@ export function ConversationPage() {
|
||||
{/* Discord-style persistent voice-channel rail. Always visible in groups
|
||||
so anyone can pop in without an invite-ring; hidden in 1:1s unless
|
||||
someone is already waiting. Hides automatically once we're in. */}
|
||||
{conversation && !incomingHere && <VoiceChannelRail conversation={conversation} />}
|
||||
{conversation && !incomingHere && <CallPreviewPanel conversation={conversation} />}
|
||||
{incomingHere && conversation && <IncomingCallPanel conversation={conversation} />}
|
||||
{conversation && <InCallPanel conversation={conversation} />}
|
||||
|
||||
@@ -669,7 +724,13 @@ export function ConversationPage() {
|
||||
) : error ? (
|
||||
<Banner>{error}</Banner>
|
||||
) : messages.length === 0 ? (
|
||||
<p className="text-center text-sm text-fg-muted">…</p>
|
||||
<EmptyState
|
||||
icon={<SendIcon className="h-8 w-8" />}
|
||||
title={t('app:chats.conv_empty_title', { defaultValue: 'Sag Hallo 👋' })}
|
||||
description={t('app:chats.conv_empty_desc', {
|
||||
defaultValue: 'Hier ist noch nichts. Schreibe die erste Nachricht.',
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-0.5">
|
||||
{displayCount < messages.length && (
|
||||
@@ -748,6 +809,8 @@ export function ConversationPage() {
|
||||
});
|
||||
}}
|
||||
highlighted={highlightedId === m.id}
|
||||
isPinned={pinnedIds.has(m.id)}
|
||||
onTogglePin={handleTogglePin}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
@@ -941,6 +1004,36 @@ export function ConversationPage() {
|
||||
onClose={() => setEmojiOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setGifPickerOpen((v) => !v)}
|
||||
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
|
||||
aria-label="GIF"
|
||||
title="GIF einfügen"
|
||||
>
|
||||
<span className="text-xs font-bold">GIF</span>
|
||||
</button>
|
||||
<GifPicker
|
||||
open={gifPickerOpen}
|
||||
onClose={() => setGifPickerOpen(false)}
|
||||
onPick={(gif) => void handleGifPick(gif)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewOnceNext((v) => !v)}
|
||||
aria-pressed={viewOnceNext}
|
||||
title={viewOnceNext ? 'Nächstes Bild: einmal ansehen' : 'Nächstes Bild: normal'}
|
||||
className={
|
||||
'flex h-9 w-9 cursor-pointer items-center justify-center rounded-md transition ' +
|
||||
(viewOnceNext
|
||||
? 'bg-accent/20 text-accent'
|
||||
: 'text-fg-muted hover:bg-surface-3 hover:text-fg')
|
||||
}
|
||||
>
|
||||
<EyeOffIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<VoiceRecorder
|
||||
disabled={sending}
|
||||
onComplete={async (file) => {
|
||||
@@ -1059,6 +1152,17 @@ export function ConversationPage() {
|
||||
onClose={() => setProfilePopover(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PinnedMessagesPanel
|
||||
open={pinnedPanelOpen}
|
||||
pins={pins}
|
||||
onClose={() => setPinnedPanelOpen(false)}
|
||||
onJump={(_messageId) => {
|
||||
// Future: scroll to message. For now just close the panel.
|
||||
setPinnedPanelOpen(false);
|
||||
}}
|
||||
onUnpin={(messageId) => void handleTogglePin(messageId)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,109 +1,32 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { DeviceRegistration } from '../components/DeviceRegistration';
|
||||
import { DeviceRestore } from '../components/DeviceRestore';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
type Mode = 'register' | 'restore';
|
||||
import { UserKeySetup } from '../components/UserKeySetup';
|
||||
import { UserKeyUnlock } from '../components/UserKeyUnlock';
|
||||
|
||||
export function DevicePage() {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { session, profile, setDevice } = useAuth();
|
||||
const { session, userKeyState, refreshUserKeyState } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [mode, setMode] = useState<Mode>('register');
|
||||
|
||||
if (!session) return null; // Guarded by RequireAuth, but be defensive.
|
||||
|
||||
const defaultName =
|
||||
(profile?.displayName ?? profile?.username ?? 'Desktop') + ' Desktop';
|
||||
|
||||
if (!session) return null;
|
||||
const onComplete = async () => {
|
||||
await refreshUserKeyState();
|
||||
navigate('/chats', { replace: true });
|
||||
};
|
||||
return (
|
||||
<main className="relative flex min-h-screen items-center justify-center overflow-hidden bg-ink-950 px-5 py-10 text-neutral-100 sm:px-8">
|
||||
<BackgroundStage />
|
||||
<div className="relative z-10 w-full max-w-md space-y-3">
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={t('app:backup.mode_label', { defaultValue: 'Gerätemodus' })}
|
||||
className="flex gap-1 rounded-xl border border-white/10 bg-ink-900/60 p-1 backdrop-blur-xl"
|
||||
>
|
||||
<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
|
||||
<main className="flex min-h-screen items-center justify-center bg-ink-950 p-6">
|
||||
<div className="w-full max-w-md">
|
||||
{userKeyState.status === 'needs-setup' && (
|
||||
<UserKeySetup userId={session.user.id} onComplete={onComplete} />
|
||||
)}
|
||||
{userKeyState.status === 'needs-unlock' && (
|
||||
<UserKeyUnlock
|
||||
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 });
|
||||
}}
|
||||
hasRecovery={userKeyState.hasRecovery}
|
||||
lockedUntil={userKeyState.lockedUntil}
|
||||
onUnlocked={onComplete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</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() {
|
||||
return (
|
||||
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
|
||||
<div className="bg-grid absolute inset-0 opacity-[0.28]" />
|
||||
<div className="absolute -left-32 top-1/4 h-[460px] w-[460px] rounded-full bg-brand-500/25 blur-3xl" />
|
||||
<div className="absolute -right-32 bottom-0 h-[460px] w-[460px] rounded-full bg-fuchsia-500/15 blur-3xl" />
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,transparent_45%,rgba(5,5,7,0.65)_100%)]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,12 +8,15 @@ import {
|
||||
sendFriendRequest,
|
||||
} from '@chat-app/shared/friends';
|
||||
import { extractErrorCode } from '@chat-app/shared/i18n';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Avatar } from '../components/Avatar';
|
||||
import { EmptyState } from '../components/EmptyState';
|
||||
import { NicknameDialog } from '../components/NicknameDialog';
|
||||
import {
|
||||
AddUserIcon,
|
||||
AlertIcon,
|
||||
ChatBubbleIcon,
|
||||
CheckCircleIcon,
|
||||
@@ -39,6 +42,12 @@ export function FriendsPage() {
|
||||
const [searchError, setSearchError] = useState<string | null>(null);
|
||||
const [pendingId, setPendingId] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [nicknameDialog, setNicknameDialog] = useState<{ userId: string; displayName: string } | null>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const focusSearch = useCallback(() => {
|
||||
searchInputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setTimeout(() => setDebounced(query.trim()), 250);
|
||||
@@ -119,6 +128,7 @@ export function FriendsPage() {
|
||||
<div className="relative">
|
||||
<SearchIcon className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-fg-muted" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
@@ -160,24 +170,44 @@ export function FriendsPage() {
|
||||
{loading ? (
|
||||
<LoadingRow />
|
||||
) : tab === 'friends' ? (
|
||||
<FriendList
|
||||
items={accepted}
|
||||
emptyKey="app:friends.empty_friends"
|
||||
renderActions={(f) => (
|
||||
<FriendActions
|
||||
busy={pendingId === f.peer.userId || pendingId === 'msg-' + f.peer.userId}
|
||||
onMessage={() =>
|
||||
performAction('msg-' + f.peer.userId, async () => {
|
||||
const id = await createDm(supabase, f.peer.userId);
|
||||
navigate('/chats/' + id);
|
||||
})
|
||||
}
|
||||
onUnfriend={() =>
|
||||
performAction(f.peer.userId, () => removeFriendship(supabase, f.peer.userId))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
accepted.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<AddUserIcon className="h-8 w-8" />}
|
||||
title={t('app:friends.empty_title', { defaultValue: 'Noch keine Freunde' })}
|
||||
description={t('app:friends.empty_desc', {
|
||||
defaultValue: 'Suche einen Friend per Username oder schicke eine Einladung.',
|
||||
})}
|
||||
action={{
|
||||
label: t('app:friends.empty_cta', { defaultValue: 'Friend hinzufügen' }),
|
||||
onClick: focusSearch,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<FriendList
|
||||
items={accepted}
|
||||
emptyKey="app:friends.empty_friends"
|
||||
onRowContextMenu={(f) =>
|
||||
setNicknameDialog({
|
||||
userId: f.peer.userId,
|
||||
displayName: f.peer.displayName ?? f.peer.username ?? 'Freund',
|
||||
})
|
||||
}
|
||||
renderActions={(f) => (
|
||||
<FriendActions
|
||||
busy={pendingId === f.peer.userId || pendingId === 'msg-' + f.peer.userId}
|
||||
onMessage={() =>
|
||||
performAction('msg-' + f.peer.userId, async () => {
|
||||
const id = await createDm(supabase, f.peer.userId);
|
||||
navigate('/chats/' + id);
|
||||
})
|
||||
}
|
||||
onUnfriend={() =>
|
||||
performAction(f.peer.userId, () => removeFriendship(supabase, f.peer.userId))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
) : tab === 'pending' ? (
|
||||
<FriendList
|
||||
items={outgoing}
|
||||
@@ -211,6 +241,12 @@ export function FriendsPage() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<NicknameDialog
|
||||
open={nicknameDialog !== null}
|
||||
userId={nicknameDialog?.userId ?? ''}
|
||||
displayName={nicknameDialog?.displayName ?? ''}
|
||||
onClose={() => setNicknameDialog(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -253,10 +289,12 @@ function FriendList({
|
||||
items,
|
||||
emptyKey,
|
||||
renderActions,
|
||||
onRowContextMenu,
|
||||
}: {
|
||||
items: Friendship[];
|
||||
emptyKey: string;
|
||||
renderActions: (f: Friendship) => React.ReactNode;
|
||||
onRowContextMenu?: (f: Friendship) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(['app']);
|
||||
if (items.length === 0) {
|
||||
@@ -274,16 +312,39 @@ function FriendList({
|
||||
<ul className="space-y-1.5">
|
||||
{items.map((f) => (
|
||||
<li key={f.peer.userId}>
|
||||
<FriendRow profile={f.peer}>{renderActions(f)}</FriendRow>
|
||||
<FriendRow
|
||||
profile={f.peer}
|
||||
{...(onRowContextMenu
|
||||
? {
|
||||
onContextMenu: (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
onRowContextMenu(f);
|
||||
},
|
||||
}
|
||||
: {})}
|
||||
>
|
||||
{renderActions(f)}
|
||||
</FriendRow>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function FriendRow({ profile, children }: { profile: ProfileBrief; children: React.ReactNode }) {
|
||||
function FriendRow({
|
||||
profile,
|
||||
children,
|
||||
onContextMenu,
|
||||
}: {
|
||||
profile: ProfileBrief;
|
||||
children: React.ReactNode;
|
||||
onContextMenu?: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-xl border border-line bg-surface-2 px-4 py-3">
|
||||
<div
|
||||
onContextMenu={onContextMenu}
|
||||
className="flex items-center gap-3 rounded-xl border border-line bg-surface-2 px-4 py-3"
|
||||
>
|
||||
<Avatar
|
||||
url={profile.avatarUrl}
|
||||
displayName={profile.displayName ?? profile.username}
|
||||
|
||||
@@ -8,17 +8,27 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Avatar } from '../components/Avatar';
|
||||
import { BackupExportDialog } from '../components/BackupExportDialog';
|
||||
import { BackupRestoreDialog } from '../components/BackupRestoreDialog';
|
||||
import {
|
||||
AtIcon,
|
||||
BellIcon,
|
||||
LockIcon,
|
||||
MicIcon,
|
||||
MonitorShareIcon,
|
||||
MusicIcon,
|
||||
ShieldIcon,
|
||||
SignOutIcon,
|
||||
SunIcon,
|
||||
UsersIcon,
|
||||
} from '../components/icons';
|
||||
import { MicTestSection } from '../components/MicTestSection';
|
||||
import { NotificationSoundSettings } from '../components/NotificationSoundSettings';
|
||||
import { RingtoneSettings } from '../components/RingtoneSettings';
|
||||
import { DeviceListTab } from '../components/settings/DeviceListTab';
|
||||
import { SecurityCenter } from '../components/SecurityCenter';
|
||||
import { SoundboardSettings } from '../components/SoundboardSettings';
|
||||
import { LockIcon } from '../components/icons';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useCall } from '../context/CallContext';
|
||||
import { useTheme } from '../context/ThemeContext';
|
||||
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
|
||||
import { isAutoStartEnabled, setAutoStart } from '../lib/autoStart';
|
||||
import {
|
||||
AVATAR_TARGET_DIM,
|
||||
@@ -34,7 +44,6 @@ import {
|
||||
} from '../lib/bannerUpload';
|
||||
import { ImageCropDialog } from '../components/ImageCropDialog';
|
||||
import { Lightbox } from '../components/Lightbox';
|
||||
import { devLocalSecretStore } from '../lib/secretStore';
|
||||
import {
|
||||
getPttSettings,
|
||||
keyCodeToLabel,
|
||||
@@ -83,7 +92,7 @@ const LOCALE_LABELS: Record<SupportedLocale, string> = {
|
||||
|
||||
export function SettingsPage() {
|
||||
const { t, i18n } = useTranslation(['app', 'common', 'auth']);
|
||||
const { profile, device, refreshProfile, signOut } = useAuth();
|
||||
const { profile, refreshProfile, signOut } = useAuth();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function patchProfile(patch: Parameters<typeof updateOwnProfile>[1]) {
|
||||
@@ -103,153 +112,279 @@ export function SettingsPage() {
|
||||
void patchProfile({ locale });
|
||||
}
|
||||
|
||||
// Tab pattern (macOS / Discord / GitHub style): the sidebar selects ONE
|
||||
// section and only that section renders. activeTab is the single source of
|
||||
// truth — no IntersectionObserver to drift, no smooth-scroll, no anchor-link
|
||||
// routing conflict with HashRouter.
|
||||
type TabId =
|
||||
| 'profile' | 'appearance' | 'privacy' | 'notifications'
|
||||
| 'voice' | 'screen-share' | 'soundboard' | 'security' | 'devices' | 'account';
|
||||
|
||||
const tabs: Array<{ id: TabId; label: string; Icon: typeof UsersIcon }> = [
|
||||
{ id: 'profile', label: t('app:settings.nav_profile', { defaultValue: 'Profil' }), Icon: UsersIcon },
|
||||
{ id: 'appearance', label: t('app:settings.nav_appearance', { defaultValue: 'Erscheinungsbild' }), Icon: SunIcon },
|
||||
{ id: 'privacy', label: t('app:settings.nav_privacy', { defaultValue: 'Privatsphäre' }), Icon: ShieldIcon },
|
||||
{ id: 'notifications', label: t('app:settings.nav_notifications', { defaultValue: 'Benachrichtigungen' }), Icon: BellIcon },
|
||||
{ id: 'voice', label: t('app:settings.nav_voice', { defaultValue: 'Sprache & Anrufe' }), Icon: MicIcon },
|
||||
{ id: 'screen-share', label: t('app:settings.nav_screen_share', { defaultValue: 'Bildschirmfreigabe' }), Icon: MonitorShareIcon },
|
||||
{ id: 'soundboard', label: t('app:settings.nav_soundboard', { defaultValue: 'Soundboard' }), Icon: MusicIcon },
|
||||
{ id: 'security', label: t('app:settings.nav_security', { defaultValue: 'Sicherheit' }), Icon: LockIcon },
|
||||
{ id: 'devices', label: t('app:settings.nav_devices', { defaultValue: 'Geräte' }), Icon: MonitorShareIcon },
|
||||
{ id: 'account', label: t('app:settings.nav_account', { defaultValue: 'Konto' }), Icon: SignOutIcon },
|
||||
];
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabId>('profile');
|
||||
|
||||
return (
|
||||
<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">
|
||||
<header className="mb-2">
|
||||
<h1 className="font-display text-2xl font-semibold tracking-tight text-fg">
|
||||
{t('app:settings.title')}
|
||||
</h1>
|
||||
</header>
|
||||
<div className="mx-auto grid max-w-6xl gap-8 px-6 py-8 lg:grid-cols-[14rem_minmax(0,1fr)]">
|
||||
{/* Sidebar */}
|
||||
<aside className="hidden lg:block">
|
||||
<div className="sticky top-8 space-y-1">
|
||||
<h1 className="mb-4 px-3 font-display text-2xl font-semibold tracking-tight text-fg">
|
||||
{t('app:settings.title')}
|
||||
</h1>
|
||||
<nav aria-label={t('app:settings.title')} role="tablist" aria-orientation="vertical">
|
||||
{tabs.map(({ id, label, Icon }) => {
|
||||
const active = activeTab === id;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
aria-controls={'settings-panel-' + id}
|
||||
onClick={() => setActiveTab(id)}
|
||||
className={
|
||||
'flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-3 py-2 text-left text-sm font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
(active
|
||||
? 'bg-accent/15 text-fg'
|
||||
: 'text-fg-muted hover:bg-surface-2 hover:text-fg')
|
||||
}
|
||||
>
|
||||
<Icon
|
||||
className={
|
||||
'h-4 w-4 shrink-0 ' + (active ? 'text-accent' : 'text-fg-muted')
|
||||
}
|
||||
/>
|
||||
<span className="truncate">{label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Account */}
|
||||
<Section title={t('app:settings.section_account')}>
|
||||
<ProfileVisualsControls patchProfile={patchProfile} busy={busy} />
|
||||
<Row label={t('auth:signed_in.username')} value={profile?.username ?? '—'} />
|
||||
<DisplayNameControls patchProfile={patchProfile} busy={busy} />
|
||||
<Row label={t('auth:signed_in.email')} value={profile?.userId ?? '—'} mono />
|
||||
</Section>
|
||||
{/* Content panel — only the active tab renders */}
|
||||
<main className="min-w-0">
|
||||
{/* Mobile-only header + tab selector (sidebar is hidden below lg) */}
|
||||
<div className="mb-6 space-y-3 lg:hidden">
|
||||
<h1 className="font-display text-2xl font-semibold tracking-tight text-fg">
|
||||
{t('app:settings.title')}
|
||||
</h1>
|
||||
<select
|
||||
value={activeTab}
|
||||
onChange={(e) => setActiveTab(e.target.value as TabId)}
|
||||
className="w-full cursor-pointer rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-medium text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
aria-label={t('app:settings.title')}
|
||||
>
|
||||
{tabs.map(({ id, label }) => (
|
||||
<option key={id} value={id}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Startup */}
|
||||
<Section title={t('app:settings.section_startup', { defaultValue: 'Start' })}>
|
||||
<AutoStartControls />
|
||||
</Section>
|
||||
<div
|
||||
id={'settings-panel-' + activeTab}
|
||||
role="tabpanel"
|
||||
aria-labelledby={'settings-tab-' + activeTab}
|
||||
>
|
||||
{activeTab === 'profile' && (
|
||||
<Section
|
||||
title={t('app:settings.section_account')}
|
||||
description={t('app:settings.section_account_hint', {
|
||||
defaultValue: 'Dein öffentliches Profil und wie andere dich sehen.',
|
||||
})}
|
||||
>
|
||||
<ProfileVisualsControls patchProfile={patchProfile} busy={busy} />
|
||||
<Row label={t('auth:signed_in.username')} value={profile?.username ?? '—'} />
|
||||
<DisplayNameControls patchProfile={patchProfile} busy={busy} />
|
||||
<Row icon={<AtIcon className="h-3.5 w-3.5" />} label={t('auth:signed_in.email')} value={profile?.userId ?? '—'} mono />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Appearance */}
|
||||
<Section title={t('app:settings.section_appearance')}>
|
||||
<ThemeRow />
|
||||
<SettingRow label={t('app:settings.language')}>
|
||||
<div className="inline-flex rounded-lg border border-line bg-surface-3 p-1">
|
||||
{SUPPORTED_LOCALES.map((locale) => {
|
||||
const active = (i18n.resolvedLanguage ?? i18n.language) === locale;
|
||||
return (
|
||||
{activeTab === 'appearance' && (
|
||||
<Section
|
||||
title={t('app:settings.section_appearance')}
|
||||
description={t('app:settings.section_appearance_hint', {
|
||||
defaultValue: 'Theme, Sprache und Verhalten beim Systemstart.',
|
||||
})}
|
||||
>
|
||||
<ThemeRow />
|
||||
<SettingRow label={t('app:settings.language')}>
|
||||
<div className="inline-flex rounded-lg border border-line bg-surface-3 p-1">
|
||||
{SUPPORTED_LOCALES.map((locale) => {
|
||||
const active = (i18n.resolvedLanguage ?? i18n.language) === locale;
|
||||
return (
|
||||
<button
|
||||
key={locale}
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void handleLocaleChange(locale)}
|
||||
className={
|
||||
'cursor-pointer rounded-md px-3 py-1.5 text-xs font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
(active
|
||||
? 'bg-accent text-accent-fg'
|
||||
: 'text-fg-muted hover:text-fg')
|
||||
}
|
||||
>
|
||||
{LOCALE_LABELS[locale]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SubGroup>
|
||||
<AutoStartControls />
|
||||
</SubGroup>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{activeTab === 'privacy' && (
|
||||
<Section
|
||||
title={t('app:settings.section_privacy')}
|
||||
description={t('app:settings.section_privacy_hint', {
|
||||
defaultValue: 'Wer dich kontaktieren darf und was Friends von dir sehen.',
|
||||
})}
|
||||
>
|
||||
<Toggle
|
||||
label={t('app:settings.show_read_receipts')}
|
||||
hint={t('app:settings.show_read_receipts_hint')}
|
||||
checked={profile?.showReadReceipts ?? true}
|
||||
disabled={busy || !profile}
|
||||
onChange={(v) => void patchProfile({ showReadReceipts: v })}
|
||||
/>
|
||||
<Toggle
|
||||
label={t('app:settings.allow_dms_strangers')}
|
||||
hint={t('app:settings.allow_dms_strangers_hint')}
|
||||
checked={profile?.allowDmsFromStrangers ?? true}
|
||||
disabled={busy || !profile}
|
||||
onChange={(v) => void patchProfile({ allowDmsFromStrangers: v })}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{activeTab === 'notifications' && (
|
||||
<Section
|
||||
title={t('app:settings.section_notifications', { defaultValue: 'Benachrichtigungen' })}
|
||||
description={t('app:settings.section_notifications_hint', {
|
||||
defaultValue: 'Töne für eingehende Nachrichten und Anrufe.',
|
||||
})}
|
||||
>
|
||||
<SubSection title={t('app:settings.subsection_message_sound', { defaultValue: 'Nachrichten-Ton' })}>
|
||||
<NotificationSoundSettings disabled={busy} />
|
||||
</SubSection>
|
||||
<SubSection title={t('app:settings.subsection_ringtone', { defaultValue: 'Klingelton bei Anruf' })}>
|
||||
<RingtoneSettings disabled={busy} />
|
||||
</SubSection>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{activeTab === 'voice' && (
|
||||
<Section
|
||||
title={t('app:settings.section_voice', { defaultValue: 'Sprache & Anrufe' })}
|
||||
description={t('app:settings.section_voice_hint', {
|
||||
defaultValue: 'Mikrofon, Audio-Qualität und Hotkeys für Anrufe.',
|
||||
})}
|
||||
>
|
||||
<SubSection title={t('app:settings.subsection_audio_device', { defaultValue: 'Audio-Gerät' })}>
|
||||
<AudioDeviceControls />
|
||||
</SubSection>
|
||||
<SubSection title={t('app:settings.subsection_audio_quality', { defaultValue: 'Audio-Qualität' })}>
|
||||
<AudioQualityControls />
|
||||
</SubSection>
|
||||
<SubSection title={t('app:settings.subsection_ptt', { defaultValue: 'Push-to-Talk' })}>
|
||||
<PttControls />
|
||||
</SubSection>
|
||||
<SubSection title={t('app:settings.subsection_hotkeys', { defaultValue: 'Hotkeys' })}>
|
||||
<div className="space-y-2">
|
||||
<VoiceHotkeyControls kind="mute" />
|
||||
<VoiceHotkeyControls kind="deafen" />
|
||||
<VoiceHotkeyControls kind="hangup" />
|
||||
<VoiceHotkeyControls kind="screenShare" />
|
||||
<VoiceHotkeyControls kind="video" />
|
||||
</div>
|
||||
</SubSection>
|
||||
<SubSection title={t('app:settings.subsection_call_e2ee', { defaultValue: 'Anruf-Verschlüsselung' })}>
|
||||
<CallE2EEControls />
|
||||
</SubSection>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{activeTab === 'screen-share' && (
|
||||
<Section
|
||||
title={t('app:settings.section_screen_share', { defaultValue: 'Bildschirmfreigabe' })}
|
||||
description={t('app:settings.section_screen_share_hint', {
|
||||
defaultValue: 'Auflösung und Bitrate beim Teilen deines Bildschirms.',
|
||||
})}
|
||||
>
|
||||
<ScreenShareControls />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{activeTab === 'soundboard' && (
|
||||
<Section
|
||||
title={t('app:settings.section_soundboard', { defaultValue: 'Soundboard' })}
|
||||
description={t('app:settings.section_soundboard_hint', {
|
||||
defaultValue: 'Eigene Sounds für Anrufe — verwaltet & abspielbar mit Hotkey.',
|
||||
})}
|
||||
>
|
||||
<SoundboardSettings />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{activeTab === 'security' && (
|
||||
<Section
|
||||
title={t('app:settings.section_security', { defaultValue: 'Sicherheit' })}
|
||||
description={t('app:settings.section_security_hint', {
|
||||
defaultValue: 'PIN, Recovery-Code und Schlüssel-Reparatur.',
|
||||
})}
|
||||
>
|
||||
{profile?.userId && <SecurityCenter userId={profile.userId} />}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{activeTab === 'devices' && (
|
||||
<Section
|
||||
title={t('app:settings.section_devices', { defaultValue: 'Geräte' })}
|
||||
description={t('app:settings.section_devices_hint', {
|
||||
defaultValue: 'Übersicht aller Geräte, die mit deinem Konto angemeldet sind.',
|
||||
})}
|
||||
>
|
||||
<DeviceListTab />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{activeTab === 'account' && (
|
||||
<Section
|
||||
title={t('app:settings.section_account_mgmt', { defaultValue: 'Konto verwalten' })}
|
||||
description={t('app:settings.section_account_mgmt_hint', {
|
||||
defaultValue: 'Abmelden oder Konto-Aktionen.',
|
||||
})}
|
||||
tone="danger"
|
||||
>
|
||||
<button
|
||||
key={locale}
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void handleLocaleChange(locale)}
|
||||
className={
|
||||
'cursor-pointer rounded-md px-3 py-1.5 text-xs font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
(active
|
||||
? 'bg-accent text-accent-fg'
|
||||
: 'text-fg-muted hover:text-fg')
|
||||
}
|
||||
onClick={() => void signOut()}
|
||||
className="inline-flex cursor-pointer items-center gap-2 rounded-lg border border-rose-500/40 bg-rose-500/10 px-4 py-2.5 text-sm font-semibold text-rose-600 transition hover:bg-rose-500/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/50 dark:text-rose-300"
|
||||
>
|
||||
{LOCALE_LABELS[locale]}
|
||||
<SignOutIcon className="h-4 w-4" />
|
||||
{t('app:settings.sign_out')}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
</SettingRow>
|
||||
</Section>
|
||||
|
||||
{/* Privacy */}
|
||||
<Section title={t('app:settings.section_privacy')}>
|
||||
<Toggle
|
||||
label={t('app:settings.show_read_receipts')}
|
||||
hint={t('app:settings.show_read_receipts_hint')}
|
||||
checked={profile?.showReadReceipts ?? true}
|
||||
disabled={busy || !profile}
|
||||
onChange={(v) => void patchProfile({ showReadReceipts: v })}
|
||||
/>
|
||||
<Toggle
|
||||
label={t('app:settings.allow_dms_strangers')}
|
||||
hint={t('app:settings.allow_dms_strangers_hint')}
|
||||
checked={profile?.allowDmsFromStrangers ?? true}
|
||||
disabled={busy || !profile}
|
||||
onChange={(v) => void patchProfile({ allowDmsFromStrangers: v })}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* Notification sound (new messages) */}
|
||||
<Section
|
||||
title={t('app:settings.section_notifications', { defaultValue: 'Benachrichtigungen' })}
|
||||
>
|
||||
<NotificationSoundSettings disabled={busy} />
|
||||
</Section>
|
||||
|
||||
{/* Ringtone (incoming custom) */}
|
||||
<Section title={t('app:settings.section_ringtone', { defaultValue: 'Klingelton' })}>
|
||||
<RingtoneSettings disabled={busy} />
|
||||
</Section>
|
||||
|
||||
{/* Soundboard */}
|
||||
<Section title={t('app:settings.section_soundboard', { defaultValue: 'Soundboard' })}>
|
||||
<SoundboardSettings />
|
||||
</Section>
|
||||
|
||||
{/* Voice / Push-to-Talk + Audio Quality + E2EE */}
|
||||
<Section title={t('app:settings.section_voice', { defaultValue: 'Sprache' })}>
|
||||
<AudioDeviceControls />
|
||||
<div className="mt-3 border-t border-line pt-3">
|
||||
<AudioQualityControls />
|
||||
</div>
|
||||
<div className="mt-3 border-t border-line pt-3">
|
||||
<PttControls />
|
||||
</div>
|
||||
<div className="mt-3 border-t border-line pt-3">
|
||||
<VoiceHotkeyControls kind="mute" />
|
||||
</div>
|
||||
<div className="mt-3 border-t border-line pt-3">
|
||||
<VoiceHotkeyControls kind="deafen" />
|
||||
</div>
|
||||
<div className="mt-3 border-t border-line pt-3">
|
||||
<VoiceHotkeyControls kind="hangup" />
|
||||
</div>
|
||||
<div className="mt-3 border-t border-line pt-3">
|
||||
<VoiceHotkeyControls kind="screenShare" />
|
||||
</div>
|
||||
<div className="mt-3 border-t border-line pt-3">
|
||||
<VoiceHotkeyControls kind="video" />
|
||||
</div>
|
||||
<div className="mt-3 border-t border-line pt-3">
|
||||
<CallE2EEControls />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Screen-share quality */}
|
||||
<Section title={t('app:settings.section_screen_share', { defaultValue: 'Bildschirmfreigabe' })}>
|
||||
<ScreenShareControls />
|
||||
</Section>
|
||||
|
||||
{/* Devices */}
|
||||
<Section title={t('app:settings.section_devices')}>
|
||||
{device && (
|
||||
<div className="rounded-xl border border-emerald-500/30 bg-emerald-500/10 p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-emerald-700 dark:text-emerald-200">
|
||||
<LockIcon className="h-4 w-4" />
|
||||
{t('app:settings.this_device')}
|
||||
</div>
|
||||
<dl className="mt-3 space-y-1.5 text-xs">
|
||||
<Row label={t('auth:signed_in.display_name')} value={device.name} />
|
||||
<Row label={t('auth:signed_in.device_platform')} value={device.platform} />
|
||||
<Row label={t('auth:signed_in.user_id')} value={device.id} mono />
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
<DeviceKeyBackupControls />
|
||||
</Section>
|
||||
|
||||
{/* Danger zone */}
|
||||
<Section title={t('app:settings.danger_zone')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void signOut()}
|
||||
className="cursor-pointer rounded-lg border border-rose-500/40 bg-rose-500/10 px-4 py-2.5 text-sm font-semibold text-rose-600 transition hover:bg-rose-500/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/50 dark:text-rose-300"
|
||||
>
|
||||
{t('app:settings.sign_out')}
|
||||
</button>
|
||||
</Section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -453,22 +588,44 @@ function VoiceHotkeyControls({ kind }: { kind: VoiceHotkeyKind }) {
|
||||
<SettingRow
|
||||
label={t('app:settings.hotkey_binding', { defaultValue: 'Hotkey' })}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCapturing((v) => !v)}
|
||||
className={
|
||||
'inline-flex min-w-[10rem] cursor-pointer items-center justify-center rounded-lg border px-3 py-1.5 text-xs font-mono font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
(capturing
|
||||
? 'border-accent bg-accent/20 text-fg animate-pulse'
|
||||
: 'border-line bg-surface-3 text-fg hover:brightness-95')
|
||||
}
|
||||
>
|
||||
{capturing
|
||||
? t('app:settings.hotkey_press_combo', {
|
||||
defaultValue: 'Kombination drücken…',
|
||||
})
|
||||
: binding.keyLabel}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCapturing((v) => !v)}
|
||||
className={
|
||||
'inline-flex min-w-[10rem] cursor-pointer items-center justify-center rounded-lg border px-3 py-1.5 text-xs font-mono font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
(capturing
|
||||
? 'border-accent bg-accent/20 text-fg animate-pulse'
|
||||
: 'border-line bg-surface-3 text-fg hover:brightness-95')
|
||||
}
|
||||
>
|
||||
{capturing
|
||||
? t('app:settings.hotkey_press_combo', {
|
||||
defaultValue: 'Kombination drücken…',
|
||||
})
|
||||
: binding.keyLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateVoiceHotkey(kind, { global: !binding.global })}
|
||||
disabled={!binding.enabled}
|
||||
aria-pressed={binding.global}
|
||||
title={
|
||||
binding.global
|
||||
? 'Global: feuert auch wenn Netralax nicht fokussiert ist (PTT-Stil)'
|
||||
: 'Nur im Fenster: feuert nur wenn Netralax fokussiert ist (empfohlen)'
|
||||
}
|
||||
className={
|
||||
'ml-2 inline-flex h-7 cursor-pointer items-center gap-1 rounded-md border px-2 text-[11px] font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50 ' +
|
||||
(binding.global
|
||||
? 'border-accent/40 bg-accent/15 text-accent'
|
||||
: 'border-line bg-surface-3 text-fg-muted hover:text-fg')
|
||||
}
|
||||
>
|
||||
<span aria-hidden>🌐</span>
|
||||
<span>{binding.global ? 'Global' : 'Im Fenster'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</SettingRow>
|
||||
</>
|
||||
);
|
||||
@@ -1094,92 +1251,6 @@ function ProfileVisualsControls({ patchProfile, busy }: AvatarControlsProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceKeyBackupControls() {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { profile, device } = useAuth();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [restoreOpen, setRestoreOpen] = useState(false);
|
||||
const [privateKey, setPrivateKey] = useState<Uint8Array | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const canRun = !!profile?.userId && !!device?.id;
|
||||
|
||||
async function handleOpen() {
|
||||
if (!canRun) return;
|
||||
setErr(null);
|
||||
try {
|
||||
const priv = await loadDevicePrivateKey(devLocalSecretStore, profile.userId, device.id);
|
||||
if (!priv) throw new Error(t('app:backup.no_key_here', {
|
||||
defaultValue: 'Kein Geräteschlüssel auf dieser Installation.',
|
||||
}));
|
||||
setPrivateKey(priv);
|
||||
setOpen(true);
|
||||
} catch (e: unknown) {
|
||||
setErr(e instanceof Error ? e.message : 'failed to load key');
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
setOpen(false);
|
||||
if (privateKey) {
|
||||
for (let i = 0; i < privateKey.length; i++) privateKey[i] = 0;
|
||||
}
|
||||
setPrivateKey(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4 rounded-xl border border-line bg-surface-3 p-4">
|
||||
<div className="text-sm font-semibold text-fg">
|
||||
{t('app:settings.device_key_backup', { defaultValue: 'Geräteschlüssel-Backup' })}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-fg-muted">
|
||||
{t('app:settings.device_key_backup_hint_v2', {
|
||||
defaultValue:
|
||||
'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>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canRun}
|
||||
onClick={() => void handleOpen()}
|
||||
className="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>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!profile?.userId}
|
||||
onClick={() => setRestoreOpen(true)}
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-semibold text-fg transition hover:bg-surface-3 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('app:backup.restore_open', { defaultValue: 'Backup wiederherstellen' })}
|
||||
</button>
|
||||
</div>
|
||||
{err && (
|
||||
<p className="mt-2 text-xs text-rose-600 dark:text-rose-300">{err}</p>
|
||||
)}
|
||||
|
||||
{open && profile && device && privateKey && (
|
||||
<BackupExportDialog
|
||||
open={open}
|
||||
userId={profile.userId}
|
||||
deviceId={device.id}
|
||||
privateKey={privateKey}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{profile && (
|
||||
<BackupRestoreDialog
|
||||
open={restoreOpen}
|
||||
userId={profile.userId}
|
||||
onClose={() => setRestoreOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScreenShareControls() {
|
||||
const { t } = useTranslation(['app']);
|
||||
const [cfg, setCfg] = useState<ScreenShareSettings>(() => getScreenShareSettings());
|
||||
@@ -1241,21 +1312,80 @@ function formatBitrate(kbps: number): string {
|
||||
return kbps + ' kbps';
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
function Section({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
tone,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
children: React.ReactNode;
|
||||
tone?: 'default' | 'danger';
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-2xl border border-line bg-surface-2 p-5">
|
||||
<h2 className="mb-4 text-xs font-semibold uppercase tracking-[0.1em] text-fg-muted">
|
||||
{title}
|
||||
</h2>
|
||||
<div className="space-y-3">{children}</div>
|
||||
<section
|
||||
className={
|
||||
'rounded-2xl border bg-surface-2 p-6 ' +
|
||||
(tone === 'danger' ? 'border-rose-500/30' : 'border-line')
|
||||
}
|
||||
>
|
||||
<header className="mb-5 border-b border-line pb-4">
|
||||
<h2 className={'font-display text-lg font-semibold ' + (tone === 'danger' ? 'text-rose-500 dark:text-rose-300' : 'text-fg')}>
|
||||
{title}
|
||||
</h2>
|
||||
{description && (
|
||||
<p className="mt-1 text-xs text-fg-muted">{description}</p>
|
||||
)}
|
||||
</header>
|
||||
<div className="space-y-4">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||
// Sub-heading inside a Section — used to chunk dense sections like Voice into
|
||||
// smaller named groups (Audio-Gerät / Qualität / PTT / Hotkeys / E2EE).
|
||||
// No border: `--color-line` is already a semi-transparent token, and applying
|
||||
// the `/60` opacity modifier brightens it (Tailwind overrides the original
|
||||
// alpha) which made the sub-cards look harsher than the outer Section. Plain
|
||||
// background tint + caps heading carry the grouping signal on their own.
|
||||
function SubSection({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-2 rounded-xl bg-surface-3/50 p-4">
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-[0.1em] text-fg-muted">
|
||||
{title}
|
||||
</h3>
|
||||
<div className="space-y-3">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Lighter wrapper for a single related extra control inside a Section that
|
||||
// doesn't warrant its own SubSection card (e.g., autostart toggle inside
|
||||
// Appearance).
|
||||
function SubGroup({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-3 border-t border-line pt-4">{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({
|
||||
label,
|
||||
value,
|
||||
mono,
|
||||
icon,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
mono?: boolean;
|
||||
icon?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<dt className="text-sm text-fg-muted">{label}</dt>
|
||||
<dt className="flex items-center gap-1.5 text-sm text-fg-muted">
|
||||
{icon}
|
||||
{label}
|
||||
</dt>
|
||||
<dd
|
||||
className={
|
||||
'max-w-[60%] truncate text-right text-sm text-fg ' +
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Copy to `.env.local` and fill in. Expo bundles only EXPO_PUBLIC_* vars
|
||||
# into the JS, which is what we want for these — they are public Supabase
|
||||
# project keys (anon key + URL) protected by RLS on the server.
|
||||
|
||||
EXPO_PUBLIC_SUPABASE_URL=https://your-supabase-host.example
|
||||
EXPO_PUBLIC_SUPABASE_ANON_KEY=sb_publishable_...
|
||||
EXPO_PUBLIC_AUTH_REDIRECT_URL=netralax://auth/callback
|
||||
+82
-14
@@ -1,27 +1,95 @@
|
||||
# @chat-app/mobile
|
||||
|
||||
Expo + React Native client for iOS and Android.
|
||||
Expo + React Native client for **Netralax** on iOS and Android.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node 22+, pnpm 9+
|
||||
- Xcode (iOS) / Android Studio (Android)
|
||||
- Optional: `npm i -g eas-cli` for cloud builds
|
||||
- Xcode (iOS) / Android Studio (Android) — only needed for local
|
||||
prebuild + emulator runs; EAS Build runs everything in the cloud.
|
||||
- An Expo account (free) — required for `eas init` below.
|
||||
- Optional: `npm i -g eas-cli` for the CLI commands. If you skip
|
||||
globals, prefix every `eas` invocation with `npx eas-cli`.
|
||||
|
||||
## Dev
|
||||
## Phase 0 quickstart
|
||||
|
||||
```bash
|
||||
# from repo root
|
||||
# From the repo root.
|
||||
pnpm install
|
||||
pnpm mobile:dev # expo start
|
||||
pnpm mobile:ios # native iOS run
|
||||
pnpm mobile:android # native Android run
|
||||
|
||||
# One-time: claim an EAS project ID. This rewrites the placeholder
|
||||
# `extra.eas.projectId` in app.json and links the local repo to your
|
||||
# Expo dashboard. Commit the resulting app.json change.
|
||||
cd apps/mobile
|
||||
npx eas-cli init
|
||||
|
||||
# Sign in to Expo if you haven't.
|
||||
npx eas-cli login
|
||||
|
||||
# Build a development client (custom dev-client APK + iOS simulator
|
||||
# bundle). First iOS build prompts for Apple ID — free signing works
|
||||
# for development distribution.
|
||||
npx eas-cli build --platform all --profile development
|
||||
```
|
||||
|
||||
## Notes
|
||||
When the builds finish, EAS gives you a QR code / install link. Install
|
||||
the dev client on your device, then start the Metro server from the repo
|
||||
root:
|
||||
|
||||
- Uses Expo Router (file-based). Screens live under `app/`.
|
||||
- Secrets stored via `expo-secure-store` (Keychain / Keystore).
|
||||
- Local encrypted history via `expo-sqlite`.
|
||||
- Crypto via `react-native-libsodium`.
|
||||
- Shared business logic lives in `@chat-app/shared`.
|
||||
```bash
|
||||
pnpm mobile:dev # = pnpm --filter @chat-app/mobile dev = expo start --dev-client
|
||||
```
|
||||
|
||||
Open the dev client on the device, scan the QR code, and Netralax's
|
||||
Landing screen should render.
|
||||
|
||||
## Day-to-day
|
||||
|
||||
```bash
|
||||
pnpm mobile:dev # Metro server
|
||||
pnpm mobile:ios # native iOS run (local Xcode)
|
||||
pnpm mobile:android # native Android run (local Android Studio)
|
||||
pnpm mobile:typecheck # tsc --noEmit
|
||||
```
|
||||
|
||||
## Architecture notes
|
||||
|
||||
- Expo Router (file-based) — screens live under `app/`.
|
||||
- `expo-secure-store` — Keychain / Keystore-backed secret store, wrapped
|
||||
by `lib/secretStore.ts` to implement `@chat-app/shared`'s `SecretStore`.
|
||||
- `@react-native-async-storage/async-storage` — Supabase session-token
|
||||
storage, surfaced via `lib/sessionStorage.ts`.
|
||||
- `expo-sqlite` — local encrypted history (slated for a later phase).
|
||||
- `react-native-libsodium` — crypto primitives, wrapped by
|
||||
`lib/cryptoBackend.ts` to implement `@chat-app/shared`'s `CryptoBackend`.
|
||||
Registered once at boot in `app/_layout.tsx`.
|
||||
- `@chat-app/shared` — business logic shared with the desktop; the mobile
|
||||
adapters plug into its `CryptoBackend` + `SecretStore` interfaces, and
|
||||
every chat / auth call goes through the namespace exports.
|
||||
|
||||
## Env vars
|
||||
|
||||
Copy `.env.example` to `.env.local` and fill in the same Supabase host +
|
||||
anon key the desktop uses. Expo bundles only `EXPO_PUBLIC_*`-prefixed
|
||||
vars into the JS, which is what the three required values use.
|
||||
|
||||
## EAS Builds and Environment Variables
|
||||
|
||||
Production and preview builds load `EXPO_PUBLIC_*` from EAS Secrets —
|
||||
`.env.local` is only honoured by `expo start` locally. Without the
|
||||
secrets configured, an APK installs but `env.ts` throws on first read
|
||||
and `<BootError>` renders.
|
||||
|
||||
Required secrets (create once per project):
|
||||
|
||||
```bash
|
||||
npx eas-cli secret:create --scope project --name EXPO_PUBLIC_SUPABASE_URL --value '<project-supabase-url>'
|
||||
npx eas-cli secret:create --scope project --name EXPO_PUBLIC_SUPABASE_ANON_KEY --value '<sb_publishable_key>'
|
||||
npx eas-cli secret:create --scope project --name EXPO_PUBLIC_AUTH_REDIRECT_URL --value 'netralax://auth/callback'
|
||||
```
|
||||
|
||||
Check with `npx eas-cli secret:list`.
|
||||
|
||||
## Roadmap
|
||||
|
||||
See `docs/superpowers/specs/2026-05-13-mobile-deployment-roadmap.md`.
|
||||
|
||||
+44
-10
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "ChatApp",
|
||||
"slug": "chat-app",
|
||||
"name": "Netralax",
|
||||
"slug": "netralax",
|
||||
"version": "0.1.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"scheme": "chatapp",
|
||||
"scheme": "netralax",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"newArchEnabled": true,
|
||||
"splash": {
|
||||
@@ -13,16 +13,33 @@
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#0b0b0f"
|
||||
},
|
||||
"assetBundlePatterns": ["**/*"],
|
||||
"assetBundlePatterns": [
|
||||
"**/*"
|
||||
],
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.meinname.chatapp",
|
||||
"bundleIdentifier": "cloud.netralax.app",
|
||||
"infoPlist": {
|
||||
"ITSAppUsesNonExemptEncryption": false
|
||||
}
|
||||
"ITSAppUsesNonExemptEncryption": false,
|
||||
"UIBackgroundModes": [
|
||||
"audio"
|
||||
],
|
||||
"NSMicrophoneUsageDescription": "Netralax nutzt das Mikrofon für Sprachanrufe."
|
||||
},
|
||||
"bitcode": false
|
||||
},
|
||||
"android": {
|
||||
"package": "com.meinname.chatapp",
|
||||
"package": "cloud.netralax.app",
|
||||
"permissions": [
|
||||
"android.permission.RECORD_AUDIO",
|
||||
"android.permission.ACCESS_NETWORK_STATE",
|
||||
"android.permission.CAMERA",
|
||||
"android.permission.INTERNET",
|
||||
"android.permission.MODIFY_AUDIO_SETTINGS",
|
||||
"android.permission.SYSTEM_ALERT_WINDOW",
|
||||
"android.permission.WAKE_LOCK",
|
||||
"android.permission.BLUETOOTH"
|
||||
],
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#0b0b0f"
|
||||
@@ -37,6 +54,19 @@
|
||||
{
|
||||
"color": "#0b0b0f"
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-image-picker",
|
||||
{
|
||||
"photosPermission": "Netralax greift auf deine Fotos zu, damit du sie in Nachrichten teilen kannst.",
|
||||
"cameraPermission": "Netralax nutzt die Kamera für Fotos in Nachrichten."
|
||||
}
|
||||
],
|
||||
[
|
||||
"@config-plugins/react-native-webrtc",
|
||||
{
|
||||
"microphonePermission": "Netralax nutzt das Mikrofon für Sprachanrufe."
|
||||
}
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
@@ -44,8 +74,12 @@
|
||||
},
|
||||
"extra": {
|
||||
"eas": {
|
||||
"projectId": "REPLACE_WITH_EAS_PROJECT_ID"
|
||||
"projectId": "255e2fde-3e27-4749-be53-8f0e13ed0ab0"
|
||||
},
|
||||
"router": {
|
||||
"origin": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"owner": "bygalax"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,42 @@
|
||||
// Authenticated app group layout.
|
||||
// Gate on session: if no session, redirect to `/`.
|
||||
// Add tab bar / drawer nav here once we have more than one screen.
|
||||
import { Redirect, Stack } from 'expo-router';
|
||||
import { ActivityIndicator, StyleSheet, View } from 'react-native';
|
||||
|
||||
import { Stack } from 'expo-router';
|
||||
import { useAuth } from '../../lib/authContext';
|
||||
import { colors } from '../../theme/colors';
|
||||
|
||||
export default function AppLayout() {
|
||||
return <Stack screenOptions={{ headerShown: true }} />;
|
||||
const { ready, session, userKeyState } = useAuth();
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<ActivityIndicator color={colors.accent} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (!session) return <Redirect href="/" />;
|
||||
if (userKeyState.status === 'loading') {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<ActivityIndicator color={colors.accent} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (userKeyState.status === 'needs-setup') return <Redirect href="/(app)/setup" />;
|
||||
if (userKeyState.status === 'needs-unlock') return <Redirect href="/(app)/unlock" />;
|
||||
|
||||
return (
|
||||
<Stack screenOptions={{ headerStyle: { backgroundColor: colors.bg } }}>
|
||||
<Stack.Screen name="chats" />
|
||||
<Stack.Screen name="conversations/[id]" />
|
||||
<Stack.Screen name="call" options={{ presentation: 'fullScreenModal' }} />
|
||||
<Stack.Screen name="settings" options={{ presentation: 'card' }} />
|
||||
<Stack.Screen name="setup" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="unlock" options={{ headerShown: false }} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
center: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.bg },
|
||||
});
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import { Avatar } from '../../components/Avatar';
|
||||
import { useCall } from '../../lib/callContext';
|
||||
import { colors } from '../../theme/colors';
|
||||
|
||||
export default function CallScreen() {
|
||||
const router = useRouter();
|
||||
const { state, toggleMute, toggleSpeaker, endCall } = useCall();
|
||||
const [seconds, setSeconds] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.kind !== 'connected') return;
|
||||
setSeconds(0);
|
||||
const id = setInterval(() => setSeconds((s) => s + 1), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [state.kind]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.kind === 'idle') {
|
||||
router.back();
|
||||
}
|
||||
}, [state.kind, router]);
|
||||
|
||||
const duration = useMemo(() => {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return [m, s].map((n) => String(n).padStart(2, '0')).join(':');
|
||||
}, [seconds]);
|
||||
|
||||
if (state.kind === 'idle' || state.kind === 'ended') {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Anruf beendet</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.kind === 'incoming') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const connecting = state.kind === 'connecting' || state.kind === 'outgoing';
|
||||
const participants = state.kind === 'connected' ? state.participants : [];
|
||||
const muted = state.kind === 'connected' ? state.muted : false;
|
||||
const speakerOn = state.kind === 'connected' ? state.speakerOn : false;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{connecting ? 'Verbinde …' : 'Anruf läuft'}</Text>
|
||||
{state.kind === 'connected' && <Text style={styles.duration}>{duration}</Text>}
|
||||
</View>
|
||||
|
||||
<View style={styles.participantList}>
|
||||
{participants.length === 0 && (
|
||||
<Text style={styles.empty}>Warten auf Teilnehmer …</Text>
|
||||
)}
|
||||
{participants.map((p) => (
|
||||
<View key={p.identity} style={styles.participantRow}>
|
||||
<Avatar name={p.name} size={48} />
|
||||
<View style={styles.participantText}>
|
||||
<Text style={styles.participantName}>{p.name}</Text>
|
||||
<Text style={[styles.participantStatus, p.speaking && styles.participantSpeaking]}>
|
||||
{p.speaking ? 'Spricht …' : 'Stumm'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={styles.toolbar}>
|
||||
<ToolbarButton
|
||||
label={muted ? 'Stumm' : 'Mikro'}
|
||||
active={muted}
|
||||
onPress={() => {
|
||||
void toggleMute();
|
||||
}}
|
||||
/>
|
||||
<ToolbarButton
|
||||
label={speakerOn ? 'Lautsprecher' : 'Hörer'}
|
||||
active={speakerOn}
|
||||
onPress={() => {
|
||||
void toggleSpeaker();
|
||||
}}
|
||||
/>
|
||||
<Pressable
|
||||
style={styles.hangup}
|
||||
onPress={() => {
|
||||
void endCall();
|
||||
}}
|
||||
>
|
||||
<Text style={styles.hangupText}>Auflegen</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarButton({
|
||||
label,
|
||||
active,
|
||||
onPress,
|
||||
}: {
|
||||
label: string;
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={[styles.toolbarButton, active && styles.toolbarButtonActive]}
|
||||
>
|
||||
<Text style={[styles.toolbarButtonText, active && styles.toolbarButtonTextActive]}>
|
||||
{label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.bg, padding: 24 },
|
||||
header: { alignItems: 'center', marginTop: 24, gap: 6 },
|
||||
title: { color: colors.text, fontSize: 24, fontWeight: '700' },
|
||||
duration: { color: colors.textMuted, fontSize: 16, fontVariant: ['tabular-nums'] },
|
||||
participantList: { flex: 1, marginTop: 24, gap: 12 },
|
||||
empty: { color: colors.textMuted, textAlign: 'center', marginTop: 32 },
|
||||
participantRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
backgroundColor: colors.surface,
|
||||
padding: 12,
|
||||
borderRadius: 12,
|
||||
borderColor: colors.border,
|
||||
borderWidth: 1,
|
||||
},
|
||||
participantText: { flex: 1 },
|
||||
participantName: { color: colors.text, fontSize: 16, fontWeight: '600' },
|
||||
participantStatus: { color: colors.textMuted, fontSize: 12, marginTop: 2 },
|
||||
participantSpeaking: { color: colors.success, fontWeight: '700' },
|
||||
toolbar: { flexDirection: 'row', gap: 12, paddingBottom: 16 },
|
||||
toolbarButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 14,
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderWidth: 1,
|
||||
alignItems: 'center',
|
||||
},
|
||||
toolbarButtonActive: { backgroundColor: colors.accentMuted, borderColor: colors.accent },
|
||||
toolbarButtonText: { color: colors.text, fontWeight: '600' },
|
||||
toolbarButtonTextActive: { color: colors.accent },
|
||||
hangup: {
|
||||
flex: 1,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 14,
|
||||
backgroundColor: colors.danger,
|
||||
alignItems: 'center',
|
||||
},
|
||||
hangupText: { color: colors.text, fontWeight: '700' },
|
||||
});
|
||||
@@ -1,18 +1,115 @@
|
||||
// Chats list placeholder.
|
||||
// Milestone 1 TODO: render list of conversations, decrypt last-message preview,
|
||||
// navigate to a conversation screen on tap.
|
||||
import { chat } from '@chat-app/shared';
|
||||
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||
import { Stack, useRouter } from 'expo-router';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { ConversationRow } from '../../components/ConversationRow';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { colors } from '../../theme/colors';
|
||||
|
||||
// Phase 1 conversation list. The last-message preview is the static
|
||||
// fallback ('…') — fetching + decrypting last-messages per conversation
|
||||
// is a Phase 1.5 follow-up. Tap → conversation detail.
|
||||
export default function Chats() {
|
||||
const router = useRouter();
|
||||
const [list, setList] = useState<ConversationSummary[] | null>(null);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const result = await chat.listConversations(supabase);
|
||||
setList(result.filter((c) => !c.archived));
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Konversationen konnten nicht geladen werden');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const onRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
await load();
|
||||
setRefreshing(false);
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.text}>Chats — placeholder</Text>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: 'Chats',
|
||||
headerStyle: { backgroundColor: colors.bg },
|
||||
headerTitleStyle: { color: colors.text },
|
||||
headerRight: () => (
|
||||
<Pressable onPress={() => router.push('/(app)/settings')} hitSlop={10}>
|
||||
<Text style={styles.logoutLink}>Einstellungen</Text>
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
{list === null && !error && (
|
||||
<View style={styles.loading}>
|
||||
<ActivityIndicator color={colors.accent} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{error && <Text style={styles.error}>{error}</Text>}
|
||||
|
||||
{list && list.length === 0 && !error && (
|
||||
<View style={styles.empty}>
|
||||
<Text style={styles.emptyText}>Noch keine Konversationen.</Text>
|
||||
<Text style={styles.emptyHint}>
|
||||
Lege dir auf dem Desktop einen Chat an — er taucht hier auf, sobald du
|
||||
herunterziehst, um zu aktualisieren.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{list && list.length > 0 && (
|
||||
<FlatList
|
||||
data={list}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => (
|
||||
<ConversationRow
|
||||
conversation={item}
|
||||
lastMessagePreview="…"
|
||||
onPress={() => router.push('/(app)/conversations/' + item.id)}
|
||||
/>
|
||||
)}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={colors.accent}
|
||||
/>
|
||||
}
|
||||
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: '#0b0b0f' },
|
||||
text: { color: '#fff' },
|
||||
container: { flex: 1, backgroundColor: colors.bg },
|
||||
loading: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
||||
empty: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32, gap: 8 },
|
||||
emptyText: { color: colors.text, fontSize: 16, fontWeight: '600' },
|
||||
emptyHint: { color: colors.textMuted, fontSize: 13, textAlign: 'center', lineHeight: 18 },
|
||||
error: { color: colors.danger, padding: 16, textAlign: 'center' },
|
||||
separator: { height: 1, backgroundColor: colors.border, marginLeft: 68 },
|
||||
logoutLink: { color: colors.accent, fontSize: 14, fontWeight: '600', paddingHorizontal: 8 },
|
||||
});
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
import { chat } from '@chat-app/shared';
|
||||
import type {
|
||||
ConversationSummary,
|
||||
DecryptedMessage,
|
||||
MessageReaction,
|
||||
} from '@chat-app/shared/chat';
|
||||
import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ActionSheetIOS,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
FlatList,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
import { MessageActionsSheet } from '../../../components/MessageActionsSheet';
|
||||
import { MessageBubble } from '../../../components/MessageBubble';
|
||||
import { useAuth } from '../../../lib/authContext';
|
||||
import { useCall } from '../../../lib/callContext';
|
||||
import { captureFromCamera, pickFromLibrary, type PickedImage } from '../../../lib/imagePicker';
|
||||
import { supabase } from '../../../lib/supabase';
|
||||
import { colors } from '../../../theme/colors';
|
||||
|
||||
export default function ConversationDetail() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const { user, userId, ownPrivateKey } = useAuth();
|
||||
const { state: callState, startCall } = useCall();
|
||||
const router = useRouter();
|
||||
|
||||
// When a call moves into `connected`, jump to the in-call screen so
|
||||
// the user can see participants + controls. The /call screen pops
|
||||
// itself when callState returns to `idle`.
|
||||
useEffect(() => {
|
||||
if (callState.kind === 'connected') {
|
||||
router.push('/(app)/call');
|
||||
}
|
||||
}, [callState.kind, router]);
|
||||
|
||||
const [conversation, setConversation] = useState<ConversationSummary | null>(null);
|
||||
const [messages, setMessages] = useState<DecryptedMessage[] | null>(null);
|
||||
const [reactions, setReactions] = useState<Map<string, MessageReaction[]>>(new Map());
|
||||
const [text, setText] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [replyTo, setReplyTo] = useState<DecryptedMessage | null>(null);
|
||||
const [activeMessage, setActiveMessage] = useState<DecryptedMessage | null>(null);
|
||||
const listRef = useRef<FlatList<DecryptedMessage>>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!id || !userId || !ownPrivateKey) return;
|
||||
setError(null);
|
||||
try {
|
||||
const all = await chat.listConversations(supabase);
|
||||
setConversation(all.find((c) => c.id === id) ?? null);
|
||||
const ciphers = await chat.fetchConversationMessages(supabase, id, 50);
|
||||
const decrypted = await chat.decryptMessages({
|
||||
client: supabase,
|
||||
messages: ciphers,
|
||||
ownUserId: userId,
|
||||
ownPrivateKey,
|
||||
});
|
||||
setMessages(decrypted);
|
||||
const rows = await chat.listReactionsForMessages(
|
||||
supabase,
|
||||
decrypted.map((m) => m.id),
|
||||
);
|
||||
const map = new Map<string, MessageReaction[]>();
|
||||
for (const r of rows) {
|
||||
const arr = map.get(r.messageId) ?? [];
|
||||
arr.push(r);
|
||||
map.set(r.messageId, arr);
|
||||
}
|
||||
setReactions(map);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Nachrichten konnten nicht geladen werden');
|
||||
}
|
||||
}, [id, userId, ownPrivateKey]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const members = useMemo(() => conversation?.members ?? [], [conversation]);
|
||||
const senderName = useCallback(
|
||||
(senderId: string) => {
|
||||
if (senderId === user?.id) return 'Du';
|
||||
const m = members.find((mm) => mm.userId === senderId);
|
||||
return m?.profile?.displayName ?? m?.profile?.username ?? 'Unbekannt';
|
||||
},
|
||||
[members, user],
|
||||
);
|
||||
|
||||
const title =
|
||||
conversation?.type === 'group'
|
||||
? (conversation.name ?? 'Gruppe')
|
||||
: (conversation?.peer?.displayName ?? '…');
|
||||
|
||||
async function handleSendText() {
|
||||
if (!text.trim() || !user || !ownPrivateKey || !id || sending) return;
|
||||
setSending(true);
|
||||
setError(null);
|
||||
const replyToId = replyTo?.id;
|
||||
try {
|
||||
await chat.sendEncryptedMessage({
|
||||
client: supabase,
|
||||
conversationId: id,
|
||||
plaintext: text.trim(),
|
||||
senderUserId: user.id,
|
||||
senderPrivateKey: ownPrivateKey,
|
||||
...(replyToId ? { replyToId } : {}),
|
||||
});
|
||||
setText('');
|
||||
setReplyTo(null);
|
||||
await load();
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Senden fehlgeschlagen');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendImage(pick: PickedImage) {
|
||||
if (!user || !ownPrivateKey || !id) return;
|
||||
setSending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const resp = await fetch(pick.uri);
|
||||
const blob = await resp.blob();
|
||||
const result = await chat.encryptAndUploadAttachment({
|
||||
client: supabase,
|
||||
conversationId: id,
|
||||
file: blob,
|
||||
mimeType: pick.mimeType,
|
||||
sizeBytes: pick.sizeBytes,
|
||||
width: pick.width,
|
||||
height: pick.height,
|
||||
});
|
||||
await chat.sendEncryptedMessage({
|
||||
client: supabase,
|
||||
conversationId: id,
|
||||
plaintext: '',
|
||||
senderUserId: user.id,
|
||||
senderPrivateKey: ownPrivateKey,
|
||||
attachmentHandles: [result.handle],
|
||||
});
|
||||
await load();
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Bild senden fehlgeschlagen');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openImageMenu() {
|
||||
if (Platform.OS === 'ios') {
|
||||
ActionSheetIOS.showActionSheetWithOptions(
|
||||
{ options: ['Foto aufnehmen', 'Aus Galerie wählen', 'Abbrechen'], cancelButtonIndex: 2 },
|
||||
async (idx) => {
|
||||
if (idx === 0) {
|
||||
const p = await captureFromCamera();
|
||||
if (p) await sendImage(p);
|
||||
} else if (idx === 1) {
|
||||
const p = await pickFromLibrary();
|
||||
if (p) await sendImage(p);
|
||||
}
|
||||
},
|
||||
);
|
||||
} else {
|
||||
Alert.alert('Bild senden', undefined, [
|
||||
{
|
||||
text: 'Foto aufnehmen',
|
||||
onPress: async () => {
|
||||
const p = await captureFromCamera();
|
||||
if (p) await sendImage(p);
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'Aus Galerie wählen',
|
||||
onPress: async () => {
|
||||
const p = await pickFromLibrary();
|
||||
if (p) await sendImage(p);
|
||||
},
|
||||
},
|
||||
{ text: 'Abbrechen', style: 'cancel' },
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReact(emoji: string, messageId: string) {
|
||||
if (!user) return;
|
||||
try {
|
||||
const mine = reactions.get(messageId)?.some((r) => r.userId === user.id && r.emoji === emoji);
|
||||
if (mine) {
|
||||
await chat.removeReaction(supabase, messageId, emoji);
|
||||
} else {
|
||||
await chat.addReaction(supabase, messageId, emoji);
|
||||
}
|
||||
const rows = await chat.listReactionsForMessages(
|
||||
supabase,
|
||||
(messages ?? []).map((m) => m.id),
|
||||
);
|
||||
const map = new Map<string, MessageReaction[]>();
|
||||
for (const r of rows) {
|
||||
const arr = map.get(r.messageId) ?? [];
|
||||
arr.push(r);
|
||||
map.set(r.messageId, arr);
|
||||
}
|
||||
setReactions(map);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Reaktion fehlgeschlagen');
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete(messageId: string) {
|
||||
Alert.alert('Nachricht löschen', 'Diese Nachricht für alle löschen?', [
|
||||
{ text: 'Abbrechen', style: 'cancel' },
|
||||
{
|
||||
text: 'Löschen',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
await chat.softDeleteMessage(supabase, messageId);
|
||||
await load();
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Löschen fehlgeschlagen');
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
const parentLookup = useMemo(() => {
|
||||
const m = new Map<string, DecryptedMessage>();
|
||||
for (const msg of messages ?? []) m.set(msg.id, msg);
|
||||
return m;
|
||||
}, [messages]);
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={styles.container}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
keyboardVerticalOffset={Platform.OS === 'ios' ? 80 : 0}
|
||||
>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title,
|
||||
headerStyle: { backgroundColor: colors.bg },
|
||||
headerTitleStyle: { color: colors.text },
|
||||
headerBackTitle: 'Chats',
|
||||
headerRight: () => (
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
if (!id) return;
|
||||
void startCall(id);
|
||||
}}
|
||||
hitSlop={10}
|
||||
style={styles.callBtn}
|
||||
>
|
||||
<Text style={styles.callBtnText}>📞</Text>
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
{messages === null && !error && (
|
||||
<View style={styles.loading}>
|
||||
<ActivityIndicator color={colors.accent} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{error && <Text style={styles.error}>{error}</Text>}
|
||||
|
||||
{messages && (
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
data={messages}
|
||||
keyExtractor={(m) => m.id}
|
||||
contentContainerStyle={styles.listContent}
|
||||
renderItem={({ item }) => {
|
||||
const parent = item.replyToId ? (parentLookup.get(item.replyToId) ?? null) : null;
|
||||
return (
|
||||
<MessageBubble
|
||||
message={item}
|
||||
mine={item.senderId === user?.id}
|
||||
senderName={senderName(item.senderId)}
|
||||
parent={parent}
|
||||
parentSenderName={parent ? senderName(parent.senderId) : ''}
|
||||
time={new Date(item.createdAt).toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
reactions={reactions.get(item.id) ?? []}
|
||||
myUserId={user?.id ?? null}
|
||||
ownPrivateKey={ownPrivateKey}
|
||||
onLongPress={() => setActiveMessage(item)}
|
||||
onToggleReaction={(emoji) => {
|
||||
void handleReact(emoji, item.id);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: false })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{replyTo && (
|
||||
<View style={styles.replyBanner}>
|
||||
<View style={styles.replyBannerLeft}>
|
||||
<Text style={styles.replyBannerLabel}>
|
||||
Antwort an {senderName(replyTo.senderId)}
|
||||
</Text>
|
||||
<Text style={styles.replyBannerBody} numberOfLines={1}>
|
||||
{replyTo.plaintext ?? '…'}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable onPress={() => setReplyTo(null)} hitSlop={10}>
|
||||
<Text style={styles.replyBannerClose}>×</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.inputRow}>
|
||||
<Pressable
|
||||
style={styles.plusBtn}
|
||||
onPress={openImageMenu}
|
||||
disabled={sending}
|
||||
hitSlop={6}
|
||||
>
|
||||
<Text style={styles.plusText}>+</Text>
|
||||
</Pressable>
|
||||
<TextInput
|
||||
value={text}
|
||||
onChangeText={setText}
|
||||
placeholder="Nachricht schreiben…"
|
||||
placeholderTextColor={colors.textDim}
|
||||
style={styles.input}
|
||||
multiline
|
||||
editable={!sending}
|
||||
/>
|
||||
<Pressable
|
||||
style={[styles.sendBtn, (!text.trim() || sending) && styles.sendBtnDisabled]}
|
||||
disabled={!text.trim() || sending}
|
||||
onPress={handleSendText}
|
||||
>
|
||||
{sending ? (
|
||||
<ActivityIndicator color={colors.text} />
|
||||
) : (
|
||||
<Text style={styles.sendBtnText}>Senden</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<MessageActionsSheet
|
||||
visible={activeMessage !== null}
|
||||
mine={activeMessage?.senderId === user?.id}
|
||||
onClose={() => setActiveMessage(null)}
|
||||
onReact={(emoji) => {
|
||||
if (activeMessage) void handleReact(emoji, activeMessage.id);
|
||||
}}
|
||||
onReply={() => {
|
||||
if (activeMessage) setReplyTo(activeMessage);
|
||||
}}
|
||||
onDelete={() => {
|
||||
if (activeMessage) handleDelete(activeMessage.id);
|
||||
}}
|
||||
/>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.bg },
|
||||
loading: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
||||
error: { color: colors.danger, padding: 12, textAlign: 'center' },
|
||||
listContent: { padding: 12 },
|
||||
replyBanner: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
backgroundColor: colors.surface,
|
||||
gap: 12,
|
||||
},
|
||||
replyBannerLeft: { flex: 1 },
|
||||
replyBannerLabel: { color: colors.accent, fontSize: 12, fontWeight: '700' },
|
||||
replyBannerBody: { color: colors.textMuted, fontSize: 13, marginTop: 2 },
|
||||
replyBannerClose: { color: colors.textMuted, fontSize: 22, paddingHorizontal: 6 },
|
||||
inputRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-end',
|
||||
padding: 8,
|
||||
gap: 8,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
backgroundColor: colors.surface,
|
||||
},
|
||||
plusBtn: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.bg,
|
||||
borderRadius: 10,
|
||||
borderColor: colors.border,
|
||||
borderWidth: 1,
|
||||
},
|
||||
plusText: { color: colors.text, fontSize: 20, lineHeight: 22 },
|
||||
input: {
|
||||
flex: 1,
|
||||
minHeight: 40,
|
||||
maxHeight: 120,
|
||||
color: colors.text,
|
||||
backgroundColor: colors.bg,
|
||||
borderColor: colors.border,
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
fontSize: 15,
|
||||
},
|
||||
sendBtn: {
|
||||
backgroundColor: colors.accent,
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 16,
|
||||
height: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
sendBtnDisabled: { opacity: 0.5 },
|
||||
sendBtnText: { color: colors.text, fontWeight: '600' },
|
||||
callBtn: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
},
|
||||
callBtnText: { fontSize: 18 },
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
import { colors } from '../../../theme/colors';
|
||||
|
||||
export default function SettingsLayout() {
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: colors.bg },
|
||||
headerTitleStyle: { color: colors.text },
|
||||
headerTintColor: colors.accent,
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="index" options={{ title: 'Einstellungen' }} />
|
||||
<Stack.Screen name="security" options={{ title: 'Sicherheit' }} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import { useAuth } from '../../../lib/authContext';
|
||||
import { colors } from '../../../theme/colors';
|
||||
|
||||
export default function SettingsIndex() {
|
||||
const router = useRouter();
|
||||
const { signOut } = useAuth();
|
||||
|
||||
function confirmLogout() {
|
||||
Alert.alert('Abmelden', 'Diese Sitzung beenden?', [
|
||||
{ text: 'Abbrechen', style: 'cancel' },
|
||||
{ text: 'Abmelden', style: 'destructive', onPress: () => void signOut() },
|
||||
]);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Pressable style={styles.row} onPress={() => router.push('/(app)/settings/security')}>
|
||||
<Text style={styles.rowTitle}>Sicherheit</Text>
|
||||
<Text style={styles.rowHint}>PIN ändern, Recovery-Code, Identität zurücksetzen</Text>
|
||||
</Pressable>
|
||||
<Pressable style={[styles.row, styles.danger]} onPress={confirmLogout}>
|
||||
<Text style={[styles.rowTitle, styles.dangerText]}>Abmelden</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.bg, padding: 16, gap: 12 },
|
||||
row: {
|
||||
backgroundColor: colors.surface,
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
borderColor: colors.border,
|
||||
borderWidth: 1,
|
||||
},
|
||||
rowTitle: { color: colors.text, fontSize: 16, fontWeight: '600' },
|
||||
rowHint: { color: colors.textMuted, fontSize: 13, marginTop: 4 },
|
||||
danger: { borderColor: colors.danger },
|
||||
dangerText: { color: colors.danger },
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
import { PinInput } from '../../../components/PinInput';
|
||||
import { useAuth } from '../../../lib/authContext';
|
||||
import {
|
||||
changePin,
|
||||
regenerateRecoveryCode,
|
||||
resetIdentity,
|
||||
retryLegacyMigration,
|
||||
type LegacyMigrationReport,
|
||||
} from '../../../lib/userIdentity';
|
||||
import { colors } from '../../../theme/colors';
|
||||
|
||||
export default function SecuritySettings() {
|
||||
const { userId, refreshUserKeyState } = useAuth();
|
||||
const [oldPin, setOldPin] = useState('');
|
||||
const [newPin, setNewPin] = useState('');
|
||||
const [newRecovery, setNewRecovery] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [report, setReport] = useState<LegacyMigrationReport | null>(null);
|
||||
|
||||
async function handleChangePin() {
|
||||
if (!userId) return;
|
||||
if (oldPin.length !== 6 || newPin.length !== 6) {
|
||||
setError('Beide PINs müssen 6 Ziffern haben.');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await changePin({ userId, oldPin, newPin });
|
||||
setOldPin('');
|
||||
setNewPin('');
|
||||
Alert.alert('PIN geändert', 'Die neue PIN gilt sofort auf allen Geräten.');
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'PIN-Änderung fehlgeschlagen');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRegenerateRecovery() {
|
||||
if (!userId) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const code = await regenerateRecoveryCode({ userId });
|
||||
setNewRecovery(code);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Recovery-Code-Erzeugung fehlgeschlagen');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
if (!userId) return;
|
||||
Alert.alert(
|
||||
'Identität zurücksetzen?',
|
||||
'Alle bisherigen Chats werden für dich unlesbar. Diese Aktion kann nicht rückgängig gemacht werden.',
|
||||
[
|
||||
{ text: 'Abbrechen', style: 'cancel' },
|
||||
{
|
||||
text: 'Zurücksetzen',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await resetIdentity({ userId, pin: '000000' });
|
||||
await refreshUserKeyState();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Reset fehlgeschlagen');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
async function handleRetryMigration() {
|
||||
if (!userId) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await retryLegacyMigration(userId);
|
||||
setReport(r);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Migration fehlgeschlagen');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView contentContainerStyle={styles.container}>
|
||||
<Text style={styles.section}>PIN ändern</Text>
|
||||
<Text style={styles.label}>Alte PIN</Text>
|
||||
<PinInput value={oldPin} onChange={setOldPin} ariaLabel="Alte PIN" />
|
||||
<Text style={styles.label}>Neue PIN</Text>
|
||||
<PinInput value={newPin} onChange={setNewPin} ariaLabel="Neue PIN" />
|
||||
<Pressable style={styles.primary} onPress={handleChangePin} disabled={submitting}>
|
||||
{submitting ? (
|
||||
<ActivityIndicator color={colors.text} />
|
||||
) : (
|
||||
<Text style={styles.primaryText}>PIN aktualisieren</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
<Text style={styles.section}>Recovery-Code</Text>
|
||||
<Pressable style={styles.primary} onPress={handleRegenerateRecovery} disabled={submitting}>
|
||||
<Text style={styles.primaryText}>Neuen Recovery-Code erzeugen</Text>
|
||||
</Pressable>
|
||||
{newRecovery && (
|
||||
<View style={styles.codeBox}>
|
||||
<Text style={styles.codeText} selectable>
|
||||
{newRecovery}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text style={styles.section}>Migration</Text>
|
||||
<Pressable style={styles.primary} onPress={handleRetryMigration} disabled={submitting}>
|
||||
<Text style={styles.primaryText}>Migration erneut versuchen</Text>
|
||||
</Pressable>
|
||||
{report && (
|
||||
<View style={styles.report}>
|
||||
<Text style={styles.reportLine}>Geräte (Server): {report.serverDevices}</Text>
|
||||
<Text style={styles.reportLine}>
|
||||
Lokale Schlüssel im Vault:{' '}
|
||||
{report.strongholdKeysFromServerDevices + report.strongholdKeysFromBundleScan}
|
||||
</Text>
|
||||
<Text style={styles.reportLine}>
|
||||
Versucht: {report.attempted}, Erfolgreich: {report.migrated}
|
||||
</Text>
|
||||
<Text style={styles.reportLine}>
|
||||
Übersprungen: kein lokaler Schlüssel = {report.noStrongholdKey}, Decrypt-Fehler ={' '}
|
||||
{report.decryptFailed}, RPC-Fehler = {report.rpcFailed}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text style={styles.section}>Gefahrenbereich</Text>
|
||||
<Pressable style={[styles.primary, styles.danger]} onPress={handleReset} disabled={submitting}>
|
||||
<Text style={[styles.primaryText, styles.dangerText]}>Identität zurücksetzen</Text>
|
||||
</Pressable>
|
||||
|
||||
{error && <Text style={styles.error}>{error}</Text>}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { padding: 16, gap: 8, backgroundColor: colors.bg },
|
||||
section: { color: colors.text, fontSize: 16, fontWeight: '700', marginTop: 16 },
|
||||
label: { color: colors.textMuted, fontSize: 12, marginTop: 4 },
|
||||
primary: {
|
||||
backgroundColor: colors.accent,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
marginTop: 8,
|
||||
},
|
||||
primaryText: { color: colors.text, fontWeight: '700' },
|
||||
danger: { backgroundColor: 'transparent', borderColor: colors.danger, borderWidth: 1 },
|
||||
dangerText: { color: colors.danger },
|
||||
codeBox: {
|
||||
backgroundColor: colors.surface,
|
||||
padding: 12,
|
||||
borderRadius: 10,
|
||||
marginTop: 4,
|
||||
},
|
||||
codeText: {
|
||||
color: colors.text,
|
||||
fontFamily: 'Courier',
|
||||
fontSize: 16,
|
||||
letterSpacing: 1.2,
|
||||
textAlign: 'center',
|
||||
},
|
||||
report: {
|
||||
backgroundColor: colors.surface,
|
||||
padding: 12,
|
||||
borderRadius: 10,
|
||||
marginTop: 4,
|
||||
gap: 4,
|
||||
},
|
||||
reportLine: { color: colors.text, fontSize: 13 },
|
||||
error: { color: colors.danger, marginTop: 12 },
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
import { PinInput } from '../../components/PinInput';
|
||||
import { useAuth } from '../../lib/authContext';
|
||||
import { setupNewUserIdentity } from '../../lib/userIdentity';
|
||||
import { colors } from '../../theme/colors';
|
||||
|
||||
type Step = 'pin' | 'confirm' | 'recovery' | 'done';
|
||||
|
||||
export default function SetupScreen() {
|
||||
const router = useRouter();
|
||||
const { userId, refreshUserKeyState } = useAuth();
|
||||
const [step, setStep] = useState<Step>('pin');
|
||||
const [pin, setPin] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [recoveryCode, setRecoveryCode] = useState<string | null>(null);
|
||||
const [withRecovery, setWithRecovery] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function runSetup(saveRecovery: boolean) {
|
||||
if (!userId) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const out = await setupNewUserIdentity({ userId, pin, withRecovery: saveRecovery });
|
||||
setRecoveryCode(out.recoveryCode);
|
||||
if (out.recoveryCode) {
|
||||
setStep('recovery');
|
||||
} else {
|
||||
setStep('done');
|
||||
await refreshUserKeyState();
|
||||
router.replace('/(app)/chats');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Setup fehlgeschlagen');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (step === 'pin') {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>PIN festlegen</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Mit dieser 6-stelligen PIN entsperrst du Netralax auf jedem Gerät.
|
||||
</Text>
|
||||
<PinInput value={pin} onChange={setPin} ariaLabel="Neue PIN" autoFocus />
|
||||
{error && <Text style={styles.error}>{error}</Text>}
|
||||
<Pressable
|
||||
style={[styles.primary, pin.length !== 6 && styles.disabled]}
|
||||
disabled={pin.length !== 6}
|
||||
onPress={() => setStep('confirm')}
|
||||
>
|
||||
<Text style={styles.primaryText}>Weiter</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === 'confirm') {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>PIN bestätigen</Text>
|
||||
<Text style={styles.subtitle}>Bitte gib dieselbe PIN erneut ein.</Text>
|
||||
<PinInput value={confirm} onChange={setConfirm} ariaLabel="PIN bestätigen" autoFocus />
|
||||
{error && <Text style={styles.error}>{error}</Text>}
|
||||
<Pressable
|
||||
style={[styles.primary, confirm.length !== 6 && styles.disabled]}
|
||||
disabled={confirm.length !== 6 || submitting}
|
||||
onPress={() => {
|
||||
if (pin !== confirm) {
|
||||
setError('PIN stimmt nicht überein.');
|
||||
setConfirm('');
|
||||
return;
|
||||
}
|
||||
void runSetup(withRecovery);
|
||||
}}
|
||||
>
|
||||
{submitting ? (
|
||||
<ActivityIndicator color={colors.text} />
|
||||
) : (
|
||||
<Text style={styles.primaryText}>PIN speichern</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
<Pressable onPress={() => setWithRecovery((v) => !v)} style={styles.secondary}>
|
||||
<Text style={styles.secondaryText}>
|
||||
{withRecovery
|
||||
? 'Recovery-Code überspringen (riskant)'
|
||||
: 'Recovery-Code erzeugen (empfohlen)'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === 'recovery' && recoveryCode) {
|
||||
return (
|
||||
<ScrollView contentContainerStyle={styles.container}>
|
||||
<Text style={styles.title}>Recovery-Code</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Notiere dir diesen Code an einem sicheren Ort. Du brauchst ihn, wenn du deine PIN
|
||||
vergisst. Wir zeigen ihn dir nur EINMAL.
|
||||
</Text>
|
||||
<View style={styles.codeBox}>
|
||||
<Text style={styles.codeText} selectable>
|
||||
{recoveryCode}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
style={styles.primary}
|
||||
onPress={async () => {
|
||||
await refreshUserKeyState();
|
||||
router.replace('/(app)/chats');
|
||||
}}
|
||||
>
|
||||
<Text style={styles.primaryText}>Habe ich gespeichert</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={styles.secondary}
|
||||
onPress={() => {
|
||||
Alert.alert(
|
||||
'Sicher?',
|
||||
'Ohne Recovery-Code verlierst du den Zugriff, wenn du die PIN vergisst.',
|
||||
[
|
||||
{ text: 'Abbrechen', style: 'cancel' },
|
||||
{
|
||||
text: 'Weiter ohne',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
await refreshUserKeyState();
|
||||
router.replace('/(app)/chats');
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Text style={styles.secondaryText}>Überspringen</Text>
|
||||
</Pressable>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 24,
|
||||
paddingTop: 64,
|
||||
backgroundColor: colors.bg,
|
||||
gap: 16,
|
||||
},
|
||||
title: { color: colors.text, fontSize: 24, fontWeight: '700' },
|
||||
subtitle: { color: colors.textMuted, fontSize: 14, lineHeight: 20 },
|
||||
error: { color: colors.danger, fontSize: 13 },
|
||||
primary: {
|
||||
backgroundColor: colors.accent,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
marginTop: 12,
|
||||
},
|
||||
primaryText: { color: colors.text, fontWeight: '700' },
|
||||
disabled: { opacity: 0.5 },
|
||||
secondary: { paddingVertical: 12, alignItems: 'center' },
|
||||
secondaryText: { color: colors.accent, fontWeight: '600' },
|
||||
codeBox: {
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderWidth: 1,
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
},
|
||||
codeText: {
|
||||
color: colors.text,
|
||||
fontSize: 20,
|
||||
fontFamily: 'Courier',
|
||||
letterSpacing: 1.5,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
import { PinInput } from '../../components/PinInput';
|
||||
import { useAuth } from '../../lib/authContext';
|
||||
import { loadOrUnlockUserKey, resetIdentity } from '../../lib/userIdentity';
|
||||
import { colors } from '../../theme/colors';
|
||||
|
||||
type Mode = 'pin' | 'recovery';
|
||||
|
||||
export default function UnlockScreen() {
|
||||
const router = useRouter();
|
||||
const { userId, userKeyState, refreshUserKeyState } = useAuth();
|
||||
const [mode, setMode] = useState<Mode>('pin');
|
||||
const [pin, setPin] = useState('');
|
||||
const [recovery, setRecovery] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const locked =
|
||||
userKeyState.status === 'needs-unlock' && userKeyState.lockedUntil !== null;
|
||||
const hasRecovery =
|
||||
userKeyState.status === 'needs-unlock' ? userKeyState.hasRecovery : false;
|
||||
|
||||
async function attempt(value: string, isRecoveryCode: boolean) {
|
||||
if (!userId) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const out = await loadOrUnlockUserKey({ userId, pin: value, isRecoveryCode });
|
||||
if (out.kind === 'unlocked') {
|
||||
await refreshUserKeyState();
|
||||
router.replace('/(app)/chats');
|
||||
return;
|
||||
}
|
||||
if (out.kind === 'locked') {
|
||||
setError('Konto bis ' + new Date(out.lockedUntil).toLocaleString('de-DE') + ' gesperrt.');
|
||||
await refreshUserKeyState();
|
||||
return;
|
||||
}
|
||||
setError('Kein Identitäts-Datensatz auf dem Server.');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Entsperren fehlgeschlagen');
|
||||
if (isRecoveryCode) setRecovery('');
|
||||
else setPin('');
|
||||
await refreshUserKeyState();
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReset() {
|
||||
if (!userId) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await resetIdentity({ userId, pin: '000000' });
|
||||
await refreshUserKeyState();
|
||||
router.replace('/(app)/setup');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Reset fehlgeschlagen');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Netralax entsperren</Text>
|
||||
<View style={styles.tabs}>
|
||||
<Pressable
|
||||
style={[styles.tab, mode === 'pin' && styles.tabActive]}
|
||||
onPress={() => setMode('pin')}
|
||||
>
|
||||
<Text style={[styles.tabText, mode === 'pin' && styles.tabTextActive]}>PIN</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[styles.tab, mode === 'recovery' && styles.tabActive]}
|
||||
disabled={!hasRecovery}
|
||||
onPress={() => setMode('recovery')}
|
||||
>
|
||||
<Text style={[styles.tabText, mode === 'recovery' && styles.tabTextActive]}>
|
||||
Recovery-Code
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{mode === 'pin' ? (
|
||||
<>
|
||||
<PinInput
|
||||
value={pin}
|
||||
onChange={setPin}
|
||||
ariaLabel="PIN eingeben"
|
||||
autoFocus
|
||||
disabled={locked || submitting}
|
||||
/>
|
||||
<Pressable
|
||||
style={[styles.primary, (pin.length !== 6 || locked || submitting) && styles.disabled]}
|
||||
disabled={pin.length !== 6 || locked || submitting}
|
||||
onPress={() => attempt(pin, false)}
|
||||
>
|
||||
{submitting ? (
|
||||
<ActivityIndicator color={colors.text} />
|
||||
) : (
|
||||
<Text style={styles.primaryText}>Entsperren</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.subtitle}>Recovery-Code eingeben (mit oder ohne Bindestriche):</Text>
|
||||
<Text style={styles.recoveryDisplay} selectable>
|
||||
{recovery}
|
||||
</Text>
|
||||
<Pressable
|
||||
style={[styles.primary, recovery.length === 0 && styles.disabled]}
|
||||
disabled={recovery.length === 0 || submitting}
|
||||
onPress={() => attempt(recovery, true)}
|
||||
>
|
||||
{submitting ? (
|
||||
<ActivityIndicator color={colors.text} />
|
||||
) : (
|
||||
<Text style={styles.primaryText}>Mit Recovery entsperren</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && <Text style={styles.error}>{error}</Text>}
|
||||
|
||||
{locked && (
|
||||
<Pressable style={styles.danger} onPress={handleReset}>
|
||||
<Text style={styles.dangerText}>Identität zurücksetzen (alte Chats verloren)</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bg,
|
||||
paddingTop: 64,
|
||||
paddingHorizontal: 24,
|
||||
gap: 16,
|
||||
},
|
||||
title: { color: colors.text, fontSize: 22, fontWeight: '700' },
|
||||
subtitle: { color: colors.textMuted, fontSize: 13 },
|
||||
tabs: { flexDirection: 'row', gap: 8 },
|
||||
tab: {
|
||||
flex: 1,
|
||||
paddingVertical: 10,
|
||||
alignItems: 'center',
|
||||
borderBottomWidth: 2,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
tabActive: { borderBottomColor: colors.accent },
|
||||
tabText: { color: colors.textMuted, fontWeight: '600' },
|
||||
tabTextActive: { color: colors.text },
|
||||
recoveryDisplay: {
|
||||
color: colors.text,
|
||||
fontFamily: 'Courier',
|
||||
fontSize: 18,
|
||||
letterSpacing: 1.5,
|
||||
backgroundColor: colors.surface,
|
||||
padding: 16,
|
||||
borderRadius: 10,
|
||||
},
|
||||
primary: {
|
||||
backgroundColor: colors.accent,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
primaryText: { color: colors.text, fontWeight: '700' },
|
||||
disabled: { opacity: 0.5 },
|
||||
danger: {
|
||||
paddingVertical: 12,
|
||||
alignItems: 'center',
|
||||
borderColor: colors.danger,
|
||||
borderWidth: 1,
|
||||
borderRadius: 12,
|
||||
},
|
||||
dangerText: { color: colors.danger, fontWeight: '700' },
|
||||
error: { color: colors.danger, fontSize: 13 },
|
||||
});
|
||||
+27
-11
@@ -1,18 +1,34 @@
|
||||
// Root layout for Expo Router.
|
||||
// Wraps every screen. Place global providers here (Theme, Supabase/Auth context,
|
||||
// SafeAreaProvider, GestureHandlerRootView, etc.) once they exist.
|
||||
|
||||
import { Stack } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
|
||||
import { AppBootstrap } from '../components/AppBootstrap';
|
||||
import { ErrorBoundary } from '../components/ErrorBoundary';
|
||||
import { IncomingCallModal } from '../components/IncomingCallModal';
|
||||
import { AuthProvider } from '../lib/authContext';
|
||||
import { CallProvider } from '../lib/callContext';
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<>
|
||||
<StatusBar style="auto" />
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="(app)" />
|
||||
</Stack>
|
||||
</>
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<SafeAreaProvider>
|
||||
<AppBootstrap>
|
||||
<ErrorBoundary>
|
||||
<AuthProvider>
|
||||
<CallProvider>
|
||||
<StatusBar style="auto" />
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="(app)" />
|
||||
<Stack.Screen name="auth/callback" />
|
||||
</Stack>
|
||||
<IncomingCallModal />
|
||||
</CallProvider>
|
||||
</AuthProvider>
|
||||
</ErrorBoundary>
|
||||
</AppBootstrap>
|
||||
</SafeAreaProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { colors } from '../../theme/colors';
|
||||
|
||||
// Supabase magic-link emails redirect to netralax://auth/callback with
|
||||
// the tokens in either the URL fragment (#access_token=...&refresh_token=...)
|
||||
// or the query string depending on the provider. Expo Router parses the
|
||||
// query string into useLocalSearchParams. The hash portion would require
|
||||
// expo-linking; we accept both shapes for safety.
|
||||
export default function AuthCallback() {
|
||||
const params = useLocalSearchParams<{
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
}>();
|
||||
const router = useRouter();
|
||||
const [status, setStatus] = useState<'working' | 'error'>('working');
|
||||
const [message, setMessage] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
if (params.error) {
|
||||
setStatus('error');
|
||||
setMessage(params.error_description ?? params.error);
|
||||
return;
|
||||
}
|
||||
if (!params.access_token || !params.refresh_token) {
|
||||
setStatus('error');
|
||||
setMessage('Magic-Link-URL enthielt keine Tokens.');
|
||||
return;
|
||||
}
|
||||
const { error } = await supabase.auth.setSession({
|
||||
access_token: params.access_token,
|
||||
refresh_token: params.refresh_token,
|
||||
});
|
||||
if (error) {
|
||||
setStatus('error');
|
||||
setMessage(error.message);
|
||||
return;
|
||||
}
|
||||
router.replace('/(app)/chats');
|
||||
})();
|
||||
}, [params, router]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{status === 'working' ? (
|
||||
<>
|
||||
<ActivityIndicator color={colors.accent} size="large" />
|
||||
<Text style={styles.text}>Du wirst angemeldet…</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.errorTitle}>Anmeldung fehlgeschlagen</Text>
|
||||
<Text style={styles.text}>{message}</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.bg,
|
||||
padding: 24,
|
||||
gap: 16,
|
||||
},
|
||||
text: { color: colors.textMuted, fontSize: 14, textAlign: 'center' },
|
||||
errorTitle: { color: colors.danger, fontSize: 18, fontWeight: '600' },
|
||||
});
|
||||
+134
-12
@@ -1,17 +1,105 @@
|
||||
// Landing / Login screen.
|
||||
//
|
||||
// Milestone 1 TODO:
|
||||
// - Show app logo + "Enter your email" field.
|
||||
// - On submit, call shared/auth requestMagicLink(email).
|
||||
// - Handle deep link return in app/_layout.tsx (or a dedicated auth callback route).
|
||||
import { auth } from '@chat-app/shared';
|
||||
import { Redirect } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { useAuth } from '../lib/authContext';
|
||||
import { env } from '../lib/env';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { colors } from '../theme/colors';
|
||||
|
||||
// Phase 1 landing — magic-link login. Signup with invite code is not
|
||||
// part of this MVP (accounts come from desktop / admin). After a
|
||||
// successful request the user sees a "Check your inbox" state until
|
||||
// they tap the email link and Expo Router routes the deep link to
|
||||
// app/auth/callback.tsx.
|
||||
export default function Landing() {
|
||||
const { session, ready } = useAuth();
|
||||
const [email, setEmail] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [sent, setSent] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (!ready) return null;
|
||||
if (session) return <Redirect href="/(app)/chats" />;
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!email.includes('@')) {
|
||||
setError('Bitte gültige E-Mail eingeben');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await auth.loginWithMagicLink(supabase, email.trim(), env.authRedirectUrl);
|
||||
setSent(true);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Login fehlgeschlagen');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (sent) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Netralax</Text>
|
||||
<Text style={styles.subtitle}>Check deine Mails</Text>
|
||||
<Text style={styles.help}>
|
||||
Wir haben dir einen Anmelde-Link an {email} geschickt. Tipp auf den Link, um
|
||||
dich anzumelden.
|
||||
</Text>
|
||||
<Pressable
|
||||
style={[styles.button, styles.buttonGhost]}
|
||||
onPress={() => {
|
||||
setSent(false);
|
||||
setEmail('');
|
||||
}}
|
||||
>
|
||||
<Text style={styles.buttonText}>Andere E-Mail verwenden</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>ChatApp</Text>
|
||||
<Text style={styles.subtitle}>Login placeholder — magic link flow goes here.</Text>
|
||||
<Text style={styles.title}>Netralax</Text>
|
||||
<Text style={styles.subtitle}>Mit Magic-Link anmelden</Text>
|
||||
|
||||
<TextInput
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
placeholder="du@beispiel.de"
|
||||
placeholderTextColor={colors.textDim}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="email-address"
|
||||
textContentType="emailAddress"
|
||||
style={styles.input}
|
||||
editable={!submitting}
|
||||
/>
|
||||
|
||||
{error && <Text style={styles.error}>{error}</Text>}
|
||||
|
||||
<Pressable
|
||||
style={[styles.button, submitting && styles.buttonDisabled]}
|
||||
onPress={handleSubmit}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? (
|
||||
<ActivityIndicator color={colors.text} />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>Magic Link senden</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -21,9 +109,43 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#0b0b0f',
|
||||
backgroundColor: colors.bg,
|
||||
padding: 24,
|
||||
},
|
||||
title: { color: '#fff', fontSize: 28, fontWeight: '600' },
|
||||
subtitle: { color: '#9ca3af', marginTop: 8, textAlign: 'center' },
|
||||
title: { color: colors.text, fontSize: 32, fontWeight: '700', letterSpacing: 1, marginBottom: 8 },
|
||||
subtitle: { color: colors.textMuted, fontSize: 14, marginBottom: 24 },
|
||||
help: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
marginHorizontal: 16,
|
||||
marginBottom: 24,
|
||||
lineHeight: 20,
|
||||
},
|
||||
input: {
|
||||
width: '100%',
|
||||
maxWidth: 360,
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderWidth: 1,
|
||||
borderRadius: 12,
|
||||
color: colors.text,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 14,
|
||||
fontSize: 15,
|
||||
marginBottom: 12,
|
||||
},
|
||||
error: { color: colors.danger, fontSize: 13, marginBottom: 12 },
|
||||
button: {
|
||||
width: '100%',
|
||||
maxWidth: 360,
|
||||
backgroundColor: colors.accent,
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
buttonGhost: { backgroundColor: 'transparent', borderWidth: 1, borderColor: colors.border },
|
||||
buttonDisabled: { opacity: 0.6 },
|
||||
buttonText: { color: colors.text, fontWeight: '600', fontSize: 15 },
|
||||
});
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
@@ -2,9 +2,5 @@ module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
plugins: [
|
||||
// expo-router requires this plugin
|
||||
'expo-router/babel',
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { crypto } from '@chat-app/shared';
|
||||
import { type ReactNode, useEffect, useState } from 'react';
|
||||
|
||||
import { createLibsodiumBackend } from '../lib/cryptoBackend';
|
||||
import { BootError } from './BootError';
|
||||
import { BootSplash } from './BootSplash';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
// React Native exposes ErrorUtils on the global. The types ship with RN but
|
||||
// we cast defensively because the renderer used by Vitest does not.
|
||||
interface RNErrorUtils {
|
||||
getGlobalHandler: () => (err: Error, isFatal?: boolean) => void;
|
||||
setGlobalHandler: (handler: (err: Error, isFatal?: boolean) => void) => void;
|
||||
}
|
||||
|
||||
// Initialises the crypto backend inside a useEffect (not at module-eval) so
|
||||
// any failure surfaces in the React tree. Also installs a global JS error
|
||||
// handler that routes unhandled throws to BootError; this catches errors
|
||||
// thrown during render (e.g. the lazy env proxy reading a missing var) that
|
||||
// would otherwise escape every per-screen ErrorBoundary.
|
||||
export function AppBootstrap({ children }: Props) {
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
crypto.setCryptoBackend(createLibsodiumBackend());
|
||||
setReady(true);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e : new Error(String(e)));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const eu = (globalThis as unknown as { ErrorUtils?: RNErrorUtils }).ErrorUtils;
|
||||
if (!eu) return;
|
||||
const prev = eu.getGlobalHandler();
|
||||
eu.setGlobalHandler((err, isFatal) => {
|
||||
prev?.(err, isFatal);
|
||||
setError(err);
|
||||
});
|
||||
return () => eu.setGlobalHandler(prev);
|
||||
}, []);
|
||||
|
||||
if (error) return <BootError error={error} />;
|
||||
if (!ready) return <BootSplash />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { chat } from '@chat-app/shared';
|
||||
import type { AttachmentHandle } from '@chat-app/shared/chat';
|
||||
import { Buffer } from 'buffer';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ActivityIndicator, Image, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import { getCachedAttachment, setCachedAttachment } from '../lib/attachmentCache';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { colors } from '../theme/colors';
|
||||
|
||||
interface Props {
|
||||
handle: AttachmentHandle;
|
||||
}
|
||||
|
||||
// Decrypts an encrypted image attachment on first mount and renders it
|
||||
// inline. Subsequent mounts hit the in-memory cache. Failure (key not
|
||||
// shared yet for this device, network error, etc.) shows a small error
|
||||
// placeholder rather than crashing the parent message bubble.
|
||||
export function AttachmentImage({ handle }: Props) {
|
||||
const [dataUrl, setDataUrl] = useState<string | null>(() => getCachedAttachment(handle.id) ?? null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (dataUrl) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const blob = await chat.downloadAndDecryptAttachment({
|
||||
client: supabase,
|
||||
handle,
|
||||
});
|
||||
const bytes = new Uint8Array(await blob.arrayBuffer());
|
||||
const b64 = Buffer.from(bytes as unknown as ArrayLike<number>).toString('base64');
|
||||
const url = 'data:' + handle.mimeType + ';base64,' + b64;
|
||||
if (cancelled) return;
|
||||
setCachedAttachment(handle.id, url);
|
||||
setDataUrl(url);
|
||||
} catch (err: unknown) {
|
||||
if (cancelled) return;
|
||||
setError(err instanceof Error ? err.message : 'decrypt failed');
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [dataUrl, handle]);
|
||||
|
||||
// Aspect ratio honoured if available; fall back to a 4:3 placeholder.
|
||||
const aspect =
|
||||
handle.width && handle.height && handle.height > 0 ? handle.width / handle.height : 4 / 3;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<View style={[styles.placeholder, { aspectRatio: aspect }]}>
|
||||
<Text style={styles.errorText}>🔒 {error}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (!dataUrl) {
|
||||
return (
|
||||
<View style={[styles.placeholder, { aspectRatio: aspect }]}>
|
||||
<ActivityIndicator color={colors.accent} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Image
|
||||
source={{ uri: dataUrl }}
|
||||
style={[styles.image, { aspectRatio: aspect }]}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
image: {
|
||||
width: '100%',
|
||||
borderRadius: 8,
|
||||
marginTop: 4,
|
||||
},
|
||||
placeholder: {
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 8,
|
||||
borderColor: colors.border,
|
||||
borderWidth: 1,
|
||||
marginTop: 4,
|
||||
},
|
||||
errorText: { color: colors.danger, fontSize: 12 },
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import { colors } from '../theme/colors';
|
||||
|
||||
// Initial-letter avatar circle. Phase 2 will swap in an image-aware
|
||||
// version that prefers profile.avatarUrl when present.
|
||||
export function Avatar({ name, size = 40 }: { name: string; size?: number }) {
|
||||
const letter = (name.trim().charAt(0) || '?').toUpperCase();
|
||||
return (
|
||||
<View style={[styles.circle, { width: size, height: size, borderRadius: size / 2 }]}>
|
||||
<Text style={[styles.letter, { fontSize: size * 0.45 }]}>{letter}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
circle: {
|
||||
backgroundColor: colors.accentMuted,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
letter: { color: colors.accent, fontWeight: '700' },
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import { colors } from '../theme/colors';
|
||||
|
||||
interface Props {
|
||||
error: Error;
|
||||
}
|
||||
|
||||
// Last-resort fallback. Renders whenever AppBootstrap's init effect throws or
|
||||
// when the global JS error handler catches an unhandled exception. The
|
||||
// env-diagnostic line ("env-ok" / "env-missing") makes future bug reports
|
||||
// triageable from a single screenshot.
|
||||
export function BootError({ error }: Props) {
|
||||
const envOk = Boolean(process.env.EXPO_PUBLIC_SUPABASE_URL);
|
||||
return (
|
||||
<ScrollView contentContainerStyle={styles.container}>
|
||||
<Text style={styles.title}>App-Start fehlgeschlagen</Text>
|
||||
<Text style={styles.message}>{error.message}</Text>
|
||||
<View style={styles.diagnostic}>
|
||||
<Text style={styles.diagnosticLabel}>EXPO_PUBLIC_SUPABASE_URL:</Text>
|
||||
<Text style={[styles.diagnosticValue, envOk ? styles.ok : styles.bad]}>
|
||||
{envOk ? 'env-ok' : 'env-missing'}
|
||||
</Text>
|
||||
</View>
|
||||
{error.stack && <Text style={styles.stack}>{error.stack}</Text>}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexGrow: 1,
|
||||
backgroundColor: colors.bg,
|
||||
padding: 24,
|
||||
paddingTop: 64,
|
||||
gap: 12,
|
||||
},
|
||||
title: { color: colors.text, fontSize: 20, fontWeight: '700' },
|
||||
message: { color: colors.danger, fontSize: 14, lineHeight: 20 },
|
||||
diagnostic: { flexDirection: 'row', gap: 8, marginTop: 8 },
|
||||
diagnosticLabel: { color: colors.textMuted, fontSize: 12 },
|
||||
diagnosticValue: { fontSize: 12, fontWeight: '700' },
|
||||
ok: { color: colors.success },
|
||||
bad: { color: colors.danger },
|
||||
stack: {
|
||||
color: colors.textDim,
|
||||
fontSize: 11,
|
||||
fontFamily: 'Courier',
|
||||
marginTop: 16,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ActivityIndicator, StyleSheet, View } from 'react-native';
|
||||
|
||||
import { colors } from '../theme/colors';
|
||||
|
||||
// Shown while AppBootstrap is initialising the crypto backend. Identical
|
||||
// background to the Expo splash so the handoff is invisible to the user.
|
||||
export function BootSplash() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ActivityIndicator color={colors.accent} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.bg,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import { formatRelativeTime } from '../lib/timeFormat';
|
||||
import { colors } from '../theme/colors';
|
||||
import { Avatar } from './Avatar';
|
||||
|
||||
interface Props {
|
||||
conversation: ConversationSummary;
|
||||
lastMessagePreview: string;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
export function ConversationRow({ conversation, lastMessagePreview, onPress }: Props) {
|
||||
const title =
|
||||
conversation.type === 'group'
|
||||
? (conversation.name ?? 'Gruppe')
|
||||
: (conversation.peer?.displayName ?? '—');
|
||||
const subtitle =
|
||||
conversation.type === 'group'
|
||||
? conversation.members.length + ' Mitglieder'
|
||||
: '@' + (conversation.peer?.username ?? '');
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && { backgroundColor: colors.surface }]}
|
||||
onPress={onPress}
|
||||
>
|
||||
<Avatar name={title} />
|
||||
<View style={styles.center}>
|
||||
<View style={styles.titleLine}>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text style={styles.time}>{formatRelativeTime(conversation.lastMessageAt)}</Text>
|
||||
</View>
|
||||
<Text style={styles.preview} numberOfLines={1}>
|
||||
{lastMessagePreview || subtitle}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
gap: 12,
|
||||
},
|
||||
center: { flex: 1, minWidth: 0 },
|
||||
titleLine: { flexDirection: 'row', alignItems: 'baseline', gap: 8 },
|
||||
title: { color: colors.text, fontSize: 15, fontWeight: '600', flex: 1 },
|
||||
time: { color: colors.textDim, fontSize: 11 },
|
||||
preview: { color: colors.textMuted, fontSize: 13, marginTop: 2 },
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import { colors } from '../theme/colors';
|
||||
|
||||
// Catch render errors anywhere below this boundary and show a readable
|
||||
// fallback. Without it, a thrown error during render produces a white
|
||||
// screen on TestFlight / production builds with no way for the user to
|
||||
// recover short of force-closing the app. Mounted once at the top of
|
||||
// app/_layout.tsx so it wraps every screen.
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends React.Component<Props, State> {
|
||||
state: State = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: React.ErrorInfo): void {
|
||||
// Bubble to Metro/console in dev so the redbox still shows; in prod
|
||||
// builds this is the only place an exception trace surfaces.
|
||||
console.error('[ErrorBoundary] caught render error', error, info.componentStack);
|
||||
}
|
||||
|
||||
reset = (): void => {
|
||||
this.setState({ error: null });
|
||||
};
|
||||
|
||||
render(): React.ReactNode {
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Etwas ist schiefgelaufen</Text>
|
||||
<Text style={styles.message}>{this.state.error.message}</Text>
|
||||
<Pressable style={styles.button} onPress={this.reset}>
|
||||
<Text style={styles.buttonText}>Erneut versuchen</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.bg,
|
||||
padding: 24,
|
||||
},
|
||||
title: { color: colors.text, fontSize: 22, fontWeight: '600', marginBottom: 8 },
|
||||
message: { color: colors.textMuted, textAlign: 'center', marginBottom: 24 },
|
||||
button: {
|
||||
backgroundColor: colors.accent,
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 10,
|
||||
},
|
||||
buttonText: { color: colors.text, fontWeight: '600' },
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { chat } from '@chat-app/shared';
|
||||
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Modal, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import { Avatar } from './Avatar';
|
||||
import { useCall } from '../lib/callContext';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { colors } from '../theme/colors';
|
||||
|
||||
// Full-screen modal that surfaces over any route when the CallContext
|
||||
// reports an `incoming` state. Resolves the caller's display name from
|
||||
// the conversation membership; falls back to "Anrufer" otherwise.
|
||||
export function IncomingCallModal() {
|
||||
const { state, acceptIncoming, rejectIncoming } = useCall();
|
||||
const visible = state.kind === 'incoming';
|
||||
|
||||
const [conversation, setConversation] = useState<ConversationSummary | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.kind !== 'incoming') {
|
||||
setConversation(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const all = await chat.listConversations(supabase);
|
||||
if (cancelled) return;
|
||||
setConversation(all.find((c) => c.id === state.conversationId) ?? null);
|
||||
} catch {
|
||||
/* swallow */
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [state]);
|
||||
|
||||
if (state.kind !== 'incoming') return null;
|
||||
|
||||
const callerName = (() => {
|
||||
if (!conversation) return 'Anrufer';
|
||||
const m = conversation.members.find((mm) => mm.userId === state.fromUserId);
|
||||
return m?.profile?.displayName ?? m?.profile?.username ?? 'Anrufer';
|
||||
})();
|
||||
|
||||
const conversationTitle =
|
||||
conversation?.type === 'group' ? (conversation.name ?? 'Gruppe') : callerName;
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={rejectIncoming}>
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.subtitle}>Eingehender Anruf</Text>
|
||||
<Avatar name={callerName} size={96} />
|
||||
<Text style={styles.title}>{callerName}</Text>
|
||||
{conversation?.type === 'group' && (
|
||||
<Text style={styles.subtitle}>in {conversationTitle}</Text>
|
||||
)}
|
||||
<View style={styles.actions}>
|
||||
<Pressable
|
||||
style={[styles.button, styles.buttonReject]}
|
||||
onPress={() => {
|
||||
void rejectIncoming();
|
||||
}}
|
||||
>
|
||||
<Text style={styles.buttonText}>Ablehnen</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[styles.button, styles.buttonAccept]}
|
||||
onPress={() => {
|
||||
void acceptIncoming();
|
||||
}}
|
||||
>
|
||||
<Text style={styles.buttonText}>Annehmen</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bg,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 20,
|
||||
paddingHorizontal: 32,
|
||||
},
|
||||
title: { color: colors.text, fontSize: 28, fontWeight: '700' },
|
||||
subtitle: { color: colors.textMuted, fontSize: 14 },
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
gap: 24,
|
||||
marginTop: 32,
|
||||
},
|
||||
button: {
|
||||
paddingHorizontal: 28,
|
||||
paddingVertical: 16,
|
||||
borderRadius: 14,
|
||||
minWidth: 140,
|
||||
alignItems: 'center',
|
||||
},
|
||||
buttonAccept: { backgroundColor: colors.success },
|
||||
buttonReject: { backgroundColor: colors.danger },
|
||||
buttonText: { color: colors.text, fontWeight: '700', fontSize: 16 },
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Modal, Pressable, StyleSheet, Text } from 'react-native';
|
||||
|
||||
import { ReactionStrip } from './ReactionStrip';
|
||||
import { colors } from '../theme/colors';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
mine: boolean;
|
||||
onClose: () => void;
|
||||
onReact: (emoji: string) => void;
|
||||
onReply: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
// Bottom-sheet-style modal opened on long-press of a message bubble.
|
||||
// Contains the reaction quick-strip plus the action rows. The "Löschen"
|
||||
// row is hidden for messages not authored by the current user — the
|
||||
// server-side trigger would refuse anyway, but trimming UI keeps the
|
||||
// surface honest.
|
||||
export function MessageActionsSheet({
|
||||
visible,
|
||||
mine,
|
||||
onClose,
|
||||
onReact,
|
||||
onReply,
|
||||
onDelete,
|
||||
}: Props) {
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<Pressable style={styles.backdrop} onPress={onClose}>
|
||||
<Pressable style={styles.sheet} onPress={() => undefined}>
|
||||
<ReactionStrip
|
||||
onReact={(e) => {
|
||||
onReact(e);
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
<Action label="Antworten" onPress={() => { onReply(); onClose(); }} />
|
||||
{mine && (
|
||||
<Action
|
||||
label="Löschen"
|
||||
danger
|
||||
onPress={() => { onDelete(); onClose(); }}
|
||||
/>
|
||||
)}
|
||||
<Action label="Abbrechen" muted onPress={onClose} />
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function Action({
|
||||
label,
|
||||
danger,
|
||||
muted,
|
||||
onPress,
|
||||
}: {
|
||||
label: string;
|
||||
danger?: boolean;
|
||||
muted?: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={({ pressed }) => [styles.action, pressed && styles.actionPressed]}
|
||||
>
|
||||
<Text style={[styles.actionText, danger && styles.actionDanger, muted && styles.actionMuted]}>
|
||||
{label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
backdrop: {
|
||||
flex: 1,
|
||||
justifyContent: 'flex-end',
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
},
|
||||
sheet: {
|
||||
backgroundColor: colors.surface,
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
paddingBottom: 24,
|
||||
},
|
||||
action: {
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 20,
|
||||
borderBottomColor: colors.border,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
actionPressed: { backgroundColor: colors.bg },
|
||||
actionText: { color: colors.text, fontSize: 15, fontWeight: '500' },
|
||||
actionDanger: { color: colors.danger },
|
||||
actionMuted: { color: colors.textMuted },
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { chat as chatNs } from '@chat-app/shared';
|
||||
import type { DecryptedMessage, MessageReaction } from '@chat-app/shared/chat';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import { colors } from '../theme/colors';
|
||||
import { AttachmentImage } from './AttachmentImage';
|
||||
import { ReactionPills } from './ReactionPills';
|
||||
|
||||
interface Props {
|
||||
message: DecryptedMessage;
|
||||
mine: boolean;
|
||||
senderName: string;
|
||||
time: string;
|
||||
parent: DecryptedMessage | null;
|
||||
parentSenderName: string;
|
||||
reactions: MessageReaction[];
|
||||
myUserId: string | null;
|
||||
ownPrivateKey: Uint8Array | null;
|
||||
onLongPress: () => void;
|
||||
onToggleReaction: (emoji: string) => void;
|
||||
}
|
||||
|
||||
// Single message bubble. Renders, in order:
|
||||
// * Reply quote (when this message has a replyToId).
|
||||
// * Body text (or "Nachricht gelöscht" placeholder).
|
||||
// * Image attachment (max 1 in Phase 2 — first one wins).
|
||||
// * Reaction pills.
|
||||
// Long-press surfaces the MessageActionsSheet via the onLongPress callback.
|
||||
export function MessageBubble({
|
||||
message,
|
||||
mine,
|
||||
senderName,
|
||||
time,
|
||||
parent,
|
||||
parentSenderName,
|
||||
reactions,
|
||||
myUserId,
|
||||
ownPrivateKey,
|
||||
onLongPress,
|
||||
onToggleReaction,
|
||||
}: Props) {
|
||||
const deleted = message.deletedAt !== null;
|
||||
const parsed = chatNs.parseMessagePayload(message.plaintext);
|
||||
const text = parsed.kind === 'text' ? parsed.text : '';
|
||||
const attachments = parsed.kind === 'text' ? parsed.attachments : [];
|
||||
const firstImage = attachments.find((a) => a.mimeType.startsWith('image/'));
|
||||
|
||||
return (
|
||||
<View style={[styles.wrap, mine ? styles.wrapMine : styles.wrapOther]}>
|
||||
<Pressable
|
||||
onLongPress={onLongPress}
|
||||
delayLongPress={250}
|
||||
style={[styles.bubble, mine ? styles.bubbleMine : styles.bubbleOther]}
|
||||
>
|
||||
{!mine && !deleted && <Text style={styles.sender}>{senderName}</Text>}
|
||||
|
||||
{message.replyToId && (
|
||||
<View style={[styles.replyQuote, mine ? styles.replyQuoteMine : null]}>
|
||||
<Text style={styles.replyAuthor}>{parent ? parentSenderName : 'Original'}</Text>
|
||||
<Text style={styles.replyBody} numberOfLines={2}>
|
||||
{parent
|
||||
? quotedPreview(parent)
|
||||
: '↩ Original-Nachricht außerhalb dieses Fensters'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{deleted ? (
|
||||
<Text style={styles.deleted}>Nachricht gelöscht</Text>
|
||||
) : (
|
||||
<>
|
||||
{text.length > 0 && <Text style={styles.body}>{text}</Text>}
|
||||
{firstImage && ownPrivateKey && (
|
||||
<AttachmentImage handle={firstImage} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Text style={styles.time}>{time}</Text>
|
||||
</Pressable>
|
||||
|
||||
{!deleted && (
|
||||
<ReactionPills
|
||||
reactions={reactions}
|
||||
myUserId={myUserId}
|
||||
onToggle={onToggleReaction}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function quotedPreview(m: DecryptedMessage): string {
|
||||
if (m.deletedAt) return '[gelöscht]';
|
||||
const parsed = chatNs.parseMessagePayload(m.plaintext);
|
||||
if (parsed.kind === 'text') {
|
||||
if (parsed.text.length > 0) return parsed.text;
|
||||
if (parsed.attachments.length > 0) return '📎 Anhang';
|
||||
}
|
||||
return '…';
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrap: { marginVertical: 4, maxWidth: '78%' },
|
||||
wrapMine: { alignSelf: 'flex-end', alignItems: 'flex-end' },
|
||||
wrapOther: { alignSelf: 'flex-start', alignItems: 'flex-start' },
|
||||
bubble: { padding: 10, borderRadius: 14 },
|
||||
bubbleMine: { backgroundColor: colors.accent },
|
||||
bubbleOther: { backgroundColor: colors.surface, borderColor: colors.border, borderWidth: 1 },
|
||||
sender: { color: colors.textMuted, fontSize: 11, fontWeight: '600', marginBottom: 4 },
|
||||
body: { color: colors.text, fontSize: 15, lineHeight: 20 },
|
||||
time: { color: colors.textDim, fontSize: 10, marginTop: 4, textAlign: 'right' },
|
||||
deleted: { color: colors.textMuted, fontSize: 14, fontStyle: 'italic' },
|
||||
replyQuote: {
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: colors.accent,
|
||||
paddingLeft: 8,
|
||||
paddingVertical: 4,
|
||||
marginBottom: 6,
|
||||
backgroundColor: colors.bg,
|
||||
borderRadius: 4,
|
||||
},
|
||||
replyQuoteMine: { backgroundColor: 'rgba(0,0,0,0.18)', borderLeftColor: colors.text },
|
||||
replyAuthor: { color: colors.textMuted, fontSize: 11, fontWeight: '700' },
|
||||
replyBody: { color: colors.text, fontSize: 13, opacity: 0.85 },
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { fireEvent, render } from '@testing-library/react-native';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { PinInput } from './PinInput';
|
||||
|
||||
describe('<PinInput>', () => {
|
||||
it('appends digits to the underlying value and stops at length', () => {
|
||||
const onChange = vi.fn();
|
||||
const { getByTestId } = render(
|
||||
<PinInput value="" onChange={onChange} length={6} ariaLabel="PIN" />,
|
||||
);
|
||||
fireEvent.changeText(getByTestId('pin-input'), '1234567890');
|
||||
expect(onChange).toHaveBeenCalledWith('123456');
|
||||
});
|
||||
|
||||
it('strips non-digits', () => {
|
||||
const onChange = vi.fn();
|
||||
const { getByTestId } = render(
|
||||
<PinInput value="" onChange={onChange} length={6} ariaLabel="PIN" />,
|
||||
);
|
||||
fireEvent.changeText(getByTestId('pin-input'), '1a2b3c');
|
||||
expect(onChange).toHaveBeenCalledWith('123');
|
||||
});
|
||||
|
||||
it('renders one bullet per filled slot', () => {
|
||||
const { getAllByText } = render(
|
||||
<PinInput value="123" onChange={() => {}} length={6} ariaLabel="PIN" />,
|
||||
);
|
||||
expect(getAllByText('•').length).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import {
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
type TextInput as TextInputType,
|
||||
} from 'react-native';
|
||||
|
||||
import { colors } from '../theme/colors';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
length?: number;
|
||||
autoFocus?: boolean;
|
||||
disabled?: boolean;
|
||||
ariaLabel: string;
|
||||
onSubmit?: () => void;
|
||||
}
|
||||
|
||||
// Six-slot numeric PIN entry. The actual input is an invisible TextInput
|
||||
// that captures the numeric keyboard; visible slots render bullets when
|
||||
// filled. Tapping anywhere on the row re-focuses the input.
|
||||
export function PinInput({
|
||||
value,
|
||||
onChange,
|
||||
length = 6,
|
||||
autoFocus,
|
||||
disabled,
|
||||
ariaLabel,
|
||||
onSubmit,
|
||||
}: Props) {
|
||||
const ref = useRef<TextInputType | null>(null);
|
||||
useEffect(() => {
|
||||
if (autoFocus) ref.current?.focus();
|
||||
}, [autoFocus]);
|
||||
return (
|
||||
<Pressable onPress={() => ref.current?.focus()} style={styles.row}>
|
||||
<TextInput
|
||||
ref={ref}
|
||||
testID="pin-input"
|
||||
accessibilityLabel={ariaLabel}
|
||||
keyboardType="numeric"
|
||||
textContentType="oneTimeCode"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={length}
|
||||
editable={!disabled}
|
||||
value={value}
|
||||
onChangeText={(t) => onChange(t.replace(/\D/g, '').slice(0, length))}
|
||||
onSubmitEditing={() => {
|
||||
if (value.length === length) onSubmit?.();
|
||||
}}
|
||||
style={styles.hidden}
|
||||
/>
|
||||
<View style={styles.slots}>
|
||||
{Array.from({ length }).map((_, i) => {
|
||||
const filled = i < value.length;
|
||||
return (
|
||||
<View key={i} style={[styles.slot, filled && styles.slotFilled]}>
|
||||
{filled && <Text style={styles.bullet}>•</Text>}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: { alignItems: 'center' },
|
||||
hidden: {
|
||||
position: 'absolute',
|
||||
width: 1,
|
||||
height: 1,
|
||||
opacity: 0,
|
||||
},
|
||||
slots: { flexDirection: 'row', gap: 8 },
|
||||
slot: {
|
||||
width: 40,
|
||||
height: 48,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.surface,
|
||||
},
|
||||
slotFilled: {
|
||||
borderColor: colors.accent,
|
||||
backgroundColor: colors.bg,
|
||||
},
|
||||
bullet: { color: colors.text, fontSize: 22 },
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { MessageReaction } from '@chat-app/shared/chat';
|
||||
import { useMemo } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import { colors } from '../theme/colors';
|
||||
|
||||
interface Props {
|
||||
reactions: MessageReaction[];
|
||||
myUserId: string | null;
|
||||
onToggle: (emoji: string) => void;
|
||||
}
|
||||
|
||||
// Renders the per-message reaction badges underneath a bubble. Pills
|
||||
// the current user has reacted to get a tinted background so they know
|
||||
// which ones to tap to un-react.
|
||||
export function ReactionPills({ reactions, myUserId, onToggle }: Props) {
|
||||
const grouped = useMemo(() => groupByEmoji(reactions, myUserId), [reactions, myUserId]);
|
||||
if (grouped.length === 0) return null;
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
{grouped.map((g) => (
|
||||
<Pressable
|
||||
key={g.emoji}
|
||||
onPress={() => onToggle(g.emoji)}
|
||||
style={[styles.pill, g.mine && styles.pillMine]}
|
||||
>
|
||||
<Text style={styles.emoji}>{g.emoji}</Text>
|
||||
<Text style={[styles.count, g.mine && styles.countMine]}>{g.count}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function groupByEmoji(
|
||||
rows: MessageReaction[],
|
||||
myUserId: string | null,
|
||||
): Array<{ emoji: string; count: number; mine: boolean }> {
|
||||
const m = new Map<string, { count: number; mine: boolean }>();
|
||||
for (const r of rows) {
|
||||
const prev = m.get(r.emoji) ?? { count: 0, mine: false };
|
||||
m.set(r.emoji, {
|
||||
count: prev.count + 1,
|
||||
mine: prev.mine || (myUserId !== null && r.userId === myUserId),
|
||||
});
|
||||
}
|
||||
return Array.from(m.entries()).map(([emoji, v]) => ({ emoji, ...v }));
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: { flexDirection: 'row', flexWrap: 'wrap', gap: 4, marginTop: 4 },
|
||||
pill: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 12,
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border,
|
||||
borderWidth: 1,
|
||||
gap: 4,
|
||||
},
|
||||
pillMine: { backgroundColor: colors.accentMuted, borderColor: colors.accent },
|
||||
emoji: { fontSize: 13 },
|
||||
count: { color: colors.textMuted, fontSize: 12, fontWeight: '600' },
|
||||
countMine: { color: colors.accent },
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import { colors } from '../theme/colors';
|
||||
|
||||
// Hardcoded 6-emoji quick reactor shown at the top of the long-press
|
||||
// sheet. A full emoji picker is post-Phase-4; this is the Discord-style
|
||||
// fast path the vast majority of reactions go through.
|
||||
export const QUICK_REACTIONS = ['👍', '❤️', '😂', '😮', '😢', '🎉'] as const;
|
||||
|
||||
export function ReactionStrip({ onReact }: { onReact: (emoji: string) => void }) {
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
{QUICK_REACTIONS.map((e) => (
|
||||
<Pressable
|
||||
key={e}
|
||||
onPress={() => onReact(e)}
|
||||
style={({ pressed }) => [styles.button, pressed && styles.buttonPressed]}
|
||||
hitSlop={6}
|
||||
>
|
||||
<Text style={styles.emoji}>{e}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-around',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 8,
|
||||
borderBottomColor: colors.border,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
button: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 22,
|
||||
},
|
||||
buttonPressed: { backgroundColor: colors.accentMuted },
|
||||
emoji: { fontSize: 26 },
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 13.0.0"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal",
|
||||
"ios": {
|
||||
"simulator": true
|
||||
},
|
||||
"android": {
|
||||
"buildType": "apk"
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal",
|
||||
"ios": {
|
||||
"simulator": false
|
||||
},
|
||||
"android": {
|
||||
"buildType": "apk"
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"ios": {
|
||||
"simulator": false
|
||||
},
|
||||
"android": {
|
||||
"buildType": "app-bundle"
|
||||
}
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {
|
||||
"ios": {
|
||||
"appleId": "REPLACE_WITH_APPLE_ID",
|
||||
"ascAppId": "REPLACE_WITH_ASC_APP_ID"
|
||||
},
|
||||
"android": {
|
||||
"serviceAccountKeyPath": "./play-service-account.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Module-level memo of decrypted-attachment data URLs by handle id.
|
||||
// Survives screen unmounts (e.g. user pops in and out of a conversation)
|
||||
// but evicts on app restart — good enough for Phase 2; an LRU + disk
|
||||
// cache is a later polish.
|
||||
|
||||
const cache = new Map<string, string>();
|
||||
|
||||
export function getCachedAttachment(id: string): string | undefined {
|
||||
return cache.get(id);
|
||||
}
|
||||
|
||||
export function setCachedAttachment(id: string, dataUrl: string): void {
|
||||
cache.set(id, dataUrl);
|
||||
}
|
||||
|
||||
export function clearAttachmentCache(): void {
|
||||
cache.clear();
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { Session, User } from '@supabase/supabase-js';
|
||||
import { fetchUserKeyBlob } from '@chat-app/shared/auth';
|
||||
import {
|
||||
type ReactNode,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { supabase } from './supabase';
|
||||
import { cachedUserKey, ensureLegacyMigrated } from './userIdentity';
|
||||
|
||||
export type UserKeyState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'needs-setup' }
|
||||
| { status: 'needs-unlock'; lockedUntil: string | null; hasRecovery: boolean }
|
||||
| { status: 'unlocked' };
|
||||
|
||||
interface AuthContextValue {
|
||||
session: Session | null;
|
||||
user: User | null;
|
||||
userId: string | null;
|
||||
ownPrivateKey: Uint8Array | null;
|
||||
userKeyState: UserKeyState;
|
||||
ready: boolean;
|
||||
refreshUserKeyState: () => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
}
|
||||
|
||||
const Ctx = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const v = useContext(Ctx);
|
||||
if (!v) throw new Error('useAuth() called outside <AuthProvider>');
|
||||
return v;
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [ownPrivateKey, setOwnPrivateKey] = useState<Uint8Array | null>(null);
|
||||
const [userKeyState, setUserKeyState] = useState<UserKeyState>({ status: 'loading' });
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
const refreshUserKeyState = useCallback(async () => {
|
||||
const s = session;
|
||||
if (!s) {
|
||||
setUserKeyState({ status: 'loading' });
|
||||
setOwnPrivateKey(null);
|
||||
return;
|
||||
}
|
||||
setUserKeyState({ status: 'loading' });
|
||||
const cached = await cachedUserKey(s.user.id);
|
||||
if (cached) {
|
||||
setOwnPrivateKey(cached);
|
||||
setUserKeyState({ status: 'unlocked' });
|
||||
void ensureLegacyMigrated(s.user.id).catch((err) => {
|
||||
console.warn('legacy migration on auth-resume failed', err);
|
||||
});
|
||||
return;
|
||||
}
|
||||
const blob = await fetchUserKeyBlob(supabase, s.user.id);
|
||||
if (!blob || !blob.exists) {
|
||||
setUserKeyState({ status: 'needs-setup' });
|
||||
return;
|
||||
}
|
||||
if (blob.locked) {
|
||||
setUserKeyState({
|
||||
status: 'needs-unlock',
|
||||
lockedUntil: blob.lockedUntil,
|
||||
hasRecovery: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setUserKeyState({
|
||||
status: 'needs-unlock',
|
||||
lockedUntil: null,
|
||||
hasRecovery: blob.recoverySealedPrivateKey !== null,
|
||||
});
|
||||
}, [session]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const { data } = await supabase.auth.getSession();
|
||||
if (cancelled) return;
|
||||
setSession(data.session);
|
||||
setReady(true);
|
||||
})();
|
||||
const { data: sub } = supabase.auth.onAuthStateChange((_event, nextSession) => {
|
||||
setSession(nextSession);
|
||||
setReady(true);
|
||||
if (!nextSession) {
|
||||
setOwnPrivateKey(null);
|
||||
setUserKeyState({ status: 'loading' });
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
sub.subscription.unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshUserKeyState().catch((err) => {
|
||||
console.warn('refreshUserKeyState failed', err);
|
||||
setUserKeyState({ status: 'needs-setup' });
|
||||
});
|
||||
}, [session, refreshUserKeyState]);
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
await supabase.auth.signOut();
|
||||
setOwnPrivateKey(null);
|
||||
setUserKeyState({ status: 'loading' });
|
||||
}, []);
|
||||
|
||||
const value: AuthContextValue = {
|
||||
session,
|
||||
user: session?.user ?? null,
|
||||
userId: session?.user.id ?? null,
|
||||
ownPrivateKey,
|
||||
userKeyState,
|
||||
ready,
|
||||
refreshUserKeyState,
|
||||
signOut,
|
||||
};
|
||||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
import { AudioSession } from '@livekit/react-native';
|
||||
import { Room, RoomEvent, Track } from 'livekit-client';
|
||||
import { chat, rtc } from '@chat-app/shared';
|
||||
import type { CallSignal } from '@chat-app/shared/rtc';
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { useAuth } from './authContext';
|
||||
import { sendCallSignal, subscribeCallSignals } from './callSignal';
|
||||
import { supabase } from './supabase';
|
||||
|
||||
type Identity = string;
|
||||
|
||||
export interface RemoteParticipantSummary {
|
||||
identity: Identity;
|
||||
name: string;
|
||||
speaking: boolean;
|
||||
}
|
||||
|
||||
export type CallState =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'outgoing'; callId: string; conversationId: string; peers: Identity[] }
|
||||
| { kind: 'incoming'; callId: string; conversationId: string; fromUserId: Identity }
|
||||
| { kind: 'connecting'; callId: string; conversationId: string }
|
||||
| {
|
||||
kind: 'connected';
|
||||
callId: string;
|
||||
conversationId: string;
|
||||
muted: boolean;
|
||||
speakerOn: boolean;
|
||||
participants: RemoteParticipantSummary[];
|
||||
}
|
||||
| { kind: 'ended'; reason: 'normal' | 'rejected' | 'cancelled' | 'error' };
|
||||
|
||||
interface CallContextValue {
|
||||
state: CallState;
|
||||
startCall: (conversationId: string) => Promise<void>;
|
||||
acceptIncoming: () => Promise<void>;
|
||||
rejectIncoming: () => Promise<void>;
|
||||
cancelOutgoing: () => Promise<void>;
|
||||
endCall: () => Promise<void>;
|
||||
toggleMute: () => Promise<void>;
|
||||
toggleSpeaker: () => Promise<void>;
|
||||
}
|
||||
|
||||
const Ctx = createContext<CallContextValue | null>(null);
|
||||
|
||||
export function useCall(): CallContextValue {
|
||||
const v = useContext(Ctx);
|
||||
if (!v) throw new Error('useCall() called outside <CallProvider>');
|
||||
return v;
|
||||
}
|
||||
|
||||
function randomCallId(): string {
|
||||
return 'call-' + Math.random().toString(36).slice(2, 10) + '-' + Date.now().toString(36);
|
||||
}
|
||||
|
||||
// Time the call state lingers in `ended` so the UI can render a transient
|
||||
// status pill ("Anruf abgelehnt", "Anruf beendet") before snapping back
|
||||
// to idle. Keep this comfortably above the user's reaction time but short
|
||||
// enough that returning to a chat feels snappy.
|
||||
const ENDED_STATE_LINGER_MS = 2500;
|
||||
|
||||
export function CallProvider({ children }: { children: React.ReactNode }) {
|
||||
const { user } = useAuth();
|
||||
const myUserId = user?.id ?? null;
|
||||
const [state, setState] = useState<CallState>({ kind: 'idle' });
|
||||
const roomRef = useRef<Room | null>(null);
|
||||
const participantsRef = useRef<Map<Identity, RemoteParticipantSummary>>(new Map());
|
||||
|
||||
const peerIdsFor = useCallback(
|
||||
async (conversationId: string): Promise<Identity[]> => {
|
||||
if (!myUserId) return [];
|
||||
const all = await chat.listConversations(supabase);
|
||||
const conv = all.find((c) => c.id === conversationId);
|
||||
if (!conv) return [];
|
||||
return conv.members.map((m) => m.userId).filter((id) => id !== myUserId);
|
||||
},
|
||||
[myUserId],
|
||||
);
|
||||
|
||||
const teardownRoom = useCallback(async () => {
|
||||
const r = roomRef.current;
|
||||
roomRef.current = null;
|
||||
participantsRef.current.clear();
|
||||
if (r) {
|
||||
try {
|
||||
await r.disconnect();
|
||||
} catch (err) {
|
||||
console.warn('[call] room.disconnect failed', err);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await AudioSession.stopAudioSession();
|
||||
} catch {
|
||||
/* already stopped */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateParticipantsState = useCallback(() => {
|
||||
setState((prev) => {
|
||||
if (prev.kind !== 'connected') return prev;
|
||||
return {
|
||||
...prev,
|
||||
participants: Array.from(participantsRef.current.values()),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const joinRoom = useCallback(
|
||||
async (conversationId: string, callId: string): Promise<void> => {
|
||||
const token = await rtc.fetchLivekitToken(supabase, conversationId);
|
||||
await AudioSession.startAudioSession();
|
||||
const room = new Room();
|
||||
roomRef.current = room;
|
||||
|
||||
room
|
||||
.on(RoomEvent.ParticipantConnected, (p) => {
|
||||
participantsRef.current.set(p.identity, {
|
||||
identity: p.identity,
|
||||
name: p.name || p.identity,
|
||||
speaking: p.isSpeaking,
|
||||
});
|
||||
updateParticipantsState();
|
||||
})
|
||||
.on(RoomEvent.ParticipantDisconnected, (p) => {
|
||||
participantsRef.current.delete(p.identity);
|
||||
updateParticipantsState();
|
||||
})
|
||||
.on(RoomEvent.ActiveSpeakersChanged, (speakers) => {
|
||||
for (const [id, entry] of participantsRef.current) {
|
||||
entry.speaking = speakers.some((sp) => sp.identity === id);
|
||||
participantsRef.current.set(id, entry);
|
||||
}
|
||||
updateParticipantsState();
|
||||
});
|
||||
|
||||
await room.connect(token.url, token.token);
|
||||
await room.localParticipant.setMicrophoneEnabled(true);
|
||||
|
||||
for (const p of room.remoteParticipants.values()) {
|
||||
participantsRef.current.set(p.identity, {
|
||||
identity: p.identity,
|
||||
name: p.name || p.identity,
|
||||
speaking: p.isSpeaking,
|
||||
});
|
||||
}
|
||||
|
||||
setState({
|
||||
kind: 'connected',
|
||||
callId,
|
||||
conversationId,
|
||||
muted: false,
|
||||
speakerOn: false,
|
||||
participants: Array.from(participantsRef.current.values()),
|
||||
});
|
||||
},
|
||||
[updateParticipantsState],
|
||||
);
|
||||
|
||||
// Signal subscription. Routes incoming invites / cancels / rejects to
|
||||
// state transitions. eslint-disable on deps because handleIncomingSignal
|
||||
// is defined inline below; re-subscribing on every render would churn
|
||||
// the realtime channel.
|
||||
useEffect(() => {
|
||||
if (!myUserId) return;
|
||||
const unsub = subscribeCallSignals(supabase, myUserId, (sig) => {
|
||||
handleIncomingSignal(sig);
|
||||
});
|
||||
return unsub;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [myUserId]);
|
||||
|
||||
function handleIncomingSignal(sig: CallSignal) {
|
||||
setState((prev) => {
|
||||
switch (sig.type) {
|
||||
case 'invite':
|
||||
if (prev.kind === 'idle') {
|
||||
return {
|
||||
kind: 'incoming',
|
||||
callId: sig.callId,
|
||||
conversationId: sig.conversationId,
|
||||
fromUserId: sig.fromUserId,
|
||||
};
|
||||
}
|
||||
return prev;
|
||||
case 'cancel':
|
||||
if (
|
||||
(prev.kind === 'incoming' || prev.kind === 'connecting') &&
|
||||
prev.callId === sig.callId
|
||||
) {
|
||||
return { kind: 'ended', reason: 'cancelled' };
|
||||
}
|
||||
return prev;
|
||||
case 'reject':
|
||||
if (prev.kind === 'outgoing' && prev.callId === sig.callId) {
|
||||
void teardownRoom();
|
||||
return { kind: 'ended', reason: 'rejected' };
|
||||
}
|
||||
return prev;
|
||||
case 'accept':
|
||||
return prev;
|
||||
case 'end':
|
||||
if (
|
||||
(prev.kind === 'connected' || prev.kind === 'connecting') &&
|
||||
prev.callId === sig.callId
|
||||
) {
|
||||
void teardownRoom();
|
||||
return { kind: 'ended', reason: 'normal' };
|
||||
}
|
||||
return prev;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const startCall = useCallback(
|
||||
async (conversationId: string): Promise<void> => {
|
||||
if (!myUserId) throw new Error('not authenticated');
|
||||
const callId = randomCallId();
|
||||
const peers = await peerIdsFor(conversationId);
|
||||
setState({ kind: 'outgoing', callId, conversationId, peers });
|
||||
try {
|
||||
await Promise.all(
|
||||
peers.map((peerId) =>
|
||||
sendCallSignal(supabase, peerId, {
|
||||
type: 'invite',
|
||||
callId,
|
||||
conversationId,
|
||||
fromUserId: myUserId,
|
||||
kind: 'audio',
|
||||
sentAt: new Date().toISOString(),
|
||||
}),
|
||||
),
|
||||
);
|
||||
await joinRoom(conversationId, callId);
|
||||
} catch (err) {
|
||||
console.warn('[call] startCall failed', err);
|
||||
await teardownRoom();
|
||||
setState({ kind: 'ended', reason: 'error' });
|
||||
}
|
||||
},
|
||||
[myUserId, peerIdsFor, joinRoom, teardownRoom],
|
||||
);
|
||||
|
||||
const acceptIncoming = useCallback(async (): Promise<void> => {
|
||||
if (!myUserId) return;
|
||||
if (state.kind !== 'incoming') return;
|
||||
const { callId, conversationId, fromUserId } = state;
|
||||
setState({ kind: 'connecting', callId, conversationId });
|
||||
try {
|
||||
await sendCallSignal(supabase, fromUserId, {
|
||||
type: 'accept',
|
||||
callId,
|
||||
byUserId: myUserId,
|
||||
});
|
||||
await joinRoom(conversationId, callId);
|
||||
} catch (err) {
|
||||
console.warn('[call] acceptIncoming failed', err);
|
||||
await teardownRoom();
|
||||
setState({ kind: 'ended', reason: 'error' });
|
||||
}
|
||||
}, [myUserId, state, joinRoom, teardownRoom]);
|
||||
|
||||
const rejectIncoming = useCallback(async (): Promise<void> => {
|
||||
if (!myUserId) return;
|
||||
if (state.kind !== 'incoming') return;
|
||||
const { callId, fromUserId } = state;
|
||||
setState({ kind: 'ended', reason: 'rejected' });
|
||||
try {
|
||||
await sendCallSignal(supabase, fromUserId, {
|
||||
type: 'reject',
|
||||
callId,
|
||||
byUserId: myUserId,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[call] rejectIncoming send failed', err);
|
||||
}
|
||||
}, [myUserId, state]);
|
||||
|
||||
const cancelOutgoing = useCallback(async (): Promise<void> => {
|
||||
if (!myUserId) return;
|
||||
if (state.kind !== 'outgoing') return;
|
||||
const { callId, peers } = state;
|
||||
setState({ kind: 'ended', reason: 'cancelled' });
|
||||
try {
|
||||
await Promise.all(
|
||||
peers.map((peerId) =>
|
||||
sendCallSignal(supabase, peerId, {
|
||||
type: 'cancel',
|
||||
callId,
|
||||
byUserId: myUserId,
|
||||
}),
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn('[call] cancelOutgoing send failed', err);
|
||||
}
|
||||
await teardownRoom();
|
||||
}, [myUserId, state, teardownRoom]);
|
||||
|
||||
const endCall = useCallback(async (): Promise<void> => {
|
||||
if (!myUserId) return;
|
||||
if (state.kind !== 'connected') {
|
||||
await teardownRoom();
|
||||
setState({ kind: 'idle' });
|
||||
return;
|
||||
}
|
||||
const { callId, conversationId } = state;
|
||||
const peers = await peerIdsFor(conversationId);
|
||||
setState({ kind: 'ended', reason: 'normal' });
|
||||
try {
|
||||
await Promise.all(
|
||||
peers.map((peerId) =>
|
||||
sendCallSignal(supabase, peerId, {
|
||||
type: 'end',
|
||||
callId,
|
||||
byUserId: myUserId,
|
||||
}),
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn('[call] endCall send failed', err);
|
||||
}
|
||||
await teardownRoom();
|
||||
}, [myUserId, state, peerIdsFor, teardownRoom]);
|
||||
|
||||
const toggleMute = useCallback(async (): Promise<void> => {
|
||||
if (state.kind !== 'connected') return;
|
||||
const r = roomRef.current;
|
||||
if (!r) return;
|
||||
const nextMuted = !state.muted;
|
||||
try {
|
||||
await r.localParticipant.setMicrophoneEnabled(!nextMuted);
|
||||
setState((prev) =>
|
||||
prev.kind === 'connected' ? { ...prev, muted: nextMuted } : prev,
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn('[call] toggleMute failed', err);
|
||||
}
|
||||
}, [state]);
|
||||
|
||||
const toggleSpeaker = useCallback(async (): Promise<void> => {
|
||||
if (state.kind !== 'connected') return;
|
||||
const nextSpeaker = !state.speakerOn;
|
||||
try {
|
||||
await AudioSession.selectAudioOutput(nextSpeaker ? 'speaker' : 'earpiece');
|
||||
setState((prev) =>
|
||||
prev.kind === 'connected' ? { ...prev, speakerOn: nextSpeaker } : prev,
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn('[call] toggleSpeaker failed', err);
|
||||
}
|
||||
}, [state]);
|
||||
|
||||
// Drift `ended` back to `idle` after the linger window so the UI can
|
||||
// show a brief status pill before snapping back.
|
||||
useEffect(() => {
|
||||
if (state.kind !== 'ended') return;
|
||||
const t = setTimeout(() => {
|
||||
setState({ kind: 'idle' });
|
||||
}, ENDED_STATE_LINGER_MS);
|
||||
return () => clearTimeout(t);
|
||||
}, [state]);
|
||||
|
||||
const value: CallContextValue = useMemo(
|
||||
() => ({
|
||||
state,
|
||||
startCall,
|
||||
acceptIncoming,
|
||||
rejectIncoming,
|
||||
cancelOutgoing,
|
||||
endCall,
|
||||
toggleMute,
|
||||
toggleSpeaker,
|
||||
}),
|
||||
[state, startCall, acceptIncoming, rejectIncoming, cancelOutgoing, endCall, toggleMute, toggleSpeaker],
|
||||
);
|
||||
|
||||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||
}
|
||||
|
||||
export { Track };
|
||||
@@ -0,0 +1,37 @@
|
||||
import { rtc } from '@chat-app/shared';
|
||||
import type { CallSignal } from '@chat-app/shared/rtc';
|
||||
import type { AppSupabaseClient } from '@chat-app/shared/supabase';
|
||||
|
||||
// Thin wrapper around Supabase realtime broadcast for call signaling.
|
||||
// One channel per peer userId. Subscriptions live for the lifetime of
|
||||
// the AuthProvider's session; teardown returns a no-arg unsubscribe.
|
||||
|
||||
export type SignalListener = (signal: CallSignal) => void;
|
||||
|
||||
export function subscribeCallSignals(
|
||||
client: AppSupabaseClient,
|
||||
myUserId: string,
|
||||
onSignal: SignalListener,
|
||||
): () => void {
|
||||
const ch = client.channel(rtc.signalTopic(myUserId));
|
||||
ch.on('broadcast', { event: 'signal' }, (msg) => {
|
||||
if (msg.payload && typeof msg.payload === 'object') {
|
||||
onSignal(msg.payload as CallSignal);
|
||||
}
|
||||
});
|
||||
void ch.subscribe();
|
||||
return () => {
|
||||
void client.removeChannel(ch);
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendCallSignal(
|
||||
client: AppSupabaseClient,
|
||||
toUserId: string,
|
||||
payload: CallSignal,
|
||||
): Promise<void> {
|
||||
const ch = client.channel(rtc.signalTopic(toUserId));
|
||||
await ch.subscribe();
|
||||
await ch.send({ type: 'broadcast', event: 'signal', payload });
|
||||
await client.removeChannel(ch);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as s from 'react-native-libsodium';
|
||||
import type { CryptoBackend } from '@chat-app/shared/crypto';
|
||||
|
||||
// react-native-libsodium re-exports libsodium-wrappers' API shape, so
|
||||
// this adapter is the synchronous twin of the desktop one
|
||||
// (`apps/desktop/src/lib/cryptoBackend.ts`). No WASM warm-up gate is
|
||||
// needed — the native module is ready as soon as the module loads.
|
||||
export function createLibsodiumBackend(): CryptoBackend {
|
||||
return {
|
||||
name: 'react-native-libsodium',
|
||||
nonceLength: s.crypto_box_NONCEBYTES,
|
||||
publicKeyLength: s.crypto_box_PUBLICKEYBYTES,
|
||||
privateKeyLength: s.crypto_box_SECRETKEYBYTES,
|
||||
secretboxKeyLength: s.crypto_secretbox_KEYBYTES,
|
||||
secretboxNonceLength: s.crypto_secretbox_NONCEBYTES,
|
||||
randomBytes: (n) => s.randombytes_buf(n),
|
||||
generateKeyPair: () => {
|
||||
const kp = s.crypto_box_keypair();
|
||||
return { publicKey: kp.publicKey, privateKey: kp.privateKey };
|
||||
},
|
||||
box: (plaintext, nonce, recipientPublicKey, senderPrivateKey) =>
|
||||
s.crypto_box_easy(plaintext, nonce, recipientPublicKey, senderPrivateKey),
|
||||
boxOpen: (ciphertext, nonce, senderPublicKey, recipientPrivateKey) =>
|
||||
s.crypto_box_open_easy(ciphertext, nonce, senderPublicKey, recipientPrivateKey),
|
||||
secretbox: (plaintext, nonce, key) => s.crypto_secretbox_easy(plaintext, nonce, key),
|
||||
secretboxOpen: (ciphertext, nonce, key) => s.crypto_secretbox_open_easy(ciphertext, nonce, key),
|
||||
pwhashConsts: {
|
||||
OPSLIMIT_MODERATE: s.crypto_pwhash_OPSLIMIT_MODERATE,
|
||||
MEMLIMIT_MODERATE: s.crypto_pwhash_MEMLIMIT_MODERATE,
|
||||
ALG_ARGON2ID13: s.crypto_pwhash_ALG_ARGON2ID13,
|
||||
},
|
||||
pwhash: (outLen, password, salt, opslimit, memlimit, alg) =>
|
||||
s.crypto_pwhash(outLen, password, salt, opslimit, memlimit, alg),
|
||||
scalarMultBase: (priv) => s.crypto_scalarmult_base(priv),
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user