Compare commits
134 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d803773261 | |||
| 49855c5d3f | |||
| c9a64bf898 | |||
| 7f704e80f6 | |||
| 940432d287 | |||
| 28b6d64936 | |||
| 92baa626d6 | |||
| cd7ef8dccc | |||
| 58efc66ca7 | |||
| 1e139fb86e | |||
| eeb713f03d | |||
| 837b5a326e | |||
| b1f37752d6 | |||
| 854c4b91a8 | |||
| d3b708636f | |||
| c449943b52 | |||
| db59e3f658 | |||
| 6c6828006b | |||
| 21376daf39 | |||
| 6d0e4fb1f0 | |||
| 36ab7eca8a | |||
| ddd696a790 | |||
| 53b3b5e1fc | |||
| a4573b315d | |||
| c271e95100 | |||
| 888ed1b217 | |||
| a974e5b8ab | |||
| 7ebc5f6c9d | |||
| b8b451ef4f | |||
| d7c0c3d0a2 | |||
| 70ce824120 | |||
| d10840e0b2 | |||
| cd59ee30d6 | |||
| e56e22631b | |||
| dbf90504b4 | |||
| 256a613134 | |||
| e1423dba32 | |||
| 7730828403 | |||
| ecbd11e369 | |||
| 7d5f3b37cd | |||
| 7ce6b1c1d4 | |||
| 4399d39f08 | |||
| ad239ec549 | |||
| d1193142ae | |||
| 997252c4cb | |||
| 890d5dc2b7 | |||
| 759113b9ce | |||
| f981904e7f | |||
| 1fe196b839 | |||
| 1b4cf63e07 | |||
| 18197a9f95 | |||
| 383245ec90 | |||
| 5e59ee22f6 | |||
| be6f5b9c6f | |||
| 2243c646ac | |||
| 3df7cc01ea | |||
| f95858c703 | |||
| d29e2c1174 | |||
| 11a9c8b173 | |||
| d623a59c87 | |||
| 2a2e334f51 | |||
| 9228cc635c | |||
| 7056bfdd1c | |||
| 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 |
@@ -33,6 +33,10 @@ web-build/
|
||||
apps/desktop/out/
|
||||
apps/desktop/release/
|
||||
|
||||
# Bundle visualizer reports
|
||||
apps/desktop/stats.html
|
||||
apps/desktop/stats.json
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
|
||||
import path from 'node:path';
|
||||
import { visualizer } from 'rollup-plugin-visualizer';
|
||||
|
||||
const ANALYZE = process.env.ANALYZE === 'true';
|
||||
|
||||
const rendererAliases = {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
@@ -44,7 +47,30 @@ export default defineConfig({
|
||||
// bundle. Relative base produces `./assets/...` which works in both
|
||||
// dev (served from /) and packaged builds.
|
||||
base: './',
|
||||
plugins: [react()],
|
||||
// Visualizer plugins are gated behind ANALYZE=true so the production
|
||||
// build never pays the analysis cost. Re-enable with:
|
||||
// ANALYZE=true pnpm --filter @chat-app/desktop build
|
||||
// which writes apps/desktop/stats.html (treemap) + stats.json (raw).
|
||||
plugins: [
|
||||
react(),
|
||||
...(ANALYZE
|
||||
? [
|
||||
visualizer({
|
||||
filename: 'stats.html',
|
||||
template: 'treemap',
|
||||
gzipSize: true,
|
||||
brotliSize: true,
|
||||
open: false,
|
||||
}),
|
||||
visualizer({
|
||||
filename: 'stats.json',
|
||||
template: 'raw-data',
|
||||
gzipSize: true,
|
||||
brotliSize: true,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
resolve: {
|
||||
alias: rendererAliases,
|
||||
},
|
||||
|
||||
@@ -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';
|
||||
@@ -145,6 +147,17 @@ async function createWindow(): Promise<BrowserWindow> {
|
||||
attachWindowState(win, WINDOW_STATE_FILE);
|
||||
|
||||
if (!app.isPackaged) {
|
||||
// Auto-open DevTools in dev — the menu bar is stripped (Discord-style)
|
||||
// so F12 / Ctrl+Shift+I have no chord; opening detached gives a
|
||||
// separate inspector window for easy debugging.
|
||||
win.webContents.openDevTools({ mode: 'detach' });
|
||||
// Forward renderer console messages to the main-process stdout so
|
||||
// errors during local dev are visible in the terminal too (helps when
|
||||
// the inspector isn't focused).
|
||||
win.webContents.on('console-message', (_event, level, message, line, sourceId) => {
|
||||
const tag = level === 3 ? 'error' : level === 2 ? 'warn' : level === 1 ? 'log' : 'info';
|
||||
console.log('[renderer ' + tag + ']', message, '(' + sourceId + ':' + line + ')');
|
||||
});
|
||||
await win.loadURL(DEV_URL);
|
||||
} else {
|
||||
await win.loadFile(resolveRendererIndex());
|
||||
@@ -253,6 +266,7 @@ if (!gotLock) {
|
||||
registerFsScoped();
|
||||
registerSql();
|
||||
registerAutostart();
|
||||
registerAppHostname();
|
||||
|
||||
// Window-dependent registrars — only call once mainWindow exists so
|
||||
// event emitters have somewhere to send.
|
||||
@@ -263,6 +277,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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -6,8 +6,10 @@
|
||||
// (<1ms per op for the current workload).
|
||||
//
|
||||
// Binding param style: Tauri's plugin-sql used $1, $2... positionals with
|
||||
// bindings as an array. SQLite natively accepts $N so existing queries
|
||||
// keep working unmodified.
|
||||
// bindings as an array. SQLite parses `$NAME` as a NAMED parameter
|
||||
// (NAME = `1`, `2`, …), not as positional, so better-sqlite3 wants the
|
||||
// bindings as `{ '1': v1, '2': v2 }` not `[v1, v2]`. We accept the old
|
||||
// array-shape from callers and convert to the named-object on the way in.
|
||||
|
||||
import { app, ipcMain } from 'electron';
|
||||
import Database from 'better-sqlite3';
|
||||
@@ -40,6 +42,20 @@ function requireHandle(h: string): Handle {
|
||||
return entry;
|
||||
}
|
||||
|
||||
// Convert a positional bindings array `[v1, v2]` to the named-params object
|
||||
// `{ '1': v1, '2': v2 }` that better-sqlite3 needs when the SQL uses
|
||||
// `$1`/`$2` named placeholders. Returns the original array (spread later)
|
||||
// when it's empty.
|
||||
function bindParams(bindings: unknown[] | undefined): Record<string, unknown> | [] {
|
||||
const arr = bindings ?? [];
|
||||
if (arr.length === 0) return [];
|
||||
const obj: Record<string, unknown> = {};
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
obj[String(i + 1)] = arr[i];
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
export function register(): void {
|
||||
ipcMain.handle(CHANNELS.SQL_LOAD, async (_evt, args: SqlLoadArgs): Promise<string> => {
|
||||
const rawName = stripPrefix(args.name);
|
||||
@@ -59,7 +75,8 @@ export function register(): void {
|
||||
async (_evt, args: SqlExecuteArgs): Promise<SqlExecuteResult> => {
|
||||
const entry = requireHandle(args.handle);
|
||||
const stmt = entry.db.prepare(args.query);
|
||||
const info = stmt.run(...((args.bindings ?? []) as unknown[]));
|
||||
const params = bindParams(args.bindings);
|
||||
const info = Array.isArray(params) ? stmt.run() : stmt.run(params);
|
||||
return {
|
||||
rowsAffected: info.changes,
|
||||
lastInsertId:
|
||||
@@ -75,7 +92,11 @@ export function register(): void {
|
||||
async (_evt, args: SqlSelectArgs): Promise<SqlSelectResult> => {
|
||||
const entry = requireHandle(args.handle);
|
||||
const stmt = entry.db.prepare(args.query);
|
||||
const rows = stmt.all(...((args.bindings ?? []) as unknown[])) as Record<string, unknown>[];
|
||||
const params = bindParams(args.bindings);
|
||||
const rows = (Array.isArray(params) ? stmt.all() : stmt.all(params)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>[];
|
||||
return rows;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -159,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.18.8",
|
||||
"version": "0.20.0",
|
||||
"private": true,
|
||||
"description": "Electron desktop client (Windows / macOS / Linux)",
|
||||
"type": "module",
|
||||
@@ -25,6 +25,7 @@
|
||||
"@livekit/track-processors": "^0.7.2",
|
||||
"@supabase/supabase-js": "^2.46.0",
|
||||
"better-sqlite3": "^11.3.0",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"electron-updater": "^6.3.0",
|
||||
"i18next": "^23.16.4",
|
||||
"libsodium-wrappers-sumo": "0.7.15",
|
||||
@@ -34,10 +35,12 @@
|
||||
"react-easy-crop": "^5.5.7",
|
||||
"react-i18next": "^15.1.1",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"react-virtuoso": "^4.18.7",
|
||||
"zustand": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.0",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/libsodium-wrappers": "^0.7.14",
|
||||
"@types/libsodium-wrappers-sumo": "^0.8.2",
|
||||
"@types/react": "^18.3.12",
|
||||
@@ -49,6 +52,7 @@
|
||||
"electron-vite": "^2.3.0",
|
||||
"postcss": "^8.4.49",
|
||||
"rimraf": "^6.0.0",
|
||||
"rollup-plugin-visualizer": "^7.0.1",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"vite": "^5.4.11"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2,10 +2,12 @@ import { useEffect } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useIdleAutoLock } from '../hooks/useIdleAutoLock';
|
||||
import { startConversationKeySync } from '../lib/conversationKeySync';
|
||||
import { startDeviceApprovalListener } from '../lib/deviceApproval';
|
||||
import { ensureInstallId } from '../lib/installId';
|
||||
import { ensureNotificationPermission } from '../lib/osNotify';
|
||||
import { useMentionNotifications } from '../lib/useMentionNotifications';
|
||||
import { cachedUserKey } from '../lib/userIdentity';
|
||||
import { CallUI } from './CallUI';
|
||||
import { DeviceApprovalBanner } from './DeviceApprovalBanner';
|
||||
@@ -13,6 +15,8 @@ import { Sidebar } from './Sidebar';
|
||||
|
||||
export function AppShell() {
|
||||
const { session } = useAuth();
|
||||
useIdleAutoLock();
|
||||
useMentionNotifications(session?.user.id);
|
||||
useEffect(() => {
|
||||
// Prompt once per authenticated shell mount. Module-level guard prevents
|
||||
// re-asking if the user already responded this session.
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
type AttachmentHandle,
|
||||
downloadAndDecryptAttachment,
|
||||
downloadAndDecryptAttachmentThumb,
|
||||
} from '@chat-app/shared/chat';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
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,12 +54,23 @@ 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);
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
|
||||
// Whether the sender shipped a pre-built WebP thumb alongside this attachment
|
||||
// (Phase 6B+). When true we render the bubble from just the thumb and only
|
||||
// fetch the full blob when the user opens the lightbox or for view-once.
|
||||
const hasServerThumb = Boolean(handle.thumbStoragePath && handle.thumbNonceB64);
|
||||
// View-once needs the full blob ready instantly the moment the recipient
|
||||
// taps (otherwise we'd show a spinner during the burn animation, then race
|
||||
// the "mark viewed" RPC). Same for the legacy path with no server thumb —
|
||||
// we have to download the whole thing just to render the client-side
|
||||
// makeThumbnail fallback.
|
||||
const needsEagerFull = handle.viewOnce === true || !hasServerThumb;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const created: string[] = [];
|
||||
@@ -63,9 +84,39 @@ export function AttachmentImage({ handle }: Props) {
|
||||
return u;
|
||||
};
|
||||
|
||||
// OPFS cache → decrypt → generate thumbnail for inline display.
|
||||
// Lightbox swaps to the full blob when opened.
|
||||
const thumbCacheId = handle.id + '-thumb';
|
||||
|
||||
void (async () => {
|
||||
// Phase 6B fast path: if the sender shipped a server-side WebP thumb,
|
||||
// grab it first so the bubble paints from ~20KB instead of waiting on
|
||||
// the multi-MB full blob. The OPFS cache is keyed separately so the
|
||||
// thumb survives independent of full-blob eviction.
|
||||
if (hasServerThumb) {
|
||||
try {
|
||||
let thumbBlob: Blob | null = await getCachedAttachment(thumbCacheId);
|
||||
if (!thumbBlob) {
|
||||
thumbBlob = await downloadAndDecryptAttachmentThumb({
|
||||
client: supabase,
|
||||
handle,
|
||||
});
|
||||
if (thumbBlob) void putCachedAttachment(thumbCacheId, thumbBlob);
|
||||
}
|
||||
if (cancelled) return;
|
||||
if (thumbBlob) {
|
||||
setThumbUrl(take(thumbBlob));
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
// Thumb decrypt failure isn't fatal — fall through to the full
|
||||
// blob path below so the user still sees the image.
|
||||
console.warn('thumb load failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Eagerly resolve the full blob when we need it for view-once or as
|
||||
// the only render source (no server thumb). For the thumb-first path
|
||||
// the full blob is deferred until the lightbox opens (see below).
|
||||
if (!needsEagerFull) return;
|
||||
|
||||
const cached = await getCachedAttachment(handle.id);
|
||||
let blob: Blob;
|
||||
if (cached) {
|
||||
@@ -84,10 +135,14 @@ export function AttachmentImage({ handle }: Props) {
|
||||
if (cancelled) return;
|
||||
const full = take(blob);
|
||||
setFullUrl(full);
|
||||
const thumb = await makeThumbnail(blob);
|
||||
if (cancelled) return;
|
||||
if (thumb) {
|
||||
setThumbUrl(take(thumb));
|
||||
// Pre-Phase-6B fallback: no server thumb shipped, so re-derive a
|
||||
// smaller preview on the client to keep memory pressure down.
|
||||
if (!hasServerThumb) {
|
||||
const thumb = await makeThumbnail(blob);
|
||||
if (cancelled) return;
|
||||
if (thumb) {
|
||||
setThumbUrl(take(thumb));
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -95,7 +150,74 @@ export function AttachmentImage({ handle }: Props) {
|
||||
cancelled = true;
|
||||
for (const u of created) URL.revokeObjectURL(u);
|
||||
};
|
||||
}, [handle.id, handle.storagePath, handle.keyB64, handle.nonceB64]);
|
||||
}, [
|
||||
handle,
|
||||
handle.id,
|
||||
handle.storagePath,
|
||||
handle.keyB64,
|
||||
handle.nonceB64,
|
||||
handle.thumbStoragePath,
|
||||
handle.thumbNonceB64,
|
||||
hasServerThumb,
|
||||
needsEagerFull,
|
||||
]);
|
||||
|
||||
// Lazy full-image fetch for click-to-expand (only kicks in when we
|
||||
// skipped the eager full-blob download above). Resolves into the same
|
||||
// `fullUrl` state that the Lightbox consumes; the thumb URL keeps
|
||||
// backing the bubble until the lightbox actually mounts.
|
||||
//
|
||||
// CRITICAL: do NOT revoke the just-created blob URL in this effect's
|
||||
// cleanup. Setting `fullUrl` re-triggers the effect (state change → re-
|
||||
// run → previous cleanup fires → URL revoked → Lightbox renders
|
||||
// referenced-but-revoked URL → "ERR_FILE_NOT_FOUND"). The dedicated
|
||||
// unmount-only effect below tracks the current URL via ref and revokes
|
||||
// it once when the component truly leaves the tree.
|
||||
//
|
||||
// Deps locked to `handle.id` (not `handle`) — handles are immutable per
|
||||
// attachment id, so object-identity churn from parent re-renders must
|
||||
// not re-trigger the fetch.
|
||||
useEffect(() => {
|
||||
if (!lightboxOpen) return;
|
||||
if (fullUrl) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const cached = await getCachedAttachment(handle.id);
|
||||
let blob: Blob;
|
||||
if (cached) {
|
||||
blob = cached;
|
||||
} else {
|
||||
try {
|
||||
blob = await downloadAndDecryptAttachment({ client: supabase, handle });
|
||||
void putCachedAttachment(handle.id, blob);
|
||||
} catch (err: unknown) {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : 'download failed');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (cancelled) return;
|
||||
const u = URL.createObjectURL(blob);
|
||||
setFullUrl(u);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [lightboxOpen, handle.id]);
|
||||
|
||||
// Track the currently-published fullUrl in a ref so the unmount-only
|
||||
// cleanup below can revoke whatever URL is live at teardown time
|
||||
// without subscribing to fullUrl changes (which would re-trigger and
|
||||
// revoke prematurely — see the comment above the fetch effect).
|
||||
const fullUrlRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
fullUrlRef.current = fullUrl;
|
||||
}, [fullUrl]);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (fullUrlRef.current) URL.revokeObjectURL(fullUrlRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const blobUrl = thumbUrl ?? fullUrl;
|
||||
|
||||
@@ -116,6 +238,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
|
||||
@@ -132,7 +273,15 @@ export function AttachmentImage({ handle }: Props) {
|
||||
className="block h-auto max-h-80 w-auto max-w-full object-contain"
|
||||
/>
|
||||
</button>
|
||||
{lightboxOpen && fullUrl && <Lightbox url={fullUrl} onClose={() => setLightboxOpen(false)} />}
|
||||
{lightboxOpen && (
|
||||
<Lightbox
|
||||
// Prefer the full blob the moment it's available; otherwise show
|
||||
// the thumb so the user sees *something* during the lazy fetch
|
||||
// (typical full-blob fetch is 100ms–2s depending on size).
|
||||
url={fullUrl ?? blobUrl}
|
||||
onClose={() => setLightboxOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,11 @@ interface Props {
|
||||
// brand-tinted fallback; callers can override (e.g. to colour-by-id).
|
||||
fallbackClass?: string;
|
||||
alt?: string;
|
||||
// Browser loading hint. Use 'eager' for above-the-fold avatars (e.g. the
|
||||
// active conversation header, call tiles). Defaults to 'lazy' so off-screen
|
||||
// avatars (chat list rows, friends list, popovers) don't hammer Supabase
|
||||
// Storage on initial render.
|
||||
loading?: 'eager' | 'lazy';
|
||||
}
|
||||
|
||||
export function Avatar({
|
||||
@@ -19,6 +24,7 @@ export function Avatar({
|
||||
className = 'h-10 w-10',
|
||||
fallbackClass = 'bg-accent/20 text-accent',
|
||||
alt,
|
||||
loading = 'lazy',
|
||||
}: Props) {
|
||||
const effectiveUrl = useCachedAvatarUrl(url);
|
||||
if (effectiveUrl) {
|
||||
@@ -28,6 +34,7 @@ export function Avatar({
|
||||
alt={alt ?? displayName ?? ''}
|
||||
className={'shrink-0 rounded-full object-cover ' + className}
|
||||
draggable={false}
|
||||
loading={loading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||
import { useCall } from '../context/CallContext';
|
||||
|
||||
interface Props {
|
||||
conversation: ConversationSummary;
|
||||
}
|
||||
|
||||
const STALE_AFTER_MS = 5000;
|
||||
|
||||
/** Discord-style live-captions overlay. Pinned to the bottom-center of the
|
||||
* call surface; renders the most recent caption per participant, fading
|
||||
* entries out after `STALE_AFTER_MS` of silence. Self-captions are shown
|
||||
* too so the speaker can sanity-check what's being broadcast. */
|
||||
export function CallCaptionsOverlay({ conversation }: Props) {
|
||||
const { captions } = useCall();
|
||||
// Re-render every second so stale entries fade without needing the data
|
||||
// channel to fire — captions module just stores timestamps.
|
||||
const [, setNow] = useState(Date.now());
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const now = Date.now();
|
||||
const visible = Object.entries(captions)
|
||||
.filter(([, v]) => now - v.timestamp < STALE_AFTER_MS)
|
||||
.sort(([, a], [, b]) => a.timestamp - b.timestamp);
|
||||
|
||||
if (visible.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-24 z-30 flex flex-col items-center gap-1.5 px-6">
|
||||
{visible.map(([identity, c]) => {
|
||||
const member = conversation.members.find((m) => m.userId === identity);
|
||||
const name = member?.profile?.displayName ?? '?';
|
||||
const age = now - c.timestamp;
|
||||
const opacity = age > 3500 ? 1 - (age - 3500) / (STALE_AFTER_MS - 3500) : 1;
|
||||
return (
|
||||
<div
|
||||
key={identity}
|
||||
style={{ opacity: Math.max(0, opacity) }}
|
||||
className="max-w-[760px] rounded-lg bg-black/72 px-3.5 py-1.5 text-sm text-white shadow-md backdrop-blur-md transition-opacity"
|
||||
>
|
||||
<span className="mr-2 text-[11px] font-semibold uppercase tracking-wide text-white/55">
|
||||
{name}
|
||||
</span>
|
||||
<span className={c.final ? '' : 'italic text-white/85'}>{c.text}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
CaptionsIcon,
|
||||
HeadphonesIcon,
|
||||
HeadphonesOffIcon,
|
||||
MicIcon,
|
||||
@@ -32,10 +31,6 @@ interface Props {
|
||||
/** Toggle the in-call soundboard popover. Active = panel currently open. */
|
||||
onToggleSoundboard?: () => void;
|
||||
soundboardOpen?: boolean;
|
||||
/** Discord-style live-captions toggle. Optional — pages that don't support
|
||||
* SpeechRecognition (Firefox) skip the prop and the button is hidden. */
|
||||
onToggleCaptions?: () => void;
|
||||
captionsOn?: boolean;
|
||||
participantsOpen?: boolean;
|
||||
/** Compact variant used inside the docked call (36px buttons). */
|
||||
compact?: boolean;
|
||||
@@ -59,8 +54,6 @@ export function CallControls({
|
||||
onOpenParticipants,
|
||||
onToggleSoundboard,
|
||||
soundboardOpen = false,
|
||||
onToggleCaptions,
|
||||
captionsOn = false,
|
||||
participantsOpen = false,
|
||||
compact = false,
|
||||
glass = false,
|
||||
@@ -148,22 +141,6 @@ export function CallControls({
|
||||
<MusicIcon className="h-5 w-5" />
|
||||
</CallButton>
|
||||
)}
|
||||
{onToggleCaptions && (
|
||||
<CallButton
|
||||
label={
|
||||
captionsOn
|
||||
? t('app:call.captions_off', { defaultValue: 'Untertitel aus' })
|
||||
: t('app:call.captions_on', { defaultValue: 'Untertitel an' })
|
||||
}
|
||||
active={captionsOn}
|
||||
activeTone="accent"
|
||||
onClick={onToggleCaptions}
|
||||
glass={glass}
|
||||
className={btnSize}
|
||||
>
|
||||
<CaptionsIcon className="h-5 w-5" />
|
||||
</CallButton>
|
||||
)}
|
||||
{onOpenParticipants && (
|
||||
<CallButton
|
||||
label={t('app:call.participants', { defaultValue: 'Teilnehmer' })}
|
||||
|
||||
@@ -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 && (
|
||||
@@ -282,6 +291,7 @@ function AudioContent({
|
||||
src={avatarUrl}
|
||||
alt=""
|
||||
className="relative h-full w-full rounded-full object-cover"
|
||||
loading="eager"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
@@ -357,7 +367,7 @@ function VideoStub({
|
||||
}
|
||||
>
|
||||
{avatarUrl ? (
|
||||
<img src={avatarUrl} alt="" className="h-full w-full rounded-full object-cover" />
|
||||
<img src={avatarUrl} alt="" className="h-full w-full rounded-full object-cover" loading="eager" />
|
||||
) : (
|
||||
letter
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { MonitorShareIcon, PollIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
anchorRef: React.RefObject<HTMLButtonElement | null>;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onAttachFile: () => void;
|
||||
onCreatePoll: () => void;
|
||||
onCreateWhiteboard: () => void;
|
||||
onStartWatchTogether: () => void;
|
||||
onStartGame: () => void;
|
||||
canStartGame?: boolean;
|
||||
}
|
||||
|
||||
export function ComposerActionsMenu({
|
||||
anchorRef,
|
||||
open,
|
||||
onClose,
|
||||
onAttachFile,
|
||||
onCreatePoll,
|
||||
onCreateWhiteboard,
|
||||
onStartWatchTogether,
|
||||
onStartGame,
|
||||
canStartGame = true,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
const firstItemRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
// Auto-focus the first item when menu opens (a11y) + click-outside/Esc handlers
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
firstItemRef.current?.focus();
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
const target = e.target as Node | null;
|
||||
if (!target) return;
|
||||
if (menuRef.current?.contains(target)) return;
|
||||
if (anchorRef.current?.contains(target)) return;
|
||||
onClose();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('mousedown', onDocClick);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDocClick);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [open, onClose, anchorRef]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
// Each item: closes the menu, then runs the action.
|
||||
const items: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
Icon: React.ComponentType<React.ComponentPropsWithoutRef<'svg'>>;
|
||||
action: () => void;
|
||||
disabled?: boolean;
|
||||
disabledTitle?: string;
|
||||
section: 'top' | 'activities';
|
||||
}> = [
|
||||
{
|
||||
key: 'attach',
|
||||
label: t('app:composer.menu.attach', { defaultValue: 'Bild / Datei' }),
|
||||
Icon: PaperclipIcon,
|
||||
action: onAttachFile,
|
||||
section: 'top',
|
||||
},
|
||||
{
|
||||
key: 'poll',
|
||||
label: t('app:composer.menu.poll', { defaultValue: 'Umfrage' }),
|
||||
Icon: PollIcon,
|
||||
action: onCreatePoll,
|
||||
section: 'top',
|
||||
},
|
||||
{
|
||||
key: 'whiteboard',
|
||||
label: t('app:composer.menu.whiteboard', { defaultValue: 'Whiteboard' }),
|
||||
Icon: MonitorShareIcon,
|
||||
action: onCreateWhiteboard,
|
||||
section: 'activities',
|
||||
},
|
||||
{
|
||||
key: 'watch',
|
||||
label: t('app:composer.menu.watch', { defaultValue: 'Watch Together' }),
|
||||
Icon: PlayBoxIcon,
|
||||
action: onStartWatchTogether,
|
||||
section: 'activities',
|
||||
},
|
||||
{
|
||||
key: 'game',
|
||||
label: t('app:composer.menu.game', { defaultValue: 'Spiel starten' }),
|
||||
Icon: GameIcon,
|
||||
action: onStartGame,
|
||||
disabled: !canStartGame,
|
||||
disabledTitle: t('app:composer.menu.game_dm_only', {
|
||||
defaultValue: 'Nur in 1:1-Chats',
|
||||
}),
|
||||
section: 'activities',
|
||||
},
|
||||
];
|
||||
|
||||
const handleItemClick = (item: (typeof items)[number]) => {
|
||||
if (item.disabled) return;
|
||||
onClose();
|
||||
item.action();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const focusable = menuRef.current?.querySelectorAll<HTMLButtonElement>(
|
||||
'button[role="menuitem"]:not([disabled])',
|
||||
);
|
||||
if (!focusable || focusable.length === 0) return;
|
||||
const list = Array.from(focusable);
|
||||
const idx = list.findIndex((el) => el === document.activeElement);
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
list[(idx + 1) % list.length]?.focus();
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
list[(idx - 1 + list.length) % list.length]?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const topItems = items.filter((i) => i.section === 'top');
|
||||
const activityItems = items.filter((i) => i.section === 'activities');
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
role="menu"
|
||||
onKeyDown={handleKeyDown}
|
||||
// Positioned absolutely above the anchor; the wrapping parent (the
|
||||
// composer) must be `position: relative` for this to anchor correctly.
|
||||
className="absolute bottom-full left-0 z-30 mb-2 w-56 overflow-hidden rounded-xl border border-line bg-surface-2 shadow-xl"
|
||||
>
|
||||
{topItems.map((item, idx) => {
|
||||
const Icon = item.Icon;
|
||||
const isFirst = idx === 0;
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
ref={isFirst ? firstItemRef : undefined}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => handleItemClick(item)}
|
||||
disabled={item.disabled}
|
||||
title={item.disabled ? item.disabledTitle : undefined}
|
||||
className={
|
||||
'flex w-full cursor-pointer items-center gap-3 px-3 py-2.5 text-left text-sm font-medium text-fg transition focus:outline-none ' +
|
||||
(item.disabled
|
||||
? 'cursor-not-allowed opacity-50'
|
||||
: 'hover:bg-surface-3 focus-visible:bg-surface-3')
|
||||
}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0 text-fg-muted" />
|
||||
<span className="flex-1 truncate">{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div
|
||||
role="separator"
|
||||
className="border-t border-line/60"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="px-3 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{t('app:composer.menu.section_activities', { defaultValue: 'Aktivitäten' })}
|
||||
</div>
|
||||
{activityItems.map((item) => {
|
||||
const Icon = item.Icon;
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => handleItemClick(item)}
|
||||
disabled={item.disabled}
|
||||
title={item.disabled ? item.disabledTitle : undefined}
|
||||
className={
|
||||
'flex w-full cursor-pointer items-center gap-3 px-3 py-2.5 text-left text-sm font-medium text-fg transition focus:outline-none ' +
|
||||
(item.disabled
|
||||
? 'cursor-not-allowed opacity-50'
|
||||
: 'hover:bg-surface-3 focus-visible:bg-surface-3')
|
||||
}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0 text-fg-muted" />
|
||||
<span className="flex-1 truncate">{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Inline icons not in the central icons module ----------------------
|
||||
|
||||
function PaperclipIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
{...props}
|
||||
>
|
||||
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayBoxIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
{...props}
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="14" rx="2" />
|
||||
<path d="M10 9l5 3-5 3V9z" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GameIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
{...props}
|
||||
>
|
||||
<rect x="3" y="6" width="18" height="12" rx="3" />
|
||||
<path d="M8 12h4M10 10v4M16 11v.01M16 14v.01" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
C4_COLS,
|
||||
C4_ROWS,
|
||||
c4DropRow,
|
||||
c4WinningCells,
|
||||
type Cell,
|
||||
} from '@chat-app/shared/chat';
|
||||
|
||||
interface Props {
|
||||
board: Cell[];
|
||||
myPlayerIdx: 0 | 1 | null;
|
||||
disabled: boolean;
|
||||
onMove: (column: number) => void;
|
||||
}
|
||||
|
||||
export function ConnectFourBoard({ board, myPlayerIdx, disabled, onMove }: Props) {
|
||||
const winCells = c4WinningCells(board);
|
||||
const winSet = new Set<number>(winCells ?? []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mx-auto w-full max-w-2xl rounded-2xl bg-sky-900/40 p-3"
|
||||
role="grid"
|
||||
aria-label="Vier-Gewinnt"
|
||||
>
|
||||
<div
|
||||
className="grid gap-1.5"
|
||||
style={{ gridTemplateColumns: 'repeat(' + C4_COLS + ', minmax(0, 1fr))' }}
|
||||
>
|
||||
{Array.from({ length: C4_ROWS * C4_COLS }, (_, idx) => {
|
||||
const cell = board[idx];
|
||||
const col = idx % C4_COLS;
|
||||
const dropTo = c4DropRow(board, col);
|
||||
const canClickColumn = !disabled && dropTo >= 0 && myPlayerIdx !== null;
|
||||
const inWin = winSet.has(idx);
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => canClickColumn && onMove(col)}
|
||||
disabled={!canClickColumn}
|
||||
aria-label={'Spalte ' + (col + 1) + (cell !== null ? ' belegt' : '')}
|
||||
className={
|
||||
'flex aspect-square items-center justify-center rounded-full border-2 transition ' +
|
||||
(inWin
|
||||
? 'border-emerald-300 bg-emerald-400 shadow-[0_0_12px_rgba(110,231,183,0.7)]'
|
||||
: cell === 0
|
||||
? 'border-rose-300 bg-rose-500'
|
||||
: cell === 1
|
||||
? 'border-amber-300 bg-amber-400'
|
||||
: canClickColumn
|
||||
? 'cursor-pointer border-sky-700 bg-sky-950 hover:bg-sky-900'
|
||||
: 'cursor-not-allowed border-sky-800 bg-sky-950 opacity-80')
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</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)
|
||||
@@ -111,9 +129,9 @@ function HeaderBar({
|
||||
className="relative shrink-0 rounded-full text-left transition enabled:cursor-pointer enabled:hover:ring-2 enabled:hover:ring-accent/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:cursor-default"
|
||||
>
|
||||
{isDm ? (
|
||||
<Avatar url={peerAvatar} displayName={title} className="h-10 w-10 text-base" />
|
||||
<Avatar url={peerAvatar} displayName={title} className="h-10 w-10 text-base" loading="eager" />
|
||||
) : peerAvatar ? (
|
||||
<Avatar url={peerAvatar} displayName={title} className="h-10 w-10 text-base" />
|
||||
<Avatar url={peerAvatar} displayName={title} className="h-10 w-10 text-base" loading="eager" />
|
||||
) : (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-accent/20 text-accent">
|
||||
<UsersIcon className="h-5 w-5" />
|
||||
@@ -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 ? (
|
||||
@@ -178,7 +197,7 @@ function HeaderBar({
|
||||
|
||||
interface HeaderActionButtonProps {
|
||||
label: string;
|
||||
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
|
||||
icon: React.ComponentType<React.ComponentPropsWithoutRef<'svg'>>;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
tone?: 'default' | 'accent';
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import {
|
||||
muteDurationToIso,
|
||||
setConversationArchived,
|
||||
setConversationMentionsOnly,
|
||||
setConversationMutedUntil,
|
||||
} from '@chat-app/shared/chat';
|
||||
import { forwardRef, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useConversationsContext } from '../context/ConversationsContext';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { ArchiveIcon, BellIcon, BellOffIcon, MoreVerticalIcon } from './icons';
|
||||
import { ArchiveIcon, AtIcon, BellIcon, BellOffIcon, MoreVerticalIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
conversationId: string;
|
||||
archived: boolean;
|
||||
mutedUntil: string | null;
|
||||
mentionsOnly: boolean;
|
||||
}
|
||||
|
||||
interface MuteOption {
|
||||
@@ -50,8 +53,9 @@ interface MenuPos {
|
||||
// escape the sidebar's `overflow-y-auto` clipping context. Position is
|
||||
// computed from the trigger's bounding rect — menu anchors right-aligned
|
||||
// under the trigger so it doesn't push off-screen on narrow windows.
|
||||
export function ConversationRowMenu({ conversationId, archived, mutedUntil }: Props) {
|
||||
export function ConversationRowMenu({ conversationId, archived, mutedUntil, mentionsOnly }: Props) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { patchConversation } = useConversationsContext();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [submenuOpen, setSubmenuOpen] = useState<'mute' | null>(null);
|
||||
const [menuPos, setMenuPos] = useState<MenuPos | null>(null);
|
||||
@@ -120,29 +124,59 @@ export function ConversationRowMenu({ conversationId, archived, mutedUntil }: Pr
|
||||
const isMuted =
|
||||
mutedUntil !== null && new Date(mutedUntil).getTime() > Date.now();
|
||||
|
||||
// Optimistic updates: flip the local state before the server RPC so the
|
||||
// bell / archive icon / checkmark update on the same frame as the click.
|
||||
// Realtime echo via ConversationsContext will reconcile (no-op since the
|
||||
// optimistic patch already matches the server row). On error we restore
|
||||
// the previous value so the menu doesn't lie about persisted state.
|
||||
const handleArchive = useCallback(
|
||||
async (next: boolean) => {
|
||||
setOpen(false);
|
||||
const previous = archived;
|
||||
patchConversation(conversationId, { archived: next });
|
||||
try {
|
||||
await setConversationArchived(supabase, conversationId, next);
|
||||
} catch (err: unknown) {
|
||||
patchConversation(conversationId, { archived: previous });
|
||||
console.error('archive toggle failed', err);
|
||||
}
|
||||
},
|
||||
[conversationId],
|
||||
[conversationId, archived, patchConversation],
|
||||
);
|
||||
|
||||
const handleMute = useCallback(
|
||||
async (minutes: number | null) => {
|
||||
setOpen(false);
|
||||
setSubmenuOpen(null);
|
||||
const previous = mutedUntil;
|
||||
const nextIso = muteDurationToIso(minutes);
|
||||
patchConversation(conversationId, { mutedUntil: nextIso });
|
||||
try {
|
||||
await setConversationMutedUntil(supabase, conversationId, muteDurationToIso(minutes));
|
||||
await setConversationMutedUntil(supabase, conversationId, nextIso);
|
||||
} catch (err: unknown) {
|
||||
patchConversation(conversationId, { mutedUntil: previous });
|
||||
console.error('mute toggle failed', err);
|
||||
}
|
||||
},
|
||||
[conversationId],
|
||||
[conversationId, mutedUntil, patchConversation],
|
||||
);
|
||||
|
||||
const handleMentionsOnly = useCallback(
|
||||
async (next: boolean) => {
|
||||
setOpen(false);
|
||||
const previous = mentionsOnly;
|
||||
patchConversation(conversationId, { mentionsOnly: next });
|
||||
try {
|
||||
await setConversationMentionsOnly(supabase, {
|
||||
conversationId,
|
||||
mentionsOnly: next,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
patchConversation(conversationId, { mentionsOnly: previous });
|
||||
console.warn('mentions-only toggle failed', err);
|
||||
}
|
||||
},
|
||||
[conversationId, mentionsOnly, patchConversation],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -196,6 +230,16 @@ export function ConversationRowMenu({ conversationId, archived, mutedUntil }: Pr
|
||||
}}
|
||||
hasSubmenu={!isMuted}
|
||||
/>
|
||||
<MenuItem
|
||||
icon={<AtIcon className="h-4 w-4" />}
|
||||
label={
|
||||
(mentionsOnly ? '✓ ' : '') +
|
||||
t('app:chats.mentions_only', {
|
||||
defaultValue: 'Nur bei @Mentions benachrichtigen',
|
||||
})
|
||||
}
|
||||
onClick={() => void handleMentionsOnly(!mentionsOnly)}
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import confetti from 'canvas-confetti';
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useGame } from '../hooks/useGame';
|
||||
import { ConnectFourBoard } from './ConnectFourBoard';
|
||||
import { TicTacToeBoard } from './TicTacToeBoard';
|
||||
import { XIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
gameId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function GameModal({ gameId, onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { game, loading, error, makeMove } = useGame(gameId);
|
||||
const { session: auth } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
const myUserId = auth?.user.id ?? null;
|
||||
const myPlayerIdx: 0 | 1 | null =
|
||||
!game || !myUserId
|
||||
? null
|
||||
: game.players[0] === myUserId
|
||||
? 0
|
||||
: game.players[1] === myUserId
|
||||
? 1
|
||||
: null;
|
||||
const isMyTurn = !!game && game.currentTurnUserId === myUserId;
|
||||
const finished = !!game?.finishedAt;
|
||||
const winnerIdx: 0 | 1 | null =
|
||||
!game?.winnerUserId
|
||||
? null
|
||||
: game.players[0] === game.winnerUserId
|
||||
? 0
|
||||
: game.players[1] === game.winnerUserId
|
||||
? 1
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (finished && winnerIdx !== null && winnerIdx === myPlayerIdx) {
|
||||
// Respect the OS-level reduced-motion preference.
|
||||
if (
|
||||
typeof window !== 'undefined' &&
|
||||
window.matchMedia &&
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Two bursts from the lower corners for a celebratory feel.
|
||||
void confetti({
|
||||
particleCount: 80,
|
||||
spread: 60,
|
||||
origin: { x: 0.2, y: 0.9 },
|
||||
});
|
||||
void confetti({
|
||||
particleCount: 80,
|
||||
spread: 60,
|
||||
origin: { x: 0.8, y: 0.9 },
|
||||
});
|
||||
}
|
||||
}, [finished, winnerIdx, myPlayerIdx]);
|
||||
const title =
|
||||
game?.gameType === 'c4'
|
||||
? t('app:game.c4', { defaultValue: 'Vier-Gewinnt' })
|
||||
: t('app:game.ttt', { defaultValue: 'Tic-Tac-Toe' });
|
||||
|
||||
const statusLine = (() => {
|
||||
if (loading) return t('app:game.loading', { defaultValue: 'Lädt…' });
|
||||
if (error) return error;
|
||||
if (!game) return t('app:game.missing', { defaultValue: 'Spiel nicht gefunden.' });
|
||||
if (finished) {
|
||||
if (winnerIdx === null) return t('app:game.draw', { defaultValue: 'Unentschieden!' });
|
||||
if (winnerIdx === myPlayerIdx) return t('app:game.you_win', { defaultValue: 'Du hast gewonnen!' });
|
||||
return t('app:game.you_lose', { defaultValue: 'Du hast verloren.' });
|
||||
}
|
||||
if (myPlayerIdx === null) return t('app:game.spectator', { defaultValue: 'Du schaust nur zu.' });
|
||||
if (isMyTurn) return t('app:game.your_turn', { defaultValue: 'Du bist dran' });
|
||||
return t('app:game.opponent_turn', { defaultValue: 'Gegner ist dran…' });
|
||||
})();
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
className="fixed inset-0 z-[80] flex flex-col bg-ink-950/95"
|
||||
>
|
||||
<header className="flex shrink-0 items-center justify-between border-b border-line/40 bg-surface-2 px-4 py-2">
|
||||
<h2 className="font-display text-sm font-semibold text-fg">{title}</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t('app:game.close', { defaultValue: '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-3.5 w-3.5" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center overflow-auto p-6">
|
||||
{game?.gameType === 'ttt' ? (
|
||||
<TicTacToeBoard
|
||||
board={game.state.board}
|
||||
myPlayerIdx={myPlayerIdx}
|
||||
disabled={!isMyTurn || finished}
|
||||
onMove={(cell) => void makeMove({ cell }).catch(() => {})}
|
||||
/>
|
||||
) : game?.gameType === 'c4' ? (
|
||||
<ConnectFourBoard
|
||||
board={game.state.board}
|
||||
myPlayerIdx={myPlayerIdx}
|
||||
disabled={!isMyTurn || finished}
|
||||
onMove={(column) => void makeMove({ column }).catch(() => {})}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<footer className="flex shrink-0 items-center justify-center border-t border-line/40 bg-surface-2 px-4 py-3 text-sm font-medium text-fg">
|
||||
{statusLine}
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { XIcon } from './icons';
|
||||
|
||||
export type AnnotatorTool = 'pen' | 'arrow' | 'rect' | 'circle' | 'text' | 'highlighter';
|
||||
export type AnnotatorColor = '#ef4444' | '#3b82f6' | '#22c55e' | '#facc15' | '#a855f7' | '#ffffff';
|
||||
export type AnnotatorWidth = 2 | 4 | 8;
|
||||
|
||||
export interface AnnotatorOp {
|
||||
tool: AnnotatorTool;
|
||||
color: AnnotatorColor;
|
||||
width: AnnotatorWidth;
|
||||
points?: Array<{ x: number; y: number }>;
|
||||
from?: { x: number; y: number };
|
||||
to?: { x: number; y: number };
|
||||
text?: string;
|
||||
at?: { x: number; y: number };
|
||||
}
|
||||
|
||||
interface Props {
|
||||
file: File;
|
||||
onCancel: () => void;
|
||||
onSave: (next: File) => void;
|
||||
}
|
||||
|
||||
export function ImageAnnotator({ file, onCancel, onSave }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
const [ops, setOps] = useState<AnnotatorOp[]>([]);
|
||||
const [redoStack, setRedoStack] = useState<AnnotatorOp[]>([]);
|
||||
// tool/color/width are read by Task 2 (drawing engine) and Task 3 (toolbar).
|
||||
// Defaults shown here are intentional so T2's pointer handlers work
|
||||
// immediately with sensible behavior before T3's UI is wired.
|
||||
const [tool, setTool] = useState<AnnotatorTool>('pen');
|
||||
const [color, setColor] = useState<AnnotatorColor>('#ef4444');
|
||||
const [width, setWidth] = useState<AnnotatorWidth>(4);
|
||||
|
||||
const draftRef = useRef<AnnotatorOp | null>(null);
|
||||
const [draftTick, setDraftTick] = useState(0);
|
||||
|
||||
// Hold a stable ref to onCancel so the image-load effect doesn't depend
|
||||
// on its identity. Without this, parents that pass an inline `() => …`
|
||||
// re-render the modal on every keystroke / state change, the effect re-
|
||||
// runs, the previous URL.createObjectURL gets revoked WHILE the new img
|
||||
// is still decoding → img.onerror fires ("file not found") → onCancel →
|
||||
// modal flashes open + closes instantly.
|
||||
const onCancelRef = useRef(onCancel);
|
||||
useEffect(() => { onCancelRef.current = onCancel; }, [onCancel]);
|
||||
|
||||
useEffect(() => {
|
||||
// React 18 strict mode in dev double-mounts effects to test idempotency.
|
||||
// The first run creates a blob URL, sets img.src, returns a cleanup
|
||||
// that revokes — and the cleanup fires BEFORE the (still-in-flight)
|
||||
// image fetch completes. The browser then emits ERR_FILE_NOT_FOUND for
|
||||
// the revoked URL → img.onerror → modal closes instantly. The
|
||||
// `cancelled` flag guards every callback so a torn-down run can't
|
||||
// close the modal that the second mount just opened.
|
||||
let cancelled = false;
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
if (cancelled) return;
|
||||
imageRef.current = img;
|
||||
setImageLoaded(true);
|
||||
};
|
||||
img.onerror = () => {
|
||||
if (cancelled) return;
|
||||
console.error('ImageAnnotator: failed to decode source image');
|
||||
onCancelRef.current();
|
||||
};
|
||||
img.src = url;
|
||||
return () => {
|
||||
cancelled = true;
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
}, [file]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!imageLoaded) return;
|
||||
const cv = canvasRef.current;
|
||||
const img = imageRef.current;
|
||||
if (!cv || !img) return;
|
||||
cv.width = img.naturalWidth;
|
||||
cv.height = img.naturalHeight;
|
||||
const ctx = cv.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
for (const op of ops) {
|
||||
renderOp(ctx, op);
|
||||
}
|
||||
if (draftRef.current) {
|
||||
renderOp(ctx, draftRef.current);
|
||||
}
|
||||
}, [imageLoaded, ops, draftTick]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onCancel();
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleUndo();
|
||||
} else if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) {
|
||||
e.preventDefault();
|
||||
handleRedo();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
});
|
||||
|
||||
const handleUndo = () => {
|
||||
setOps((cur) => {
|
||||
if (cur.length === 0) return cur;
|
||||
const next = cur.slice(0, -1);
|
||||
setRedoStack((r) => [...r, cur[cur.length - 1]!]);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleRedo = () => {
|
||||
setRedoStack((r) => {
|
||||
if (r.length === 0) return r;
|
||||
const top = r[r.length - 1]!;
|
||||
setOps((cur) => [...cur, top]);
|
||||
return r.slice(0, -1);
|
||||
});
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setOps([]);
|
||||
setRedoStack([]);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const cv = canvasRef.current;
|
||||
if (!cv) return;
|
||||
cv.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
console.error('ImageAnnotator: toBlob returned null');
|
||||
return;
|
||||
}
|
||||
const baseName = file.name.replace(/\.[^.]+$/, '');
|
||||
const next = new File([blob], baseName + '-annotated.png', {
|
||||
type: 'image/png',
|
||||
lastModified: Date.now(),
|
||||
});
|
||||
onSave(next);
|
||||
}, 'image/png');
|
||||
};
|
||||
|
||||
function canvasPoint(e: React.PointerEvent<HTMLCanvasElement>): { x: number; y: number } {
|
||||
const cv = canvasRef.current!;
|
||||
const rect = cv.getBoundingClientRect();
|
||||
const scaleX = cv.width / rect.width;
|
||||
const scaleY = cv.height / rect.height;
|
||||
return {
|
||||
x: (e.clientX - rect.left) * scaleX,
|
||||
y: (e.clientY - rect.top) * scaleY,
|
||||
};
|
||||
}
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (!imageLoaded) return;
|
||||
const cv = canvasRef.current;
|
||||
if (!cv) return;
|
||||
cv.setPointerCapture(e.pointerId);
|
||||
const p = canvasPoint(e);
|
||||
|
||||
if (tool === 'text') {
|
||||
const value = window.prompt(
|
||||
t('app:annotator.text_prompt', { defaultValue: 'Text eingeben:' }),
|
||||
'',
|
||||
);
|
||||
if (value !== null && value.trim().length > 0) {
|
||||
setOps((cur) => [...cur, { tool: 'text', color, width, text: value, at: p }]);
|
||||
setRedoStack([]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (tool === 'pen' || tool === 'highlighter') {
|
||||
draftRef.current = { tool, color, width, points: [p] };
|
||||
} else {
|
||||
draftRef.current = { tool, color, width, from: p, to: p };
|
||||
}
|
||||
setDraftTick((n) => n + 1);
|
||||
};
|
||||
|
||||
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (!draftRef.current) return;
|
||||
const p = canvasPoint(e);
|
||||
const cur = draftRef.current;
|
||||
if (cur.tool === 'pen' || cur.tool === 'highlighter') {
|
||||
cur.points = [...(cur.points ?? []), p];
|
||||
} else {
|
||||
cur.to = p;
|
||||
}
|
||||
setDraftTick((n) => n + 1);
|
||||
};
|
||||
|
||||
const handlePointerUp = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
const cv = canvasRef.current;
|
||||
if (cv && cv.hasPointerCapture(e.pointerId)) cv.releasePointerCapture(e.pointerId);
|
||||
const cur = draftRef.current;
|
||||
draftRef.current = null;
|
||||
if (!cur) return;
|
||||
const hasContent =
|
||||
(cur.tool === 'pen' || cur.tool === 'highlighter')
|
||||
? (cur.points?.length ?? 0) >= 2
|
||||
: !!(cur.from && cur.to && (cur.from.x !== cur.to.x || cur.from.y !== cur.to.y));
|
||||
if (hasContent) {
|
||||
setOps((p) => [...p, cur]);
|
||||
setRedoStack([]);
|
||||
}
|
||||
setDraftTick((n) => n + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('app:annotator.title', { defaultValue: 'Bild bearbeiten' })}
|
||||
className="fixed inset-0 z-[80] flex flex-col bg-ink-950/95"
|
||||
>
|
||||
<header className="flex shrink-0 items-center justify-between border-b border-line/40 bg-surface-2 px-4 py-2">
|
||||
<h2 className="font-display text-sm font-semibold text-fg">
|
||||
{t('app:annotator.title', { defaultValue: 'Bild bearbeiten' })}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
aria-label={t('app:annotator.cancel', { defaultValue: 'Abbrechen' })}
|
||||
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-3.5 w-3.5" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center overflow-hidden p-6">
|
||||
{imageLoaded ? (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerLeave={handlePointerUp}
|
||||
className="max-h-full max-w-full cursor-crosshair touch-none rounded-lg border border-line/40 bg-black shadow-2xl"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-fg-muted">
|
||||
{t('app:annotator.loading', { defaultValue: 'Bild wird geladen…' })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="flex shrink-0 flex-wrap items-center gap-4 border-t border-line/40 bg-surface-2 px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
{(['pen', 'highlighter', 'arrow', 'rect', 'circle', 'text'] as AnnotatorTool[]).map((id) => {
|
||||
const label = t('app:annotator.tool.' + id, {
|
||||
defaultValue:
|
||||
id === 'pen' ? 'Stift'
|
||||
: id === 'highlighter' ? 'Marker'
|
||||
: id === 'arrow' ? 'Pfeil'
|
||||
: id === 'rect' ? 'Rechteck'
|
||||
: id === 'circle' ? 'Kreis'
|
||||
: 'Text',
|
||||
});
|
||||
const active = tool === id;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setTool(id)}
|
||||
aria-pressed={active}
|
||||
title={label}
|
||||
className={
|
||||
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-xs font-semibold transition ' +
|
||||
(active
|
||||
? 'bg-accent/20 text-accent ring-2 ring-accent/40'
|
||||
: 'bg-surface-3 text-fg-muted hover:bg-surface hover:text-fg')
|
||||
}
|
||||
>
|
||||
{toolGlyph(id)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="h-6 w-px bg-line/40" aria-hidden />
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{(['#ef4444', '#3b82f6', '#22c55e', '#facc15', '#a855f7', '#ffffff'] as AnnotatorColor[]).map((c) => {
|
||||
const active = color === c;
|
||||
return (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setColor(c)}
|
||||
aria-pressed={active}
|
||||
aria-label={c}
|
||||
className={
|
||||
'h-6 w-6 cursor-pointer rounded-full border-2 transition ' +
|
||||
(active ? 'border-fg scale-110' : 'border-line/40 hover:scale-105')
|
||||
}
|
||||
style={{ backgroundColor: c }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="h-6 w-px bg-line/40" aria-hidden />
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{([2, 4, 8] as AnnotatorWidth[]).map((w) => {
|
||||
const active = width === w;
|
||||
return (
|
||||
<button
|
||||
key={w}
|
||||
type="button"
|
||||
onClick={() => setWidth(w)}
|
||||
aria-pressed={active}
|
||||
title={w + 'px'}
|
||||
className={
|
||||
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-md transition ' +
|
||||
(active
|
||||
? 'bg-accent/20 ring-2 ring-accent/40'
|
||||
: 'bg-surface-3 hover:bg-surface')
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="rounded-full bg-fg"
|
||||
style={{ width: w + 2 + 'px', height: w + 2 + 'px' }}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="h-6 w-px bg-line/40" aria-hidden />
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUndo}
|
||||
disabled={ops.length === 0}
|
||||
title={t('app:annotator.undo', { defaultValue: 'Rückgängig (Ctrl+Z)' })}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md bg-surface-3 text-fg-muted transition hover:bg-surface hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
↶
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRedo}
|
||||
disabled={redoStack.length === 0}
|
||||
title={t('app:annotator.redo', { defaultValue: 'Wiederholen (Ctrl+Y)' })}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md bg-surface-3 text-fg-muted transition hover:bg-surface hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
↷
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReset}
|
||||
disabled={ops.length === 0}
|
||||
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-1.5 text-xs font-medium text-fg-muted transition hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('app:annotator.reset', { defaultValue: 'Zurücksetzen' })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={!imageLoaded}
|
||||
className="cursor-pointer rounded-md bg-accent px-4 py-1.5 text-xs font-semibold text-accent-fg transition hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('app:annotator.save', { defaultValue: 'Speichern' })}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function toolGlyph(t: AnnotatorTool): string {
|
||||
switch (t) {
|
||||
case 'pen': return '✎';
|
||||
case 'highlighter': return '🖍';
|
||||
case 'arrow': return '↗';
|
||||
case 'rect': return '▭';
|
||||
case 'circle': return '◯';
|
||||
case 'text': return 'T';
|
||||
}
|
||||
}
|
||||
|
||||
function renderOp(ctx: CanvasRenderingContext2D, op: AnnotatorOp): void {
|
||||
ctx.save();
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.strokeStyle = op.color;
|
||||
ctx.fillStyle = op.color;
|
||||
ctx.lineWidth = op.width;
|
||||
|
||||
switch (op.tool) {
|
||||
case 'pen': {
|
||||
const pts = op.points;
|
||||
if (!pts || pts.length < 1) break;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pts[0]!.x, pts[0]!.y);
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
ctx.lineTo(pts[i]!.x, pts[i]!.y);
|
||||
}
|
||||
ctx.stroke();
|
||||
break;
|
||||
}
|
||||
case 'highlighter': {
|
||||
const pts = op.points;
|
||||
if (!pts || pts.length < 1) break;
|
||||
ctx.globalAlpha = 0.35;
|
||||
ctx.lineWidth = op.width * 4;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pts[0]!.x, pts[0]!.y);
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
ctx.lineTo(pts[i]!.x, pts[i]!.y);
|
||||
}
|
||||
ctx.stroke();
|
||||
break;
|
||||
}
|
||||
case 'rect': {
|
||||
const { from, to } = op;
|
||||
if (!from || !to) break;
|
||||
ctx.strokeRect(
|
||||
Math.min(from.x, to.x),
|
||||
Math.min(from.y, to.y),
|
||||
Math.abs(to.x - from.x),
|
||||
Math.abs(to.y - from.y),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'circle': {
|
||||
const { from, to } = op;
|
||||
if (!from || !to) break;
|
||||
const cx = (from.x + to.x) / 2;
|
||||
const cy = (from.y + to.y) / 2;
|
||||
const rx = Math.abs(to.x - from.x) / 2;
|
||||
const ry = Math.abs(to.y - from.y) / 2;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
break;
|
||||
}
|
||||
case 'arrow': {
|
||||
const { from, to } = op;
|
||||
if (!from || !to) break;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(from.x, from.y);
|
||||
ctx.lineTo(to.x, to.y);
|
||||
ctx.stroke();
|
||||
const angle = Math.atan2(to.y - from.y, to.x - from.x);
|
||||
const head = Math.max(12, op.width * 3);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(to.x, to.y);
|
||||
ctx.lineTo(
|
||||
to.x - head * Math.cos(angle - Math.PI / 6),
|
||||
to.y - head * Math.sin(angle - Math.PI / 6),
|
||||
);
|
||||
ctx.moveTo(to.x, to.y);
|
||||
ctx.lineTo(
|
||||
to.x - head * Math.cos(angle + Math.PI / 6),
|
||||
to.y - head * Math.sin(angle + Math.PI / 6),
|
||||
);
|
||||
ctx.stroke();
|
||||
break;
|
||||
}
|
||||
case 'text': {
|
||||
const { at, text } = op;
|
||||
if (!at || !text) break;
|
||||
const fontSize = Math.max(14, op.width * 6);
|
||||
ctx.font = '600 ' + fontSize + 'px Inter, system-ui, sans-serif';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillText(text, at.x, at.y);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
@@ -15,14 +15,7 @@ import {
|
||||
listSounds,
|
||||
subscribeSoundboardChanges,
|
||||
} from '../lib/soundboardStorage';
|
||||
import {
|
||||
getLiveCaptionsSettings,
|
||||
isLiveCaptionsSupported,
|
||||
subscribeLiveCaptionsSettings,
|
||||
updateLiveCaptionsSettings,
|
||||
} from '../lib/liveCaptions';
|
||||
import { useActiveSpeakers } from '../lib/useActiveSpeakers';
|
||||
import { CallCaptionsOverlay } from './CallCaptionsOverlay';
|
||||
import { CallControls } from './CallControls';
|
||||
import { CallParticipantTile, type TileConnectionQuality } from './CallParticipantTile';
|
||||
import { CallStatsOverlay } from './CallStatsOverlay';
|
||||
@@ -119,17 +112,6 @@ export function InCallPanel({ conversation }: Props) {
|
||||
const [sharePickerOpen, setSharePickerOpen] = useState(false);
|
||||
// Discord-style debug stats overlay (Ctrl+Shift+S toggles).
|
||||
const [statsOverlayOpen, setStatsOverlayOpen] = useState(false);
|
||||
// Live-captions toggle — mirror of getLiveCaptionsSettings().enabled so the
|
||||
// controls bar can show an "active" state without polling. Captions
|
||||
// broadcasting is wired in CallContext via useLiveCaptions; this only
|
||||
// tracks the toggle state for the button.
|
||||
const [captionsEnabled, setCaptionsEnabled] = useState<boolean>(
|
||||
() => getLiveCaptionsSettings().enabled,
|
||||
);
|
||||
useEffect(
|
||||
() => subscribeLiveCaptionsSettings((s) => setCaptionsEnabled(s.enabled)),
|
||||
[],
|
||||
);
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
// Match the modifier exactly to avoid clobbering other Ctrl+Shift combos.
|
||||
@@ -362,15 +344,6 @@ export function InCallPanel({ conversation }: Props) {
|
||||
soundboardOpen,
|
||||
}
|
||||
: {})}
|
||||
// Live-Captions only when SpeechRecognition is available in the
|
||||
// runtime — Firefox lacks it, would just show a dead button.
|
||||
{...(isLiveCaptionsSupported()
|
||||
? {
|
||||
onToggleCaptions: () =>
|
||||
updateLiveCaptionsSettings({ enabled: !captionsEnabled }),
|
||||
captionsOn: captionsEnabled,
|
||||
}
|
||||
: {})}
|
||||
onHangup={() => void hangup()}
|
||||
compact={callMode !== 'fullscreen'}
|
||||
glass={callMode === 'fullscreen'}
|
||||
@@ -499,7 +472,6 @@ export function InCallPanel({ conversation }: Props) {
|
||||
onClose={() => setStatsOverlayOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{captionsEnabled && <CallCaptionsOverlay conversation={conversation} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -644,8 +616,6 @@ export function InCallPanel({ conversation }: Props) {
|
||||
onClose={() => setStatsOverlayOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{captionsEnabled && <CallCaptionsOverlay conversation={conversation} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1542,8 +1512,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,11 +5,12 @@ import {
|
||||
softDeleteMessage,
|
||||
} from '@chat-app/shared/chat';
|
||||
import { extractErrorCode } from '@chat-app/shared/i18n';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useNickname } from '../lib/friendNicknames';
|
||||
import { ensureInstallId } from '../lib/installId';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { cachedUserKey } from '../lib/userIdentity';
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
PencilIcon,
|
||||
PhoneIcon,
|
||||
PhoneOffIcon,
|
||||
PinIcon,
|
||||
ReplyIcon,
|
||||
SmileIcon,
|
||||
SpinnerIcon,
|
||||
@@ -58,8 +60,17 @@ interface Props {
|
||||
senderAvatarUrl?: string | null | undefined;
|
||||
conversationId: string;
|
||||
reactions: AggregatedReaction[];
|
||||
onToggleReaction: (emoji: string) => Promise<void>;
|
||||
onVotePoll?: (emoji: string, optionEmojis: string[]) => Promise<void>;
|
||||
/**
|
||||
* Toggle a reaction on this message. Receives the message id so the parent
|
||||
* can pass a stable handler reference across every row (lets `React.memo`
|
||||
* actually skip re-renders triggered by composer keystrokes / typing pings).
|
||||
*/
|
||||
onToggleReaction: (messageId: string, emoji: string) => Promise<void>;
|
||||
/**
|
||||
* Cast/clear an exclusive poll vote. Receives the message id for the same
|
||||
* reason as `onToggleReaction`.
|
||||
*/
|
||||
onVotePoll?: (messageId: string, emoji: string, optionEmojis: string[]) => Promise<void>;
|
||||
showSeen?: boolean;
|
||||
/** Delivery state for own messages: 'sent' / 'delivered' / 'read'. */
|
||||
deliveryState?: 'sent' | 'delivered' | 'read';
|
||||
@@ -75,9 +86,13 @@ 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({
|
||||
function MessageBubbleInner({
|
||||
message,
|
||||
mine,
|
||||
groupedWithPrev,
|
||||
@@ -96,10 +111,18 @@ export function MessageBubble({
|
||||
onForward,
|
||||
onAvatarClick,
|
||||
highlighted = false,
|
||||
isPinned = false,
|
||||
onTogglePin,
|
||||
}: Props) {
|
||||
const { t } = useTranslation(['app']);
|
||||
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 : '';
|
||||
const initialAttachments = parsed.kind === 'text' ? parsed.attachments : [];
|
||||
@@ -243,12 +266,12 @@ export function MessageBubble({
|
||||
async (emoji: string) => {
|
||||
setPickerOpen(false);
|
||||
try {
|
||||
await onToggleReaction(emoji);
|
||||
await onToggleReaction(message.id, emoji);
|
||||
} catch (err: unknown) {
|
||||
console.error('toggleReaction failed', err);
|
||||
}
|
||||
},
|
||||
[onToggleReaction],
|
||||
[onToggleReaction, message.id],
|
||||
);
|
||||
|
||||
const copyableText =
|
||||
@@ -281,7 +304,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 +322,7 @@ export function MessageBubble({
|
||||
parsed={parsed}
|
||||
mine={mine}
|
||||
time={time}
|
||||
senderDisplayName={senderDisplayName ?? null}
|
||||
senderDisplayName={resolvedSenderDisplayName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -315,7 +338,7 @@ export function MessageBubble({
|
||||
<AvatarSlot
|
||||
show={isLastOfRun}
|
||||
url={senderAvatarUrl ?? null}
|
||||
displayName={senderDisplayName ?? null}
|
||||
displayName={resolvedSenderDisplayName}
|
||||
{...(onAvatarClick
|
||||
? { onClick: (ev: React.MouseEvent) => onAvatarClick(message.senderId, ev) }
|
||||
: {})}
|
||||
@@ -428,6 +451,84 @@ export function MessageBubble({
|
||||
defaultValue: 'Nachricht nicht lesbar',
|
||||
})}
|
||||
</span>
|
||||
) : parsed.kind === 'game' ? (
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-accent/30 bg-accent/5 px-4 py-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-accent/20 text-accent">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
|
||||
strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
|
||||
<rect x="3" y="6" width="18" height="12" rx="3" />
|
||||
<path d="M8 12h4M10 10v4M16 11v.01M16 14v.01" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-fg">{parsed.gameType === 'c4' ? 'Vier-Gewinnt' : 'Tic-Tac-Toe'}</div>
|
||||
<div className="text-xs text-fg-muted">Gemeinsam spielen</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('chatapp:open-game', { detail: { id: parsed.gameId } }),
|
||||
);
|
||||
}}
|
||||
disabled={!parsed.gameId}
|
||||
className="cursor-pointer rounded-md bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Spielen
|
||||
</button>
|
||||
</div>
|
||||
) : parsed.kind === 'watch_together' ? (
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-accent/30 bg-accent/5 px-4 py-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-accent/20 text-accent">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
|
||||
strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
|
||||
<rect x="3" y="4" width="18" height="14" rx="2" />
|
||||
<path d="M10 9l5 3-5 3V9z" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-fg">Watch Together</div>
|
||||
<div className="text-xs text-fg-muted">YouTube synchronisiert ansehen</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('chatapp:open-watch-together', { detail: { id: parsed.sessionId } }),
|
||||
);
|
||||
}}
|
||||
disabled={!parsed.sessionId}
|
||||
className="cursor-pointer rounded-md bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Beitreten
|
||||
</button>
|
||||
</div>
|
||||
) : parsed.kind === 'whiteboard' ? (
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-accent/30 bg-accent/5 px-4 py-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-accent/20 text-accent">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
|
||||
strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
|
||||
<rect x="3" y="4" width="18" height="13" rx="2" />
|
||||
<path d="M8 21h8M12 17v4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-fg">Whiteboard</div>
|
||||
<div className="text-xs text-fg-muted">Gemeinsames Zeichnen</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('chatapp:open-whiteboard', { detail: { id: parsed.whiteboardId } }),
|
||||
);
|
||||
}}
|
||||
disabled={!parsed.whiteboardId}
|
||||
className="cursor-pointer rounded-md bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Öffnen
|
||||
</button>
|
||||
</div>
|
||||
) : parsed.kind === 'poll' ? (
|
||||
<PollCard
|
||||
question={parsed.question}
|
||||
@@ -435,7 +536,9 @@ export function MessageBubble({
|
||||
reactions={reactions}
|
||||
mine={mine}
|
||||
onVote={(emoji) =>
|
||||
onVotePoll ? onVotePoll(emoji, pollOptionEmojis) : onToggleReaction(emoji)
|
||||
onVotePoll
|
||||
? onVotePoll(message.id, emoji, pollOptionEmojis)
|
||||
: onToggleReaction(message.id, emoji)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
@@ -451,7 +554,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} />;
|
||||
@@ -501,7 +604,7 @@ export function MessageBubble({
|
||||
<button
|
||||
key={r.emoji + ':' + r.count}
|
||||
type="button"
|
||||
onClick={() => void onToggleReaction(r.emoji)}
|
||||
onClick={() => void onToggleReaction(message.id, r.emoji)}
|
||||
className={
|
||||
'inline-flex origin-bottom animate-reaction-pop cursor-pointer items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
(r.mine
|
||||
@@ -635,6 +738,15 @@ export function MessageBubble({
|
||||
setContextMenu(null);
|
||||
void handleDelete();
|
||||
}}
|
||||
isPinned={isPinned}
|
||||
{...(onTogglePin
|
||||
? {
|
||||
onTogglePin: () => {
|
||||
setContextMenu(null);
|
||||
onTogglePin(message.id);
|
||||
},
|
||||
}
|
||||
: {})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -642,6 +754,14 @@ export function MessageBubble({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoized export. Skips re-rendering when none of its props' shallow
|
||||
* references change — i.e. when the parent re-renders due to composer
|
||||
* keystrokes, typing-indicator updates, presence pings, etc. Relies on
|
||||
* the parent passing stable callback refs (see `ConversationPage`).
|
||||
*/
|
||||
export const MessageBubble = memo(MessageBubbleInner);
|
||||
|
||||
function PollCard({
|
||||
question,
|
||||
options,
|
||||
@@ -732,6 +852,8 @@ function MessageContextMenu({
|
||||
onEdit,
|
||||
onCopy,
|
||||
onDelete,
|
||||
isPinned,
|
||||
onTogglePin,
|
||||
}: {
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -746,6 +868,8 @@ function MessageContextMenu({
|
||||
onEdit: () => void;
|
||||
onCopy: () => void;
|
||||
onDelete: () => void;
|
||||
isPinned?: boolean;
|
||||
onTogglePin?: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
@@ -812,6 +936,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,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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
type AutoLockMinutes,
|
||||
getAutoLockMinutes,
|
||||
notifyAutoLockChanged,
|
||||
setAutoLockMinutes,
|
||||
} from '../lib/autoLockSettings';
|
||||
import { isWipeOnCloseEnabled, setWipeOnClose } from '../lib/memoryWipeSettings';
|
||||
import {
|
||||
changePin,
|
||||
type LegacyMigrationReport,
|
||||
@@ -19,6 +26,8 @@ export function SecurityCenter({ userId }: Props) {
|
||||
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());
|
||||
const [autoLockMinutes, setAutoLockMinutesState] = useState<AutoLockMinutes>(() => getAutoLockMinutes());
|
||||
|
||||
async function handleRetryMigration() {
|
||||
setBusy(true); setMsg(null); setMigration(null);
|
||||
@@ -126,6 +135,60 @@ export function SecurityCenter({ userId }: Props) {
|
||||
)}
|
||||
</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-fg-muted">
|
||||
Auto-Lock nach Inaktivität
|
||||
</h3>
|
||||
<p className="mb-2 text-xs text-fg-muted">
|
||||
Verlangt erneute PIN-Eingabe nach der gewählten Inaktivitätsdauer. Empfohlen für gemeinsam genutzte Rechner.
|
||||
</p>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-fg">Automatisch sperren</div>
|
||||
<div className="text-xs text-fg-muted">
|
||||
Verlangt erneute PIN-Eingabe nach X Minuten Inaktivität.
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
value={autoLockMinutes}
|
||||
onChange={(e) => {
|
||||
const next = Number(e.target.value) as AutoLockMinutes;
|
||||
setAutoLockMinutes(next);
|
||||
notifyAutoLockChanged(next);
|
||||
setAutoLockMinutesState(next);
|
||||
}}
|
||||
className="cursor-pointer rounded-md border border-line bg-surface-2 px-3 py-1.5 text-sm text-fg focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/40"
|
||||
>
|
||||
<option value={0}>Aus</option>
|
||||
<option value={5}>5 min</option>
|
||||
<option value={15}>15 min</option>
|
||||
<option value={30}>30 min</option>
|
||||
<option value={60}>60 min</option>
|
||||
</select>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
interface NavItem {
|
||||
to: string;
|
||||
labelKey: string;
|
||||
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
|
||||
icon: React.ComponentType<React.ComponentPropsWithoutRef<'svg'>>;
|
||||
badge?: 'friends' | 'chats';
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ export function Sidebar() {
|
||||
interface RailNavLinkProps {
|
||||
to: string;
|
||||
label: string;
|
||||
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
|
||||
icon: React.ComponentType<React.ComponentPropsWithoutRef<'svg'>>;
|
||||
badge?: number;
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ function RailNavLink({ to, label, icon: Icon, badge = 0 }: RailNavLinkProps) {
|
||||
interface RailIconButtonProps {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
|
||||
icon: React.ComponentType<React.ComponentPropsWithoutRef<'svg'>>;
|
||||
tone?: 'default' | 'danger';
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { deleteSound as deleteRemoteSound } from '@chat-app/shared/chat';
|
||||
import { getPttSettings } from '../lib/pttSettings';
|
||||
import { codeToShortcut } from '../lib/globalShortcut';
|
||||
import { invalidate as invalidateSoundCache, preload } from '../lib/soundboardPlayback';
|
||||
@@ -15,6 +16,8 @@ import {
|
||||
subscribeSoundboardChanges,
|
||||
updateSound,
|
||||
} from '../lib/soundboardStorage';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { useSoundboardSync, type SyncBadge } from '../hooks/useSoundboardSync';
|
||||
import { Modal } from './Modal';
|
||||
import {
|
||||
AlertIcon,
|
||||
@@ -46,6 +49,7 @@ export function SoundboardManagerDialog({ open, onClose }: Props) {
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [previewingId, setPreviewingId] = useState<string | null>(null);
|
||||
const { badges } = useSoundboardSync();
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -167,6 +171,11 @@ export function SoundboardManagerDialog({ open, onClose }: Props) {
|
||||
}
|
||||
setBusyId(id);
|
||||
try {
|
||||
try {
|
||||
await deleteRemoteSound(supabase, id);
|
||||
} catch (err) {
|
||||
console.warn('remote sound delete failed (local delete proceeds)', err);
|
||||
}
|
||||
await deleteSound(id);
|
||||
invalidateSoundCache(id);
|
||||
if (previewingId === id) stopPreview();
|
||||
@@ -301,6 +310,7 @@ export function SoundboardManagerDialog({ open, onClose }: Props) {
|
||||
entriesTotal={entries}
|
||||
busyId={busyId}
|
||||
previewingId={previewingId}
|
||||
badges={badges}
|
||||
onPatch={handlePatch}
|
||||
onDelete={handleDelete}
|
||||
onPreview={handlePreview}
|
||||
@@ -323,6 +333,7 @@ interface GroupProps {
|
||||
entriesTotal: SoundboardEntry[];
|
||||
busyId: string | null;
|
||||
previewingId: string | null;
|
||||
badges: Map<string, SyncBadge>;
|
||||
onPatch: (id: string, patch: Parameters<typeof updateSound>[1]) => Promise<void>;
|
||||
onDelete: (id: string) => Promise<void>;
|
||||
onPreview: (entry: SoundboardEntry) => Promise<void>;
|
||||
@@ -335,6 +346,7 @@ function SoundboardCategoryGroup({
|
||||
entriesTotal,
|
||||
busyId,
|
||||
previewingId,
|
||||
badges,
|
||||
onPatch,
|
||||
onDelete,
|
||||
onPreview,
|
||||
@@ -370,6 +382,7 @@ function SoundboardCategoryGroup({
|
||||
isLast={idx === entries.length - 1}
|
||||
busy={busyId === entry.id}
|
||||
previewing={previewingId === entry.id}
|
||||
badge={badges.get(entry.id)}
|
||||
onPatch={onPatch}
|
||||
onDelete={onDelete}
|
||||
onPreview={onPreview}
|
||||
@@ -392,6 +405,7 @@ interface RowProps {
|
||||
isLast: boolean;
|
||||
busy: boolean;
|
||||
previewing: boolean;
|
||||
badge: SyncBadge | undefined;
|
||||
onPatch: (id: string, patch: Parameters<typeof updateSound>[1]) => Promise<void>;
|
||||
onDelete: (id: string) => Promise<void>;
|
||||
onPreview: (entry: SoundboardEntry) => Promise<void>;
|
||||
@@ -406,6 +420,7 @@ function SoundboardRow({
|
||||
isLast,
|
||||
busy,
|
||||
previewing,
|
||||
badge,
|
||||
onPatch,
|
||||
onDelete,
|
||||
onPreview,
|
||||
@@ -503,6 +518,13 @@ function SoundboardRow({
|
||||
)}
|
||||
<p className="text-[10px] text-fg-muted">
|
||||
{(entry.size / BYTES_PER_MB).toFixed(2)} MB · {entry.mime}
|
||||
<span
|
||||
title={badgeTitle(badge)}
|
||||
aria-label={badgeTitle(badge)}
|
||||
className="ml-2 inline-flex items-center text-[10px] font-medium text-fg-muted"
|
||||
>
|
||||
{badgeGlyph(badge)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -623,3 +645,25 @@ function SoundboardRow({
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function badgeGlyph(b: SyncBadge | undefined): string {
|
||||
switch (b) {
|
||||
case 'uploading': return '↑';
|
||||
case 'downloading': return '↓';
|
||||
case 'error': return '⚠';
|
||||
case 'synced':
|
||||
default: return '☁';
|
||||
}
|
||||
}
|
||||
|
||||
function badgeTitle(b: SyncBadge | undefined): string {
|
||||
switch (b) {
|
||||
case 'uploading': return 'Hochladen…';
|
||||
case 'downloading': return 'Wird heruntergeladen…';
|
||||
case 'error': return 'Synchronisationsfehler';
|
||||
case 'synced':
|
||||
default: return 'Synchronisiert';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { type Cell, tttWinningLine } from '@chat-app/shared/chat';
|
||||
|
||||
interface Props {
|
||||
board: Cell[];
|
||||
myPlayerIdx: 0 | 1 | null;
|
||||
disabled: boolean;
|
||||
onMove: (cell: number) => void;
|
||||
}
|
||||
|
||||
const MARK = ['×', '○'] as const;
|
||||
|
||||
export function TicTacToeBoard({ board, myPlayerIdx, disabled, onMove }: Props) {
|
||||
const winLine = tttWinningLine(board);
|
||||
const winSet = new Set<number>(winLine ?? []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mx-auto grid w-full max-w-md grid-cols-3 gap-2 p-4"
|
||||
style={{ aspectRatio: '1 / 1' }}
|
||||
role="grid"
|
||||
aria-label="Tic-Tac-Toe"
|
||||
>
|
||||
{board.map((cell, i) => {
|
||||
const filled = cell !== null;
|
||||
const inWin = winSet.has(i);
|
||||
const canClick = !disabled && !filled && myPlayerIdx !== null;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => canClick && onMove(i)}
|
||||
disabled={!canClick}
|
||||
aria-label={'Feld ' + (i + 1) + (filled ? ' belegt' : ' frei')}
|
||||
className={
|
||||
'flex aspect-square items-center justify-center rounded-xl border-2 text-5xl font-bold transition ' +
|
||||
(inWin
|
||||
? 'border-emerald-400 bg-emerald-400/20 text-emerald-200'
|
||||
: filled
|
||||
? cell === 0
|
||||
? 'border-rose-500/60 bg-rose-500/10 text-rose-300'
|
||||
: 'border-sky-500/60 bg-sky-500/10 text-sky-300'
|
||||
: canClick
|
||||
? 'cursor-pointer border-line bg-surface-2 text-fg-muted hover:bg-surface-3 hover:text-fg'
|
||||
: 'cursor-not-allowed border-line bg-surface-2 text-fg-muted opacity-60')
|
||||
}
|
||||
>
|
||||
{filled ? MARK[cell as 0 | 1] : ''}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useWatchSession } from '../hooks/useWatchSession';
|
||||
import { XIcon } from './icons';
|
||||
|
||||
interface YTPlayer {
|
||||
playVideo: () => void;
|
||||
pauseVideo: () => void;
|
||||
seekTo: (seconds: number, allowSeekAhead: boolean) => void;
|
||||
getCurrentTime: () => number;
|
||||
getPlayerState: () => number;
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
interface YTPlayerOptions {
|
||||
width: string | number;
|
||||
height: string | number;
|
||||
videoId: string;
|
||||
playerVars?: { autoplay?: 0 | 1; controls?: 0 | 1; modestbranding?: 0 | 1 };
|
||||
events?: {
|
||||
onReady?: (ev: { target: YTPlayer }) => void;
|
||||
onStateChange?: (ev: { data: number; target: YTPlayer }) => void;
|
||||
};
|
||||
}
|
||||
|
||||
interface YTNamespace {
|
||||
Player: new (elementId: string | HTMLElement, opts: YTPlayerOptions) => YTPlayer;
|
||||
PlayerState: { UNSTARTED: -1; ENDED: 0; PLAYING: 1; PAUSED: 2; BUFFERING: 3; CUED: 5 };
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
YT?: YTNamespace;
|
||||
onYouTubeIframeAPIReady?: () => void;
|
||||
}
|
||||
}
|
||||
|
||||
const IFRAME_API_URL = 'https://www.youtube.com/iframe_api';
|
||||
let apiPromise: Promise<YTNamespace> | null = null;
|
||||
|
||||
function loadIframeApi(): Promise<YTNamespace> {
|
||||
if (apiPromise) return apiPromise;
|
||||
apiPromise = new Promise((resolve, reject) => {
|
||||
if (typeof window === 'undefined') {
|
||||
reject(new Error('no window'));
|
||||
return;
|
||||
}
|
||||
if (window.YT?.Player) {
|
||||
resolve(window.YT);
|
||||
return;
|
||||
}
|
||||
const prev = window.onYouTubeIframeAPIReady;
|
||||
window.onYouTubeIframeAPIReady = () => {
|
||||
try {
|
||||
prev?.();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (window.YT?.Player) resolve(window.YT);
|
||||
else reject(new Error('YT namespace missing after ready'));
|
||||
};
|
||||
const existing = document.querySelector(
|
||||
'script[src="' + IFRAME_API_URL + '"]',
|
||||
);
|
||||
if (existing) return;
|
||||
const tag = document.createElement('script');
|
||||
tag.src = IFRAME_API_URL;
|
||||
tag.async = true;
|
||||
document.head.appendChild(tag);
|
||||
});
|
||||
return apiPromise;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
sessionId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const DRIFT_THRESHOLD_SECONDS = 2;
|
||||
|
||||
export function WatchTogetherModal({ sessionId, onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { session, pushState, endSession, error, loading } = useWatchSession(sessionId);
|
||||
const { session: auth } = useAuth();
|
||||
const mountRef = useRef<HTMLDivElement | null>(null);
|
||||
const playerRef = useRef<YTPlayer | null>(null);
|
||||
const [playerReady, setPlayerReady] = useState(false);
|
||||
const ownerId = session?.ownerUserId ?? null;
|
||||
const isOwner = !!auth?.user.id && auth.user.id === ownerId;
|
||||
const ended = !!session?.endedAt;
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session?.videoId || !mountRef.current) return;
|
||||
if (playerRef.current) return;
|
||||
let disposed = false;
|
||||
void loadIframeApi().then((YT) => {
|
||||
if (disposed || !mountRef.current) return;
|
||||
playerRef.current = new YT.Player(mountRef.current, {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
videoId: session.videoId,
|
||||
playerVars: { autoplay: 1, controls: isOwner ? 1 : 0, modestbranding: 1 },
|
||||
events: {
|
||||
onReady: () => setPlayerReady(true),
|
||||
onStateChange: (ev) => {
|
||||
if (!isOwner) return;
|
||||
const playing = ev.data === YT.PlayerState.PLAYING;
|
||||
const pos = ev.target.getCurrentTime();
|
||||
pushState({ playing, positionSeconds: pos, updatedAtMs: Date.now() });
|
||||
},
|
||||
},
|
||||
});
|
||||
}).catch((err) => {
|
||||
console.error('YouTube IFrame API failed', err);
|
||||
});
|
||||
return () => {
|
||||
disposed = true;
|
||||
try {
|
||||
playerRef.current?.destroy();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
playerRef.current = null;
|
||||
};
|
||||
}, [session?.videoId, isOwner, pushState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOwner || !playerReady) return;
|
||||
const id = window.setInterval(() => {
|
||||
const p = playerRef.current;
|
||||
if (!p) return;
|
||||
try {
|
||||
const state = p.getPlayerState();
|
||||
const playing = state === window.YT?.PlayerState.PLAYING;
|
||||
pushState({
|
||||
playing,
|
||||
positionSeconds: p.getCurrentTime(),
|
||||
updatedAtMs: Date.now(),
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [isOwner, playerReady, pushState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOwner || !playerReady || !session) return;
|
||||
const p = playerRef.current;
|
||||
if (!p) return;
|
||||
const remoteAgeSec = Math.max(0, (Date.now() - session.currentState.updatedAtMs) / 1000);
|
||||
const projectedRemote = session.currentState.playing
|
||||
? session.currentState.positionSeconds + remoteAgeSec
|
||||
: session.currentState.positionSeconds;
|
||||
let local = 0;
|
||||
try {
|
||||
local = p.getCurrentTime();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (Math.abs(local - projectedRemote) > DRIFT_THRESHOLD_SECONDS) {
|
||||
try {
|
||||
p.seekTo(projectedRemote, true);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
try {
|
||||
const state = p.getPlayerState();
|
||||
const localPlaying = state === window.YT?.PlayerState.PLAYING;
|
||||
if (session.currentState.playing && !localPlaying) {
|
||||
p.playVideo();
|
||||
} else if (!session.currentState.playing && localPlaying) {
|
||||
p.pauseVideo();
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [isOwner, playerReady, session]);
|
||||
|
||||
const handleClose = async () => {
|
||||
if (isOwner && !ended) {
|
||||
try {
|
||||
await endSession();
|
||||
} catch (err) {
|
||||
console.warn('endSession failed', err);
|
||||
}
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('app:watch.title', { defaultValue: 'Watch Together' })}
|
||||
className="fixed inset-0 z-[80] flex flex-col bg-black"
|
||||
>
|
||||
<header className="flex shrink-0 items-center justify-between border-b border-line/40 bg-surface-2 px-4 py-2">
|
||||
<h2 className="font-display text-sm font-semibold text-fg">
|
||||
{t('app:watch.title', { defaultValue: 'Watch Together' })}
|
||||
{ended && (
|
||||
<span className="ml-2 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:watch.ended', { defaultValue: 'Beendet' })}
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleClose()}
|
||||
aria-label={t('app:watch.close', { defaultValue: '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-3.5 w-3.5" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center overflow-hidden bg-black p-4">
|
||||
{loading ? (
|
||||
<p className="text-sm text-fg-muted">
|
||||
{t('app:watch.loading', { defaultValue: 'Lädt…' })}
|
||||
</p>
|
||||
) : error ? (
|
||||
<p className="text-sm text-rose-400">{error}</p>
|
||||
) : !session ? (
|
||||
<p className="text-sm text-fg-muted">
|
||||
{t('app:watch.missing', { defaultValue: 'Session nicht gefunden.' })}
|
||||
</p>
|
||||
) : (
|
||||
<div className="aspect-video w-full max-w-5xl">
|
||||
<div ref={mountRef} className="h-full w-full" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="flex shrink-0 items-center justify-between gap-3 border-t border-line/40 bg-surface-2 px-4 py-2 text-xs text-fg-muted">
|
||||
<span>
|
||||
{isOwner
|
||||
? t('app:watch.you_are_host', { defaultValue: 'Du steuerst die Wiedergabe.' })
|
||||
: t('app:watch.you_are_guest', { defaultValue: 'Nur der Host kann steuern.' })}
|
||||
</span>
|
||||
<span>
|
||||
{session?.currentState.playing
|
||||
? t('app:watch.playing', { defaultValue: '▶ Läuft' })
|
||||
: t('app:watch.paused', { defaultValue: '⏸ Pause' })}
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { WhiteboardStroke } from '@chat-app/shared/chat';
|
||||
|
||||
export type WhiteboardTool = 'pen' | 'eraser';
|
||||
export type WhiteboardColor = '#000000' | '#ef4444' | '#3b82f6' | '#22c55e' | '#facc15' | '#a855f7';
|
||||
export type WhiteboardWidth = 2 | 4 | 8;
|
||||
|
||||
export interface WhiteboardStrokePayload {
|
||||
tool: WhiteboardTool;
|
||||
color: WhiteboardColor;
|
||||
width: WhiteboardWidth;
|
||||
// [x, y, t-ms-since-stroke-start]
|
||||
points: Array<[number, number, number]>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
strokes: WhiteboardStroke[];
|
||||
tool: WhiteboardTool;
|
||||
color: WhiteboardColor;
|
||||
width: WhiteboardWidth;
|
||||
onStroke: (payload: WhiteboardStrokePayload) => void;
|
||||
logicalWidth?: number;
|
||||
logicalHeight?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_LOGICAL_W = 1280;
|
||||
const DEFAULT_LOGICAL_H = 720;
|
||||
|
||||
export function WhiteboardCanvas({
|
||||
strokes,
|
||||
tool,
|
||||
color,
|
||||
width,
|
||||
onStroke,
|
||||
logicalWidth = DEFAULT_LOGICAL_W,
|
||||
logicalHeight = DEFAULT_LOGICAL_H,
|
||||
}: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const draftRef = useRef<WhiteboardStrokePayload | null>(null);
|
||||
const strokeStartRef = useRef<number>(0);
|
||||
const [, forceTick] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const cv = canvasRef.current;
|
||||
if (!cv) return;
|
||||
cv.width = logicalWidth;
|
||||
cv.height = logicalHeight;
|
||||
const ctx = cv.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, cv.width, cv.height);
|
||||
for (const s of strokes) {
|
||||
const payload = s.strokeJson as Partial<WhiteboardStrokePayload> | null;
|
||||
if (payload) renderStroke(ctx, payload);
|
||||
}
|
||||
if (draftRef.current) renderStroke(ctx, draftRef.current);
|
||||
});
|
||||
|
||||
function canvasPoint(e: React.PointerEvent<HTMLCanvasElement>): [number, number] {
|
||||
const cv = canvasRef.current!;
|
||||
const rect = cv.getBoundingClientRect();
|
||||
const scaleX = cv.width / rect.width;
|
||||
const scaleY = cv.height / rect.height;
|
||||
return [(e.clientX - rect.left) * scaleX, (e.clientY - rect.top) * scaleY];
|
||||
}
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
const cv = canvasRef.current;
|
||||
if (!cv) return;
|
||||
cv.setPointerCapture(e.pointerId);
|
||||
const [x, y] = canvasPoint(e);
|
||||
strokeStartRef.current = Date.now();
|
||||
draftRef.current = {
|
||||
tool,
|
||||
color,
|
||||
width,
|
||||
points: [[x, y, 0]],
|
||||
};
|
||||
forceTick((n) => n + 1);
|
||||
};
|
||||
|
||||
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (!draftRef.current) return;
|
||||
const [x, y] = canvasPoint(e);
|
||||
draftRef.current.points.push([x, y, Date.now() - strokeStartRef.current]);
|
||||
forceTick((n) => n + 1);
|
||||
};
|
||||
|
||||
const handlePointerUp = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
const cv = canvasRef.current;
|
||||
if (cv && cv.hasPointerCapture(e.pointerId)) cv.releasePointerCapture(e.pointerId);
|
||||
const draft = draftRef.current;
|
||||
draftRef.current = null;
|
||||
if (!draft) return;
|
||||
if (draft.points.length < 2) {
|
||||
forceTick((n) => n + 1);
|
||||
return;
|
||||
}
|
||||
onStroke(draft);
|
||||
forceTick((n) => n + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerLeave={handlePointerUp}
|
||||
className="block max-h-full max-w-full cursor-crosshair touch-none rounded-lg border border-line/40 bg-white shadow-2xl"
|
||||
style={{ aspectRatio: logicalWidth + ' / ' + logicalHeight, width: '100%' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function renderStroke(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
s: Partial<WhiteboardStrokePayload>,
|
||||
): void {
|
||||
const points = Array.isArray(s.points) ? s.points : null;
|
||||
if (!points || points.length < 1) return;
|
||||
|
||||
ctx.save();
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.lineWidth = typeof s.width === 'number' ? s.width : 4;
|
||||
if (s.tool === 'eraser') {
|
||||
ctx.strokeStyle = '#ffffff';
|
||||
ctx.lineWidth = Math.max(8, (typeof s.width === 'number' ? s.width : 4) * 4);
|
||||
} else {
|
||||
ctx.strokeStyle = typeof s.color === 'string' ? s.color : '#000000';
|
||||
}
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(points[0]![0], points[0]![1]);
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
ctx.lineTo(points[i]![0], points[i]![1]);
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useWhiteboardStrokes } from '../hooks/useWhiteboardStrokes';
|
||||
import { XIcon } from './icons';
|
||||
import {
|
||||
WhiteboardCanvas,
|
||||
type WhiteboardColor,
|
||||
type WhiteboardTool,
|
||||
type WhiteboardWidth,
|
||||
} from './WhiteboardCanvas';
|
||||
|
||||
interface Props {
|
||||
whiteboardId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const COLORS: WhiteboardColor[] = ['#000000', '#ef4444', '#3b82f6', '#22c55e', '#facc15', '#a855f7'];
|
||||
const WIDTHS: WhiteboardWidth[] = [2, 4, 8];
|
||||
|
||||
export function WhiteboardModal({ whiteboardId, onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { strokes, loading, error, insertStroke, clearAll } = useWhiteboardStrokes(whiteboardId);
|
||||
const [tool, setTool] = useState<WhiteboardTool>('pen');
|
||||
const [color, setColor] = useState<WhiteboardColor>('#000000');
|
||||
const [width, setWidth] = useState<WhiteboardWidth>(4);
|
||||
const [confirmClear, setConfirmClear] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
const handleConfirmClear = async () => {
|
||||
setConfirmClear(false);
|
||||
try {
|
||||
await clearAll();
|
||||
} catch (err) {
|
||||
console.error('clearAll failed', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('app:whiteboard.title', { defaultValue: 'Whiteboard' })}
|
||||
className="fixed inset-0 z-[80] flex flex-col bg-ink-950/95"
|
||||
>
|
||||
<header className="flex shrink-0 items-center justify-between border-b border-line/40 bg-surface-2 px-4 py-2">
|
||||
<h2 className="font-display text-sm font-semibold text-fg">
|
||||
{t('app:whiteboard.title', { defaultValue: 'Whiteboard' })}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t('app:whiteboard.close', { defaultValue: '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-3.5 w-3.5" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center overflow-hidden p-6">
|
||||
{loading ? (
|
||||
<p className="text-sm text-fg-muted">
|
||||
{t('app:whiteboard.loading', { defaultValue: 'Lädt…' })}
|
||||
</p>
|
||||
) : error ? (
|
||||
<p className="text-sm text-rose-400">
|
||||
{t('app:whiteboard.error', { defaultValue: 'Whiteboard konnte nicht geladen werden.' })}
|
||||
</p>
|
||||
) : (
|
||||
<WhiteboardCanvas
|
||||
strokes={strokes}
|
||||
tool={tool}
|
||||
color={color}
|
||||
width={width}
|
||||
onStroke={(payload) => void insertStroke(payload)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="flex shrink-0 flex-wrap items-center gap-4 border-t border-line/40 bg-surface-2 px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
{(['pen', 'eraser'] as WhiteboardTool[]).map((id) => {
|
||||
const label = id === 'pen' ? 'Stift' : 'Radierer';
|
||||
const active = tool === id;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setTool(id)}
|
||||
aria-pressed={active}
|
||||
title={label}
|
||||
className={
|
||||
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-xs font-semibold transition ' +
|
||||
(active
|
||||
? 'bg-accent/20 text-accent ring-2 ring-accent/40'
|
||||
: 'bg-surface-3 text-fg-muted hover:bg-surface hover:text-fg')
|
||||
}
|
||||
>
|
||||
{id === 'pen' ? '✎' : '⌫'}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="h-6 w-px bg-line/40" aria-hidden />
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{COLORS.map((c) => {
|
||||
const active = color === c;
|
||||
return (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setColor(c)}
|
||||
aria-pressed={active}
|
||||
aria-label={c}
|
||||
className={
|
||||
'h-6 w-6 cursor-pointer rounded-full border-2 transition ' +
|
||||
(active ? 'border-fg scale-110' : 'border-line/40 hover:scale-105')
|
||||
}
|
||||
style={{ backgroundColor: c }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="h-6 w-px bg-line/40" aria-hidden />
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{WIDTHS.map((w) => {
|
||||
const active = width === w;
|
||||
return (
|
||||
<button
|
||||
key={w}
|
||||
type="button"
|
||||
onClick={() => setWidth(w)}
|
||||
aria-pressed={active}
|
||||
title={w + 'px'}
|
||||
className={
|
||||
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-md transition ' +
|
||||
(active
|
||||
? 'bg-accent/20 ring-2 ring-accent/40'
|
||||
: 'bg-surface-3 hover:bg-surface')
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="rounded-full bg-fg"
|
||||
style={{ width: w + 2 + 'px', height: w + 2 + 'px' }}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{confirmClear ? (
|
||||
<>
|
||||
<span className="text-xs text-fg-muted">
|
||||
{t('app:whiteboard.confirm_clear', { defaultValue: 'Alles löschen?' })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmClear(false)}
|
||||
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-1.5 text-xs font-medium text-fg-muted transition hover:bg-surface"
|
||||
>
|
||||
{t('app:whiteboard.cancel', { defaultValue: 'Abbrechen' })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleConfirmClear()}
|
||||
className="cursor-pointer rounded-md bg-rose-500 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-rose-500/90"
|
||||
>
|
||||
{t('app:whiteboard.confirm', { defaultValue: 'Ja, löschen' })}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmClear(true)}
|
||||
disabled={strokes.length === 0}
|
||||
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-not-allowed disabled:opacity-50 dark:text-rose-300"
|
||||
>
|
||||
{t('app:whiteboard.clear_all', { defaultValue: 'Alles löschen' })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// Inline SVG icons — no icon library dependency. Lucide-style stroke=1.75.
|
||||
import { memo } from 'react';
|
||||
|
||||
type IconProps = React.SVGProps<SVGSVGElement>;
|
||||
type IconProps = React.ComponentPropsWithoutRef<'svg'>;
|
||||
|
||||
function Base({ children, ...props }: IconProps & { children: React.ReactNode }) {
|
||||
return (
|
||||
@@ -20,7 +21,7 @@ function Base({ children, ...props }: IconProps & { children: React.ReactNode })
|
||||
);
|
||||
}
|
||||
|
||||
export function MailIcon(props: IconProps) {
|
||||
function MailIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" />
|
||||
@@ -28,8 +29,9 @@ export function MailIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const MailIcon = memo(MailIconInner);
|
||||
|
||||
export function AtIcon(props: IconProps) {
|
||||
function AtIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
@@ -37,8 +39,9 @@ export function AtIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const AtIcon = memo(AtIconInner);
|
||||
|
||||
export function TicketIcon(props: IconProps) {
|
||||
function TicketIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M3 8a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v2a2 2 0 1 0 0 4v2a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-2a2 2 0 1 0 0-4V8Z" />
|
||||
@@ -46,8 +49,9 @@ export function TicketIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const TicketIcon = memo(TicketIconInner);
|
||||
|
||||
export function ArrowRightIcon(props: IconProps) {
|
||||
function ArrowRightIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M5 12h14" />
|
||||
@@ -55,8 +59,9 @@ export function ArrowRightIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const ArrowRightIcon = memo(ArrowRightIconInner);
|
||||
|
||||
export function CheckCircleIcon(props: IconProps) {
|
||||
function CheckCircleIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
@@ -64,8 +69,9 @@ export function CheckCircleIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const CheckCircleIcon = memo(CheckCircleIconInner);
|
||||
|
||||
export function AlertIcon(props: IconProps) {
|
||||
function AlertIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M12 9v4" />
|
||||
@@ -74,8 +80,9 @@ export function AlertIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const AlertIcon = memo(AlertIconInner);
|
||||
|
||||
export function ShieldIcon(props: IconProps) {
|
||||
function ShieldIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M12 3 4 6v6c0 5 3.5 8.5 8 9 4.5-.5 8-4 8-9V6l-8-3Z" />
|
||||
@@ -83,8 +90,9 @@ export function ShieldIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const ShieldIcon = memo(ShieldIconInner);
|
||||
|
||||
export function LockIcon(props: IconProps) {
|
||||
function LockIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<rect x="4" y="11" width="16" height="10" rx="2" />
|
||||
@@ -92,8 +100,9 @@ export function LockIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const LockIcon = memo(LockIconInner);
|
||||
|
||||
export function WifiLowIcon(props: IconProps) {
|
||||
function WifiLowIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M5 12.55a11 11 0 0 1 14 0" opacity="0.35" />
|
||||
@@ -102,8 +111,9 @@ export function WifiLowIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const WifiLowIcon = memo(WifiLowIconInner);
|
||||
|
||||
export function WifiOffIcon(props: IconProps) {
|
||||
function WifiOffIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="m2 2 20 20" />
|
||||
@@ -116,8 +126,9 @@ export function WifiOffIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const WifiOffIcon = memo(WifiOffIconInner);
|
||||
|
||||
export function PinIcon(props: IconProps) {
|
||||
function PinIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M12 17v5" />
|
||||
@@ -125,8 +136,9 @@ export function PinIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const PinIcon = memo(PinIconInner);
|
||||
|
||||
export function EyeOffIcon(props: IconProps) {
|
||||
function EyeOffIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="m2 2 20 20" />
|
||||
@@ -136,18 +148,19 @@ export function EyeOffIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const EyeOffIcon = memo(EyeOffIconInner);
|
||||
|
||||
export function CaptionsIcon(props: IconProps) {
|
||||
function EyeIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<rect x="3" y="6" width="18" height="12" rx="2" />
|
||||
<path d="M7 13a2 2 0 1 1 0-2" />
|
||||
<path d="M14 13a2 2 0 1 1 0-2" />
|
||||
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const EyeIcon = memo(EyeIconInner);
|
||||
|
||||
export function PinOffIcon(props: IconProps) {
|
||||
function PinOffIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="m2 2 20 20" />
|
||||
@@ -157,8 +170,9 @@ export function PinOffIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const PinOffIcon = memo(PinOffIconInner);
|
||||
|
||||
export function SparklesIcon(props: IconProps) {
|
||||
function SparklesIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M12 3v4" />
|
||||
@@ -172,8 +186,9 @@ export function SparklesIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const SparklesIcon = memo(SparklesIconInner);
|
||||
|
||||
export function SpinnerIcon(props: IconProps) {
|
||||
function SpinnerIconInner(props: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -208,16 +223,18 @@ export function SpinnerIcon(props: IconProps) {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
export const SpinnerIcon = memo(SpinnerIconInner);
|
||||
|
||||
export function ChatBubbleIcon(props: IconProps) {
|
||||
function ChatBubbleIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M21 15a2 2 0 0 1-2 2H8l-5 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const ChatBubbleIcon = memo(ChatBubbleIconInner);
|
||||
|
||||
export function UsersIcon(props: IconProps) {
|
||||
function UsersIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
@@ -227,8 +244,9 @@ export function UsersIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const UsersIcon = memo(UsersIconInner);
|
||||
|
||||
export function GearIcon(props: IconProps) {
|
||||
function GearIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
@@ -236,8 +254,9 @@ export function GearIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const GearIcon = memo(GearIconInner);
|
||||
|
||||
export function SearchIcon(props: IconProps) {
|
||||
function SearchIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
@@ -245,16 +264,18 @@ export function SearchIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const SearchIcon = memo(SearchIconInner);
|
||||
|
||||
export function PlusIcon(props: IconProps) {
|
||||
function PlusIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const PlusIcon = memo(PlusIconInner);
|
||||
|
||||
export function SignOutIcon(props: IconProps) {
|
||||
function SignOutIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
@@ -263,32 +284,36 @@ export function SignOutIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const SignOutIcon = memo(SignOutIconInner);
|
||||
|
||||
export function MenuIcon(props: IconProps) {
|
||||
function MenuIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M3 6h18M3 12h18M3 18h18" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const MenuIcon = memo(MenuIconInner);
|
||||
|
||||
export function ChevronDownIcon(props: IconProps) {
|
||||
function ChevronDownIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const ChevronDownIcon = memo(ChevronDownIconInner);
|
||||
|
||||
export function PencilIcon(props: IconProps) {
|
||||
function PencilIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const PencilIcon = memo(PencilIconInner);
|
||||
|
||||
export function TrashIcon(props: IconProps) {
|
||||
function TrashIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M3 6h18" />
|
||||
@@ -299,8 +324,9 @@ export function TrashIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const TrashIcon = memo(TrashIconInner);
|
||||
|
||||
export function SmileIcon(props: IconProps) {
|
||||
function SmileIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
@@ -310,8 +336,9 @@ export function SmileIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const SmileIcon = memo(SmileIconInner);
|
||||
|
||||
export function CopyIcon(props: IconProps) {
|
||||
function CopyIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" />
|
||||
@@ -319,16 +346,18 @@ export function CopyIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const CopyIcon = memo(CopyIconInner);
|
||||
|
||||
export function PhoneIcon(props: IconProps) {
|
||||
function PhoneIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6A19.79 19.79 0 0 1 2.12 4.18 2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92Z" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const PhoneIcon = memo(PhoneIconInner);
|
||||
|
||||
export function PhoneOffIcon(props: IconProps) {
|
||||
function PhoneOffIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-3.48-3.05" />
|
||||
@@ -337,8 +366,9 @@ export function PhoneOffIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const PhoneOffIcon = memo(PhoneOffIconInner);
|
||||
|
||||
export function MicIcon(props: IconProps) {
|
||||
function MicIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<rect x="9" y="2" width="6" height="12" rx="3" />
|
||||
@@ -348,8 +378,9 @@ export function MicIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const MicIcon = memo(MicIconInner);
|
||||
|
||||
export function MicOffIcon(props: IconProps) {
|
||||
function MicOffIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M1 1l22 22" />
|
||||
@@ -362,8 +393,9 @@ export function MicOffIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const MicOffIcon = memo(MicOffIconInner);
|
||||
|
||||
export function InfoIcon(props: IconProps) {
|
||||
function InfoIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
@@ -372,16 +404,18 @@ export function InfoIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const InfoIcon = memo(InfoIconInner);
|
||||
|
||||
export function XIcon(props: IconProps) {
|
||||
function XIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M18 6 6 18M6 6l12 12" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const XIcon = memo(XIconInner);
|
||||
|
||||
export function MonitorShareIcon(props: IconProps) {
|
||||
function MonitorShareIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<rect x="3" y="4" width="18" height="12" rx="2" />
|
||||
@@ -390,8 +424,9 @@ export function MonitorShareIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const MonitorShareIcon = memo(MonitorShareIconInner);
|
||||
|
||||
export function MonitorStopIcon(props: IconProps) {
|
||||
function MonitorStopIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<rect x="3" y="4" width="18" height="12" rx="2" />
|
||||
@@ -400,8 +435,9 @@ export function MonitorStopIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const MonitorStopIcon = memo(MonitorStopIconInner);
|
||||
|
||||
export function SunIcon(props: IconProps) {
|
||||
function SunIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
@@ -409,16 +445,18 @@ export function SunIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const SunIcon = memo(SunIconInner);
|
||||
|
||||
export function MoonIcon(props: IconProps) {
|
||||
function MoonIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3a7 7 0 0 0 9.79 9.79Z" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const MoonIcon = memo(MoonIconInner);
|
||||
|
||||
export function GridIcon(props: IconProps) {
|
||||
function GridIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<rect x="3" y="3" width="7" height="7" rx="1.5" />
|
||||
@@ -428,8 +466,9 @@ export function GridIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const GridIcon = memo(GridIconInner);
|
||||
|
||||
export function ImageIcon(props: IconProps) {
|
||||
function ImageIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" />
|
||||
@@ -438,8 +477,9 @@ export function ImageIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const ImageIcon = memo(ImageIconInner);
|
||||
|
||||
export function FileIcon(props: IconProps) {
|
||||
function FileIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M14 2H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7Z" />
|
||||
@@ -447,8 +487,9 @@ export function FileIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const FileIcon = memo(FileIconInner);
|
||||
|
||||
export function PollIcon(props: IconProps) {
|
||||
function PollIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M5 19V9" />
|
||||
@@ -458,8 +499,9 @@ export function PollIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const PollIcon = memo(PollIconInner);
|
||||
|
||||
export function FocusIcon(props: IconProps) {
|
||||
function FocusIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
@@ -467,8 +509,9 @@ export function FocusIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const FocusIcon = memo(FocusIconInner);
|
||||
|
||||
export function MaximizeIcon(props: IconProps) {
|
||||
function MaximizeIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M4 9V5a1 1 0 0 1 1-1h4" />
|
||||
@@ -478,8 +521,9 @@ export function MaximizeIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const MaximizeIcon = memo(MaximizeIconInner);
|
||||
|
||||
export function VideoIcon(props: IconProps) {
|
||||
function VideoIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<rect x="2" y="6" width="15" height="12" rx="2" />
|
||||
@@ -487,16 +531,18 @@ export function VideoIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const VideoIcon = memo(VideoIconInner);
|
||||
|
||||
export function CrownIcon(props: IconProps) {
|
||||
function CrownIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="m3 7 4 4 5-6 5 6 4-4v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const CrownIcon = memo(CrownIconInner);
|
||||
|
||||
export function MusicIcon(props: IconProps) {
|
||||
function MusicIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M9 18V5l12-2v13" />
|
||||
@@ -505,16 +551,18 @@ export function MusicIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const MusicIcon = memo(MusicIconInner);
|
||||
|
||||
export function SendIcon(props: IconProps) {
|
||||
function SendIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="m3 11 18-8-8 18-2-8-8-2Z" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const SendIcon = memo(SendIconInner);
|
||||
|
||||
export function ArchiveIcon(props: IconProps) {
|
||||
function ArchiveIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<rect x="3" y="4" width="18" height="5" rx="1" />
|
||||
@@ -523,8 +571,9 @@ export function ArchiveIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const ArchiveIcon = memo(ArchiveIconInner);
|
||||
|
||||
export function BellIcon(props: IconProps) {
|
||||
function BellIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M6 8a6 6 0 1 1 12 0c0 4 1.5 5 2 6H4c.5-1 2-2 2-6Z" />
|
||||
@@ -532,8 +581,9 @@ export function BellIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const BellIcon = memo(BellIconInner);
|
||||
|
||||
export function BellOffIcon(props: IconProps) {
|
||||
function BellOffIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M9.5 18a2.5 2.5 0 0 0 5 0" />
|
||||
@@ -545,8 +595,9 @@ export function BellOffIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const BellOffIcon = memo(BellOffIconInner);
|
||||
|
||||
export function MoreVerticalIcon(props: IconProps) {
|
||||
function MoreVerticalIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<circle cx="12" cy="5" r="1.5" />
|
||||
@@ -555,8 +606,9 @@ export function MoreVerticalIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const MoreVerticalIcon = memo(MoreVerticalIconInner);
|
||||
|
||||
export function HeadphonesIcon(props: IconProps) {
|
||||
function HeadphonesIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M3 14v-2a9 9 0 0 1 18 0v2" />
|
||||
@@ -565,8 +617,9 @@ export function HeadphonesIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const HeadphonesIcon = memo(HeadphonesIconInner);
|
||||
|
||||
export function HeadphonesOffIcon(props: IconProps) {
|
||||
function HeadphonesOffIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M3 14v-2a9 9 0 0 1 11.3-8.7" />
|
||||
@@ -577,8 +630,9 @@ export function HeadphonesOffIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const HeadphonesOffIcon = memo(HeadphonesOffIconInner);
|
||||
|
||||
export function ReplyIcon(props: IconProps) {
|
||||
function ReplyIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<polyline points="9 17 4 12 9 7" />
|
||||
@@ -586,8 +640,9 @@ export function ReplyIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const ReplyIcon = memo(ReplyIconInner);
|
||||
|
||||
export function ForwardIcon(props: IconProps) {
|
||||
function ForwardIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<polyline points="15 17 20 12 15 7" />
|
||||
@@ -595,16 +650,18 @@ export function ForwardIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const ForwardIcon = memo(ForwardIconInner);
|
||||
|
||||
export function ChevronUpIcon(props: IconProps) {
|
||||
function ChevronUpIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<polyline points="18 15 12 9 6 15" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const ChevronUpIcon = memo(ChevronUpIconInner);
|
||||
|
||||
export function AddUserIcon(props: IconProps) {
|
||||
function AddUserIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
@@ -613,12 +670,13 @@ export function AddUserIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const AddUserIcon = memo(AddUserIconInner);
|
||||
|
||||
// Soft-N mark: rounded-square violet tile with a stylised "N" glyph. Used
|
||||
// wherever the app needs a standalone icon (sidebar rail, auth screen,
|
||||
// favicon). Colour decisions sit inside the SVG so consumers just size the
|
||||
// element via `className`.
|
||||
export function LogoMark(props: IconProps) {
|
||||
function LogoMarkInner(props: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -638,10 +696,11 @@ export function LogoMark(props: IconProps) {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
export const LogoMark = memo(LogoMarkInner);
|
||||
|
||||
// Full lockup: soft-N mark + "Netralax" wordmark. `tone` decides text colour:
|
||||
// "dark" = white text (use on dark background), "light" = near-black.
|
||||
export function LogoLockup({
|
||||
function LogoLockupInner({
|
||||
tone = 'dark',
|
||||
...props
|
||||
}: IconProps & { tone?: 'dark' | 'light' }) {
|
||||
@@ -676,3 +735,4 @@ export function LogoLockup({
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
export const LogoLockup = memo(LogoLockupInner);
|
||||
|
||||
@@ -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,11 +1,15 @@
|
||||
import {
|
||||
fetchUserKeyBlob,
|
||||
getOwnProfile,
|
||||
listOwnDevices,
|
||||
registerDevice,
|
||||
signOut as supabaseSignOut,
|
||||
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,
|
||||
@@ -19,7 +23,10 @@ import {
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
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';
|
||||
@@ -48,6 +55,8 @@ interface AuthContextValue {
|
||||
refreshProfile: () => Promise<void>;
|
||||
refreshUserKeyState: () => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
revokedRemotely: boolean;
|
||||
acknowledgeRevocation: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
@@ -58,6 +67,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [ready, setReady] = useState(false);
|
||||
const [profile, setProfile] = useState<Profile | null>(null);
|
||||
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
|
||||
@@ -194,6 +204,83 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
void registerWebPush(installId);
|
||||
}, [session]);
|
||||
|
||||
// Pre-warm Supabase: fires the first round-trip in the background so the
|
||||
// first user-triggered query (e.g. loading conversations) doesn't pay
|
||||
// the cold-connection latency.
|
||||
//
|
||||
// Uses auth.getSession() instead of a `profiles` SELECT because the
|
||||
// SELECT race-fired before the supabase client committed its JWT to
|
||||
// request headers, causing a 400 from PostgREST on app boot. Auth
|
||||
// endpoints don't depend on RLS and tolerate the race.
|
||||
useEffect(() => {
|
||||
if (!session) return;
|
||||
void supabase.auth.getSession();
|
||||
}, [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.
|
||||
//
|
||||
// - On mount with a session whose last persisted state is `offline`, flip
|
||||
@@ -235,12 +322,54 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}, [session, profile, refreshProfile]);
|
||||
|
||||
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,
|
||||
@@ -250,8 +379,20 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
refreshProfile,
|
||||
refreshUserKeyState,
|
||||
signOut,
|
||||
revokedRemotely,
|
||||
acknowledgeRevocation,
|
||||
}),
|
||||
[session, profile, userKeyState, ready, refreshProfile, refreshUserKeyState, signOut],
|
||||
[
|
||||
session,
|
||||
profile,
|
||||
userKeyState,
|
||||
ready,
|
||||
refreshProfile,
|
||||
refreshUserKeyState,
|
||||
signOut,
|
||||
revokedRemotely,
|
||||
acknowledgeRevocation,
|
||||
],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
|
||||
@@ -39,7 +39,6 @@ import {
|
||||
playUndeafenBeep,
|
||||
playUnmuteBeep,
|
||||
} from '../lib/callSounds';
|
||||
import { useLiveCaptions } from '../lib/useLiveCaptions';
|
||||
import { setCallWakeLock } from '../lib/wakeLock';
|
||||
import { notify } from '../lib/osNotify';
|
||||
import {
|
||||
@@ -209,14 +208,6 @@ interface CallContextValue {
|
||||
* invite (fromUserId). Cleared on disconnect. Drives the crown badge,
|
||||
* but only in group calls. Null while idle or in 1:1 contexts. */
|
||||
callHostId: string | null;
|
||||
/** identity -> latest live-caption fragment received via data channel.
|
||||
* Includes own captions for self-overlay. Receivers prune entries whose
|
||||
* timestamp is older than ~5s so stale lines fade out. */
|
||||
captions: Record<string, { text: string; final: boolean; timestamp: number }>;
|
||||
/** Surface a caption for the local user — the live-captions hook calls
|
||||
* this on every interim/final SpeechRecognition result so the overlay
|
||||
* shows our own line without going through the SFU round-trip. */
|
||||
pushLocalCaption: (text: string, final: boolean) => void;
|
||||
/** identity -> mute state. Broadcast from peer whenever mic-gain flips.
|
||||
* Needed because we can't rely on LiveKit's native isMicrophoneEnabled —
|
||||
* the mic pipeline keeps the track published with sound flowing even
|
||||
@@ -347,9 +338,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
// useEffect) so peers don't hear themselves echoed back when the OS-level
|
||||
// process-tree exclusion isn't watertight.
|
||||
const [isCapturingSystemAudio, setIsCapturingSystemAudio] = useState(false);
|
||||
const [captions, setCaptions] = useState<
|
||||
Record<string, { text: string; final: boolean; timestamp: number }>
|
||||
>({});
|
||||
const [lastCallConversationId, setLastCallConversationId] = useState<string | null>(null);
|
||||
const [callMode, setCallModeState] = useState<CallMode>('grid');
|
||||
const [focusedId, setFocusedIdState] = useState<string | null>(null);
|
||||
@@ -828,7 +816,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
setRemoteScreenShares([]);
|
||||
setConnectionQualities({});
|
||||
setCallHostId(null);
|
||||
setCaptions({});
|
||||
setIsScreenSharing(false);
|
||||
setIsE2EEActive(false);
|
||||
}
|
||||
@@ -970,8 +957,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
type?: string;
|
||||
deafened?: boolean;
|
||||
muted?: boolean;
|
||||
captionText?: string;
|
||||
captionFinal?: boolean;
|
||||
};
|
||||
const id: string = participant.identity;
|
||||
if (msg.type === 'presence') {
|
||||
@@ -991,15 +976,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'caption' && typeof msg.captionText === 'string') {
|
||||
const text2 = msg.captionText;
|
||||
const final = msg.captionFinal === true;
|
||||
setCaptions((prev) => ({
|
||||
...prev,
|
||||
[id]: { text: text2, final, timestamp: Date.now() },
|
||||
}));
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* ignore malformed */
|
||||
}
|
||||
@@ -1442,20 +1418,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 })
|
||||
@@ -1745,17 +1730,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const pushLocalCaption = useCallback(
|
||||
(text: string, final: boolean) => {
|
||||
if (!myId) return;
|
||||
setCaptions((prev) => ({
|
||||
...prev,
|
||||
[myId]: { text, final, timestamp: Date.now() },
|
||||
}));
|
||||
},
|
||||
[myId],
|
||||
);
|
||||
|
||||
const toggleCamera = useCallback(async () => {
|
||||
const r = roomRef.current;
|
||||
if (!r) return;
|
||||
@@ -2004,14 +1978,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];
|
||||
@@ -2592,15 +2568,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [callMode, state.kind]);
|
||||
|
||||
// Discord-style live-captions broadcaster — runs on the local mic while
|
||||
// we're connected, and ships interim/final transcripts on the LiveKit
|
||||
// DataChannel so peers can render them.
|
||||
useLiveCaptions({
|
||||
room,
|
||||
active: state.kind === 'connected' || state.kind === 'reconnecting',
|
||||
onLocalCaption: pushLocalCaption,
|
||||
});
|
||||
|
||||
const value = useMemo<CallContextValue>(
|
||||
() => ({
|
||||
state,
|
||||
@@ -2615,8 +2582,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
remoteMute,
|
||||
connectionQualities,
|
||||
callHostId,
|
||||
captions,
|
||||
pushLocalCaption,
|
||||
remoteScreenShares,
|
||||
lastCallConversationId,
|
||||
callMode,
|
||||
@@ -2672,8 +2637,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
remoteMute,
|
||||
connectionQualities,
|
||||
callHostId,
|
||||
captions,
|
||||
pushLocalCaption,
|
||||
remoteScreenShares,
|
||||
lastCallConversationId,
|
||||
callMode,
|
||||
|
||||
@@ -54,6 +54,15 @@ interface ConversationsContextValue {
|
||||
refresh: () => Promise<void>;
|
||||
markRead: (conversationId: string) => void;
|
||||
setActiveConversation: (conversationId: string | null) => void;
|
||||
// Optimistic patch for the caller's per-membership preferences (mute /
|
||||
// mentions-only / archive). Mutations to `conversation_members` echo back
|
||||
// via the realtime channel and `refresh()` reconciles canonically, but the
|
||||
// ~100-200ms roundtrip leaves the UI looking unresponsive. Callers patch
|
||||
// immediately, snapshot the previous state, and roll back on failure.
|
||||
patchConversation: (
|
||||
conversationId: string,
|
||||
patch: Partial<Pick<ConversationSummary, 'archived' | 'mutedUntil' | 'mentionsOnly'>>,
|
||||
) => void;
|
||||
}
|
||||
|
||||
const ConversationsContext = createContext<ConversationsContextValue | null>(null);
|
||||
@@ -164,6 +173,19 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
|
||||
[markRead],
|
||||
);
|
||||
|
||||
const patchConversation = useCallback<ConversationsContextValue['patchConversation']>(
|
||||
(convId, patch) => {
|
||||
setConversations((prev) => {
|
||||
const idx = prev.findIndex((c) => c.id === convId);
|
||||
if (idx === -1) return prev;
|
||||
const next = [...prev];
|
||||
next[idx] = { ...next[idx]!, ...patch };
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!myId) {
|
||||
setConversations([]);
|
||||
@@ -225,7 +247,12 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
|
||||
(c) => c.id === row.conversation_id,
|
||||
);
|
||||
const muted = isConversationMuted(convForMute?.mutedUntil ?? null);
|
||||
if (presenceRef.current !== 'dnd' && !muted) {
|
||||
// "Mentions only" silences non-mention messages here. Mentions
|
||||
// still fire via the independent useMentionNotifications
|
||||
// subscription on message_mentions, so this branch doesn't
|
||||
// lose the @-alerts.
|
||||
const mentionsOnly = convForMute?.mentionsOnly ?? false;
|
||||
if (presenceRef.current !== 'dnd' && !muted && !mentionsOnly) {
|
||||
playNotificationTone();
|
||||
const conv = conversationsRef.current.find(
|
||||
(c) => c.id === row.conversation_id,
|
||||
@@ -303,6 +330,7 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
|
||||
refresh,
|
||||
markRead,
|
||||
setActiveConversation,
|
||||
patchConversation,
|
||||
}),
|
||||
[
|
||||
conversations,
|
||||
@@ -313,6 +341,7 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
|
||||
refresh,
|
||||
markRead,
|
||||
setActiveConversation,
|
||||
patchConversation,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { getGame, makeGameMove, type GameRecord } from '@chat-app/shared/chat';
|
||||
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
export function useGame(gameId: string | null): {
|
||||
game: GameRecord | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
makeMove: (move: object) => Promise<void>;
|
||||
} {
|
||||
const [game, setGame] = useState<GameRecord | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!gameId) {
|
||||
setGame(null);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const fresh = await getGame(supabase, gameId);
|
||||
if (!cancelled) {
|
||||
setGame(fresh);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
setError(err instanceof Error ? err.message : 'failed to load game');
|
||||
}
|
||||
}
|
||||
})();
|
||||
const channel = supabase
|
||||
.channel('game:' + gameId)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'UPDATE',
|
||||
schema: 'public',
|
||||
table: 'conversation_games',
|
||||
filter: 'id=eq.' + gameId,
|
||||
},
|
||||
() => {
|
||||
void getGame(supabase, gameId)
|
||||
.then((fresh) => { if (fresh) setGame(fresh); })
|
||||
.catch((err) => { console.warn('game realtime refetch failed', err); });
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
void supabase.removeChannel(channel);
|
||||
};
|
||||
}, [gameId]);
|
||||
|
||||
const makeMove = useCallback(async (move: object) => {
|
||||
if (!gameId) return;
|
||||
try {
|
||||
await makeGameMove(supabase, { gameId, move });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'move failed');
|
||||
throw err;
|
||||
}
|
||||
}, [gameId]);
|
||||
|
||||
return { game, loading, error, makeMove };
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import {
|
||||
getAutoLockMinutes,
|
||||
subscribeAutoLockSetting,
|
||||
type AutoLockMinutes,
|
||||
} from '../lib/autoLockSettings';
|
||||
|
||||
const ACTIVITY_EVENTS: Array<keyof WindowEventMap> = [
|
||||
'keydown',
|
||||
'mousedown',
|
||||
'pointermove',
|
||||
'touchstart',
|
||||
'wheel',
|
||||
];
|
||||
|
||||
// Throttle activity-event resets to once per second to avoid thrashing the
|
||||
// timer on rapid mouse movement.
|
||||
const RESET_THROTTLE_MS = 1000;
|
||||
|
||||
export function useIdleAutoLock(): void {
|
||||
const { session, signOut } = useAuth();
|
||||
const minutesRef = useRef<AutoLockMinutes>(getAutoLockMinutes());
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const lastResetAtRef = useRef<number>(0);
|
||||
|
||||
// Keep minutesRef live to the setting.
|
||||
useEffect(() => {
|
||||
const unsub = subscribeAutoLockSetting((v) => {
|
||||
minutesRef.current = v;
|
||||
scheduleNext();
|
||||
});
|
||||
return unsub;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Helper: schedule the lock based on the current setting.
|
||||
function scheduleNext(): void {
|
||||
if (timerRef.current !== null) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
const min = minutesRef.current;
|
||||
if (min === 0) return; // disabled
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
timerRef.current = null;
|
||||
// Fire the lock. signOut wipes local state and navigates to /device
|
||||
// (PIN re-entry screen).
|
||||
void signOut().catch((err) => console.warn('auto-lock signOut failed', err));
|
||||
}, min * 60 * 1000);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!session) return;
|
||||
scheduleNext();
|
||||
|
||||
const onActivity = () => {
|
||||
const now = Date.now();
|
||||
if (now - lastResetAtRef.current < RESET_THROTTLE_MS) return;
|
||||
lastResetAtRef.current = now;
|
||||
scheduleNext();
|
||||
};
|
||||
|
||||
for (const ev of ACTIVITY_EVENTS) {
|
||||
window.addEventListener(ev, onActivity, { passive: true });
|
||||
}
|
||||
return () => {
|
||||
for (const ev of ACTIVITY_EVENTS) {
|
||||
window.removeEventListener(ev, onActivity);
|
||||
}
|
||||
if (timerRef.current !== null) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [session]);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
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) => {
|
||||
// Optimistic: flip `revokedAt` on the row so the "Abgemeldet" badge
|
||||
// appears on the same frame as the click. Capture a snapshot so we
|
||||
// can restore exactly on RPC failure (the realtime subscription's
|
||||
// own UPDATE echo would otherwise reconcile back to "not revoked"
|
||||
// anyway). Skip if the row isn't in our list — nothing to undo.
|
||||
let snapshot: DeviceRecord[] | null = null;
|
||||
const stampedAt = new Date().toISOString();
|
||||
setState((prev) => {
|
||||
if (!prev.devices.some((d) => d.id === deviceId)) return prev;
|
||||
snapshot = prev.devices;
|
||||
return {
|
||||
...prev,
|
||||
devices: prev.devices.map((d) =>
|
||||
d.id === deviceId ? { ...d, revokedAt: d.revokedAt ?? stampedAt } : d,
|
||||
),
|
||||
};
|
||||
});
|
||||
try {
|
||||
await revokeDevice(supabase, deviceId);
|
||||
// Skip the eager refresh: the realtime UPDATE on `devices` triggers
|
||||
// refresh() via the subscription and the optimistic row already
|
||||
// shows the badge. Avoids a list flicker between optimistic and
|
||||
// canonical state.
|
||||
} catch (err) {
|
||||
if (snapshot) {
|
||||
setState((prev) => ({ ...prev, devices: snapshot! }));
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return { devices: state.devices, loading: state.loading, error: state.error, refresh, revoke };
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { getCryptoBackend } from '@chat-app/shared/crypto';
|
||||
import {
|
||||
decryptSoundEnvelope,
|
||||
downloadSoundCiphertext,
|
||||
encryptSoundBlob,
|
||||
listOwnSounds,
|
||||
type RemoteSound,
|
||||
upsertSound,
|
||||
uploadSoundCiphertext,
|
||||
} from '@chat-app/shared/chat';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import {
|
||||
deleteRawStoredSound,
|
||||
getRawStoredSound,
|
||||
listSounds,
|
||||
putRawStoredSound,
|
||||
type SoundboardEntry,
|
||||
subscribeSoundboardChanges,
|
||||
} from '../lib/soundboardStorage';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { cachedUserKey } from '../lib/userIdentity';
|
||||
|
||||
export type SyncBadge = 'synced' | 'uploading' | 'downloading' | 'error';
|
||||
|
||||
const PUSH_DEBOUNCE_MS = 500;
|
||||
const DELETE_GRACE_MS = 5000;
|
||||
|
||||
function storagePathFor(userId: string, soundId: string): string {
|
||||
return userId + '/' + soundId + '.bin';
|
||||
}
|
||||
|
||||
export function useSoundboardSync(): {
|
||||
badges: Map<string, SyncBadge>;
|
||||
initialPullDone: boolean;
|
||||
} {
|
||||
const { session } = useAuth();
|
||||
const userId = session?.user.id ?? null;
|
||||
const [badges, setBadges] = useState<Map<string, SyncBadge>>(new Map());
|
||||
const [initialPullDone, setInitialPullDone] = useState(false);
|
||||
const debounceRef = useRef<number | null>(null);
|
||||
|
||||
function setBadge(id: string, badge: SyncBadge): void {
|
||||
setBadges((cur) => {
|
||||
const next = new Map(cur);
|
||||
next.set(id, badge);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId) {
|
||||
setInitialPullDone(false);
|
||||
setBadges(new Map());
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
let teardown: (() => void) | null = null;
|
||||
|
||||
const init = async () => {
|
||||
const priv = await cachedUserKey(userId);
|
||||
if (!priv) return;
|
||||
const pub = getCryptoBackend().scalarMultBase(priv);
|
||||
|
||||
try {
|
||||
await runDiff(userId, priv, pub);
|
||||
} catch (err) {
|
||||
console.error('soundboard initial diff failed', err);
|
||||
}
|
||||
if (cancelled) return;
|
||||
setInitialPullDone(true);
|
||||
|
||||
const unsubLocal = subscribeSoundboardChanges(() => {
|
||||
if (debounceRef.current !== null) window.clearTimeout(debounceRef.current);
|
||||
debounceRef.current = window.setTimeout(() => {
|
||||
debounceRef.current = null;
|
||||
void runDiff(userId, priv, pub).catch((err) => {
|
||||
console.error('soundboard push diff failed', err);
|
||||
});
|
||||
}, PUSH_DEBOUNCE_MS);
|
||||
});
|
||||
|
||||
const channel = supabase
|
||||
.channel('soundboards:' + userId)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: '*',
|
||||
schema: 'public',
|
||||
table: 'user_soundboards',
|
||||
filter: 'user_id=eq.' + userId,
|
||||
},
|
||||
() => {
|
||||
void runDiff(userId, priv, pub).catch((err) => {
|
||||
console.error('soundboard realtime pull failed', err);
|
||||
});
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
teardown = () => {
|
||||
unsubLocal();
|
||||
void supabase.removeChannel(channel);
|
||||
if (debounceRef.current !== null) {
|
||||
window.clearTimeout(debounceRef.current);
|
||||
debounceRef.current = null;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
void init();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
teardown?.();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [userId]);
|
||||
|
||||
async function runDiff(uid: string, priv: Uint8Array, pub: Uint8Array): Promise<void> {
|
||||
const [localList, remoteList] = await Promise.all([
|
||||
listSounds(),
|
||||
listOwnSounds(supabase),
|
||||
]);
|
||||
const remoteById = new Map<string, RemoteSound>();
|
||||
for (const r of remoteList) remoteById.set(r.id, r);
|
||||
const localById = new Map<string, SoundboardEntry>();
|
||||
for (const l of localList) localById.set(l.id, l);
|
||||
|
||||
// Push pass
|
||||
for (const local of localList) {
|
||||
const remote = remoteById.get(local.id);
|
||||
const localIso = new Date(local.updatedAt).toISOString();
|
||||
if (!remote) {
|
||||
await uploadAndUpsert(local, uid, priv, pub, localIso);
|
||||
} else {
|
||||
const remoteMs = Date.parse(remote.updatedAt);
|
||||
if (local.updatedAt > remoteMs) {
|
||||
await upsertMetadataOnly(local, remote, localIso);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pull pass
|
||||
for (const remote of remoteList) {
|
||||
const local = localById.get(remote.id);
|
||||
const remoteMs = Date.parse(remote.updatedAt);
|
||||
if (!local) {
|
||||
await pullAndStore(remote, priv, pub);
|
||||
} else if (remoteMs > local.updatedAt) {
|
||||
const stored = await getRawStoredSound(remote.id);
|
||||
if (stored) {
|
||||
await putRawStoredSound({
|
||||
...stored,
|
||||
name: remote.name,
|
||||
mime: remote.mime,
|
||||
size: remote.size,
|
||||
category: remote.category,
|
||||
hotkey: remote.hotkey,
|
||||
gain: remote.gain,
|
||||
order: remote.sortOrder,
|
||||
updatedAt: remoteMs,
|
||||
});
|
||||
setBadge(remote.id, 'synced');
|
||||
}
|
||||
} else {
|
||||
setBadge(remote.id, 'synced');
|
||||
}
|
||||
}
|
||||
|
||||
// Remote-absence → local delete (with grace window for fresh adds).
|
||||
const now = Date.now();
|
||||
for (const local of localList) {
|
||||
if (!remoteById.has(local.id) && now - local.updatedAt > DELETE_GRACE_MS) {
|
||||
await deleteRawStoredSound(local.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadAndUpsert(
|
||||
local: SoundboardEntry,
|
||||
uid: string,
|
||||
priv: Uint8Array,
|
||||
pub: Uint8Array,
|
||||
localIso: string,
|
||||
): Promise<void> {
|
||||
setBadge(local.id, 'uploading');
|
||||
try {
|
||||
const stored = await getRawStoredSound(local.id);
|
||||
if (!stored) return;
|
||||
const ciphertext = await encryptSoundBlob(stored.blob, pub, priv);
|
||||
const path = storagePathFor(uid, local.id);
|
||||
await uploadSoundCiphertext(supabase, path, ciphertext);
|
||||
await upsertSound(supabase, {
|
||||
id: local.id,
|
||||
name: local.name,
|
||||
mime: local.mime,
|
||||
size: local.size,
|
||||
category: local.category,
|
||||
hotkey: local.hotkey,
|
||||
gain: local.gain,
|
||||
sortOrder: local.order,
|
||||
storagePath: path,
|
||||
updatedAtIso: localIso,
|
||||
});
|
||||
setBadge(local.id, 'synced');
|
||||
} catch (err) {
|
||||
console.error('soundboard upload failed', err);
|
||||
setBadge(local.id, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertMetadataOnly(
|
||||
local: SoundboardEntry,
|
||||
remote: RemoteSound,
|
||||
localIso: string,
|
||||
): Promise<void> {
|
||||
setBadge(local.id, 'uploading');
|
||||
try {
|
||||
await upsertSound(supabase, {
|
||||
id: local.id,
|
||||
name: local.name,
|
||||
mime: local.mime,
|
||||
size: local.size,
|
||||
category: local.category,
|
||||
hotkey: local.hotkey,
|
||||
gain: local.gain,
|
||||
sortOrder: local.order,
|
||||
storagePath: remote.storagePath,
|
||||
updatedAtIso: localIso,
|
||||
});
|
||||
setBadge(local.id, 'synced');
|
||||
} catch (err) {
|
||||
console.error('soundboard metadata upload failed', err);
|
||||
setBadge(local.id, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function pullAndStore(
|
||||
remote: RemoteSound,
|
||||
priv: Uint8Array,
|
||||
pub: Uint8Array,
|
||||
): Promise<void> {
|
||||
setBadge(remote.id, 'downloading');
|
||||
try {
|
||||
const envelope = await downloadSoundCiphertext(supabase, remote.storagePath);
|
||||
const plain = await decryptSoundEnvelope(envelope, pub, priv);
|
||||
// Copy into a fresh ArrayBuffer so Blob accepts it across TS lib variants
|
||||
// (mirrors the pattern in @chat-app/shared/chat/attachments.ts).
|
||||
const copy = new Uint8Array(plain.byteLength);
|
||||
copy.set(plain);
|
||||
const blob = new Blob([copy.buffer], { type: remote.mime });
|
||||
const ms = Date.parse(remote.updatedAt);
|
||||
await putRawStoredSound({
|
||||
id: remote.id,
|
||||
name: remote.name,
|
||||
mime: remote.mime,
|
||||
size: remote.size,
|
||||
category: remote.category,
|
||||
hotkey: remote.hotkey,
|
||||
gain: remote.gain,
|
||||
order: remote.sortOrder,
|
||||
createdAt: Date.parse(remote.createdAt),
|
||||
updatedAt: ms,
|
||||
blob,
|
||||
});
|
||||
setBadge(remote.id, 'synced');
|
||||
} catch (err) {
|
||||
console.error('soundboard pull failed', err);
|
||||
setBadge(remote.id, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
return { badges, initialPullDone };
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
endWatchSession,
|
||||
getWatchSession,
|
||||
updateWatchSessionState,
|
||||
type WatchSession,
|
||||
type WatchSessionState,
|
||||
} from '@chat-app/shared/chat';
|
||||
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
const PUSH_THROTTLE_MS = 500;
|
||||
|
||||
export function useWatchSession(sessionId: string | null): {
|
||||
session: WatchSession | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
pushState: (state: WatchSessionState) => void;
|
||||
endSession: () => Promise<void>;
|
||||
} {
|
||||
const [session, setSession] = useState<WatchSession | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const pendingRef = useRef<WatchSessionState | null>(null);
|
||||
const lastPushAtRef = useRef<number>(0);
|
||||
const pushTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) {
|
||||
setSession(null);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const fresh = await getWatchSession(supabase, sessionId);
|
||||
if (!cancelled) {
|
||||
setSession(fresh);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
setError(err instanceof Error ? err.message : 'failed to load session');
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
const channel = supabase
|
||||
.channel('watch_session:' + sessionId)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'UPDATE',
|
||||
schema: 'public',
|
||||
table: 'conversation_watch_sessions',
|
||||
filter: 'id=eq.' + sessionId,
|
||||
},
|
||||
(payload) => {
|
||||
const row = payload.new as {
|
||||
id?: string;
|
||||
ended_at?: string | null;
|
||||
current_state?: unknown;
|
||||
} | null;
|
||||
if (!row?.id) return;
|
||||
const raw = (row.current_state ?? {}) as Partial<{
|
||||
playing: boolean;
|
||||
position_seconds: number;
|
||||
updated_at_ms: number;
|
||||
}>;
|
||||
setSession((cur) => {
|
||||
if (!cur) return cur;
|
||||
return {
|
||||
...cur,
|
||||
endedAt: row.ended_at ?? null,
|
||||
currentState: {
|
||||
playing: typeof raw.playing === 'boolean' ? raw.playing : cur.currentState.playing,
|
||||
positionSeconds:
|
||||
typeof raw.position_seconds === 'number'
|
||||
? raw.position_seconds
|
||||
: cur.currentState.positionSeconds,
|
||||
updatedAtMs:
|
||||
typeof raw.updated_at_ms === 'number'
|
||||
? raw.updated_at_ms
|
||||
: cur.currentState.updatedAtMs,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
void supabase.removeChannel(channel);
|
||||
if (pushTimerRef.current !== null) {
|
||||
window.clearTimeout(pushTimerRef.current);
|
||||
pushTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
// Throttled writer: keeps the latest state in pendingRef; fires at most
|
||||
// once per PUSH_THROTTLE_MS. Trailing-edge push guarantees the final
|
||||
// state is always sent even when a rapid burst stops before the leading-
|
||||
// edge timeout expires.
|
||||
const pushState = useCallback((state: WatchSessionState) => {
|
||||
if (!sessionId) return;
|
||||
pendingRef.current = state;
|
||||
const now = Date.now();
|
||||
const elapsed = now - lastPushAtRef.current;
|
||||
if (elapsed >= PUSH_THROTTLE_MS) {
|
||||
lastPushAtRef.current = now;
|
||||
const toPush = pendingRef.current;
|
||||
pendingRef.current = null;
|
||||
void updateWatchSessionState(supabase, sessionId, toPush).catch((err) => {
|
||||
console.warn('updateWatchSessionState failed', err);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (pushTimerRef.current !== null) window.clearTimeout(pushTimerRef.current);
|
||||
pushTimerRef.current = window.setTimeout(() => {
|
||||
pushTimerRef.current = null;
|
||||
const toPush = pendingRef.current;
|
||||
if (!toPush) return;
|
||||
pendingRef.current = null;
|
||||
lastPushAtRef.current = Date.now();
|
||||
void updateWatchSessionState(supabase, sessionId, toPush).catch((err) => {
|
||||
console.warn('updateWatchSessionState trailing failed', err);
|
||||
});
|
||||
}, PUSH_THROTTLE_MS - elapsed);
|
||||
}, [sessionId]);
|
||||
|
||||
const endSession = useCallback(async () => {
|
||||
if (!sessionId) return;
|
||||
await endWatchSession(supabase, sessionId);
|
||||
}, [sessionId]);
|
||||
|
||||
return { session, loading, error, pushState, endSession };
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
clearWhiteboardStrokes,
|
||||
insertWhiteboardStroke,
|
||||
listWhiteboardStrokes,
|
||||
type WhiteboardStroke,
|
||||
} from '@chat-app/shared/chat';
|
||||
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
interface State {
|
||||
strokes: WhiteboardStroke[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function useWhiteboardStrokes(whiteboardId: string | null): {
|
||||
strokes: WhiteboardStroke[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
insertStroke: (strokeJson: unknown) => Promise<void>;
|
||||
clearAll: () => Promise<void>;
|
||||
} {
|
||||
const [state, setState] = useState<State>({ strokes: [], loading: true, error: null });
|
||||
|
||||
useEffect(() => {
|
||||
if (!whiteboardId) {
|
||||
setState({ strokes: [], loading: false, error: null });
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
setState((s) => ({ ...s, loading: true, error: null }));
|
||||
const list = await listWhiteboardStrokes(supabase, whiteboardId);
|
||||
if (!cancelled) setState({ strokes: list, loading: false, error: null });
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setState({
|
||||
strokes: [],
|
||||
loading: false,
|
||||
error: err instanceof Error ? err.message : 'failed to load strokes',
|
||||
});
|
||||
}
|
||||
}
|
||||
})();
|
||||
const channel = supabase
|
||||
.channel('whiteboard:' + whiteboardId)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'INSERT',
|
||||
schema: 'public',
|
||||
table: 'whiteboard_strokes',
|
||||
filter: 'whiteboard_id=eq.' + whiteboardId,
|
||||
},
|
||||
(payload) => {
|
||||
const row = payload.new as {
|
||||
id?: string;
|
||||
whiteboard_id?: string;
|
||||
author_user_id?: string;
|
||||
stroke_json?: unknown;
|
||||
created_at?: string;
|
||||
} | null;
|
||||
if (!row?.id || !row.whiteboard_id || !row.author_user_id || !row.created_at) return;
|
||||
const next: WhiteboardStroke = {
|
||||
id: row.id,
|
||||
whiteboardId: row.whiteboard_id,
|
||||
authorUserId: row.author_user_id,
|
||||
strokeJson: row.stroke_json,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
setState((s) => {
|
||||
if (s.strokes.some((x) => x.id === next.id)) return s;
|
||||
return { ...s, strokes: [...s.strokes, next] };
|
||||
});
|
||||
},
|
||||
)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'DELETE',
|
||||
schema: 'public',
|
||||
table: 'whiteboard_strokes',
|
||||
filter: 'whiteboard_id=eq.' + whiteboardId,
|
||||
},
|
||||
() => {
|
||||
// Bulk delete via "Clear all" — drop everything; future inserts
|
||||
// come back via the INSERT branch above.
|
||||
setState((s) => ({ ...s, strokes: [] }));
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
void supabase.removeChannel(channel);
|
||||
};
|
||||
}, [whiteboardId]);
|
||||
|
||||
const insertStroke = useCallback(
|
||||
async (strokeJson: unknown) => {
|
||||
if (!whiteboardId) return;
|
||||
try {
|
||||
await insertWhiteboardStroke(supabase, { whiteboardId, strokeJson });
|
||||
// No optimistic append — realtime echoes the row back in <150ms.
|
||||
} catch (err) {
|
||||
console.error('insertWhiteboardStroke failed', err);
|
||||
setState((s) => ({
|
||||
...s,
|
||||
error: err instanceof Error ? err.message : 'stroke insert failed',
|
||||
}));
|
||||
}
|
||||
},
|
||||
[whiteboardId],
|
||||
);
|
||||
|
||||
const clearAll = useCallback(async () => {
|
||||
if (!whiteboardId) return;
|
||||
await clearWhiteboardStrokes(supabase, whiteboardId);
|
||||
}, [whiteboardId]);
|
||||
|
||||
return {
|
||||
strokes: state.strokes,
|
||||
loading: state.loading,
|
||||
error: state.error,
|
||||
insertStroke,
|
||||
clearAll,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Per-install setting for PIN-idle-auto-lock. 0 = disabled.
|
||||
// Values match the dropdown options (5/15/30/60 minutes).
|
||||
|
||||
const KEY = 'chatapp.autoLockMinutes.v1';
|
||||
|
||||
export type AutoLockMinutes = 0 | 5 | 15 | 30 | 60;
|
||||
|
||||
const VALID: AutoLockMinutes[] = [0, 5, 15, 30, 60];
|
||||
|
||||
export function getAutoLockMinutes(): AutoLockMinutes {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(KEY);
|
||||
if (!raw) return 0;
|
||||
const n = Number(raw);
|
||||
if (VALID.includes(n as AutoLockMinutes)) return n as AutoLockMinutes;
|
||||
return 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
type Listener = (value: AutoLockMinutes) => void;
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
export function subscribeAutoLockSetting(l: Listener): () => void {
|
||||
listeners.add(l);
|
||||
return () => listeners.delete(l);
|
||||
}
|
||||
|
||||
export function notifyAutoLockChanged(value: AutoLockMinutes): void {
|
||||
for (const l of listeners) {
|
||||
try { l(value); } catch (err) { console.warn(err); }
|
||||
}
|
||||
}
|
||||
|
||||
export function setAutoLockMinutes(value: AutoLockMinutes): void {
|
||||
try {
|
||||
window.localStorage.setItem(KEY, String(value));
|
||||
} catch {
|
||||
/* quota */
|
||||
}
|
||||
notifyAutoLockChanged(value);
|
||||
}
|
||||
@@ -4,6 +4,9 @@ import {
|
||||
type AttachmentHandle,
|
||||
type DecryptedMessage,
|
||||
type PollOption,
|
||||
type WhiteboardPayload,
|
||||
type WatchTogetherPayload,
|
||||
type GamePayload,
|
||||
} from '@chat-app/shared/chat';
|
||||
|
||||
export type AttachmentBucket = 'media' | 'audio' | 'files';
|
||||
@@ -97,6 +100,34 @@ export function createPollPayload(question: string, optionTexts: string[]): stri
|
||||
});
|
||||
}
|
||||
|
||||
export function createWhiteboardPayload(whiteboardId: string): string {
|
||||
const payload: WhiteboardPayload = {
|
||||
v: 1,
|
||||
type: 'whiteboard',
|
||||
whiteboard_id: whiteboardId,
|
||||
};
|
||||
return serializeMessagePayload(payload);
|
||||
}
|
||||
|
||||
export function createWatchTogetherPayload(sessionId: string): string {
|
||||
const payload: WatchTogetherPayload = {
|
||||
v: 1,
|
||||
type: 'watch_together',
|
||||
session_id: sessionId,
|
||||
};
|
||||
return serializeMessagePayload(payload);
|
||||
}
|
||||
|
||||
export function createGamePayload(gameId: string, gameType: 'ttt' | 'c4'): string {
|
||||
const payload: GamePayload = {
|
||||
v: 1,
|
||||
type: 'game',
|
||||
game_id: gameId,
|
||||
game_type: gameType,
|
||||
};
|
||||
return serializeMessagePayload(payload);
|
||||
}
|
||||
|
||||
export function summarizePollVotes(
|
||||
options: PollOption[],
|
||||
reactions: ReactionSummaryInput[],
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// Main-thread wrapper around the crypto Web Worker (Argon2id pwhash +
|
||||
// sealed user-key open). Each unlock attempt spawns a fresh one-shot worker
|
||||
// — workers are cheap and the pwhash is a one-time cost per login, so we
|
||||
// avoid the bookkeeping needed for a persistent request queue.
|
||||
//
|
||||
// Falls back to inline (main-thread) `openUserKey` when the `Worker`
|
||||
// constructor is unavailable (e.g. vitest's jsdom environment, strict CSPs).
|
||||
// The fallback path is identical in semantics to the worker path; the only
|
||||
// difference is whether it blocks the main thread.
|
||||
//
|
||||
// Why not a long-lived worker? The KDF cost dwarfs the spawn cost (~1-2 s
|
||||
// vs. a few ms), and PIN-unlock happens at most once per session. Keeping a
|
||||
// worker resident would also require a request-id correlation map which the
|
||||
// decrypt.worker uses (because per-message decrypts are high-volume).
|
||||
|
||||
import { openUserKey } from '@chat-app/shared/crypto';
|
||||
|
||||
import type {
|
||||
OpenUserKeyInput,
|
||||
OpenUserKeyResult,
|
||||
} from '../workers/crypto.worker';
|
||||
|
||||
type WorkerResponse =
|
||||
| { ok: true; result: OpenUserKeyResult }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export type { OpenUserKeyInput, OpenUserKeyResult };
|
||||
|
||||
export async function openUserKeyInWorker(
|
||||
input: OpenUserKeyInput,
|
||||
): Promise<OpenUserKeyResult> {
|
||||
const worker = new Worker(
|
||||
new URL('../workers/crypto.worker.ts', import.meta.url),
|
||||
{ type: 'module' },
|
||||
);
|
||||
try {
|
||||
return await new Promise<OpenUserKeyResult>((resolve, reject) => {
|
||||
worker.addEventListener('message', (ev: MessageEvent<WorkerResponse>) => {
|
||||
const msg = ev.data;
|
||||
if (msg && msg.ok) resolve(msg.result);
|
||||
else reject(new Error(msg?.error ?? 'crypto worker returned malformed response'));
|
||||
});
|
||||
worker.addEventListener('error', (ev: ErrorEvent) => {
|
||||
reject(new Error(ev.message || 'crypto worker error'));
|
||||
});
|
||||
worker.postMessage({ op: 'openUserKey', input });
|
||||
});
|
||||
} finally {
|
||||
worker.terminate();
|
||||
}
|
||||
}
|
||||
|
||||
// Public entry point: route through the worker when possible, fall back to
|
||||
// the synchronous-on-main-thread path otherwise. Callers should prefer this
|
||||
// over importing `openUserKey` directly so we get the worker speedup
|
||||
// everywhere it's available.
|
||||
export async function openUserKeyMaybeWorker(
|
||||
input: OpenUserKeyInput,
|
||||
): Promise<Uint8Array> {
|
||||
if (typeof Worker === 'undefined') {
|
||||
return openUserKey(input);
|
||||
}
|
||||
try {
|
||||
const result = await openUserKeyInWorker(input);
|
||||
return result.privateKey;
|
||||
} catch (err) {
|
||||
// If the worker spawn or message round-trip fails (e.g. CSP blocks
|
||||
// module workers in some packaging modes), fall back to inline so the
|
||||
// unlock still succeeds — just with a brief main-thread hitch.
|
||||
console.warn('[cryptoWorker] worker path failed, falling back to inline', err);
|
||||
return openUserKey(input);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -55,3 +55,51 @@ function renameToWebp(original: string): string {
|
||||
export async function compressImages(files: File[]): Promise<File[]> {
|
||||
return Promise.all(files.map((f) => compressImage(f)));
|
||||
}
|
||||
|
||||
// Bandwidth threshold for thumb generation. Below ~50KB the WebP overhead
|
||||
// of a fresh re-encode can exceed the original; not worth a second upload.
|
||||
const THUMB_SKIP_BELOW_BYTES = 50 * 1024;
|
||||
const THUMB_MAX_DIM = 320;
|
||||
const THUMB_QUALITY = 0.7;
|
||||
// Animated formats lose motion when redrawn onto a Canvas, so we skip them
|
||||
// and let the receiver render the full file. GIF is the dominant case; the
|
||||
// rest stay too (apng/animated-webp).
|
||||
const THUMB_ANIMATED_MIME = /^image\/(gif|apng)$/;
|
||||
|
||||
// Generates a small WebP preview thumb (max 320×320) from an image file.
|
||||
// Used by the send path so each image attachment can ship a tiny inline
|
||||
// preview alongside the encrypted full blob. Returns `null` when:
|
||||
// - the input isn't an image,
|
||||
// - the input is animated (GIF/APNG — would lose motion),
|
||||
// - the input is already small enough that a thumb wouldn't save bandwidth,
|
||||
// - OffscreenCanvas / createImageBitmap aren't available, or
|
||||
// - decode/encode threw (corrupt input).
|
||||
// The caller treats `null` as "skip thumb" and uploads only the full blob.
|
||||
export async function generateWebPThumb(
|
||||
file: File,
|
||||
maxDim: number = THUMB_MAX_DIM,
|
||||
): Promise<Blob | null> {
|
||||
if (!file.type.startsWith('image/')) return null;
|
||||
if (THUMB_ANIMATED_MIME.test(file.type)) return null;
|
||||
if (file.size < THUMB_SKIP_BELOW_BYTES) return null;
|
||||
if (typeof createImageBitmap !== 'function') return null;
|
||||
if (typeof OffscreenCanvas !== 'function') return null;
|
||||
try {
|
||||
const bitmap = await createImageBitmap(file);
|
||||
const ratio = Math.min(maxDim / bitmap.width, maxDim / bitmap.height, 1);
|
||||
const w = Math.max(1, Math.round(bitmap.width * ratio));
|
||||
const h = Math.max(1, Math.round(bitmap.height * ratio));
|
||||
const canvas = new OffscreenCanvas(w, h);
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
bitmap.close();
|
||||
return null;
|
||||
}
|
||||
ctx.drawImage(bitmap, 0, 0, w, h);
|
||||
bitmap.close();
|
||||
return await canvas.convertToBlob({ type: 'image/webp', quality: THUMB_QUALITY });
|
||||
} catch (err: unknown) {
|
||||
console.warn('generateWebPThumb failed', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
// Discord-style live captions. Uses the browser's SpeechRecognition API to
|
||||
// transcribe the LOCAL user's mic, then broadcasts each interim/final result
|
||||
// to peers via the LiveKit DataChannel. Receivers store and display them.
|
||||
//
|
||||
// Privacy note: speech recognition runs in the browser. On Chromium-based
|
||||
// runtimes (incl. Tauri's WebView2 on Windows) this calls into the browser's
|
||||
// own engine, which today reaches Google's cloud — same trade-off as Discord.
|
||||
// We ship a hard off switch and require an explicit user toggle.
|
||||
|
||||
const STORAGE_KEY = 'chatapp.liveCaptions.v1';
|
||||
|
||||
export interface LiveCaptionsSettings {
|
||||
enabled: boolean;
|
||||
/** BCP-47 language tag, e.g. "de-DE" or "en-US". Auto-detect uses navigator.language. */
|
||||
lang: string | null;
|
||||
}
|
||||
|
||||
const DEFAULTS: LiveCaptionsSettings = {
|
||||
enabled: false,
|
||||
lang: null,
|
||||
};
|
||||
|
||||
type Listener = (s: LiveCaptionsSettings) => void;
|
||||
const listeners = new Set<Listener>();
|
||||
let cached: LiveCaptionsSettings | null = null;
|
||||
|
||||
function read(): LiveCaptionsSettings {
|
||||
if (cached) return cached;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
cached = DEFAULTS;
|
||||
return cached;
|
||||
}
|
||||
const parsed = JSON.parse(raw) as Partial<LiveCaptionsSettings>;
|
||||
cached = {
|
||||
enabled: typeof parsed.enabled === 'boolean' ? parsed.enabled : DEFAULTS.enabled,
|
||||
lang:
|
||||
typeof parsed.lang === 'string' && parsed.lang.length > 0
|
||||
? parsed.lang
|
||||
: DEFAULTS.lang,
|
||||
};
|
||||
return cached;
|
||||
} catch {
|
||||
cached = DEFAULTS;
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
function write(s: LiveCaptionsSettings): void {
|
||||
cached = s;
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
|
||||
} catch {
|
||||
/* quota / private mode */
|
||||
}
|
||||
for (const l of listeners) l(s);
|
||||
}
|
||||
|
||||
export function getLiveCaptionsSettings(): LiveCaptionsSettings {
|
||||
return read();
|
||||
}
|
||||
|
||||
export function updateLiveCaptionsSettings(
|
||||
patch: Partial<LiveCaptionsSettings>,
|
||||
): LiveCaptionsSettings {
|
||||
const next = { ...read(), ...patch };
|
||||
write(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function subscribeLiveCaptionsSettings(listener: Listener): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
// Browser feature-detect. Chromium ships under both names; Firefox lacks it
|
||||
// outright. Returns the constructor or null.
|
||||
type SpeechRecognitionCtor = new () => SpeechRecognitionLike;
|
||||
interface SpeechRecognitionLike extends EventTarget {
|
||||
continuous: boolean;
|
||||
interimResults: boolean;
|
||||
lang: string;
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
abort: () => void;
|
||||
onresult: ((e: SpeechRecognitionEventLike) => void) | null;
|
||||
onerror: ((e: SpeechRecognitionErrorLike) => void) | null;
|
||||
onend: (() => void) | null;
|
||||
}
|
||||
interface SpeechRecognitionEventLike {
|
||||
resultIndex: number;
|
||||
results: ArrayLike<{
|
||||
isFinal: boolean;
|
||||
[index: number]: { transcript: string };
|
||||
length: number;
|
||||
}>;
|
||||
}
|
||||
interface SpeechRecognitionErrorLike {
|
||||
error: string;
|
||||
}
|
||||
|
||||
export function getSpeechRecognitionCtor(): SpeechRecognitionCtor | null {
|
||||
const w = window as unknown as {
|
||||
SpeechRecognition?: SpeechRecognitionCtor;
|
||||
webkitSpeechRecognition?: SpeechRecognitionCtor;
|
||||
};
|
||||
return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;
|
||||
}
|
||||
|
||||
export function isLiveCaptionsSupported(): boolean {
|
||||
return getSpeechRecognitionCtor() !== null;
|
||||
}
|
||||
|
||||
export type {
|
||||
SpeechRecognitionLike,
|
||||
SpeechRecognitionEventLike,
|
||||
SpeechRecognitionErrorLike,
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
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',
|
||||
'chatapp.autoLockMinutes.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 */
|
||||
}
|
||||
}
|
||||
@@ -369,3 +369,52 @@ export async function isHotkeyTaken(
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- Sync-engine bypass helpers ------------------------------------------
|
||||
// Used by useSoundboardSync to write/delete IndexedDB rows without firing
|
||||
// notifyChange — pulls and remote-driven deletes are not "edits". If they
|
||||
// triggered notifyChange the engine would loop:
|
||||
// push debounce → upsert → realtime → pull → notifyChange → push debounce → ...
|
||||
|
||||
export async function putRawStoredSound(stored: {
|
||||
id: string;
|
||||
name: string;
|
||||
mime: string;
|
||||
size: number;
|
||||
category: string | null;
|
||||
hotkey: string | null;
|
||||
gain: number;
|
||||
order: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
blob: Blob;
|
||||
}): Promise<void> {
|
||||
await tx(SOUNDS_STORE, 'readwrite', (s) => s.put(stored));
|
||||
}
|
||||
|
||||
export async function deleteRawStoredSound(id: string): Promise<void> {
|
||||
await tx(SOUNDS_STORE, 'readwrite', (s) => s.delete(id));
|
||||
}
|
||||
|
||||
export async function getRawStoredSound(id: string): Promise<{
|
||||
id: string;
|
||||
name: string;
|
||||
mime: string;
|
||||
size: number;
|
||||
category: string | null;
|
||||
hotkey: string | null;
|
||||
gain: number;
|
||||
order: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
blob: Blob;
|
||||
} | null> {
|
||||
const db = await openDb();
|
||||
return new Promise((resolve, reject) => {
|
||||
const t = db.transaction(SOUNDS_STORE, 'readonly');
|
||||
const s = t.objectStore(SOUNDS_STORE);
|
||||
const req = s.get(id);
|
||||
req.onsuccess = () => resolve((req.result as any) ?? null);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { bytesToPgHex, pgBytesToBytes } from '@chat-app/shared/supabase';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { decryptBatch as decryptBatchWorker } from './decryptWorker';
|
||||
import { generateWebPThumb } from './imageCompress';
|
||||
import {
|
||||
loadCachedMessages,
|
||||
persistMessages,
|
||||
@@ -70,7 +71,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?: { viewOnceFlags?: boolean[] },
|
||||
) => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
pending: OutboxItem[];
|
||||
retryPending: (id: string) => void;
|
||||
@@ -559,7 +565,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: { viewOnceFlags?: boolean[] } = {},
|
||||
) => {
|
||||
const trimmed = text.trim();
|
||||
if ((!trimmed && images.length === 0) || !conversationId || !userId || !deviceId) return;
|
||||
const priv = privateKeyRef.current;
|
||||
@@ -615,13 +626,23 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
|
||||
// 1. Upload + encrypt each image. Collect handles + raw blob nonces
|
||||
// (so the public attachment row can reference the blob-level nonce).
|
||||
// P7.T4: view-once is now a per-attachment flag rather than a
|
||||
// composer-wide toggle. `opts.viewOnceFlags` is a parallel array;
|
||||
// missing entries (or whole-array absence) default to false.
|
||||
const handles: AttachmentHandle[] = [];
|
||||
const blobNonceHexByHandleId = new Map<string, string>();
|
||||
for (const file of images) {
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
const file = images[i]!;
|
||||
if (file.size > MAX_ATTACHMENT_BYTES) {
|
||||
throw new Error('attachment exceeds max size (10 MB)');
|
||||
}
|
||||
const dims = await readImageDimensions(file);
|
||||
// Phase 6B: generate a small WebP preview thumb so the receiver's
|
||||
// bubble loads fast (typical 320×240 WebP is 10–30KB vs the full
|
||||
// image's 1–10MB). `generateWebPThumb` short-circuits to null on
|
||||
// non-images, animated formats, and small files — and on failure;
|
||||
// the upload helper then just skips the second upload.
|
||||
const thumbBlob = await generateWebPThumb(file);
|
||||
const res = await encryptAndUploadAttachment({
|
||||
client: supabase,
|
||||
conversationId,
|
||||
@@ -630,7 +651,16 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
sizeBytes: file.size,
|
||||
...(dims.width !== undefined ? { width: dims.width } : {}),
|
||||
...(dims.height !== undefined ? { height: dims.height } : {}),
|
||||
...(thumbBlob ? { thumbBlob } : {}),
|
||||
});
|
||||
// Stamp the view-once flag on each handle the caller flagged. 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.viewOnceFlags?.[i]) {
|
||||
res.handle.viewOnce = true;
|
||||
}
|
||||
handles.push(res.handle);
|
||||
blobNonceHexByHandleId.set(res.handle.id, bytesToPgHex(res.nonce));
|
||||
}
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
// Hook that runs SpeechRecognition on the local mic when live-captions are
|
||||
// enabled and a Room is connected. Each interim/final result is broadcast as
|
||||
// a `caption`-typed message via the LiveKit DataChannel so peers can render
|
||||
// it. Recognition stops cleanly when the call ends or the toggle flips off.
|
||||
|
||||
import type { Room } from 'livekit-client';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import {
|
||||
type LiveCaptionsSettings,
|
||||
getLiveCaptionsSettings,
|
||||
getSpeechRecognitionCtor,
|
||||
type SpeechRecognitionEventLike,
|
||||
type SpeechRecognitionLike,
|
||||
subscribeLiveCaptionsSettings,
|
||||
} from './liveCaptions';
|
||||
|
||||
interface Args {
|
||||
room: Room | null;
|
||||
/** True while we're connected and want captions to flow. */
|
||||
active: boolean;
|
||||
/** Callback fired locally for our own captions so the overlay can show
|
||||
* them without going through the SFU round-trip. */
|
||||
onLocalCaption: (text: string, final: boolean) => void;
|
||||
}
|
||||
|
||||
export function useLiveCaptions({ room, active, onLocalCaption }: Args): void {
|
||||
const recognitionRef = useRef<SpeechRecognitionLike | null>(null);
|
||||
const settingsRef = useRef<LiveCaptionsSettings>(getLiveCaptionsSettings());
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeLiveCaptionsSettings((s) => {
|
||||
settingsRef.current = s;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const Ctor = getSpeechRecognitionCtor();
|
||||
if (!Ctor) return; // unsupported runtime
|
||||
if (!active || !room) return;
|
||||
if (!getLiveCaptionsSettings().enabled) return;
|
||||
|
||||
const send = (text: string, final: boolean) => {
|
||||
onLocalCaption(text, final);
|
||||
try {
|
||||
const payload = new TextEncoder().encode(
|
||||
JSON.stringify({ type: 'caption', captionText: text, captionFinal: final }),
|
||||
);
|
||||
// Reliable channel — captions are infrequent enough to afford it,
|
||||
// and dropping interims looks worse than slight lag.
|
||||
void room.localParticipant.publishData(payload, { reliable: true });
|
||||
} catch {
|
||||
/* ignore — best-effort */
|
||||
}
|
||||
};
|
||||
|
||||
const start = () => {
|
||||
const r = new Ctor();
|
||||
r.continuous = true;
|
||||
r.interimResults = true;
|
||||
const lang = settingsRef.current.lang ?? navigator.language ?? 'de-DE';
|
||||
r.lang = lang;
|
||||
r.onresult = (e: SpeechRecognitionEventLike) => {
|
||||
// Pull whichever results arrived since last fire. Interim fires
|
||||
// many times per second; the final one is sticky and persists.
|
||||
for (let i = e.resultIndex; i < e.results.length; i++) {
|
||||
const result = e.results[i];
|
||||
if (!result || result.length === 0) continue;
|
||||
const alt = result[0];
|
||||
if (!alt) continue;
|
||||
const transcript = alt.transcript.trim();
|
||||
if (!transcript) continue;
|
||||
send(transcript, result.isFinal);
|
||||
}
|
||||
};
|
||||
r.onerror = () => {
|
||||
// Recoverable: stop + retry on next effect cycle. `not-allowed` and
|
||||
// `service-not-allowed` are permission-permanent — bail.
|
||||
try {
|
||||
r.stop();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
r.onend = () => {
|
||||
// SpeechRecognition tends to auto-stop after silence — if we still
|
||||
// want captions, restart it. Guard against tear-down race.
|
||||
if (recognitionRef.current === r && getLiveCaptionsSettings().enabled) {
|
||||
try {
|
||||
r.start();
|
||||
} catch {
|
||||
/* already running or browser refused */
|
||||
}
|
||||
}
|
||||
};
|
||||
try {
|
||||
r.start();
|
||||
recognitionRef.current = r;
|
||||
} catch {
|
||||
// Some browsers throw when start() is called too soon after a
|
||||
// previous abort — wait a tick and retry.
|
||||
window.setTimeout(() => {
|
||||
try {
|
||||
r.start();
|
||||
recognitionRef.current = r;
|
||||
} catch {
|
||||
/* give up */
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
};
|
||||
|
||||
start();
|
||||
|
||||
const unsub = subscribeLiveCaptionsSettings((s) => {
|
||||
const cur = recognitionRef.current;
|
||||
if (!s.enabled && cur) {
|
||||
recognitionRef.current = null;
|
||||
try {
|
||||
cur.abort();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
} else if (s.enabled && !cur) {
|
||||
start();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsub();
|
||||
const cur = recognitionRef.current;
|
||||
recognitionRef.current = null;
|
||||
if (cur) {
|
||||
try {
|
||||
cur.abort();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [active, room, onLocalCaption]);
|
||||
}
|
||||
@@ -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,99 @@
|
||||
import { listPinnedMessages, type PinnedMessage } from '@chat-app/shared/chat';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { supabase } from './supabase';
|
||||
|
||||
export interface UsePinnedMessagesResult {
|
||||
pins: PinnedMessage[];
|
||||
// Optimistic insert. Caller flips the UI immediately; server insert +
|
||||
// realtime echo will reconcile (dedup'd by messageId). Returns the
|
||||
// previous snapshot so the caller can roll back on error.
|
||||
applyOptimisticPin: (messageId: string, pinnedBy: string) => PinnedMessage[];
|
||||
applyOptimisticUnpin: (messageId: string) => PinnedMessage[];
|
||||
// Hard restore for rollback after a failed server call.
|
||||
restorePins: (snapshot: PinnedMessage[]) => void;
|
||||
}
|
||||
|
||||
// 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. The `applyOptimistic*` helpers let
|
||||
// callers flip local state synchronously on user action so the pin button
|
||||
// doesn't appear unresponsive while the ~100-200ms server roundtrip + the
|
||||
// realtime refetch round complete.
|
||||
export function usePinnedMessages(conversationId: string | undefined): UsePinnedMessagesResult {
|
||||
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]);
|
||||
|
||||
const applyOptimisticPin = useCallback<UsePinnedMessagesResult['applyOptimisticPin']>(
|
||||
(messageId, pinnedBy) => {
|
||||
if (!conversationId) return pins;
|
||||
let snapshot: PinnedMessage[] = pins;
|
||||
setPins((prev) => {
|
||||
snapshot = prev;
|
||||
if (prev.some((p) => p.messageId === messageId)) return prev;
|
||||
const optimistic: PinnedMessage = {
|
||||
conversationId,
|
||||
messageId,
|
||||
pinnedBy,
|
||||
pinnedAt: new Date().toISOString(),
|
||||
};
|
||||
// Newest first matches the listPinnedMessages order.
|
||||
return [optimistic, ...prev];
|
||||
});
|
||||
return snapshot;
|
||||
},
|
||||
[conversationId, pins],
|
||||
);
|
||||
|
||||
const applyOptimisticUnpin = useCallback<UsePinnedMessagesResult['applyOptimisticUnpin']>(
|
||||
(messageId) => {
|
||||
let snapshot: PinnedMessage[] = pins;
|
||||
setPins((prev) => {
|
||||
snapshot = prev;
|
||||
return prev.filter((p) => p.messageId !== messageId);
|
||||
});
|
||||
return snapshot;
|
||||
},
|
||||
[pins],
|
||||
);
|
||||
|
||||
const restorePins = useCallback<UsePinnedMessagesResult['restorePins']>((snapshot) => {
|
||||
setPins(snapshot);
|
||||
}, []);
|
||||
|
||||
return { pins, applyOptimisticPin, applyOptimisticUnpin, restorePins };
|
||||
}
|
||||
@@ -3,11 +3,12 @@ import {
|
||||
resetUserKey, tryUnlockUserKey, uploadUserKeyBlob,
|
||||
} from '@chat-app/shared/auth';
|
||||
import {
|
||||
generateRecoveryCode, generateUserKeyPair, normalizeRecoveryCode,
|
||||
openUserKey, sealUserKey,
|
||||
generateRecoveryCode, generateUserKeyPair, getCryptoBackend, normalizeRecoveryCode,
|
||||
sealUserKey,
|
||||
} from '@chat-app/shared/crypto';
|
||||
import { migrateOwnLegacyBundles } from '@chat-app/shared/chat';
|
||||
|
||||
import { openUserKeyMaybeWorker } from './cryptoWorker';
|
||||
import { devLocalSecretStore } from './secretStore';
|
||||
import { supabase } from './supabase';
|
||||
|
||||
@@ -50,7 +51,7 @@ export async function setupNewUserIdentity(p: SetupParams): Promise<SetupResult>
|
||||
export async function ensureLegacyMigrated(userId: string): Promise<void> {
|
||||
const priv = await devLocalSecretStore.getSecret(cacheKey(userId));
|
||||
if (!priv) return;
|
||||
const pub = await derivePublicKey(priv);
|
||||
const pub = derivePublicKey(priv);
|
||||
await runLegacyMigration(userId, priv, pub);
|
||||
}
|
||||
|
||||
@@ -71,7 +72,7 @@ export async function loadOrUnlockUserKey(p: UnlockParams): Promise<UnlockOutcom
|
||||
if (!sealed || !salt) throw new Error('no recovery blob configured');
|
||||
let priv: Uint8Array;
|
||||
try {
|
||||
priv = await openUserKey({ sealed, pin: secret, salt, kdfParams: remote.kdfParams });
|
||||
priv = await openUserKeyMaybeWorker({ sealed, pin: secret, salt, kdfParams: remote.kdfParams });
|
||||
} catch (err) {
|
||||
await recordPinAttempt(supabase, p.userId, false, p.isRecoveryCode === true).catch(() => {});
|
||||
throw err;
|
||||
@@ -105,7 +106,7 @@ export async function changePin(params: {
|
||||
const fresh = await sealUserKey({ privateKey: cached, pin: params.newPin });
|
||||
await uploadUserKeyBlob(supabase, {
|
||||
userId: params.userId,
|
||||
publicKey: await derivePublicKey(cached),
|
||||
publicKey: derivePublicKey(cached),
|
||||
sealedPrivateKey: fresh.sealedPrivateKey,
|
||||
salt: fresh.salt,
|
||||
kdfParams: fresh.kdfParams,
|
||||
@@ -122,7 +123,7 @@ export async function regenerateRecoveryCode(params: { userId: string }): Promis
|
||||
const sealed = await sealUserKey({ privateKey: cached, pin: normalizeRecoveryCode(recoveryCode) });
|
||||
await uploadUserKeyBlob(supabase, {
|
||||
userId: params.userId,
|
||||
publicKey: await derivePublicKey(cached),
|
||||
publicKey: derivePublicKey(cached),
|
||||
sealedPrivateKey: blob.sealedPrivateKey,
|
||||
salt: blob.salt,
|
||||
kdfParams: blob.kdfParams,
|
||||
@@ -212,7 +213,7 @@ async function runLegacyMigration(
|
||||
}
|
||||
}
|
||||
|
||||
console.info(
|
||||
console.debug(
|
||||
'[crypto-migration] vault scan:',
|
||||
'serverDevices=' + report.serverDevices,
|
||||
'keysFromServerList=' + report.strongholdKeysFromServerDevices,
|
||||
@@ -247,12 +248,10 @@ async function runLegacyMigration(
|
||||
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 = await derivePublicKey(priv);
|
||||
const pub = derivePublicKey(priv);
|
||||
return runLegacyMigration(userId, priv, pub);
|
||||
}
|
||||
|
||||
async function derivePublicKey(privateKey: Uint8Array): Promise<Uint8Array> {
|
||||
const sodium = (await import('libsodium-wrappers-sumo')).default;
|
||||
await sodium.ready;
|
||||
return sodium.crypto_scalarmult_base(privateKey);
|
||||
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
|
||||
@@ -222,28 +224,36 @@ function ConversationList({
|
||||
</div>
|
||||
) : error ? (
|
||||
<p className="px-4 py-2 text-xs text-rose-500 dark:text-rose-300">{error}</p>
|
||||
) : query.trim().length > 0 && items.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<SearchIcon className="h-8 w-8" />}
|
||||
title={t('app:chats.search_empty_title', { defaultValue: 'Keine Treffer' })}
|
||||
description={t('app:chats.search_empty_desc', {
|
||||
defaultValue: 'Keine Chats passen zu "' + query.trim() + '".',
|
||||
})}
|
||||
/>
|
||||
) : 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}
|
||||
@@ -415,6 +425,7 @@ function ConversationRow({
|
||||
conversationId={item.id}
|
||||
archived={item.archived}
|
||||
mutedUntil={item.mutedUntil}
|
||||
mentionsOnly={item.mentionsOnly}
|
||||
/>
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
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 { useAuth } from '../context/AuthContext';
|
||||
@@ -117,7 +118,7 @@ export function SettingsPage() {
|
||||
// routing conflict with HashRouter.
|
||||
type TabId =
|
||||
| 'profile' | 'appearance' | 'privacy' | 'notifications'
|
||||
| 'voice' | 'screen-share' | 'soundboard' | 'security' | 'account';
|
||||
| '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 },
|
||||
@@ -128,6 +129,7 @@ export function SettingsPage() {
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
@@ -352,6 +354,17 @@ export function SettingsPage() {
|
||||
</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' })}
|
||||
@@ -575,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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -85,9 +85,10 @@
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-duration: 0.001ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
transition-duration: 0.001ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// Web Worker — runs Argon2id pwhash + sealed user-key open off the main
|
||||
// thread. PIN-unlock used to freeze the UI for ~1-2 s on mid-hardware while
|
||||
// the moderate-preset KDF ran; pushing it here keeps the unlock screen
|
||||
// responsive.
|
||||
//
|
||||
// The worker bundles its own libsodium-wrappers-sumo instance and registers
|
||||
// it as the shared CryptoBackend inside this worker realm — there is no
|
||||
// shared state with the main thread, so we initialise once per worker and
|
||||
// re-use it across messages (the client wrapper currently spawns one-shot,
|
||||
// but the worker is safe to keep alive too).
|
||||
//
|
||||
// Message protocol (one-shot RPC):
|
||||
// request: { op: 'openUserKey', input: OpenUserKeyInput }
|
||||
// response: { ok: true, result: { privateKey: Uint8Array } }
|
||||
// | { ok: false, error: string }
|
||||
//
|
||||
// The private key bytes are transferred (zero-copy) back to the caller via
|
||||
// the structured-clone Transferable list; the worker's view of the buffer is
|
||||
// detached on transfer which also clears the only worker-side reference.
|
||||
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
import { setCryptoBackend, openUserKey } from '@chat-app/shared/crypto';
|
||||
import type { KdfParams } from '@chat-app/shared/crypto';
|
||||
import sodium from 'libsodium-wrappers-sumo';
|
||||
|
||||
import { createLibsodiumBackend } from '../lib/cryptoBackend';
|
||||
|
||||
export interface OpenUserKeyInput {
|
||||
sealed: Uint8Array;
|
||||
pin: string;
|
||||
salt: Uint8Array;
|
||||
kdfParams: KdfParams;
|
||||
}
|
||||
|
||||
export interface OpenUserKeyResult {
|
||||
privateKey: Uint8Array;
|
||||
}
|
||||
|
||||
type WorkerRequest = { op: 'openUserKey'; input: OpenUserKeyInput };
|
||||
type WorkerResponse =
|
||||
| { ok: true; result: OpenUserKeyResult }
|
||||
| { ok: false; error: string };
|
||||
|
||||
let backendReady: Promise<void> | null = null;
|
||||
|
||||
async function ensureBackend(): Promise<void> {
|
||||
if (!backendReady) {
|
||||
backendReady = (async () => {
|
||||
await sodium.ready;
|
||||
setCryptoBackend(await createLibsodiumBackend());
|
||||
})();
|
||||
}
|
||||
return backendReady;
|
||||
}
|
||||
|
||||
self.addEventListener('message', (ev: MessageEvent<WorkerRequest>) => {
|
||||
const msg = ev.data;
|
||||
void (async () => {
|
||||
try {
|
||||
if (!msg || msg.op !== 'openUserKey') {
|
||||
throw new Error('unknown op: ' + String((msg as { op?: unknown })?.op));
|
||||
}
|
||||
await ensureBackend();
|
||||
const privateKey = await openUserKey(msg.input);
|
||||
const response: WorkerResponse = { ok: true, result: { privateKey } };
|
||||
const transfers: Transferable[] = [];
|
||||
if (privateKey?.buffer instanceof ArrayBuffer) {
|
||||
transfers.push(privateKey.buffer);
|
||||
}
|
||||
(self as unknown as Worker).postMessage(response, transfers);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const response: WorkerResponse = { ok: false, error: message };
|
||||
(self as unknown as Worker).postMessage(response);
|
||||
}
|
||||
})();
|
||||
});
|
||||
@@ -73,6 +73,23 @@ 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`.
|
||||
|
||||
@@ -1,19 +1,42 @@
|
||||
import { Redirect, Stack } from 'expo-router';
|
||||
import { ActivityIndicator, StyleSheet, View } from 'react-native';
|
||||
|
||||
import { useAuth } from '../../lib/authContext';
|
||||
import { colors } from '../../theme/colors';
|
||||
|
||||
export default function AppLayout() {
|
||||
const { session, loading } = useAuth();
|
||||
if (loading) return null;
|
||||
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={{ headerShown: true }}>
|
||||
<Stack screenOptions={{ headerStyle: { backgroundColor: colors.bg } }}>
|
||||
<Stack.Screen name="chats" />
|
||||
<Stack.Screen name="conversations/[id]" />
|
||||
<Stack.Screen
|
||||
name="call"
|
||||
options={{ headerShown: false, presentation: 'fullScreenModal' }}
|
||||
/>
|
||||
<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 },
|
||||
});
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Stack, useRouter } from 'expo-router';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
FlatList,
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
@@ -14,7 +13,6 @@ import {
|
||||
} from 'react-native';
|
||||
|
||||
import { ConversationRow } from '../../components/ConversationRow';
|
||||
import { useAuth } from '../../lib/authContext';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { colors } from '../../theme/colors';
|
||||
|
||||
@@ -23,7 +21,6 @@ import { colors } from '../../theme/colors';
|
||||
// is a Phase 1.5 follow-up. Tap → conversation detail.
|
||||
export default function Chats() {
|
||||
const router = useRouter();
|
||||
const { signOut } = useAuth();
|
||||
const [list, setList] = useState<ConversationSummary[] | null>(null);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -48,19 +45,6 @@ export default function Chats() {
|
||||
setRefreshing(false);
|
||||
}, [load]);
|
||||
|
||||
const confirmLogout = () => {
|
||||
Alert.alert('Abmelden', 'Diese Sitzung beenden?', [
|
||||
{ text: 'Abbrechen', style: 'cancel' },
|
||||
{
|
||||
text: 'Abmelden',
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
void signOut();
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Stack.Screen
|
||||
@@ -69,8 +53,8 @@ export default function Chats() {
|
||||
headerStyle: { backgroundColor: colors.bg },
|
||||
headerTitleStyle: { color: colors.text },
|
||||
headerRight: () => (
|
||||
<Pressable onPress={confirmLogout} hitSlop={10}>
|
||||
<Text style={styles.logoutLink}>Abmelden</Text>
|
||||
<Pressable onPress={() => router.push('/(app)/settings')} hitSlop={10}>
|
||||
<Text style={styles.logoutLink}>Einstellungen</Text>
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
|
||||
@@ -30,7 +30,7 @@ import { colors } from '../../../theme/colors';
|
||||
|
||||
export default function ConversationDetail() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const { user, device, ownPrivateKey } = useAuth();
|
||||
const { user, userId, ownPrivateKey } = useAuth();
|
||||
const { state: callState, startCall } = useCall();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -54,7 +54,7 @@ export default function ConversationDetail() {
|
||||
const listRef = useRef<FlatList<DecryptedMessage>>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!id || !device || !ownPrivateKey) return;
|
||||
if (!id || !userId || !ownPrivateKey) return;
|
||||
setError(null);
|
||||
try {
|
||||
const all = await chat.listConversations(supabase);
|
||||
@@ -63,7 +63,7 @@ export default function ConversationDetail() {
|
||||
const decrypted = await chat.decryptMessages({
|
||||
client: supabase,
|
||||
messages: ciphers,
|
||||
ownDeviceId: device.id,
|
||||
ownUserId: userId,
|
||||
ownPrivateKey,
|
||||
});
|
||||
setMessages(decrypted);
|
||||
@@ -81,7 +81,7 @@ export default function ConversationDetail() {
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Nachrichten konnten nicht geladen werden');
|
||||
}
|
||||
}, [id, device, ownPrivateKey]);
|
||||
}, [id, userId, ownPrivateKey]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
@@ -103,7 +103,7 @@ export default function ConversationDetail() {
|
||||
: (conversation?.peer?.displayName ?? '…');
|
||||
|
||||
async function handleSendText() {
|
||||
if (!text.trim() || !user || !device || !ownPrivateKey || !id || sending) return;
|
||||
if (!text.trim() || !user || !ownPrivateKey || !id || sending) return;
|
||||
setSending(true);
|
||||
setError(null);
|
||||
const replyToId = replyTo?.id;
|
||||
@@ -113,7 +113,6 @@ export default function ConversationDetail() {
|
||||
conversationId: id,
|
||||
plaintext: text.trim(),
|
||||
senderUserId: user.id,
|
||||
senderDeviceId: device.id,
|
||||
senderPrivateKey: ownPrivateKey,
|
||||
...(replyToId ? { replyToId } : {}),
|
||||
});
|
||||
@@ -128,7 +127,7 @@ export default function ConversationDetail() {
|
||||
}
|
||||
|
||||
async function sendImage(pick: PickedImage) {
|
||||
if (!user || !device || !ownPrivateKey || !id) return;
|
||||
if (!user || !ownPrivateKey || !id) return;
|
||||
setSending(true);
|
||||
setError(null);
|
||||
try {
|
||||
@@ -148,7 +147,6 @@ export default function ConversationDetail() {
|
||||
conversationId: id,
|
||||
plaintext: '',
|
||||
senderUserId: user.id,
|
||||
senderDeviceId: device.id,
|
||||
senderPrivateKey: ownPrivateKey,
|
||||
attachmentHandles: [result.handle],
|
||||
});
|
||||
@@ -300,7 +298,6 @@ export default function ConversationDetail() {
|
||||
})}
|
||||
reactions={reactions.get(item.id) ?? []}
|
||||
myUserId={user?.id ?? null}
|
||||
ownDeviceId={device?.id ?? null}
|
||||
ownPrivateKey={ownPrivateKey}
|
||||
onLongPress={() => setActiveMessage(item)}
|
||||
onToggleReaction={(emoji) => {
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
+16
-17
@@ -1,34 +1,33 @@
|
||||
import { crypto } from '@chat-app/shared';
|
||||
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';
|
||||
import { createLibsodiumBackend } from '../lib/cryptoBackend';
|
||||
|
||||
crypto.setCryptoBackend(createLibsodiumBackend());
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<SafeAreaProvider>
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -21,13 +21,13 @@ import { colors } from '../theme/colors';
|
||||
// they tap the email link and Expo Router routes the deep link to
|
||||
// app/auth/callback.tsx.
|
||||
export default function Landing() {
|
||||
const { session, loading } = useAuth();
|
||||
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 (loading) return null;
|
||||
if (!ready) return null;
|
||||
if (session) return <Redirect href="/(app)/chats" />;
|
||||
|
||||
async function handleSubmit() {
|
||||
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -10,8 +10,6 @@ import { colors } from '../theme/colors';
|
||||
|
||||
interface Props {
|
||||
handle: AttachmentHandle;
|
||||
ownDeviceId: string;
|
||||
ownPrivateKey: Uint8Array;
|
||||
}
|
||||
|
||||
// Decrypts an encrypted image attachment on first mount and renders it
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -15,7 +15,6 @@ interface Props {
|
||||
parentSenderName: string;
|
||||
reactions: MessageReaction[];
|
||||
myUserId: string | null;
|
||||
ownDeviceId: string | null;
|
||||
ownPrivateKey: Uint8Array | null;
|
||||
onLongPress: () => void;
|
||||
onToggleReaction: (emoji: string) => void;
|
||||
@@ -36,7 +35,6 @@ export function MessageBubble({
|
||||
parentSenderName,
|
||||
reactions,
|
||||
myUserId,
|
||||
ownDeviceId,
|
||||
ownPrivateKey,
|
||||
onLongPress,
|
||||
onToggleReaction,
|
||||
@@ -72,12 +70,8 @@ export function MessageBubble({
|
||||
) : (
|
||||
<>
|
||||
{text.length > 0 && <Text style={styles.body}>{text}</Text>}
|
||||
{firstImage && ownDeviceId && ownPrivateKey && (
|
||||
<AttachmentImage
|
||||
handle={firstImage}
|
||||
ownDeviceId={ownDeviceId}
|
||||
ownPrivateKey={ownPrivateKey}
|
||||
/>
|
||||
{firstImage && ownPrivateKey && (
|
||||
<AttachmentImage handle={firstImage} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -1,23 +1,31 @@
|
||||
import type { Session, User } from '@supabase/supabase-js';
|
||||
import { auth, crypto } from '@chat-app/shared';
|
||||
import type { DeviceRecord } from '@chat-app/shared/auth';
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
import { fetchUserKeyBlob } from '@chat-app/shared/auth';
|
||||
import {
|
||||
type ReactNode,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { secretStore } from './secretStore';
|
||||
import { supabase } from './supabase';
|
||||
import { cachedUserKey, ensureLegacyMigrated } from './userIdentity';
|
||||
|
||||
// Locally-stored secrets keyed by stable names. Mirrors the desktop
|
||||
// convention so the migration tests (later) can compare snapshots.
|
||||
const KEY_DEVICE_ID = 'device.id';
|
||||
const KEY_DEVICE_PRIVKEY = 'device.privateKey';
|
||||
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;
|
||||
device: DeviceRecord | null;
|
||||
userId: string | null;
|
||||
ownPrivateKey: Uint8Array | null;
|
||||
loading: boolean;
|
||||
userKeyState: UserKeyState;
|
||||
ready: boolean;
|
||||
refreshUserKeyState: () => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -29,44 +37,48 @@ export function useAuth(): AuthContextValue {
|
||||
return v;
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [device, setDevice] = useState<DeviceRecord | null>(null);
|
||||
const [ownPrivateKey, setOwnPrivateKey] = useState<Uint8Array | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [userKeyState, setUserKeyState] = useState<UserKeyState>({ status: 'loading' });
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
// Resolve or create the device record for this install given an active
|
||||
// session. Stores the private key in expo-secure-store on first run.
|
||||
const ensureDevice = useCallback(async (_currentSession: Session): Promise<void> => {
|
||||
const savedDeviceId = await secretStore.getSecret(KEY_DEVICE_ID);
|
||||
const savedPrivKey = await secretStore.getSecret(KEY_DEVICE_PRIVKEY);
|
||||
|
||||
if (savedDeviceId && savedPrivKey) {
|
||||
const devices = await auth.listOwnDevices(supabase);
|
||||
const deviceIdStr = new TextDecoder().decode(savedDeviceId);
|
||||
const match = devices.find((d) => d.id === deviceIdStr);
|
||||
if (match) {
|
||||
setDevice(match);
|
||||
setOwnPrivateKey(savedPrivKey);
|
||||
return;
|
||||
}
|
||||
// Stored id no longer matches any device on the server (revoked,
|
||||
// wiped). Fall through to register a fresh one.
|
||||
const refreshUserKeyState = useCallback(async () => {
|
||||
const s = session;
|
||||
if (!s) {
|
||||
setUserKeyState({ status: 'loading' });
|
||||
setOwnPrivateKey(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const backend = crypto.getCryptoBackend();
|
||||
const kp = backend.generateKeyPair();
|
||||
const platform = Platform.OS === 'ios' ? 'ios' : Platform.OS === 'android' ? 'android' : 'linux';
|
||||
const record = await auth.registerDevice(supabase, {
|
||||
name: `Netralax Mobile (${Platform.OS})`,
|
||||
platform,
|
||||
publicKey: kp.publicKey,
|
||||
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,
|
||||
});
|
||||
await secretStore.setSecret(KEY_DEVICE_ID, new TextEncoder().encode(record.id));
|
||||
await secretStore.setSecret(KEY_DEVICE_PRIVKEY, kp.privateKey);
|
||||
setDevice(record);
|
||||
setOwnPrivateKey(kp.privateKey);
|
||||
}, []);
|
||||
}, [session]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -74,48 +86,43 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const { data } = await supabase.auth.getSession();
|
||||
if (cancelled) return;
|
||||
setSession(data.session);
|
||||
if (data.session) {
|
||||
try {
|
||||
await ensureDevice(data.session);
|
||||
} catch (err) {
|
||||
console.warn('[auth] ensureDevice failed', err);
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
setReady(true);
|
||||
})();
|
||||
|
||||
const { data: sub } = supabase.auth.onAuthStateChange((_event, nextSession) => {
|
||||
setSession(nextSession);
|
||||
setReady(true);
|
||||
if (!nextSession) {
|
||||
setDevice(null);
|
||||
setOwnPrivateKey(null);
|
||||
} else {
|
||||
void ensureDevice(nextSession).catch((err) =>
|
||||
console.warn('[auth] ensureDevice (state change) failed', err),
|
||||
);
|
||||
setUserKeyState({ status: 'loading' });
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
sub.subscription.unsubscribe();
|
||||
};
|
||||
}, [ensureDevice]);
|
||||
}, []);
|
||||
|
||||
const signOut = useCallback(async (): Promise<void> => {
|
||||
useEffect(() => {
|
||||
void refreshUserKeyState().catch((err) => {
|
||||
console.warn('refreshUserKeyState failed', err);
|
||||
setUserKeyState({ status: 'needs-setup' });
|
||||
});
|
||||
}, [session, refreshUserKeyState]);
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
await supabase.auth.signOut();
|
||||
await secretStore.removeSecret(KEY_DEVICE_ID);
|
||||
await secretStore.removeSecret(KEY_DEVICE_PRIVKEY);
|
||||
setDevice(null);
|
||||
setOwnPrivateKey(null);
|
||||
setUserKeyState({ status: 'loading' });
|
||||
}, []);
|
||||
|
||||
const value: AuthContextValue = {
|
||||
session,
|
||||
user: session?.user ?? null,
|
||||
device,
|
||||
userId: session?.user.id ?? null,
|
||||
ownPrivateKey,
|
||||
loading,
|
||||
userKeyState,
|
||||
ready,
|
||||
refreshUserKeyState,
|
||||
signOut,
|
||||
};
|
||||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||
|
||||
@@ -24,5 +24,13 @@ export function createLibsodiumBackend(): 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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
describe('mobile env (lazy proxy)', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
delete process.env.EXPO_PUBLIC_SUPABASE_URL;
|
||||
delete process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY;
|
||||
delete process.env.EXPO_PUBLIC_AUTH_REDIRECT_URL;
|
||||
});
|
||||
|
||||
it('importing the module does NOT throw when required vars are missing', async () => {
|
||||
await expect(import('./env')).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it('reading a property with no env set throws a clear error', async () => {
|
||||
const mod = await import('./env');
|
||||
expect(() => mod.env.supabaseUrl).toThrowError(
|
||||
/Missing required env var EXPO_PUBLIC_SUPABASE_URL/,
|
||||
);
|
||||
});
|
||||
|
||||
it('reading a property after setting env returns the value and memoises', async () => {
|
||||
process.env.EXPO_PUBLIC_SUPABASE_URL = 'https://example.supabase.co';
|
||||
process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY = 'anon-123';
|
||||
const mod = await import('./env');
|
||||
expect(mod.env.supabaseUrl).toBe('https://example.supabase.co');
|
||||
expect(mod.env.supabaseAnonKey).toBe('anon-123');
|
||||
expect(mod.env.authRedirectUrl).toBe('netralax://auth/callback');
|
||||
});
|
||||
});
|
||||
+31
-9
@@ -1,19 +1,41 @@
|
||||
// EXPO_PUBLIC_* vars are inlined at bundle time by Expo's Babel plugin.
|
||||
// We pull them through a single typed module so a missing var is a loud
|
||||
// startup error rather than a confusing Supabase 401 later.
|
||||
// EXPO_PUBLIC_* vars are inlined at bundle time by Expo's Babel plugin (or
|
||||
// shipped via EAS Secrets for EAS builds — see apps/mobile/README.md).
|
||||
// We pull them through a Proxy so missing vars throw on first READ, not at
|
||||
// module-eval time. That keeps the throw inside the React tree where the
|
||||
// <BootError> boundary can render it as a readable screen instead of a blank
|
||||
// white window.
|
||||
|
||||
function required(name: string): string {
|
||||
const v = process.env[name];
|
||||
if (!v || v.length === 0) {
|
||||
throw new Error(
|
||||
'Missing required env var ' + name + '. Set it in apps/mobile/.env.local — see .env.example.',
|
||||
'Missing required env var ' + name +
|
||||
'. Set it via `eas secret:create --scope project --name ' + name +
|
||||
' --value ...` or in apps/mobile/.env.local for local dev (see .env.example).',
|
||||
);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
export const env = {
|
||||
supabaseUrl: required('EXPO_PUBLIC_SUPABASE_URL'),
|
||||
supabaseAnonKey: required('EXPO_PUBLIC_SUPABASE_ANON_KEY'),
|
||||
authRedirectUrl: process.env.EXPO_PUBLIC_AUTH_REDIRECT_URL ?? 'netralax://auth/callback',
|
||||
} as const;
|
||||
interface EnvShape {
|
||||
supabaseUrl: string;
|
||||
supabaseAnonKey: string;
|
||||
authRedirectUrl: string;
|
||||
}
|
||||
|
||||
function readEnv(): EnvShape {
|
||||
return {
|
||||
supabaseUrl: required('EXPO_PUBLIC_SUPABASE_URL'),
|
||||
supabaseAnonKey: required('EXPO_PUBLIC_SUPABASE_ANON_KEY'),
|
||||
authRedirectUrl: process.env.EXPO_PUBLIC_AUTH_REDIRECT_URL ?? 'netralax://auth/callback',
|
||||
};
|
||||
}
|
||||
|
||||
let cached: EnvShape | null = null;
|
||||
|
||||
export const env: EnvShape = new Proxy({} as EnvShape, {
|
||||
get(_target, key: string | symbol): unknown {
|
||||
cached ??= readEnv();
|
||||
return cached[key as keyof EnvShape];
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { secretStore } from './secretStore';
|
||||
|
||||
// During the migration window the orchestrator probes SecureStore for legacy
|
||||
// per-device private keys. We keep this read-only — never write — so a future
|
||||
// reset can safely wipe the new chatapp.userpriv.* slot without affecting
|
||||
// pre-existing legacy entries.
|
||||
export function legacyDeviceKey(userId: string, deviceId: string): Promise<Uint8Array | null> {
|
||||
return secretStore.getSecret('chatapp.priv.' + userId + '.' + deviceId);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { crypto } from '@chat-app/shared';
|
||||
import { makeWasmTestBackend } from '@chat-app/shared/crypto/testBackend';
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// We mock react-native modules so this Vitest file can run in Node without
|
||||
// loading native code. The mocks live next to the test for clarity.
|
||||
vi.mock('expo-secure-store', () => {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
getItemAsync: vi.fn(async (k: string) => store.get(k) ?? null),
|
||||
setItemAsync: vi.fn(async (k: string, v: string) => {
|
||||
store.set(k, v);
|
||||
}),
|
||||
deleteItemAsync: vi.fn(async (k: string) => {
|
||||
store.delete(k);
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const rpcImpl = vi.fn();
|
||||
vi.mock('./supabase', () => ({
|
||||
supabase: {
|
||||
rpc: (name: string, params: unknown) => rpcImpl(name, params),
|
||||
from: () => ({
|
||||
select: () => ({ in: () => Promise.resolve({ data: [], error: null }) }),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
beforeAll(async () => {
|
||||
crypto.setCryptoBackend(await makeWasmTestBackend());
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
rpcImpl.mockReset();
|
||||
});
|
||||
|
||||
describe('mobile userIdentity', () => {
|
||||
it('setupNewUserIdentity uploads + caches', async () => {
|
||||
rpcImpl.mockResolvedValue({ data: 0, error: null });
|
||||
const { setupNewUserIdentity, cachedUserKey } = await import('./userIdentity');
|
||||
const out = await setupNewUserIdentity({
|
||||
userId: 'user-1',
|
||||
pin: '123456',
|
||||
withRecovery: true,
|
||||
});
|
||||
expect(out.publicKey.length).toBe(32);
|
||||
expect(out.recoveryCode).toMatch(/^[A-Z0-9-]+$/);
|
||||
const cached = await cachedUserKey('user-1');
|
||||
expect(cached).not.toBeNull();
|
||||
expect(cached!.length).toBe(32);
|
||||
expect(rpcImpl).toHaveBeenCalledWith('upsert_user_key', expect.any(Object));
|
||||
});
|
||||
|
||||
it('loadOrUnlockUserKey returns `missing` when blob does not exist', async () => {
|
||||
rpcImpl.mockResolvedValue({ data: { exists: false }, error: null });
|
||||
const { loadOrUnlockUserKey } = await import('./userIdentity');
|
||||
const out = await loadOrUnlockUserKey({ userId: 'user-2', pin: '000000' });
|
||||
expect(out.kind).toBe('missing');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,266 @@
|
||||
import { migrateOwnLegacyBundles } from '@chat-app/shared/chat';
|
||||
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 { legacyDeviceKey } from './legacyDeviceVault';
|
||||
import { secretStore } 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 secretStore.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 };
|
||||
}
|
||||
|
||||
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 secretStore.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 secretStore.getSecret(cacheKey(userId));
|
||||
}
|
||||
|
||||
export async function clearUserKeyCache(userId: string): Promise<void> {
|
||||
await secretStore.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: getCryptoBackend().scalarMultBase(cached),
|
||||
sealedPrivateKey: fresh.sealedPrivateKey,
|
||||
salt: fresh.salt,
|
||||
kdfParams: fresh.kdfParams,
|
||||
});
|
||||
void params.oldPin; // 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: getCryptoBackend().scalarMultBase(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);
|
||||
await resetUserKey(supabase, {
|
||||
userId: params.userId,
|
||||
publicKey: new Uint8Array(32),
|
||||
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 ?? '';
|
||||
}
|
||||
|
||||
export interface LegacyMigrationReport {
|
||||
serverDevices: number;
|
||||
strongholdKeysFromServerDevices: number;
|
||||
strongholdKeysFromBundleScan: number;
|
||||
attempted: number;
|
||||
migrated: number;
|
||||
noStrongholdKey: number;
|
||||
decryptFailed: number;
|
||||
rpcFailed: number;
|
||||
}
|
||||
|
||||
export async function ensureLegacyMigrated(userId: string): Promise<LegacyMigrationReport | null> {
|
||||
const priv = await cachedUserKey(userId);
|
||||
if (!priv) return null;
|
||||
const pub = getCryptoBackend().scalarMultBase(priv);
|
||||
return runLegacyMigration(userId, priv, pub);
|
||||
}
|
||||
|
||||
export async function retryLegacyMigration(userId: string): Promise<LegacyMigrationReport> {
|
||||
const priv = await cachedUserKey(userId);
|
||||
if (!priv) throw new Error('user key not cached locally — re-login required');
|
||||
const pub = getCryptoBackend().scalarMultBase(priv);
|
||||
return runLegacyMigration(userId, priv, pub);
|
||||
}
|
||||
|
||||
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> = {};
|
||||
|
||||
for (const d of devices) {
|
||||
const k = await legacyDeviceKey(userId, d.id);
|
||||
if (k) ownLegacyDevicePrivateKeys[d.id] = k;
|
||||
}
|
||||
report.strongholdKeysFromServerDevices = Object.keys(ownLegacyDevicePrivateKeys).length;
|
||||
|
||||
// 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 => Boolean(id)),
|
||||
),
|
||||
);
|
||||
for (const id of scanIds) {
|
||||
if (ownLegacyDevicePrivateKeys[id]) continue;
|
||||
const k = await legacyDeviceKey(userId, id);
|
||||
if (k) {
|
||||
ownLegacyDevicePrivateKeys[id] = k;
|
||||
report.strongholdKeysFromBundleScan += 1;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user