Compare commits
262 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c8f0e8efd5 | |||
| fd9b8a88d6 | |||
| ab2f7130fe | |||
| b9a3dde1aa | |||
| 65d2446804 | |||
| faa12a4ebb | |||
| 37dd1b4f23 | |||
| 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 | |||
| 90fa0a7e95 | |||
| 767db72847 | |||
| 0288d7a476 | |||
| 34b972ec2a | |||
| c55173800b | |||
| fc9f1ec143 | |||
| 12e4b597a0 | |||
| 5c05afb009 | |||
| a636a3c1c1 | |||
| c9fe4879e0 | |||
| 61462516d2 | |||
| d39a0fb6dc | |||
| 9207f473cd | |||
| 5367544b59 | |||
| e6b698bf14 | |||
| 6caa674c19 | |||
| d9377fc52a | |||
| 1c18078b1f | |||
| 0e05a2cd85 | |||
| b55ccf899f | |||
| b2eb214d9f | |||
| 010a810485 | |||
| 6a9a0bb804 | |||
| f7c60945d0 | |||
| 15ef9ece66 | |||
| b789f4b10d | |||
| 02e1af4517 | |||
| e292df82f0 | |||
| b3804d805b | |||
| 02c4bb1c9b | |||
| 20216b37c6 | |||
| 2c586351fc | |||
| b89a7e2617 | |||
| 8d69329763 | |||
| 2fdbe9ee3a | |||
| 0d1d4496c3 | |||
| baf9c2e054 | |||
| b54fe0b56d | |||
| 89eb8d97c6 | |||
| 32bff77fd0 | |||
| d10b0fb5c3 | |||
| 1370f8794b | |||
| eef884c782 | |||
| d9f3c6c562 | |||
| b61f929cf7 | |||
| b0967dd2c5 | |||
| f8859b4601 | |||
| 4068e8c7b6 | |||
| cb8e729624 | |||
| 1e9395fdc2 | |||
| 9b3ef8c24f | |||
| ba8ba75490 | |||
| 122191276e | |||
| 5a3dc15704 | |||
| 10d3e91bbb | |||
| 04da0f211d | |||
| 4d787b68b4 | |||
| c2df741055 | |||
| b81c63a2d3 | |||
| 098e4f4c1d | |||
| e83c23d698 | |||
| 68cd4108dd | |||
| ed7cb72ebe | |||
| 177a4c059f | |||
| 1440b11740 | |||
| 286836f539 | |||
| 3b62fbc243 | |||
| 8d7ed592b8 | |||
| 153e40bdcb | |||
| 415fa10ed0 | |||
| 8b7b6a3b0f | |||
| 05a17b7c1c | |||
| dd6ae63491 | |||
| b1cdc4d184 | |||
| 263d2455a8 | |||
| 3755a927c3 | |||
| 0b3a6280fc | |||
| 3313dd84f3 | |||
| 2a5f836b86 | |||
| bf02380c19 | |||
| 375d28efa5 | |||
| 1e88941796 | |||
| 7ac93a95e6 | |||
| a275703ba6 | |||
| 035b5f078d | |||
| 13c6ad68f0 | |||
| 84a9f35ed8 | |||
| 7eb8224bb0 | |||
| 574436ab71 | |||
| 5e483368ec | |||
| b4457b74f5 | |||
| bb7aeec345 | |||
| 1bf4f4bb83 | |||
| 3049a0e39d | |||
| 2003a26c23 | |||
| 1e0cf67335 | |||
| 5f753412a6 | |||
| a560f24c45 | |||
| 87c2e45bb4 | |||
| b65a3994f3 | |||
| aa609389fa | |||
| cb3cbd8827 | |||
| d1f38ce313 | |||
| 0d65a134fd | |||
| 12c66d676a | |||
| 0dde1dd1a3 | |||
| 81d3587a91 | |||
| 8be1105333 | |||
| f9e1d2f073 | |||
| 341f5f227d | |||
| 8baac2fd1e | |||
| 05870ef8fa | |||
| f9e340dbec | |||
| 91eedc0e8e | |||
| e56533918e | |||
| c87d4e82d3 | |||
| 187f8dc95a | |||
| f612c1bb50 | |||
| a9d5e2d430 | |||
| a065cc0a2c | |||
| 005aebd60b |
@@ -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*
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# React Native / Expo expects a flat (npm/yarn-style) node_modules layout
|
||||
# where ALL transitive deps are reachable from any package. pnpm's strict
|
||||
# isolation breaks that — Metro hits a fresh missing-module error for every
|
||||
# transitive dep (invariant, nullthrows, @babel/runtime/helpers/*, ...).
|
||||
#
|
||||
# shamefully-hoist=true hoists everything to the workspace-root node_modules
|
||||
# (like npm/yarn) so all transitive deps are reachable via standard Node
|
||||
# resolution. This is the official Expo monorepo workaround and is the
|
||||
# only sustainable answer for Expo + RN + pnpm. Desktop (Electron+Vite)
|
||||
# bundles via Vite, so the flatter layout doesn't affect it.
|
||||
shamefully-hoist=true
|
||||
@@ -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';
|
||||
@@ -37,6 +39,26 @@ const __dirnameSafe = path.dirname(__filenameSafe);
|
||||
const DEV_URL = 'http://localhost:1420';
|
||||
const WINDOW_STATE_FILE = 'window-state.json';
|
||||
|
||||
// Pin userData FIRST — before any other Electron call that might cache a
|
||||
// productName-derived path. The 0.17.0 release saw users get logged out
|
||||
// after upgrading from 0.16.x: the most likely culprit was an internal
|
||||
// path resolution kicking off the moment `setName('Netralax')` ran, so
|
||||
// 0.17.1 swaps the order so the explicit override wins regardless of
|
||||
// what setName triggers internally. The literal 'ChatApp' here is the
|
||||
// pre-rename product folder — installed users' SQLite, secrets, sounds,
|
||||
// IndexedDB all live there and we never want to leave them stranded by
|
||||
// a future rebrand.
|
||||
app.setPath('userData', path.join(app.getPath('appData'), 'ChatApp'));
|
||||
|
||||
// App branding. productName in package.json drives the packaged exe name
|
||||
// (Netralax.exe) and electron-builder installer title. setName + the
|
||||
// AppUserModelId cover the live process: window title fallback, Windows
|
||||
// taskbar grouping, notification source attribution.
|
||||
app.setName('Netralax');
|
||||
if (process.platform === 'win32') {
|
||||
app.setAppUserModelId('cloud.netralax.desktop');
|
||||
}
|
||||
|
||||
// Run dev side-by-side with the installed packaged build by isolating the
|
||||
// renderer profile / secret-store / SQLite / IndexedDB / localStorage in
|
||||
// a separate userData dir. Without this both share `%APPDATA%\ChatApp`,
|
||||
@@ -47,6 +69,21 @@ if (!app.isPackaged) {
|
||||
app.setPath('userData', app.getPath('userData') + '-Dev');
|
||||
}
|
||||
|
||||
// Startup diagnostics — the 0.17.0 logout regression was hard to debug
|
||||
// because we had no record of the actual resolved paths. With this log
|
||||
// any future user can paste their main-process output and we can tell
|
||||
// at a glance whether userData ended up where we intended.
|
||||
console.log(
|
||||
'[main] resolved paths',
|
||||
JSON.stringify({
|
||||
appName: app.getName(),
|
||||
appData: app.getPath('appData'),
|
||||
userData: app.getPath('userData'),
|
||||
isPackaged: app.isPackaged,
|
||||
platform: process.platform,
|
||||
}),
|
||||
);
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
|
||||
function resolvePreloadPath(): string {
|
||||
@@ -74,7 +111,7 @@ async function createWindow(): Promise<BrowserWindow> {
|
||||
const state = await loadState(WINDOW_STATE_FILE);
|
||||
|
||||
const win = new BrowserWindow({
|
||||
title: app.isPackaged ? 'ChatApp' : 'ChatApp (Dev)',
|
||||
title: app.isPackaged ? 'Netralax' : 'Netralax (Dev)',
|
||||
width: state.width,
|
||||
height: state.height,
|
||||
...(state.x !== undefined ? { x: state.x } : {}),
|
||||
@@ -110,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());
|
||||
@@ -218,6 +266,7 @@ if (!gotLock) {
|
||||
registerFsScoped();
|
||||
registerSql();
|
||||
registerAutostart();
|
||||
registerAppHostname();
|
||||
|
||||
// Window-dependent registrars — only call once mainWindow exists so
|
||||
// event emitters have somewhere to send.
|
||||
@@ -228,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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -40,20 +40,68 @@ function filePathFor(userId: string, encrypted: boolean): string {
|
||||
}
|
||||
|
||||
async function loadState(filePath: string, encrypted: boolean): Promise<Map<string, string>> {
|
||||
// Read step. Distinguish "no file yet" (genuinely new user — empty Map
|
||||
// is correct) from "file exists but unreadable" (corruption / DPAPI
|
||||
// breakage — we MUST NOT let the next write overwrite those bytes,
|
||||
// because the original ciphertext is the only path back to the user's
|
||||
// device keys if a future build can fix the read path).
|
||||
let rawBuf: Buffer | null = null;
|
||||
let rawStr: string | null = null;
|
||||
try {
|
||||
if (encrypted) {
|
||||
const buf = await fs.readFile(filePath);
|
||||
const json = safeStorage.decryptString(buf);
|
||||
rawBuf = await fs.readFile(filePath);
|
||||
} else {
|
||||
rawStr = await fs.readFile(filePath, 'utf8');
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const code = (err as NodeJS.ErrnoException | null)?.code;
|
||||
if (code === 'ENOENT') return new Map();
|
||||
console.warn('[secure-store] read failed (non-ENOENT)', filePath, err);
|
||||
// For non-ENOENT read failures (EACCES, EBUSY, …) don't quarantine —
|
||||
// the file might be transiently locked. Empty map + future writes
|
||||
// will attempt to overwrite, matching the pre-0.17.1 behaviour for
|
||||
// these rarer cases.
|
||||
return new Map();
|
||||
}
|
||||
|
||||
// Parse / decrypt step.
|
||||
try {
|
||||
if (encrypted && rawBuf) {
|
||||
const json = safeStorage.decryptString(rawBuf);
|
||||
const parsed = JSON.parse(json) as { version?: number; entries?: Record<string, string> };
|
||||
return new Map(Object.entries(parsed.entries ?? {}));
|
||||
} else {
|
||||
const raw = await fs.readFile(filePath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as { version?: number; entries?: Record<string, string> };
|
||||
}
|
||||
if (rawStr) {
|
||||
const parsed = JSON.parse(rawStr) as { version?: number; entries?: Record<string, string> };
|
||||
return new Map(Object.entries(parsed.entries ?? {}));
|
||||
}
|
||||
} catch {
|
||||
// Missing file or malformed contents — start fresh. The next write
|
||||
// will overwrite with a fresh blob.
|
||||
return new Map();
|
||||
} catch (err: unknown) {
|
||||
// CRITICAL: file existed but we couldn't decrypt or parse it. In the
|
||||
// pre-0.17.1 build we silently started fresh — the next set() then
|
||||
// scheduledSave() over-wrote the original ciphertext, destroying the
|
||||
// user's device keys forever. Now we rename the original to
|
||||
// `<file>.broken-<iso-ts>` BEFORE returning the empty map so the next
|
||||
// write goes to a new file and the original bytes survive for
|
||||
// forensics or a future decrypt-recovery path.
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const brokenPath = `${filePath}.broken-${ts}`;
|
||||
try {
|
||||
await fs.rename(filePath, brokenPath);
|
||||
console.error(
|
||||
`[secure-store] DECRYPT/PARSE FAILED for ${filePath} — preserved original at ${brokenPath}. Original error:`,
|
||||
err,
|
||||
);
|
||||
} catch (renameErr: unknown) {
|
||||
// Even rename failed — fall back to the old behaviour (silent empty
|
||||
// map) but log loudly so it's visible in the main-process output.
|
||||
console.error(
|
||||
'[secure-store] rename of broken file failed; original may be overwritten on next save',
|
||||
renameErr,
|
||||
'original decrypt error:',
|
||||
err,
|
||||
);
|
||||
}
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,50 +64,75 @@ function buildOverlay(): NativeImage {
|
||||
}
|
||||
|
||||
export function register(mainWindow: BrowserWindow): void {
|
||||
const icon = loadTrayIcon();
|
||||
trayRef = new Tray(icon.isEmpty() ? nativeImage.createEmpty() : icon);
|
||||
trayRef.setToolTip('ChatApp');
|
||||
// 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, async (_evt, count: number): Promise<void> => {
|
||||
const n = Math.max(0, Math.floor(Number(count) || 0));
|
||||
if (trayRef && !trayRef.isDestroyed()) {
|
||||
trayRef.setToolTip(n > 0 ? `ChatApp — ${n} unread` : 'ChatApp');
|
||||
}
|
||||
if (mainWindow.isDestroyed()) return;
|
||||
if (process.platform === 'win32') {
|
||||
if (n > 0) {
|
||||
mainWindow.setOverlayIcon(buildOverlay(), `${n} unread`);
|
||||
} else {
|
||||
mainWindow.setOverlayIcon(null, '');
|
||||
ipcMain.handle(
|
||||
CHANNELS.TRAY_UNREAD,
|
||||
async (_evt, count: number, badgeDataUrl?: string | null): Promise<void> => {
|
||||
const n = Math.max(0, Math.floor(Number(count) || 0));
|
||||
if (trayRef && !trayRef.isDestroyed()) {
|
||||
trayRef.setToolTip(n > 0 ? `Netralax — ${n} ungelesen` : 'Netralax');
|
||||
}
|
||||
}
|
||||
});
|
||||
if (mainWindow.isDestroyed()) return;
|
||||
if (process.platform !== 'win32') return;
|
||||
if (n <= 0) {
|
||||
mainWindow.setOverlayIcon(null, '');
|
||||
return;
|
||||
}
|
||||
// Discord-style: prefer the renderer-painted badge (red circle with
|
||||
// the actual unread number). Fall back to the static red dot only if
|
||||
// the renderer didn't supply one or decoding failed — keeps the
|
||||
// visual indicator alive even when the canvas pipeline is unavailable.
|
||||
let overlay: NativeImage | null = null;
|
||||
if (typeof badgeDataUrl === 'string' && badgeDataUrl.startsWith('data:image/')) {
|
||||
const decoded = nativeImage.createFromDataURL(badgeDataUrl);
|
||||
if (!decoded.isEmpty()) overlay = decoded;
|
||||
}
|
||||
mainWindow.setOverlayIcon(overlay ?? buildOverlay(), `${n} ungelesen`);
|
||||
},
|
||||
);
|
||||
|
||||
app.on('before-quit', () => {
|
||||
try {
|
||||
|
||||
@@ -9,6 +9,14 @@ import { BrowserWindow, ipcMain } from 'electron';
|
||||
import { CHANNELS } from '../ipc-types';
|
||||
|
||||
export function register(mainWindow: BrowserWindow): void {
|
||||
// Per-window maximize-before-fullscreen memo. We have to drop the
|
||||
// maximized flag on Windows before setFullScreen so DWM recomposes
|
||||
// cleanly (taskbar quirk), but Electron doesn't remember that the
|
||||
// window WAS maximized — exiting fullscreen would leave it as a small
|
||||
// floating window. Track it ourselves keyed by window-id so a future
|
||||
// multi-window setup doesn't cross-pollute state.
|
||||
const wasMaximized = new Map<number, boolean>();
|
||||
|
||||
ipcMain.handle(CHANNELS.WINDOW_SET_FULLSCREEN, (evt, enabled: boolean) => {
|
||||
try {
|
||||
// Prefer the BrowserWindow that issued the IPC so multi-window setups
|
||||
@@ -16,7 +24,38 @@ export function register(mainWindow: BrowserWindow): void {
|
||||
// registered against (matches autostart.ts's app-singleton shape).
|
||||
const win = BrowserWindow.fromWebContents(evt.sender) ?? mainWindow;
|
||||
if (!win || win.isDestroyed()) return;
|
||||
win.setFullScreen(!!enabled);
|
||||
const id = win.id;
|
||||
if (enabled) {
|
||||
// Windows DWM quirk: maximized → fullscreen sometimes leaves the
|
||||
// taskbar drawn on top of the window because DWM keeps the
|
||||
// maximized work-area constraints. Drop the maximize flag first
|
||||
// so setFullScreen covers the whole monitor cleanly. Remember the
|
||||
// pre-fullscreen state so the exit path can restore it.
|
||||
if (process.platform === 'win32') {
|
||||
const was = win.isMaximized();
|
||||
wasMaximized.set(id, was);
|
||||
if (was) win.unmaximize();
|
||||
}
|
||||
win.setFullScreen(true);
|
||||
} else {
|
||||
win.setFullScreen(false);
|
||||
// Restore maximize if we dropped it on entry. setFullScreen(false)
|
||||
// emits 'leave-full-screen' asynchronously; maximize() needs to
|
||||
// wait until the window is back in normal mode or it silently
|
||||
// no-ops. The event fires same-tick in Electron 33, but we listen
|
||||
// for it once just to be safe across versions.
|
||||
if (process.platform === 'win32' && wasMaximized.get(id)) {
|
||||
wasMaximized.delete(id);
|
||||
const restore = (): void => {
|
||||
if (!win.isDestroyed()) win.maximize();
|
||||
};
|
||||
if (win.isFullScreen()) {
|
||||
win.once('leave-full-screen', restore);
|
||||
} else {
|
||||
restore();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.warn('window setFullscreen failed', err);
|
||||
throw err;
|
||||
|
||||
+12
-1
@@ -58,7 +58,7 @@ export interface ElectronAPI {
|
||||
notify: (args: NotifyArgs) => Promise<void>;
|
||||
getNotificationPermission: () => Promise<'granted' | 'denied' | 'default'>;
|
||||
|
||||
setTrayUnread: (count: number) => Promise<void>;
|
||||
setTrayUnread: (count: number, badgeDataUrl?: string | null) => Promise<void>;
|
||||
|
||||
secureStoreOpen: (args: SecureStoreOpenArgs) => Promise<SecureStoreHandle>;
|
||||
secureStoreGet: (handle: string, key: string) => Promise<string | null>;
|
||||
@@ -95,6 +95,17 @@ export interface ElectronAPI {
|
||||
setAutoStart: (enabled: boolean) => Promise<void>;
|
||||
|
||||
setFullscreen: (enabled: boolean) => Promise<void>;
|
||||
|
||||
/** Subscribe to the main-process pre-quit notification. Used by the
|
||||
* "Cache beim Schließen leeren" Settings toggle. */
|
||||
onWipeBeforeQuit: (cb: () => Promise<void>) => () => void;
|
||||
|
||||
/** Returns the OS hostname (via Node's `os.hostname()`). Used by
|
||||
* AuthContext on first launch to populate the `devices` row with a
|
||||
* human-readable default device name. Optional: always feature-check
|
||||
* via `typeof window.electronAPI?.getHostname === 'function'` because
|
||||
* the web build has no preload bridge. */
|
||||
getHostname?: () => Promise<string | null>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -28,6 +28,13 @@ import {
|
||||
type UpdateCheckResult,
|
||||
type UpdateProgress,
|
||||
} from './ipc-types';
|
||||
// Static import of the desktop package.json so the bundler inlines the
|
||||
// version string at build time. The previous `process.env.npm_package_-
|
||||
// version` approach worked in dev (pnpm sets it as a script env var) but
|
||||
// fell back to '0.0.0' in packaged builds — every installed user got
|
||||
// flagged as "Update verfügbar" against their own actually-current
|
||||
// version. resolveJsonModule + esModuleInterop are on in tsconfig.node.
|
||||
import pkg from '../package.json';
|
||||
|
||||
type Unsubscribe = () => void;
|
||||
|
||||
@@ -40,7 +47,7 @@ function on<T>(channel: string, cb: (payload: T) => void): Unsubscribe {
|
||||
const api = {
|
||||
platform: ELECTRON_RUNTIME_MARKER,
|
||||
osPlatform: process.platform as NodeJS.Platform,
|
||||
appVersion: process.env.npm_package_version ?? '0.0.0',
|
||||
appVersion: pkg.version,
|
||||
|
||||
// Screen sources ---------------------------------------------------------
|
||||
getScreenSources: (): Promise<ScreenSource[]> => ipcRenderer.invoke(CHANNELS.SCREEN_GET_SOURCES),
|
||||
@@ -94,7 +101,12 @@ const api = {
|
||||
ipcRenderer.invoke(CHANNELS.NOTIFY_PERMISSION),
|
||||
|
||||
// Tray -------------------------------------------------------------------
|
||||
setTrayUnread: (count: number): Promise<void> => ipcRenderer.invoke(CHANNELS.TRAY_UNREAD, count),
|
||||
// `badgeDataUrl` (optional): renderer-painted PNG (data:image/png;base64)
|
||||
// that main applies as the Windows taskbar overlay icon. We render in the
|
||||
// renderer because main has no Canvas2D; passing a finished image avoids
|
||||
// bundling a native canvas backend just for a 32×32 badge.
|
||||
setTrayUnread: (count: number, badgeDataUrl?: string | null): Promise<void> =>
|
||||
ipcRenderer.invoke(CHANNELS.TRAY_UNREAD, count, badgeDataUrl ?? null),
|
||||
|
||||
// Secure store -----------------------------------------------------------
|
||||
secureStoreOpen: (args: SecureStoreOpenArgs): Promise<SecureStoreHandle> =>
|
||||
@@ -147,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.16.3",
|
||||
"version": "0.20.1",
|
||||
"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,12 +52,13 @@
|
||||
"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"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.meinname.chatapp",
|
||||
"productName": "ChatApp",
|
||||
"productName": "Netralax",
|
||||
"directories": {
|
||||
"output": "release",
|
||||
"buildResources": "resources"
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { HashRouter, Navigate, Outlet, Route, Routes } from 'react-router-dom';
|
||||
import { HashRouter, Navigate, Outlet, Route, Routes, useParams } 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';
|
||||
@@ -59,6 +60,18 @@ function RouteBoundary({ scope }: { scope: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Forces a fresh `ConversationPage` instance per `:id` so React unmounts
|
||||
// the previous conversation entirely on switch. Without this, the same
|
||||
// component instance handles every conversation, which leaks state
|
||||
// between chats (messages, scroll position, composer drafts) for one
|
||||
// render frame and gives the "flicker" we're trying to remove.
|
||||
// `useConversationMessages` re-hydrates from `messageMemoryCache` on the
|
||||
// fresh mount so previously-visited chats still render instantly.
|
||||
function ConversationRoute() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
return <ConversationPage key={id ?? '__no_id__'} />;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<ErrorBoundary scope="root">
|
||||
@@ -113,7 +126,7 @@ export function App() {
|
||||
path=":id"
|
||||
element={
|
||||
<ErrorBoundary scope="conversation">
|
||||
<ConversationPage />
|
||||
<ConversationRoute />
|
||||
</ErrorBoundary>
|
||||
}
|
||||
/>
|
||||
@@ -168,6 +181,7 @@ export function App() {
|
||||
</Routes>
|
||||
<UpdateToast />
|
||||
<CrashToast />
|
||||
<RemoteRevokedScreen />
|
||||
</HashRouter>
|
||||
</CallProvider>
|
||||
</ConversationsProvider>
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
|
||||
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 { devLocalSecretStore } from '../lib/secretStore';
|
||||
import { BackupPromptBanner } from './BackupPromptBanner';
|
||||
import { useMentionNotifications } from '../lib/useMentionNotifications';
|
||||
import { cachedUserKey } from '../lib/userIdentity';
|
||||
import { CallUI } from './CallUI';
|
||||
import { DeviceApprovalBanner } from './DeviceApprovalBanner';
|
||||
import { Sidebar } from './Sidebar';
|
||||
|
||||
export function AppShell() {
|
||||
const { session, device } = useAuth();
|
||||
const { session } = useAuth();
|
||||
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.
|
||||
@@ -21,20 +24,25 @@ export function AppShell() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session?.user.id || !device?.id) return;
|
||||
if (!session?.user.id) return;
|
||||
const userId = session.user.id;
|
||||
const deviceId = device.id;
|
||||
const stopKeySync = startConversationKeySync(userId, deviceId);
|
||||
// Per-install id replaces the per-device record now that crypto is
|
||||
// user-keyed. The legacy conv-key-sync and device-approval subsystems
|
||||
// still take a "deviceId" argument for telemetry / row identity; passing
|
||||
// the install id keeps those wires intact until they are themselves
|
||||
// reworked in later tasks (Task 16/17/19 territory).
|
||||
const installId = ensureInstallId();
|
||||
const stopKeySync = startConversationKeySync(userId, installId);
|
||||
const stopApproval = startDeviceApprovalListener({
|
||||
ownUserId: userId,
|
||||
ownDeviceId: deviceId,
|
||||
getPriv: () => loadDevicePrivateKey(devLocalSecretStore, userId, deviceId),
|
||||
ownDeviceId: installId,
|
||||
getPriv: () => cachedUserKey(userId),
|
||||
});
|
||||
return () => {
|
||||
stopKeySync();
|
||||
stopApproval();
|
||||
};
|
||||
}, [session?.user.id, device?.id]);
|
||||
}, [session?.user.id]);
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen overflow-hidden bg-surface text-fg">
|
||||
@@ -48,7 +56,6 @@ export function AppShell() {
|
||||
</main>
|
||||
</div>
|
||||
<CallUI />
|
||||
<BackupPromptBanner />
|
||||
<DeviceApprovalBanner />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,12 +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, XIcon } from './icons';
|
||||
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
|
||||
@@ -43,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[] = [];
|
||||
@@ -62,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) {
|
||||
@@ -83,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));
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -94,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;
|
||||
|
||||
@@ -115,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
|
||||
@@ -131,47 +273,18 @@ 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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Lightbox({ url, onClose }: { url: string; onClose: () => void }) {
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}
|
||||
document.addEventListener('keydown', onKey);
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Bildansicht"
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-ink-950/90 p-6 backdrop-blur-sm animate-fade-in"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Schließen"
|
||||
className="absolute right-4 top-4 flex h-10 w-10 cursor-pointer items-center justify-center rounded-full border border-white/10 bg-ink-900/80 text-neutral-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
|
||||
>
|
||||
<XIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<img
|
||||
src={url}
|
||||
alt="attachment full"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="max-h-[92vh] max-w-[92vw] rounded-xl object-contain shadow-2xl"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Lightbox extracted to ./Lightbox.tsx so the settings avatar preview and
|
||||
// any future surface can reuse the same dialog without duplication.
|
||||
|
||||
@@ -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,306 +0,0 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { type BackupBundle, exportDeviceBackupWithRecovery } from '../lib/deviceBackup';
|
||||
import { AlertIcon, CopyIcon, LockIcon, ShieldIcon, SpinnerIcon, XIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
privateKey: Uint8Array;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// Exports the device's private key + identity into a passphrase-protected
|
||||
// portable string. The user can store this string anywhere (password manager,
|
||||
// printed paper, encrypted file on a USB stick). Without it, losing local
|
||||
// storage on this install means losing all past conversation keys.
|
||||
export function BackupExportDialog({ open, userId, deviceId, privateKey, onClose }: Props) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const [passphrase, setPassphrase] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [bundle, setBundle] = useState<BackupBundle | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [copiedRecovery, setCopiedRecovery] = useState(false);
|
||||
|
||||
const canGenerate = useMemo(() => {
|
||||
return passphrase.length >= 8 && passphrase === confirm && !busy;
|
||||
}, [passphrase, confirm, busy]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setPassphrase('');
|
||||
setConfirm('');
|
||||
setBundle(null);
|
||||
setError(null);
|
||||
setCopied(false);
|
||||
setCopiedRecovery(false);
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
reset();
|
||||
onClose();
|
||||
}, [reset, onClose]);
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
if (!canGenerate) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const b = await exportDeviceBackupWithRecovery({
|
||||
userId,
|
||||
deviceId,
|
||||
privateKey,
|
||||
passphrase,
|
||||
});
|
||||
setBundle(b);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [canGenerate, userId, deviceId, privateKey, passphrase]);
|
||||
|
||||
const copyText = async (text: string, marker: 'main' | 'recovery'): Promise<void> => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
if (marker === 'main') {
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
} else {
|
||||
setCopiedRecovery(true);
|
||||
window.setTimeout(() => setCopiedRecovery(false), 1500);
|
||||
}
|
||||
} catch {
|
||||
/* fall back — user can select manually */
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
if (!bundle) return;
|
||||
const text =
|
||||
'=== Passphrase backup ===\n' +
|
||||
bundle.passphraseBackup +
|
||||
'\n\n=== Recovery code ===\n' +
|
||||
bundle.recoveryCode +
|
||||
'\n\n=== Recovery backup (use with the recovery code) ===\n' +
|
||||
bundle.recoveryBackup +
|
||||
'\n';
|
||||
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `chatapp-device-backup-${deviceId.slice(0, 8)}.txt`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}, [bundle, deviceId]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('app:backup.export_title', { defaultValue: 'Gerätesicherung erstellen' })}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex max-h-[90vh] w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-line bg-surface-3 shadow-xl"
|
||||
>
|
||||
<header className="flex items-center justify-between border-b border-line px-5 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldIcon className="h-4 w-4 text-accent" />
|
||||
<h3 className="font-display text-sm font-semibold text-fg">
|
||||
{t('app:backup.export_title', { defaultValue: 'Gerätesicherung erstellen' })}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
aria-label="Close"
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-2 hover:text-fg"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-5">
|
||||
{!bundle ? (
|
||||
<>
|
||||
<p className="text-sm text-fg-muted">
|
||||
{t('app:backup.export_explainer', {
|
||||
defaultValue:
|
||||
'Verschlüssele den Geräteschlüssel mit einer Passphrase. Ohne Passphrase UND Backup-String ist keine Wiederherstellung möglich.',
|
||||
})}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs font-medium uppercase tracking-wide text-fg-muted">
|
||||
{t('app:backup.passphrase', { defaultValue: 'Passphrase' })}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
autoFocus
|
||||
minLength={8}
|
||||
value={passphrase}
|
||||
onChange={(e) => setPassphrase(e.target.value)}
|
||||
placeholder="min. 8 Zeichen"
|
||||
className="w-full rounded-lg border border-line bg-surface-2 px-3 py-2.5 text-sm text-fg placeholder-fg-muted transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs font-medium uppercase tracking-wide text-fg-muted">
|
||||
{t('app:backup.passphrase_confirm', { defaultValue: 'Passphrase wiederholen' })}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
className="w-full rounded-lg border border-line bg-surface-2 px-3 py-2.5 text-sm text-fg placeholder-fg-muted transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
|
||||
/>
|
||||
</div>
|
||||
{confirm.length > 0 && confirm !== passphrase && (
|
||||
<p className="text-xs text-rose-500 dark:text-rose-300">
|
||||
{t('app:backup.passphrase_mismatch', { defaultValue: 'Passphrasen stimmen nicht überein.' })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-700 dark:text-amber-200">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertIcon className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-300" />
|
||||
<span>
|
||||
{t('app:backup.export_warning', {
|
||||
defaultValue:
|
||||
'Anthropic: Backup + Passphrase sicher aufbewahren. Passphrase kann nicht wiederhergestellt werden.',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="mt-3 text-sm text-rose-600 dark:text-rose-200">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/10 p-3 text-xs text-emerald-700 dark:text-emerald-200">
|
||||
<div className="flex items-start gap-2">
|
||||
<LockIcon className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>
|
||||
{t('app:backup.export_success', {
|
||||
defaultValue:
|
||||
'Backup erstellt. Speichere diesen String + Passphrase in einem Passwortmanager oder drucke ihn aus.',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<label className="mt-3 block text-xs font-medium uppercase tracking-wide text-fg-muted">
|
||||
Backup-String
|
||||
</label>
|
||||
<textarea
|
||||
readOnly
|
||||
value={bundle.passphraseBackup}
|
||||
rows={6}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
className="mt-1 w-full resize-none rounded-lg border border-line bg-surface-2 p-3 font-mono text-[11px] leading-relaxed text-fg"
|
||||
/>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyText(bundle.passphraseBackup, 'main')}
|
||||
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-medium text-fg transition hover:bg-surface-3"
|
||||
>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
<span>{copied ? 'Kopiert!' : 'Kopieren'}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
className="inline-flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-medium text-fg transition hover:bg-surface-3"
|
||||
>
|
||||
Als Datei speichern (alles)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<ShieldIcon className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-300" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-semibold text-amber-700 dark:text-amber-200">
|
||||
Recovery-Code (Passphrase vergessen?)
|
||||
</p>
|
||||
<p className="mt-0.5 text-[11px] text-amber-700/80 dark:text-amber-200/80">
|
||||
Code separat aufbewahren. Mit dem Recovery-Backup unten lässt sich der Schlüssel
|
||||
ohne Passphrase wiederherstellen.
|
||||
</p>
|
||||
<p className="mt-2 select-all rounded bg-surface-3 px-2 py-1.5 font-mono text-sm tracking-widest text-fg">
|
||||
{bundle.recoveryCode}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="mt-3 block text-xs font-medium uppercase tracking-wide text-fg-muted">
|
||||
Recovery-Backup-String
|
||||
</label>
|
||||
<textarea
|
||||
readOnly
|
||||
value={bundle.recoveryBackup}
|
||||
rows={6}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
className="mt-1 w-full resize-none rounded-lg border border-line bg-surface-2 p-3 font-mono text-[11px] leading-relaxed text-fg"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyText(bundle.recoveryBackup, 'recovery')}
|
||||
className="mt-2 inline-flex w-full cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm font-medium text-fg transition hover:bg-surface-3"
|
||||
>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
<span>{copiedRecovery ? 'Kopiert!' : 'Recovery-Backup kopieren'}</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="flex items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3">
|
||||
{!bundle ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg transition hover:bg-surface-2"
|
||||
>
|
||||
{t('app:friends.action_cancel', { defaultValue: 'Abbrechen' })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleGenerate()}
|
||||
disabled={!canGenerate}
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{busy && <SpinnerIcon className="h-4 w-4" />}
|
||||
<span>{t('app:backup.generate', { defaultValue: 'Backup erstellen' })}</span>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="cursor-pointer rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110"
|
||||
>
|
||||
{t('app:backup.done', { defaultValue: 'Fertig' })}
|
||||
</button>
|
||||
)}
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { devLocalSecretStore } from '../lib/secretStore';
|
||||
import { BackupExportDialog } from './BackupExportDialog';
|
||||
import { ShieldIcon, XIcon } from './icons';
|
||||
|
||||
const DISMISS_KEY = 'chatapp.backup.prompt.dismissed';
|
||||
const SESSION_KEY = 'chatapp.backup.prompt';
|
||||
|
||||
// Post-registration nudge: right after a fresh device provision we set
|
||||
// `chatapp.backup.prompt` in sessionStorage. This component reads it and
|
||||
// shows a floating "mach jetzt ein Backup" banner until the user either
|
||||
// creates one or explicitly dismisses (persisted in localStorage so we stop
|
||||
// nagging across reloads).
|
||||
export function BackupPromptBanner() {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { profile, device } = useAuth();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [privateKey, setPrivateKey] = useState<Uint8Array | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (window.localStorage.getItem(DISMISS_KEY) === '1') return;
|
||||
if (window.sessionStorage.getItem(SESSION_KEY) !== '1') return;
|
||||
setVisible(true);
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const dismiss = useCallback((persist: boolean) => {
|
||||
setVisible(false);
|
||||
try {
|
||||
window.sessionStorage.removeItem(SESSION_KEY);
|
||||
if (persist) window.localStorage.setItem(DISMISS_KEY, '1');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const openDialog = useCallback(async () => {
|
||||
if (!profile?.userId || !device?.id) return;
|
||||
const priv = await loadDevicePrivateKey(devLocalSecretStore, profile.userId, device.id);
|
||||
if (!priv) return;
|
||||
setPrivateKey(priv);
|
||||
setDialogOpen(true);
|
||||
}, [profile, device]);
|
||||
|
||||
const closeDialog = useCallback(() => {
|
||||
setDialogOpen(false);
|
||||
if (privateKey) {
|
||||
for (let i = 0; i < privateKey.length; i++) privateKey[i] = 0;
|
||||
}
|
||||
setPrivateKey(null);
|
||||
// After the user interacts with the dialog, drop the banner regardless
|
||||
// of whether they actually completed the backup — they're aware now.
|
||||
dismiss(true);
|
||||
}, [privateKey, dismiss]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
role="status"
|
||||
className="fixed bottom-6 left-1/2 z-40 flex w-[min(92vw,520px)] -translate-x-1/2 items-start gap-3 rounded-2xl border border-amber-500/40 bg-amber-500/10 p-4 text-sm text-amber-800 shadow-xl backdrop-blur-md dark:text-amber-100"
|
||||
>
|
||||
<ShieldIcon className="mt-0.5 h-5 w-5 shrink-0 text-amber-600 dark:text-amber-300" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-semibold">
|
||||
{t('app:backup.prompt_title', { defaultValue: 'Erstelle jetzt ein Geräte-Backup' })}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-amber-700/90 dark:text-amber-200/90">
|
||||
{t('app:backup.prompt_body', {
|
||||
defaultValue:
|
||||
'Ohne Backup verlierst du Zugriff auf alte Nachrichten, wenn Browser oder Gerät ihren Speicher verlieren. Dauert 10 Sekunden.',
|
||||
})}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openDialog()}
|
||||
className="cursor-pointer rounded-md bg-amber-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-amber-500"
|
||||
>
|
||||
{t('app:backup.prompt_create', { defaultValue: 'Jetzt erstellen' })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dismiss(true)}
|
||||
className="cursor-pointer rounded-md border border-amber-500/40 bg-transparent px-3 py-1.5 text-xs font-semibold text-amber-700 transition hover:bg-amber-500/15 dark:text-amber-200"
|
||||
>
|
||||
{t('app:backup.prompt_never', { defaultValue: 'Nicht mehr fragen' })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dismiss(false)}
|
||||
aria-label={t('app:backup.prompt_dismiss', { defaultValue: 'Später' })}
|
||||
className="cursor-pointer text-amber-600/70 transition hover:text-amber-600 dark:text-amber-200/70 dark:hover:text-amber-200"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{dialogOpen && profile?.userId && device?.id && privateKey && (
|
||||
<BackupExportDialog
|
||||
open={dialogOpen}
|
||||
userId={profile.userId}
|
||||
deviceId={device.id}
|
||||
privateKey={privateKey}
|
||||
onClose={closeDialog}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { DeviceRestore } from './DeviceRestore';
|
||||
import { AlertIcon, ShieldIcon, XIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
userId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// Modal wrapper around DeviceRestore for the already-signed-in case. A
|
||||
// successful restore swaps the local device-identity for the one embedded
|
||||
// in the backup string — the app then hard-reloads so every hook
|
||||
// re-initialises against the restored keys (simpler than invalidating
|
||||
// supabase-realtime subscriptions, stronghold caches, livekit rooms, etc.
|
||||
// individually).
|
||||
export function BackupRestoreDialog({ open, userId, onClose }: Props) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey);
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Backup wiederherstellen"
|
||||
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/60 p-6 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex w-full max-w-md flex-col gap-4"
|
||||
>
|
||||
<div className="flex items-center justify-between rounded-lg border border-white/10 bg-ink-900/70 p-3 backdrop-blur-xl">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldIcon className="h-4 w-4 text-brand-300" />
|
||||
<h3 className="text-sm font-semibold text-white">
|
||||
Gerät aus Backup wiederherstellen
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Schließen"
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-neutral-400 transition hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-100">
|
||||
<AlertIcon className="mt-0.5 h-4 w-4 shrink-0 text-amber-300" />
|
||||
<p className="min-w-0 flex-1">
|
||||
Restore ersetzt das aktuelle Gerät durch das aus dem Backup.
|
||||
Die App lädt danach neu. Nachrichten, die auf diesem Gerät seit
|
||||
dem Backup eingegangen sind, sind erst wieder lesbar, nachdem
|
||||
Peer-Geräte den Conversation-Key erneut für die wiederhergestellte
|
||||
Device-ID wrappen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DeviceRestore
|
||||
userId={userId}
|
||||
onRestored={() => {
|
||||
// Hard reload — cleanest way to reset every hook, supabase
|
||||
// realtime channel, stronghold handle, and cached state.
|
||||
window.location.reload();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,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,
|
||||
@@ -83,11 +84,13 @@ export interface ParticipantTileProps {
|
||||
size?: 'default' | 'small';
|
||||
focused?: boolean;
|
||||
onClick?: () => void;
|
||||
onDoubleClick?: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export function CallParticipantTile(props: ParticipantTileProps) {
|
||||
const {
|
||||
userId,
|
||||
displayName,
|
||||
me,
|
||||
muted,
|
||||
@@ -102,9 +105,17 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
||||
size = 'default',
|
||||
focused = false,
|
||||
onClick,
|
||||
onDoubleClick,
|
||||
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
|
||||
@@ -120,6 +131,7 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onContextMenu={onContextMenu}
|
||||
className={
|
||||
'relative flex flex-col overflow-hidden rounded-[14px] border-[2px] bg-surface-3 transition-colors duration-150 ' +
|
||||
@@ -129,9 +141,9 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
||||
}
|
||||
>
|
||||
{video ? (
|
||||
<VideoStub {...props} small={small} />
|
||||
<VideoStub {...tileProps} small={small} fit={focused ? 'contain' : 'cover'} />
|
||||
) : (
|
||||
<AudioContent {...props} small={small} />
|
||||
<AudioContent {...tileProps} small={small} />
|
||||
)}
|
||||
|
||||
{speaking && (
|
||||
@@ -184,7 +196,7 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
||||
/>
|
||||
)}
|
||||
<span className="truncate">
|
||||
{displayName}
|
||||
{resolvedName}
|
||||
{me ? ' (du)' : ''}
|
||||
</span>
|
||||
{e2ee && (
|
||||
@@ -279,6 +291,7 @@ function AudioContent({
|
||||
src={avatarUrl}
|
||||
alt=""
|
||||
className="relative h-full w-full rounded-full object-cover"
|
||||
loading="eager"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
@@ -303,7 +316,8 @@ function VideoStub({
|
||||
videoTrack,
|
||||
me,
|
||||
small,
|
||||
}: ParticipantTileProps & { small: boolean }) {
|
||||
fit,
|
||||
}: ParticipantTileProps & { small: boolean; fit: 'cover' | 'contain' }) {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
useEffect(() => {
|
||||
const el = videoRef.current;
|
||||
@@ -330,7 +344,8 @@ function VideoStub({
|
||||
playsInline
|
||||
muted
|
||||
className={
|
||||
'h-full w-full object-cover ' +
|
||||
'h-full w-full ' +
|
||||
(fit === 'contain' ? 'object-contain ' : 'object-cover ') +
|
||||
(me ? 'scale-x-[-1]' : '') /* mirror local preview */
|
||||
}
|
||||
/>
|
||||
@@ -352,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,118 @@
|
||||
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useCall } from '../context/CallContext';
|
||||
import { useCallPresence } from '../lib/useCallPresence';
|
||||
import { Avatar } from './Avatar';
|
||||
import { PhoneIcon, SpinnerIcon, XIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
conversation: ConversationSummary;
|
||||
}
|
||||
|
||||
const MAX_TILES = 7;
|
||||
|
||||
/**
|
||||
* Discord-DM-style call preview panel. Renders only while peers are in the
|
||||
* conversation's active call and the local user is NOT in it. Provides large
|
||||
* avatar tiles plus a single "Beitreten" call-to-action. Calls are still
|
||||
* STARTED via the topbar phone icon (`ConversationHeader.startCall`); this
|
||||
* component never initiates — only joins.
|
||||
*/
|
||||
export function CallPreviewPanel({ conversation }: Props) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { session } = useAuth();
|
||||
const { state, joinActiveCall } = useCall();
|
||||
const presentIds = useCallPresence(conversation.id);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
const myId = session?.user.id ?? null;
|
||||
const iAmIn =
|
||||
(state.kind === 'connected' ||
|
||||
state.kind === 'connecting' ||
|
||||
state.kind === 'reconnecting') &&
|
||||
state.conversationId === conversation.id;
|
||||
|
||||
const others = presentIds.filter((u) => u !== myId);
|
||||
if (iAmIn || others.length === 0) return null;
|
||||
|
||||
const visibleTiles = others.slice(0, MAX_TILES);
|
||||
const overflow = Math.max(0, others.length - MAX_TILES);
|
||||
const busy = state.kind !== 'idle';
|
||||
|
||||
const handleJoin = () => {
|
||||
if (busy) return;
|
||||
void joinActiveCall(conversation.id, 'audio');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-b border-line bg-surface-3/70">
|
||||
<div className="flex items-center gap-2 px-5 py-2 text-xs">
|
||||
<PhoneIcon className="h-3.5 w-3.5 text-emerald-500" />
|
||||
<span className="font-semibold uppercase tracking-[0.12em] text-fg-muted">
|
||||
{t('app:call.active_in_conv', {
|
||||
defaultValue: 'Laufender Anruf · {{count}} im Raum',
|
||||
count: others.length,
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
aria-label={collapsed ? 'Anrufvorschau ausklappen' : 'Anrufvorschau einklappen'}
|
||||
className="ml-auto inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-2 hover:text-fg"
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!collapsed && (
|
||||
<div className="flex flex-col items-center gap-4 px-5 pb-4 pt-1">
|
||||
<div className="grid w-full max-w-2xl grid-cols-2 gap-3 md:grid-cols-3">
|
||||
{visibleTiles.map((id) => {
|
||||
const member = conversation.members.find((m) => m.userId === id);
|
||||
const name = member?.profile?.displayName ?? '?';
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
title={name}
|
||||
className="flex aspect-square flex-col items-center justify-center gap-2 rounded-lg border border-line bg-surface-2 p-3"
|
||||
>
|
||||
<div className="h-16 w-16 overflow-hidden rounded-full">
|
||||
<Avatar
|
||||
url={member?.profile?.avatarUrl ?? null}
|
||||
displayName={name}
|
||||
className="h-full w-full text-base"
|
||||
/>
|
||||
</div>
|
||||
<span className="line-clamp-1 text-xs font-medium text-fg">{name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{overflow > 0 && (
|
||||
<div className="flex aspect-square flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-line bg-surface-2 p-3 text-fg-muted">
|
||||
<span className="text-xl font-semibold">+{overflow}</span>
|
||||
<span className="text-xs">weitere</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleJoin}
|
||||
disabled={busy}
|
||||
className="inline-flex w-full max-w-xs cursor-pointer items-center justify-center gap-2 rounded-lg bg-emerald-600 px-4 py-3 text-sm font-semibold text-white transition hover:bg-emerald-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{busy ? (
|
||||
<SpinnerIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<PhoneIcon className="h-4 w-4" />
|
||||
)}
|
||||
<span>{t('app:call.join', { defaultValue: 'Beitreten' })}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
)}
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import type { DeviceRecord } from '@chat-app/shared/auth';
|
||||
import { extractErrorCode } from '@chat-app/shared/i18n';
|
||||
import { useCallback, useId, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { detectDesktopPlatform, registerCurrentDevice } from '../lib/device';
|
||||
import { AlertIcon, ArrowRightIcon, LockIcon, ShieldIcon, SpinnerIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
userId: string;
|
||||
defaultName: string;
|
||||
onRegistered: (device: DeviceRecord) => void;
|
||||
}
|
||||
|
||||
export function DeviceRegistration({ userId, defaultName, onRegistered }: Props) {
|
||||
const { t } = useTranslation(['auth', 'errors']);
|
||||
const nameId = useId();
|
||||
const [name, setName] = useState(defaultName);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const device = await registerCurrentDevice({ userId, name: trimmed });
|
||||
onRegistered(device);
|
||||
} catch (err: unknown) {
|
||||
const code = extractErrorCode(err);
|
||||
if (code) {
|
||||
setError(t(`errors:${code}`, { defaultValue: t('errors:generic') }));
|
||||
} else if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError(t('errors:generic'));
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[name, userId, onRegistered, t],
|
||||
);
|
||||
|
||||
const platform = detectDesktopPlatform();
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="w-full max-w-md animate-slide-up rounded-2xl border border-white/10 bg-ink-900/70 p-7 shadow-glow backdrop-blur-xl sm:p-8"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-brand-500/20 ring-1 ring-brand-400/30">
|
||||
<LockIcon className="h-5 w-5 text-brand-300" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-display text-lg font-semibold text-white">{t('auth:device.title')}</h2>
|
||||
<p className="text-xs text-neutral-400">{t('auth:device.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-1.5">
|
||||
<label
|
||||
htmlFor={nameId}
|
||||
className="block text-xs font-medium uppercase tracking-wide text-neutral-400"
|
||||
>
|
||||
{t('auth:device.name_label')}
|
||||
</label>
|
||||
<input
|
||||
id={nameId}
|
||||
type="text"
|
||||
required
|
||||
autoFocus
|
||||
maxLength={64}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t('auth:device.name_placeholder')}
|
||||
className="w-full rounded-lg border border-white/10 bg-ink-800 px-3 py-2.5 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
|
||||
/>
|
||||
<p className="text-xs text-neutral-500">{t('auth:device.name_hint')}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 rounded-lg border border-amber-500/20 bg-amber-500/10 p-3 text-xs text-amber-200">
|
||||
<div className="flex items-start gap-2">
|
||||
<ShieldIcon className="mt-0.5 h-4 w-4 shrink-0 text-amber-300" />
|
||||
<span className="min-w-0 flex-1 break-words">{t('auth:device.security_note_dev')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || name.trim().length === 0}
|
||||
aria-busy={busy}
|
||||
className="group mt-6 inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-gradient-to-br from-brand-400 to-brand-600 px-4 py-3 text-sm font-semibold text-white shadow-glow transition hover:from-brand-300 hover:to-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-400/60 focus:ring-offset-2 focus:ring-offset-ink-900 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{busy ? (
|
||||
<>
|
||||
<SpinnerIcon className="h-4 w-4" />
|
||||
<span>{t('auth:device.cta_loading')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>{t('auth:device.cta')}</span>
|
||||
<ArrowRightIcon className="h-4 w-4 transition group-hover:translate-x-0.5" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-4 flex items-start gap-3 rounded-lg border border-rose-500/20 bg-rose-500/10 p-3 text-sm text-rose-100"
|
||||
>
|
||||
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" />
|
||||
<p className="min-w-0 flex-1 break-words">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-4 text-center text-xs text-neutral-500">
|
||||
{t('auth:device.device_platform', { defaultValue: 'Platform' })}: {platform}
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
import {
|
||||
type DeviceRecord,
|
||||
restoreDeviceFromServerRecord,
|
||||
} from '@chat-app/shared/auth';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
decodePrivateKeyFromBackup,
|
||||
importDeviceBackup,
|
||||
normalizeRecoveryCode,
|
||||
} from '../lib/deviceBackup';
|
||||
import { devLocalSecretStore } from '../lib/secretStore';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { writeLocalDeviceId } from '../lib/device';
|
||||
import { AlertIcon, ArrowRightIcon, LockIcon, ShieldIcon, SpinnerIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
userId: string;
|
||||
onRestored: (device: DeviceRecord) => void;
|
||||
}
|
||||
|
||||
// Restores a device from a user-provided backup string. The backup embeds
|
||||
// userId + deviceId + X25519 private key; we verify userId matches the current
|
||||
// session, confirm the device row still exists server-side, and then re-seed
|
||||
// the local vault + cached deviceId so the app treats this install as the
|
||||
// original device (conv-key bundles stay valid, no "awaiting" state).
|
||||
export function DeviceRestore({ userId, onRestored }: Props) {
|
||||
const { t } = useTranslation(['app', 'errors']);
|
||||
const [backup, setBackup] = useState('');
|
||||
const [passphrase, setPassphrase] = useState('');
|
||||
const [mode, setMode] = useState<'passphrase' | 'recovery'>('passphrase');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!backup.trim() || passphrase.length < 1 || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
let privateKey: Uint8Array | null = null;
|
||||
try {
|
||||
const secret =
|
||||
mode === 'recovery' ? normalizeRecoveryCode(passphrase) : passphrase;
|
||||
const payload = await importDeviceBackup(backup.trim(), secret);
|
||||
privateKey = decodePrivateKeyFromBackup(payload);
|
||||
|
||||
const device = await restoreDeviceFromServerRecord({
|
||||
client: supabase,
|
||||
secretStore: devLocalSecretStore,
|
||||
userId: payload.userId,
|
||||
deviceId: payload.deviceId,
|
||||
privateKey,
|
||||
});
|
||||
|
||||
// Cache deviceId locally so findExistingDevice picks it up on next load.
|
||||
writeLocalDeviceId(userId, device.id);
|
||||
|
||||
onRestored(device);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
if (privateKey) {
|
||||
for (let i = 0; i < privateKey.length; i++) privateKey[i] = 0;
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[backup, passphrase, mode, userId, onRestored, busy],
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="w-full max-w-md animate-slide-up rounded-2xl border border-white/10 bg-ink-900/70 p-7 shadow-glow backdrop-blur-xl sm:p-8"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-500/20 ring-1 ring-emerald-400/30">
|
||||
<ShieldIcon className="h-5 w-5 text-emerald-300" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-display text-lg font-semibold text-white">
|
||||
{t('app:backup.restore_title', { defaultValue: 'Backup wiederherstellen' })}
|
||||
</h2>
|
||||
<p className="text-xs text-neutral-400">
|
||||
{t('app:backup.restore_subtitle', {
|
||||
defaultValue: 'Bringe einen zuvor erstellten Backup-String + Passphrase mit.',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-3">
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs font-medium uppercase tracking-wide text-neutral-400">
|
||||
{t('app:backup.backup_string', { defaultValue: 'Backup-String' })}
|
||||
</label>
|
||||
<textarea
|
||||
required
|
||||
rows={5}
|
||||
value={backup}
|
||||
onChange={(e) => setBackup(e.target.value)}
|
||||
placeholder="chatapp-backup-v1…"
|
||||
className="w-full resize-none rounded-lg border border-white/10 bg-ink-800 px-3 py-2.5 font-mono text-[11px] leading-relaxed text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-xs font-medium uppercase tracking-wide text-neutral-400">
|
||||
{mode === 'passphrase' ? 'Passphrase' : 'Recovery-Code'}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMode((m) => (m === 'passphrase' ? 'recovery' : 'passphrase'));
|
||||
setPassphrase('');
|
||||
setError(null);
|
||||
}}
|
||||
className="cursor-pointer text-[11px] font-semibold text-brand-300 hover:underline"
|
||||
>
|
||||
{mode === 'passphrase'
|
||||
? 'Passphrase vergessen? Recovery-Code nutzen'
|
||||
: 'Stattdessen Passphrase eingeben'}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type={mode === 'passphrase' ? 'password' : 'text'}
|
||||
required
|
||||
value={passphrase}
|
||||
onChange={(e) => setPassphrase(e.target.value)}
|
||||
placeholder={mode === 'recovery' ? 'XXXXXX-XXXXXX-XXXXXX-XXXXXX' : ''}
|
||||
spellCheck={false}
|
||||
autoComplete={mode === 'recovery' ? 'off' : 'current-password'}
|
||||
className={
|
||||
'w-full rounded-lg border border-white/10 bg-ink-800 px-3 py-2.5 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40 ' +
|
||||
(mode === 'recovery' ? 'font-mono tracking-widest' : '')
|
||||
}
|
||||
/>
|
||||
{mode === 'recovery' && (
|
||||
<p className="text-[11px] text-neutral-500">
|
||||
Stattdessen den Recovery-Backup-String oben einfügen.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 rounded-lg border border-brand-500/20 bg-brand-500/10 p-3 text-xs text-brand-100">
|
||||
<div className="flex items-start gap-2">
|
||||
<LockIcon className="mt-0.5 h-4 w-4 shrink-0 text-brand-300" />
|
||||
<span className="min-w-0 flex-1 break-words">
|
||||
{t('app:backup.restore_hint', {
|
||||
defaultValue:
|
||||
'Nach Wiederherstellung übernimmt dieses Gerät die alte Identität — existierende Nachrichten sind wieder entschlüsselbar.',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || backup.trim().length === 0 || passphrase.length === 0}
|
||||
aria-busy={busy}
|
||||
className="group mt-6 inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-gradient-to-br from-emerald-400 to-emerald-600 px-4 py-3 text-sm font-semibold text-white shadow-glow transition hover:from-emerald-300 hover:to-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-400/60 focus:ring-offset-2 focus:ring-offset-ink-900 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{busy ? (
|
||||
<>
|
||||
<SpinnerIcon className="h-4 w-4" />
|
||||
<span>{t('app:backup.restoring', { defaultValue: 'Wiederherstellen…' })}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>{t('app:backup.restore_cta', { defaultValue: 'Gerät wiederherstellen' })}</span>
|
||||
<ArrowRightIcon className="h-4 w-4 transition group-hover:translate-x-0.5" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-4 flex items-start gap-3 rounded-lg border border-rose-500/20 bg-rose-500/10 p-3 text-sm text-rose-100"
|
||||
>
|
||||
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" />
|
||||
<p className="min-w-0 flex-1 break-words">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
action?: { label: string; onClick: () => void };
|
||||
}
|
||||
|
||||
// Reusable empty-state placeholder: large icon + heading + description +
|
||||
// optional primary CTA. Used everywhere there's a meaningfully-empty list
|
||||
// (no chats, no friends, no search results, fresh conversation).
|
||||
export function EmptyState({ icon, title, description, action }: Props) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center px-6 py-12 text-center">
|
||||
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-accent/10 text-accent">
|
||||
{icon}
|
||||
</div>
|
||||
<h3 className="font-display text-lg font-semibold text-fg">{title}</h3>
|
||||
<p className="mt-2 max-w-xs text-sm text-fg-muted">{description}</p>
|
||||
{action && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={action.onClick}
|
||||
className="mt-5 inline-flex cursor-pointer items-center justify-center rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
|
||||
import {
|
||||
type AttachmentHandle,
|
||||
type DecryptedMessage,
|
||||
@@ -15,8 +14,9 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useConversationsContext } from '../context/ConversationsContext';
|
||||
import { devLocalSecretStore } from '../lib/secretStore';
|
||||
import { ensureInstallId } from '../lib/installId';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { cachedUserKey } from '../lib/userIdentity';
|
||||
import { Avatar } from './Avatar';
|
||||
import { ForwardIcon, SpinnerIcon, UsersIcon, XIcon } from './icons';
|
||||
|
||||
@@ -33,7 +33,7 @@ interface Props {
|
||||
// preview hints at the dropped attachment.
|
||||
export function ForwardDialog({ open, message, currentConversationId, onClose }: Props) {
|
||||
const { t } = useTranslation(['app', 'errors']);
|
||||
const { session, device } = useAuth();
|
||||
const { session } = useAuth();
|
||||
const { conversations } = useConversationsContext();
|
||||
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
@@ -84,13 +84,13 @@ export function ForwardDialog({ open, message, currentConversationId, onClose }:
|
||||
}
|
||||
|
||||
async function handleSend() {
|
||||
if (!session?.user.id || !device?.id || !message) return;
|
||||
if (!session?.user.id || !message) return;
|
||||
if (selected.size === 0) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const priv = await loadDevicePrivateKey(devLocalSecretStore, session.user.id, device.id);
|
||||
if (!priv) throw new Error('private key not loaded');
|
||||
const priv = await cachedUserKey(session.user.id);
|
||||
if (!priv) throw new Error('user key not unlocked');
|
||||
|
||||
const hasAttachments = sourceAttachments.length > 0;
|
||||
const text = preview || (hasAttachments ? '' : '');
|
||||
@@ -138,7 +138,7 @@ export function ForwardDialog({ open, message, currentConversationId, onClose }:
|
||||
conversationId: convId,
|
||||
plaintext: text,
|
||||
senderUserId: session.user.id,
|
||||
senderDeviceId: device.id,
|
||||
senderDeviceId: ensureInstallId(),
|
||||
senderPrivateKey: priv,
|
||||
...(newHandles.length > 0 ? { attachmentHandles: newHandles } : {}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
@@ -93,7 +93,7 @@ export function GroupInfoPanel({ open, onClose, conversation }: Props) {
|
||||
<aside
|
||||
role="dialog"
|
||||
aria-label={t('app:group.info_title')}
|
||||
className="absolute inset-y-0 right-0 z-20 flex w-[320px] flex-col border-l border-white/5 bg-ink-900/95 shadow-xl backdrop-blur-xl animate-slide-up"
|
||||
className="flex w-[320px] shrink-0 flex-col border-l border-white/5 bg-ink-900/95"
|
||||
>
|
||||
<header className="flex items-center justify-between border-b border-white/5 px-5 py-4">
|
||||
<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();
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||
import type { ConnectionQuality, RemoteParticipant, Room } from 'livekit-client';
|
||||
import { RoomEvent, Track } from 'livekit-client';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
@@ -15,19 +15,12 @@ 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';
|
||||
import { ScreenSharePickerModal } from './ScreenSharePickerModal';
|
||||
import { FocusIcon, GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } from './icons';
|
||||
import { GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } from './icons';
|
||||
import { ParticipantsPopover, type ParticipantRow } from './ParticipantsPopover';
|
||||
import { ParticipantVolumeMenu } from './ParticipantVolumeMenu';
|
||||
import { ScreenShareContextMenu } from './ScreenShareContextMenu';
|
||||
@@ -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.
|
||||
@@ -195,22 +177,6 @@ export function InCallPanel({ conversation }: Props) {
|
||||
if (soundboardCount === 0) setSoundboardOpen(false);
|
||||
}, [soundboardCount]);
|
||||
|
||||
// Active-speaker auto-focus uses "who most recently started speaking"
|
||||
// rather than "exactly one speaker" — matches Discord more closely and
|
||||
// handles the case where two people talk briefly without the focus
|
||||
// collapsing to nobody.
|
||||
const [lastStartedSpeakerId, setLastStartedSpeakerId] = useState<string | null>(null);
|
||||
const prevActiveSpeakersRef = useRef<Set<string>>(new Set());
|
||||
useEffect(() => {
|
||||
for (const id of activeSpeakers) {
|
||||
if (!prevActiveSpeakersRef.current.has(id)) {
|
||||
setLastStartedSpeakerId(id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
prevActiveSpeakersRef.current = new Set(activeSpeakers);
|
||||
}, [activeSpeakers]);
|
||||
|
||||
// Single right-click dispatcher for all tiles. User-tiles open the volume
|
||||
// menu; screen-tiles open the share-specific menu (volume + mute + stop
|
||||
// watching). Self-tiles get no menu — no volume to control, and you can
|
||||
@@ -310,12 +276,35 @@ export function InCallPanel({ conversation }: Props) {
|
||||
? t('app:call.waiting_for_peers', { defaultValue: 'Warte auf andere…' })
|
||||
: t('app:call.connected');
|
||||
|
||||
// Screen shares no longer auto-promote — the user opts in by clicking the
|
||||
// "Bildschirm anschauen" overlay, which also toggles whether the audio
|
||||
// plays. Focus falls back to the first tile so focus-mode always has
|
||||
// something to show when no tile was explicitly picked.
|
||||
const effectiveFocusedId = focusedId ?? tiles[0]?.id ?? null;
|
||||
const speaker = tiles.find((p) => p.id === effectiveFocusedId) ?? tiles[0];
|
||||
// Discord-style precedence:
|
||||
// 1. focusedId set → 'focus', that tile is the stage.
|
||||
// 2. ≥2 shares, no pin → 'bento', shares fill the stage, webcams strip.
|
||||
// 3. exactly 1 share, no pin → 'focus' (auto-promote share).
|
||||
// 4. no shares, no pin → 'equal-grid'.
|
||||
type StageLayout =
|
||||
| { kind: 'equal-grid' }
|
||||
| { kind: 'focus'; bigTileId: string }
|
||||
| { kind: 'bento'; shareIds: string[] };
|
||||
|
||||
const shareIds = tiles.filter((t) => t.kind === 'screen').map((t) => t.id);
|
||||
const stageLayout: StageLayout = (() => {
|
||||
if (focusedId !== null && tiles.some((t) => t.id === focusedId)) {
|
||||
return { kind: 'focus', bigTileId: focusedId };
|
||||
}
|
||||
if (shareIds.length >= 2) return { kind: 'bento', shareIds };
|
||||
if (shareIds.length === 1 && shareIds[0]) {
|
||||
return { kind: 'focus', bigTileId: shareIds[0] };
|
||||
}
|
||||
return { kind: 'equal-grid' };
|
||||
})();
|
||||
|
||||
// Tile that owns the big stage when layout is 'focus'. Resolved lazily by
|
||||
// callers below — kept here just so the speaker prop on CallStage/Fullscreen
|
||||
// stays consistent with the layout decision.
|
||||
const bigTile =
|
||||
stageLayout.kind === 'focus'
|
||||
? tiles.find((t) => t.id === stageLayout.bigTileId)
|
||||
: undefined;
|
||||
|
||||
const controls = (
|
||||
<CallControls
|
||||
@@ -355,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'}
|
||||
@@ -385,24 +365,6 @@ export function InCallPanel({ conversation }: Props) {
|
||||
}));
|
||||
|
||||
if (callMode === 'fullscreen') {
|
||||
// In fullscreen, a "manual focus" = user explicitly picked someone OR
|
||||
// the person who most recently started speaking (tracked in
|
||||
// lastStartedSpeakerId). Screen shares are no longer an auto-focus
|
||||
// trigger; they stay as equal-size grid tiles until the user clicks
|
||||
// one. "Most recent speaker" beats "exactly one currently speaking"
|
||||
// because two people briefly overlapping shouldn't kick us out of
|
||||
// auto-focus.
|
||||
const autoSpeaker =
|
||||
focusedId === null && lastStartedSpeakerId !== null
|
||||
? tiles.find(
|
||||
(t) =>
|
||||
t.kind === 'user' &&
|
||||
!t.self &&
|
||||
t.userId === lastStartedSpeakerId,
|
||||
)
|
||||
: undefined;
|
||||
const hasFocus = focusedId !== null || autoSpeaker !== undefined;
|
||||
const effectiveSpeaker = hasFocus ? speaker ?? autoSpeaker : undefined;
|
||||
return (
|
||||
<>
|
||||
{micError && (
|
||||
@@ -418,7 +380,7 @@ export function InCallPanel({ conversation }: Props) {
|
||||
)}
|
||||
<FullscreenCall
|
||||
tiles={tiles}
|
||||
speaker={effectiveSpeaker}
|
||||
speaker={bigTile}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversation.members}
|
||||
activeSpeakers={activeSpeakers}
|
||||
@@ -510,7 +472,6 @@ export function InCallPanel({ conversation }: Props) {
|
||||
onClose={() => setStatsOverlayOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{captionsEnabled && <CallCaptionsOverlay conversation={conversation} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -523,10 +484,10 @@ export function InCallPanel({ conversation }: Props) {
|
||||
// Focus mode dedicates the entire call-panel vertical slot to the speaker so
|
||||
// the tile can grow in height (grid mode's 420px cap leaves it squashed).
|
||||
const sectionClass =
|
||||
callMode === 'focus'
|
||||
stageLayout.kind === 'focus'
|
||||
? 'flex min-h-[420px] shrink-0 flex-col overflow-hidden border-b border-line bg-surface-2'
|
||||
: 'flex min-h-[280px] max-h-[420px] shrink-0 flex-col overflow-hidden border-b border-line bg-surface-2';
|
||||
const sectionHeight = callMode === 'focus' ? '75%' : '50%';
|
||||
const sectionHeight = stageLayout.kind === 'focus' ? '75%' : '50%';
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -567,23 +528,14 @@ export function InCallPanel({ conversation }: Props) {
|
||||
|
||||
<CallStage
|
||||
tiles={tiles}
|
||||
speaker={speaker}
|
||||
mode={callMode}
|
||||
speaker={bigTile}
|
||||
stageLayout={stageLayout}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={isE2EEActive}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversation.members}
|
||||
onFocusTile={(id) => {
|
||||
// Discord-style toggle: clicking the already-focused tile
|
||||
// collapses back to grid; clicking another tile swaps focus;
|
||||
// clicking any tile in grid mode focuses it.
|
||||
if (callMode === 'focus' && focusedId === id) {
|
||||
setFocusedId(null);
|
||||
setCallMode('grid');
|
||||
return;
|
||||
}
|
||||
setFocusedId(id);
|
||||
if (callMode === 'grid') setCallMode('focus');
|
||||
setFocusedId(focusedId === id ? null : id);
|
||||
}}
|
||||
onTileContextMenu={openTileContextMenu}
|
||||
compact
|
||||
@@ -601,9 +553,7 @@ export function InCallPanel({ conversation }: Props) {
|
||||
y={volumeMenu.y}
|
||||
pinned={focusedId === volumeMenu.tileId}
|
||||
onTogglePin={() => {
|
||||
const isPinned = focusedId === volumeMenu.tileId;
|
||||
setFocusedId(isPinned ? null : volumeMenu.tileId);
|
||||
if (!isPinned && callMode === 'grid') setCallMode('focus');
|
||||
setFocusedId(focusedId === volumeMenu.tileId ? null : volumeMenu.tileId);
|
||||
}}
|
||||
{...(volumeMenu.self ? { renderVolume: false } : {})}
|
||||
{...(volumeMenu.self
|
||||
@@ -666,8 +616,6 @@ export function InCallPanel({ conversation }: Props) {
|
||||
onClose={() => setStatsOverlayOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{captionsEnabled && <CallCaptionsOverlay conversation={conversation} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -869,9 +817,6 @@ function ModeToggles({
|
||||
<ModeButton active={mode === 'grid'} onClick={() => onChange('grid')} label="Grid">
|
||||
<GridIcon className="h-4 w-4" />
|
||||
</ModeButton>
|
||||
<ModeButton active={mode === 'focus'} onClick={() => onChange('focus')} label="Fokus">
|
||||
<FocusIcon className="h-4 w-4" />
|
||||
</ModeButton>
|
||||
<ModeButton
|
||||
active={mode === 'fullscreen'}
|
||||
onClick={() => onChange('fullscreen')}
|
||||
@@ -916,7 +861,12 @@ function ModeButton({
|
||||
interface StageProps {
|
||||
tiles: Tile[];
|
||||
speaker: Tile | undefined;
|
||||
mode: CallMode;
|
||||
/** Discriminated layout decision driven by InCallPanel's StageLayout
|
||||
* selector. Drives the bento-vs-grid-vs-focus render branch. */
|
||||
stageLayout:
|
||||
| { kind: 'equal-grid' }
|
||||
| { kind: 'focus'; bigTileId: string }
|
||||
| { kind: 'bento'; shareIds: string[] };
|
||||
activeSpeakers: Set<string>;
|
||||
e2ee: boolean;
|
||||
remoteScreenShares: {
|
||||
@@ -941,7 +891,9 @@ function TileRender({
|
||||
conversationMembers,
|
||||
size,
|
||||
focused,
|
||||
cinema,
|
||||
onClick,
|
||||
onDoubleClick,
|
||||
onContextMenu,
|
||||
}: {
|
||||
tile: Tile;
|
||||
@@ -951,7 +903,12 @@ function TileRender({
|
||||
conversationMembers: StageProps['conversationMembers'];
|
||||
size?: 'default' | 'small';
|
||||
focused?: boolean;
|
||||
/** True when rendered inside FullscreenCall's big-tile slot. Drives
|
||||
* chrome-suppression on the inner ScreenShareViewer so its toggle
|
||||
* doesn't visually collide with the cinema-mode strip-hidden button. */
|
||||
cinema?: boolean;
|
||||
onClick?: () => void;
|
||||
onDoubleClick?: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent) => void;
|
||||
}): JSX.Element {
|
||||
if (tile.kind === 'screen') {
|
||||
@@ -964,6 +921,7 @@ function TileRender({
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onContextMenu={onContextMenu}
|
||||
className={'h-full w-full [&>div]:h-full [&>div]:w-full ' + (onClick ? 'cursor-pointer' : '')}
|
||||
>
|
||||
@@ -971,6 +929,7 @@ function TileRender({
|
||||
share={share}
|
||||
avatarUrl={member?.profile?.avatarUrl ?? tile.avatarUrl}
|
||||
displayName={member?.profile?.displayName ?? tile.displayName}
|
||||
hideFullscreenToggle={cinema === true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -994,6 +953,7 @@ function TileRender({
|
||||
{...(size ? { size } : {})}
|
||||
{...(focused ? { focused } : {})}
|
||||
{...(onClick ? { onClick } : {})}
|
||||
{...(onDoubleClick ? { onDoubleClick } : {})}
|
||||
{...(onContextMenu ? { onContextMenu } : {})}
|
||||
/>
|
||||
);
|
||||
@@ -1002,7 +962,7 @@ function TileRender({
|
||||
function CallStage({
|
||||
tiles,
|
||||
speaker,
|
||||
mode,
|
||||
stageLayout,
|
||||
activeSpeakers,
|
||||
e2ee,
|
||||
remoteScreenShares,
|
||||
@@ -1011,7 +971,7 @@ function CallStage({
|
||||
onTileContextMenu,
|
||||
compact = false,
|
||||
}: StageProps) {
|
||||
if (mode === 'focus' && speaker) {
|
||||
if (stageLayout.kind === 'focus' && speaker) {
|
||||
const others = tiles.filter((p) => p.id !== speaker.id);
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2.5 p-3">
|
||||
@@ -1022,6 +982,7 @@ function CallStage({
|
||||
activeSpeakers={activeSpeakers}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
onDoubleClick={() => onFocusTile(speaker.id)}
|
||||
{...(onTileContextMenu
|
||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(speaker, e) }
|
||||
: {})}
|
||||
@@ -1032,7 +993,11 @@ function CallStage({
|
||||
{others.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="h-full w-[240px] shrink-0 [&>div]:h-full"
|
||||
// Docked strip: thumbs grow to share the row width evenly
|
||||
// (flex-1) but stay bounded so 1-2 tiles don't stretch into
|
||||
// 2:1 panoramas. Max-w cap keeps the visual rhythm aligned
|
||||
// with the share above; min-w keeps them readable when many.
|
||||
className="h-full flex-1 min-w-[200px] max-w-[460px] [&>div]:h-full [&>div]:w-full"
|
||||
>
|
||||
<TileRender
|
||||
tile={p}
|
||||
@@ -1054,13 +1019,91 @@ function CallStage({
|
||||
);
|
||||
}
|
||||
|
||||
// Grid
|
||||
if (stageLayout.kind === 'bento') {
|
||||
const shares = tiles.filter((t) => stageLayout.shareIds.includes(t.id));
|
||||
const webcams = tiles.filter((t) => !stageLayout.shareIds.includes(t.id));
|
||||
const bentoCols = gridColsFor(shares.length);
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2.5 p-3">
|
||||
<div className="min-h-0 flex-1">
|
||||
<div
|
||||
className={
|
||||
'grid h-full max-h-full gap-2 place-content-center ' + bentoCols
|
||||
}
|
||||
>
|
||||
{shares.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className="aspect-video min-h-0 w-full [&>div]:h-full [&>div]:w-full"
|
||||
>
|
||||
<TileRender
|
||||
tile={s}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={e2ee}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
onClick={() => onFocusTile(s.id)}
|
||||
{...(onTileContextMenu
|
||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(s, e) }
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{webcams.length > 0 && (
|
||||
<div className="flex h-[180px] gap-2.5 overflow-x-auto">
|
||||
{webcams.map((w) => (
|
||||
<div
|
||||
key={w.id}
|
||||
// Same docked-strip sizing as the focus branch above.
|
||||
className="h-full flex-1 min-w-[200px] max-w-[460px] [&>div]:h-full [&>div]:w-full"
|
||||
>
|
||||
<TileRender
|
||||
tile={w}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={e2ee}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
size="small"
|
||||
onClick={() => onFocusTile(w.id)}
|
||||
{...(onTileContextMenu
|
||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(w, e) }
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Grid (equal-grid fallthrough)
|
||||
const gridClass = gridColsFor(tiles.length);
|
||||
// aspect-video on every cell pushed the row past the section height on
|
||||
// wide chat panels — a single cell at full width forced height = width
|
||||
// × 9/16 (~400px on a 700px panel), which clipped the controls bar
|
||||
// below. Let cells fill grid tracks normally for n>=2, and only enforce
|
||||
// a 16:9 silhouette (capped width, centred) for the solo-user case.
|
||||
const isSolo = tiles.length === 1;
|
||||
return (
|
||||
<div className={'min-h-0 flex-1 p-3 ' + (compact ? '' : 'p-4')}>
|
||||
<div className={'grid h-full gap-2 ' + gridClass}>
|
||||
<div
|
||||
className={
|
||||
'grid h-full max-h-full gap-2 auto-rows-fr place-content-center ' +
|
||||
gridClass
|
||||
}
|
||||
>
|
||||
{tiles.map((p) => (
|
||||
<div key={p.id} className="[&>div]:h-full [&>div]:w-full">
|
||||
<div
|
||||
key={p.id}
|
||||
className={
|
||||
isSolo
|
||||
? 'aspect-video w-full max-w-[480px] justify-self-center [&>div]:h-full [&>div]:w-full'
|
||||
: 'min-h-0 min-w-0 [&>div]:h-full [&>div]:w-full'
|
||||
}
|
||||
>
|
||||
<TileRender
|
||||
tile={p}
|
||||
activeSpeakers={activeSpeakers}
|
||||
@@ -1086,6 +1129,7 @@ function FocusedTile({
|
||||
remoteScreenShares,
|
||||
conversationMembers,
|
||||
onContextMenu,
|
||||
onDoubleClick,
|
||||
}: {
|
||||
tile: Tile;
|
||||
e2ee: boolean;
|
||||
@@ -1093,6 +1137,7 @@ function FocusedTile({
|
||||
remoteScreenShares: StageProps['remoteScreenShares'];
|
||||
conversationMembers: StageProps['conversationMembers'];
|
||||
onContextMenu?: (e: React.MouseEvent) => void;
|
||||
onDoubleClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="h-full [&>div]:h-full">
|
||||
@@ -1104,6 +1149,7 @@ function FocusedTile({
|
||||
conversationMembers={conversationMembers}
|
||||
focused
|
||||
{...(onContextMenu ? { onContextMenu } : {})}
|
||||
{...(onDoubleClick ? { onDoubleClick } : {})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -1112,17 +1158,16 @@ function FocusedTile({
|
||||
const GRID_PAGE_SIZE = 12;
|
||||
|
||||
function gridColsFor(n: number): string {
|
||||
// Explicit `grid-rows-*` so cells get a defined height (1fr of available
|
||||
// space). Without this, implicit rows default to auto → they size to
|
||||
// content, and a video element's intrinsic size blows the tile past the
|
||||
// container bounds (overlapping the toolbar below).
|
||||
if (n <= 1) return 'grid-cols-1 grid-rows-1';
|
||||
if (n === 2) return 'grid-cols-2 grid-rows-1';
|
||||
if (n === 3) return 'grid-cols-3 grid-rows-1';
|
||||
if (n === 4) return 'grid-cols-2 grid-rows-2';
|
||||
if (n <= 6) return 'grid-cols-3 grid-rows-2';
|
||||
if (n <= 9) return 'grid-cols-3 grid-rows-3';
|
||||
return 'grid-cols-4 grid-rows-3';
|
||||
// Discord-style: column count only. Cells are `aspect-video` so their
|
||||
// height follows from their width, and the container centers them
|
||||
// vertically when the row stack is shorter than the available area.
|
||||
if (n <= 1) return 'grid-cols-1';
|
||||
if (n === 2) return 'grid-cols-2';
|
||||
if (n === 3) return 'grid-cols-3';
|
||||
if (n === 4) return 'grid-cols-2';
|
||||
if (n <= 6) return 'grid-cols-3';
|
||||
if (n <= 9) return 'grid-cols-3';
|
||||
return 'grid-cols-4';
|
||||
}
|
||||
|
||||
// Promote self + active speakers to the front of the tile list. Stable
|
||||
@@ -1216,12 +1261,25 @@ function FullscreenCall({
|
||||
const hasFocus = speaker !== undefined;
|
||||
const others = hasFocus ? tiles.filter((p) => p.id !== speaker!.id) : [];
|
||||
|
||||
const fsShareIds = tiles
|
||||
.filter((t) => t.kind === 'screen')
|
||||
.map((t) => t.id);
|
||||
const bentoMode = !hasFocus && fsShareIds.length >= 2;
|
||||
const bentoShares = bentoMode
|
||||
? tiles.filter((t) => fsShareIds.includes(t.id))
|
||||
: [];
|
||||
const bentoWebcams = bentoMode
|
||||
? tiles.filter((t) => !fsShareIds.includes(t.id))
|
||||
: [];
|
||||
|
||||
// Active-speaker reorder + paginate. When more than GRID_PAGE_SIZE tiles
|
||||
// exist, slice them into pages. Reset to page 0 if the page count drops
|
||||
// below the current page (someone left).
|
||||
// exist, slice them into pages and prioritize active speakers onto page 1.
|
||||
// Otherwise keep a stable order (Discord-style) so tiles don't shuffle
|
||||
// whenever someone speaks.
|
||||
const needsPagination = tiles.length > GRID_PAGE_SIZE;
|
||||
const sortedGridTiles = useMemo(
|
||||
() => prioritizeTiles(tiles, activeSpeakers),
|
||||
[tiles, activeSpeakers],
|
||||
() => (needsPagination ? prioritizeTiles(tiles, activeSpeakers) : tiles),
|
||||
[tiles, activeSpeakers, needsPagination],
|
||||
);
|
||||
const pageCount = Math.max(1, Math.ceil(sortedGridTiles.length / GRID_PAGE_SIZE));
|
||||
useEffect(() => {
|
||||
@@ -1235,14 +1293,18 @@ function FullscreenCall({
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex flex-col overflow-hidden bg-surface">
|
||||
{/* Content area. pb-24 reserves ~96px space at the bottom for the
|
||||
floating controls bar so tiles never sit behind it. */}
|
||||
<div className="relative flex min-h-0 flex-1 flex-col pb-24">
|
||||
{/* Content area. pb-28 reserves ~112px space at the bottom for the
|
||||
floating controls bar plus extra clearance so the tiles' bottom
|
||||
name-chip (positioned `bottom-2` inside each tile) doesn't sit
|
||||
directly underneath the controls — pb-24 was tight enough that
|
||||
on wide screens with audio-only avatars the chip got eclipsed. */}
|
||||
<div className="relative flex min-h-0 flex-1 flex-col pb-28">
|
||||
{hasFocus ? (
|
||||
<>
|
||||
<div
|
||||
className="relative min-h-0 flex-1 cursor-pointer p-4 pb-2 [&>div]:h-full [&>div]:w-full"
|
||||
onClick={() => onFocusTile(speaker!.id)}
|
||||
onDoubleClick={() => onFocusTile(speaker!.id)}
|
||||
title="Zurück zur Übersicht"
|
||||
>
|
||||
<TileRender
|
||||
@@ -1252,6 +1314,7 @@ function FullscreenCall({
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
focused
|
||||
cinema
|
||||
{...(onTileContextMenu
|
||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(speaker!, e) }
|
||||
: {})}
|
||||
@@ -1262,7 +1325,7 @@ function FullscreenCall({
|
||||
{others.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="h-full w-[220px] shrink-0 [&>div]:h-full [&>div]:w-full"
|
||||
className="aspect-video h-full shrink-0 [&>div]:h-full [&>div]:w-full"
|
||||
>
|
||||
<TileRender
|
||||
tile={p}
|
||||
@@ -1281,24 +1344,86 @@ function FullscreenCall({
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : bentoMode ? (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2 p-4">
|
||||
<div className="min-h-0 flex-1">
|
||||
<div className={'grid h-full max-h-full gap-2 place-content-center ' + gridColsFor(bentoShares.length)}>
|
||||
{bentoShares.map((s) => (
|
||||
<div key={s.id} className="aspect-video min-h-0 w-full [&>div]:h-full [&>div]:w-full">
|
||||
<TileRender
|
||||
tile={s}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={e2ee}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
onClick={() => onFocusTile(s.id)}
|
||||
{...(onTileContextMenu
|
||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(s, e) }
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{bentoWebcams.length > 0 && (
|
||||
<div className="flex h-[180px] gap-2 overflow-x-auto">
|
||||
{bentoWebcams.map((w) => (
|
||||
<div key={w.id} className="aspect-video h-full shrink-0 [&>div]:h-full [&>div]:w-full">
|
||||
<TileRender
|
||||
tile={w}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={e2ee}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
size="small"
|
||||
onClick={() => onFocusTile(w.id)}
|
||||
{...(onTileContextMenu
|
||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(w, e) }
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-h-0 flex-1 p-4">
|
||||
<div className={'grid h-full gap-2 ' + gridClass}>
|
||||
{visibleTiles.map((p) => (
|
||||
<div key={p.id} className="min-h-0 min-w-0 [&>div]:h-full [&>div]:w-full">
|
||||
<TileRender
|
||||
tile={p}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={e2ee}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
onClick={() => onFocusTile(p.id)}
|
||||
{...(onTileContextMenu
|
||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
className={
|
||||
'grid h-full max-h-full gap-2 auto-rows-fr place-content-center ' +
|
||||
gridClass
|
||||
}
|
||||
>
|
||||
{visibleTiles.map((p) => {
|
||||
const isSolo = visibleTiles.length === 1;
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
// Same fix as docked equal-grid: aspect-video w-full on
|
||||
// wide screens pushed cell height to ~ width × 9/16,
|
||||
// which dragged the tile's bottom name-chip down behind
|
||||
// the floating controls bar. For n>=2 fill the grid
|
||||
// tracks normally; for solo, cap width + centre.
|
||||
className={
|
||||
isSolo
|
||||
? 'aspect-video w-full max-w-[720px] justify-self-center [&>div]:h-full [&>div]:w-full'
|
||||
: 'min-h-0 min-w-0 [&>div]:h-full [&>div]:w-full'
|
||||
}
|
||||
>
|
||||
<TileRender
|
||||
tile={p}
|
||||
activeSpeakers={activeSpeakers}
|
||||
e2ee={e2ee}
|
||||
remoteScreenShares={remoteScreenShares}
|
||||
conversationMembers={conversationMembers}
|
||||
onClick={() => onFocusTile(p.id)}
|
||||
{...(onTileContextMenu
|
||||
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{pageCount > 1 && (
|
||||
<div className="mt-2 flex items-center justify-center gap-3 text-xs text-fg-muted">
|
||||
@@ -1387,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"
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { XIcon } from './icons';
|
||||
|
||||
// Fullscreen image viewer. Backdrop click + Esc close. Originally lived
|
||||
// inside AttachmentImage.tsx as a file-private component; extracted here
|
||||
// so other surfaces (settings avatar preview, future profile popover,
|
||||
// etc.) can reuse the exact same dialog without duplicating the chrome.
|
||||
//
|
||||
// The image itself stops click propagation so a click on the picture
|
||||
// keeps the lightbox open — only the backdrop or the explicit close
|
||||
// button dismisses.
|
||||
export function Lightbox({ url, onClose }: { url: string; onClose: () => void }) {
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}
|
||||
document.addEventListener('keydown', onKey);
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Bildansicht"
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-ink-950/90 p-6 backdrop-blur-sm animate-fade-in"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Schließen"
|
||||
className="absolute right-4 top-4 flex h-10 w-10 cursor-pointer items-center justify-center rounded-full border border-white/10 bg-ink-900/80 text-neutral-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
|
||||
>
|
||||
<XIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="max-h-[92vh] max-w-[92vw] rounded-xl object-contain shadow-2xl"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,7 +31,7 @@ export function MediaFilesDrawer({ open, index, senderNameFor, onJumpToMessage,
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<aside className="absolute inset-y-0 right-0 z-40 flex w-full max-w-[380px] flex-col border-l border-line bg-surface-2 shadow-2xl dark:bg-[#2b2d31]">
|
||||
<aside className="flex w-[380px] shrink-0 flex-col border-l border-line bg-surface-2 dark:bg-[#2b2d31]">
|
||||
<header className="flex min-h-[65px] items-center gap-3 border-b border-line px-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-accent/10 text-accent">
|
||||
<ImageIcon className="h-5 w-5" />
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useNickname } from '../lib/friendNicknames';
|
||||
import { Avatar } from './Avatar';
|
||||
|
||||
type Member = ConversationSummary['members'][number];
|
||||
|
||||
interface Props {
|
||||
members: ConversationSummary['members'];
|
||||
query: string;
|
||||
@@ -63,37 +66,58 @@ export function MentionAutocomplete({ members, query, excludeUserId, onSelect, o
|
||||
aria-label="Mitglieder"
|
||||
className="absolute bottom-full left-0 right-0 z-20 mb-2 max-h-64 overflow-y-auto rounded-xl border border-line bg-surface-2 p-1 shadow-xl dark:bg-[#2b2d31]"
|
||||
>
|
||||
{matches.map((m, idx) => {
|
||||
const name = m.profile?.displayName ?? m.profile?.username ?? '?';
|
||||
const handle = m.profile?.username ?? '';
|
||||
const isActive = idx === active;
|
||||
return (
|
||||
<button
|
||||
key={m.userId}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isActive}
|
||||
onMouseEnter={() => setActive(idx)}
|
||||
onClick={() => {
|
||||
if (m.profile?.username) onSelect(m.profile.username);
|
||||
}}
|
||||
className={
|
||||
'flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ' +
|
||||
(isActive
|
||||
? 'bg-accent/20 text-fg'
|
||||
: 'text-fg-muted hover:bg-surface-3 dark:hover:bg-[#383a40]')
|
||||
}
|
||||
>
|
||||
<Avatar
|
||||
displayName={name}
|
||||
url={m.profile?.avatarUrl ?? null}
|
||||
className="h-6 w-6 text-[10px]"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-fg">{name}</span>
|
||||
<span className="shrink-0 text-[10px] text-fg-muted">@{handle}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{matches.map((m, idx) => (
|
||||
<MemberRow
|
||||
key={m.userId}
|
||||
member={m}
|
||||
isActive={idx === active}
|
||||
onActivate={() => setActive(idx)}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Extracted per-row component so we can call `useNickname` once per member
|
||||
// at the top of THIS component (hooks can't run inside .map callbacks).
|
||||
function MemberRow({
|
||||
member,
|
||||
isActive,
|
||||
onActivate,
|
||||
onSelect,
|
||||
}: {
|
||||
member: Member;
|
||||
isActive: boolean;
|
||||
onActivate: () => void;
|
||||
onSelect: (username: string) => void;
|
||||
}) {
|
||||
const fallback = member.profile?.displayName ?? member.profile?.username ?? '?';
|
||||
const name = useNickname(member.userId, fallback);
|
||||
const handle = member.profile?.username ?? '';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isActive}
|
||||
onMouseEnter={onActivate}
|
||||
onClick={() => {
|
||||
if (member.profile?.username) onSelect(member.profile.username);
|
||||
}}
|
||||
className={
|
||||
'flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ' +
|
||||
(isActive
|
||||
? 'bg-accent/20 text-fg'
|
||||
: 'text-fg-muted hover:bg-surface-3 dark:hover:bg-[#383a40]')
|
||||
}
|
||||
>
|
||||
<Avatar
|
||||
displayName={name}
|
||||
url={member.profile?.avatarUrl ?? null}
|
||||
className="h-6 w-6 text-[10px]"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-fg">{name}</span>
|
||||
<span className="shrink-0 text-[10px] text-fg-muted">@{handle}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
|
||||
import {
|
||||
type DecryptedMessage,
|
||||
editEncryptedMessage,
|
||||
@@ -6,13 +5,15 @@ 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 { devLocalSecretStore } from '../lib/secretStore';
|
||||
import { useNickname } from '../lib/friendNicknames';
|
||||
import { ensureInstallId } from '../lib/installId';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { cachedUserKey } from '../lib/userIdentity';
|
||||
import type { AggregatedReaction } from '../lib/useMessageReactions';
|
||||
import { summarizePollVotes } from '../lib/conversationFeatures';
|
||||
import { extractFirstUrl } from '../lib/useLinkPreview';
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
PencilIcon,
|
||||
PhoneIcon,
|
||||
PhoneOffIcon,
|
||||
PinIcon,
|
||||
ReplyIcon,
|
||||
SmileIcon,
|
||||
SpinnerIcon,
|
||||
@@ -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,9 +111,17 @@ export function MessageBubble({
|
||||
onForward,
|
||||
onAvatarClick,
|
||||
highlighted = false,
|
||||
isPinned = false,
|
||||
onTogglePin,
|
||||
}: Props) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { session, device } = useAuth();
|
||||
const { session } = useAuth();
|
||||
|
||||
// Per-viewer nickname override for the message sender. Fallback chain
|
||||
// keeps the existing behavior when no nickname is set. Skipped for the
|
||||
// quoted-sender snippet — that's a different user.
|
||||
const senderName = useNickname(message.senderId, senderDisplayName ?? '');
|
||||
const resolvedSenderDisplayName = senderName.length > 0 ? senderName : null;
|
||||
|
||||
const parsed = parseMessagePayload(message.plaintext);
|
||||
const initialText = parsed.kind === 'text' ? parsed.text : '';
|
||||
@@ -191,7 +214,7 @@ export function MessageBubble({
|
||||
}, [pickerOpen]);
|
||||
|
||||
const handleEditSave = useCallback(async () => {
|
||||
if (!session || !device) return;
|
||||
if (!session) return;
|
||||
const trimmed = editText.trim();
|
||||
if (!trimmed || trimmed === bodyText) {
|
||||
setEditing(false);
|
||||
@@ -201,15 +224,15 @@ export function MessageBubble({
|
||||
setBusy(true);
|
||||
setEditError(null);
|
||||
try {
|
||||
const priv = await loadDevicePrivateKey(devLocalSecretStore, session.user.id, device.id);
|
||||
if (!priv) throw new Error('private key not loaded');
|
||||
const priv = await cachedUserKey(session.user.id);
|
||||
if (!priv) throw new Error('user key not unlocked');
|
||||
await editEncryptedMessage({
|
||||
client: supabase,
|
||||
messageId: message.id,
|
||||
conversationId,
|
||||
newPlaintext: trimmed,
|
||||
senderUserId: session.user.id,
|
||||
senderDeviceId: device.id,
|
||||
senderDeviceId: ensureInstallId(),
|
||||
senderPrivateKey: priv,
|
||||
});
|
||||
setEditing(false);
|
||||
@@ -225,7 +248,7 @@ export function MessageBubble({
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [editText, message.id, message.plaintext, conversationId, session, device, t]);
|
||||
}, [editText, message.id, message.plaintext, conversationId, session, t]);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (busy) return;
|
||||
@@ -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) }
|
||||
: {})}
|
||||
@@ -294,7 +317,14 @@ export function MessageBubble({
|
||||
}
|
||||
|
||||
if (parsed.kind === 'call_event') {
|
||||
return <CallEventRow parsed={parsed} mine={mine} time={time} />;
|
||||
return (
|
||||
<CallEventRow
|
||||
parsed={parsed}
|
||||
mine={mine}
|
||||
time={time}
|
||||
senderDisplayName={resolvedSenderDisplayName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -308,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) }
|
||||
: {})}
|
||||
@@ -409,7 +439,96 @@ export function MessageBubble({
|
||||
</button>
|
||||
)}
|
||||
{message.plaintext === null ? (
|
||||
<span className="italic opacity-70">…cannot decrypt</span>
|
||||
<span
|
||||
className={
|
||||
'italic ' +
|
||||
// Mine = blue/accent bubble → use accent-fg with reduced opacity
|
||||
// (still meets 4.5:1). Peer = surface-2 grey → muted-fg works.
|
||||
(mine ? 'text-accent-fg/80' : 'text-fg-muted')
|
||||
}
|
||||
>
|
||||
{t('app:chats.unreadable', {
|
||||
defaultValue: 'Nachricht nicht lesbar',
|
||||
})}
|
||||
</span>
|
||||
) : parsed.kind === '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}
|
||||
@@ -417,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)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
@@ -433,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} />;
|
||||
@@ -483,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
|
||||
@@ -617,6 +738,15 @@ export function MessageBubble({
|
||||
setContextMenu(null);
|
||||
void handleDelete();
|
||||
}}
|
||||
isPinned={isPinned}
|
||||
{...(onTogglePin
|
||||
? {
|
||||
onTogglePin: () => {
|
||||
setContextMenu(null);
|
||||
onTogglePin(message.id);
|
||||
},
|
||||
}
|
||||
: {})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -624,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,
|
||||
@@ -714,6 +852,8 @@ function MessageContextMenu({
|
||||
onEdit,
|
||||
onCopy,
|
||||
onDelete,
|
||||
isPinned,
|
||||
onTogglePin,
|
||||
}: {
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -728,6 +868,8 @@ function MessageContextMenu({
|
||||
onEdit: () => void;
|
||||
onCopy: () => void;
|
||||
onDelete: () => void;
|
||||
isPinned?: boolean;
|
||||
onTogglePin?: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
@@ -794,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,
|
||||
);
|
||||
@@ -866,10 +1015,15 @@ function CallEventRow({
|
||||
parsed,
|
||||
mine,
|
||||
time,
|
||||
senderDisplayName,
|
||||
}: {
|
||||
parsed: { status: string; mediaKind: string; durationSec: number };
|
||||
mine: boolean;
|
||||
time: string;
|
||||
/** Discord-parity: in group chats the system pill should say WHO
|
||||
* started/missed the call. Null means we don't know (fall back to the
|
||||
* legacy generic labels). */
|
||||
senderDisplayName: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const status = parsed.status;
|
||||
@@ -880,16 +1034,36 @@ function CallEventRow({
|
||||
? 'border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-200'
|
||||
: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-200';
|
||||
|
||||
// For non-own events we prefer the name-aware label so group chats
|
||||
// make it clear who triggered the call event. Own events stay generic
|
||||
// ("Outgoing call" / "No answer") since the user already knows they
|
||||
// were the initiator.
|
||||
const hasName = !mine && !!senderDisplayName;
|
||||
const label =
|
||||
status === 'ended'
|
||||
? mine
|
||||
? t('app:chats.call_outgoing', { defaultValue: 'Outgoing call' })
|
||||
: t('app:chats.call_incoming', { defaultValue: 'Incoming call' })
|
||||
: hasName
|
||||
? t('app:chats.call_started_by', {
|
||||
name: senderDisplayName,
|
||||
defaultValue: '{{name}} hat einen Anruf gestartet',
|
||||
})
|
||||
: t('app:chats.call_incoming', { defaultValue: 'Incoming call' })
|
||||
: status === 'missed'
|
||||
? mine
|
||||
? t('app:chats.call_no_answer', { defaultValue: 'No answer' })
|
||||
: t('app:chats.call_missed', { defaultValue: 'Missed call' })
|
||||
: t('app:chats.call_declined', { defaultValue: 'Call declined' });
|
||||
: hasName
|
||||
? t('app:chats.call_missed_by', {
|
||||
name: senderDisplayName,
|
||||
defaultValue: 'Verpasster Anruf von {{name}}',
|
||||
})
|
||||
: t('app:chats.call_missed', { defaultValue: 'Missed call' })
|
||||
: hasName
|
||||
? t('app:chats.call_declined_by', {
|
||||
name: senderDisplayName,
|
||||
defaultValue: 'Anruf von {{name}} abgelehnt',
|
||||
})
|
||||
: t('app:chats.call_declined', { defaultValue: 'Call declined' });
|
||||
|
||||
const duration = parsed.durationSec > 0 ? formatDuration(parsed.durationSec) : null;
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { getNickname, setNickname } from '../lib/friendNicknames';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
userId: string;
|
||||
displayName: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function NicknameDialog({ open, userId, displayName, onClose }: Props) {
|
||||
const [value, setValue] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setValue(getNickname(userId) ?? '');
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}, [open, userId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const submit = (): void => {
|
||||
setNickname(userId, value);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Spitzname setzen"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-full max-w-sm rounded-2xl border border-line bg-surface-2 p-5 shadow-xl"
|
||||
>
|
||||
<h3 className="font-display text-base font-semibold text-fg">Spitzname für {displayName}</h3>
|
||||
<p className="mt-1 text-xs text-fg-muted">
|
||||
Nur du siehst diesen Namen. Leer lassen = den richtigen Namen verwenden.
|
||||
</p>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') submit(); }}
|
||||
maxLength={32}
|
||||
placeholder={displayName}
|
||||
className="mt-4 w-full rounded-lg border border-line bg-surface-3 px-3 py-2 text-sm text-fg placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
|
||||
/>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg hover:bg-surface-2"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
className="cursor-pointer rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg hover:brightness-110"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
length?: number;
|
||||
autoFocus?: boolean;
|
||||
disabled?: boolean;
|
||||
ariaLabel: string;
|
||||
onSubmit?: () => void;
|
||||
}
|
||||
|
||||
export function PinInput({ value, onChange, length = 6, autoFocus, disabled, ariaLabel, onSubmit }: Props) {
|
||||
const ref = useRef<HTMLInputElement | null>(null);
|
||||
const [focused, setFocused] = useState(false);
|
||||
useEffect(() => { if (autoFocus) ref.current?.focus(); }, [autoFocus]);
|
||||
// Index of the next empty slot the next keystroke will fill. When the user
|
||||
// has typed all `length` digits, no slot is "active" — the form should
|
||||
// submit instead of pretending one is still focused.
|
||||
const activeIndex = value.length < length ? value.length : -1;
|
||||
return (
|
||||
<div className="relative flex justify-center" onClick={() => ref.current?.focus()}>
|
||||
<input
|
||||
ref={ref}
|
||||
aria-label={ariaLabel}
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
pattern="\d*"
|
||||
maxLength={length}
|
||||
disabled={disabled}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value.replace(/\D/g, '').slice(0, length))}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && value.length === length) onSubmit?.(); }}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
className="absolute h-px w-px overflow-hidden p-0 opacity-0"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
{Array.from({ length }).map((_, i) => {
|
||||
const filled = i < value.length;
|
||||
const active = focused && i === activeIndex;
|
||||
let classes =
|
||||
'flex h-12 w-10 items-center justify-center rounded-lg border text-lg font-semibold transition ';
|
||||
if (filled) {
|
||||
classes += 'border-brand-400 bg-brand-500/10 text-white';
|
||||
} else if (active) {
|
||||
// Brand-coloured ring + glow so the user immediately sees where
|
||||
// the next keystroke lands. The animated cursor inside reinforces
|
||||
// the "input is alive" feeling.
|
||||
classes += 'border-brand-400 bg-brand-500/10 text-brand-300 ring-2 ring-brand-400/40 shadow-[0_0_12px_-2px] shadow-brand-500/40';
|
||||
} else {
|
||||
classes += 'border-white/10 bg-ink-800 text-neutral-500';
|
||||
}
|
||||
return (
|
||||
<span key={i} className={classes}>
|
||||
{filled ? '•' : active ? <span className="animate-pulse">|</span> : ''}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { PinnedMessage } from '@chat-app/shared/chat';
|
||||
|
||||
import { PinIcon, XIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
pins: PinnedMessage[];
|
||||
onClose: () => void;
|
||||
onJump: (messageId: string) => void;
|
||||
onUnpin: (messageId: string) => void;
|
||||
// Optional preview-renderer: parent resolves messageId → short text/snippet
|
||||
// since the panel itself doesn't decrypt. If absent, the panel just shows
|
||||
// the message-id stub.
|
||||
renderPreview?: (messageId: string) => React.ReactNode;
|
||||
}
|
||||
|
||||
export function PinnedMessagesPanel({ open, pins, onClose, onJump, onUnpin, renderPreview }: Props) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<aside
|
||||
role="complementary"
|
||||
aria-label="Angepinnte Nachrichten"
|
||||
className="absolute right-0 top-0 z-30 flex h-full w-80 flex-col border-l border-line bg-surface-2 shadow-xl"
|
||||
>
|
||||
<header className="flex items-center justify-between border-b border-line px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<PinIcon className="h-4 w-4 text-accent" />
|
||||
<h3 className="text-sm font-semibold text-fg">Angepinnt · {pins.length}</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Schließen"
|
||||
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
{pins.length === 0 ? (
|
||||
<p className="p-6 text-center text-xs text-fg-muted">Noch nichts angepinnt.</p>
|
||||
) : (
|
||||
<ul className="flex-1 overflow-y-auto">
|
||||
{pins.map((p) => (
|
||||
<li key={p.messageId} className="border-b border-line/60 px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onJump(p.messageId)}
|
||||
className="block w-full cursor-pointer text-left text-sm text-fg hover:text-accent"
|
||||
>
|
||||
{renderPreview ? renderPreview(p.messageId) : <span className="font-mono text-xs">{p.messageId.slice(0, 8)}</span>}
|
||||
</button>
|
||||
<div className="mt-1 flex items-center justify-between text-[11px] text-fg-muted">
|
||||
<time dateTime={p.pinnedAt}>{new Date(p.pinnedAt).toLocaleString()}</time>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUnpin(p.messageId)}
|
||||
className="cursor-pointer text-rose-400 hover:underline"
|
||||
>
|
||||
Anheftung entfernen
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { PinIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
count: number;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
// Compact chip rendered in the conv header that opens the pinned panel.
|
||||
// Renders nothing when count is 0 so a fresh conv shows no clutter.
|
||||
export function PinnedMessagesPill({ count, onClick }: Props) {
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-full border border-line bg-surface-3 px-2.5 text-xs font-medium text-fg-muted transition hover:bg-surface-2 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
title="Angepinnte Nachrichten anzeigen"
|
||||
>
|
||||
<PinIcon className="h-3 w-3 text-accent" />
|
||||
<span>{count} angepinnt</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
export function RemoteRevokedScreen() {
|
||||
const { t } = useTranslation();
|
||||
const { revokedRemotely, acknowledgeRevocation } = useAuth();
|
||||
|
||||
if (!revokedRemotely) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="remote-revoked-title"
|
||||
className="fixed inset-0 z-[1000] flex items-center justify-center bg-ink-950/95 p-6"
|
||||
>
|
||||
<div className="max-w-sm rounded-2xl border border-line bg-surface-2 p-6 text-center shadow-xl">
|
||||
<h2
|
||||
id="remote-revoked-title"
|
||||
className="mb-2 font-display text-xl font-semibold text-fg"
|
||||
>
|
||||
{t('app:auth.revoked_title', { defaultValue: 'Du wurdest remote abgemeldet' })}
|
||||
</h2>
|
||||
<p className="mb-5 text-sm text-fg-muted">
|
||||
{t('app:auth.revoked_body', {
|
||||
defaultValue:
|
||||
'Ein anderes deiner Geräte hat diesen Login beendet. Aus Sicherheitsgründen wurden alle lokalen Daten gelöscht.',
|
||||
})}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={acknowledgeRevocation}
|
||||
className="inline-flex cursor-pointer items-center justify-center rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
{t('app:auth.revoked_acknowledge', { defaultValue: 'Verstanden' })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -26,13 +26,65 @@ const TABS = [
|
||||
];
|
||||
type TabId = (typeof TABS)[number]['id'];
|
||||
|
||||
const QUALITY_PILLS: { id: ScreenSharePreset; label: string }[] = [
|
||||
// Resolution and framerate are picked independently. The preset table
|
||||
// (screenShareSettings.ts) still provides the per-tier bitrate/dimension
|
||||
// caps, so we map (res, fps) → existing preset and rely on
|
||||
// `framerateOverride` for the non-default framerate combinations
|
||||
// (e.g. 1440p · 30, 4K · 30).
|
||||
type ResChoice = 'auto' | '720p' | '1080p' | '1440p' | '4k';
|
||||
type FpsChoice = 30 | 60;
|
||||
|
||||
const RES_PILLS: { id: ResChoice; label: string }[] = [
|
||||
{ id: 'auto', label: 'Auto' },
|
||||
{ id: '720p60', label: '720p · 60' },
|
||||
{ id: '1080p60', label: '1080p · 60' },
|
||||
{ id: '1440p60', label: '1440p · 60' },
|
||||
{ id: '720p', label: '720p' },
|
||||
{ id: '1080p', label: '1080p' },
|
||||
{ id: '1440p', label: '1440p' },
|
||||
{ id: '4k', label: '4K' },
|
||||
];
|
||||
|
||||
const FPS_PILLS: { id: FpsChoice; label: string }[] = [
|
||||
{ id: 30, label: '30 fps' },
|
||||
{ id: 60, label: '60 fps' },
|
||||
];
|
||||
|
||||
function presetForResFps(res: ResChoice, fps: FpsChoice): ScreenSharePreset {
|
||||
switch (res) {
|
||||
case 'auto':
|
||||
return 'auto';
|
||||
case '720p':
|
||||
return fps === 60 ? '720p60' : '720p30';
|
||||
case '1080p':
|
||||
return fps === 60 ? '1080p60' : '1080p30';
|
||||
case '1440p':
|
||||
return '1440p60';
|
||||
case '4k':
|
||||
return '4k60';
|
||||
}
|
||||
}
|
||||
|
||||
function decomposePreset(
|
||||
p: ScreenSharePreset,
|
||||
framerateOverride: number | null,
|
||||
): { res: ResChoice; fps: FpsChoice } {
|
||||
const fallback: FpsChoice = framerateOverride === 30 ? 30 : 60;
|
||||
switch (p) {
|
||||
case 'auto':
|
||||
return { res: 'auto', fps: framerateOverride === 60 ? 60 : 30 };
|
||||
case '720p30':
|
||||
return { res: '720p', fps: 30 };
|
||||
case '720p60':
|
||||
return { res: '720p', fps: 60 };
|
||||
case '1080p30':
|
||||
return { res: '1080p', fps: 30 };
|
||||
case '1080p60':
|
||||
return { res: '1080p', fps: 60 };
|
||||
case '1440p60':
|
||||
return { res: '1440p', fps: fallback };
|
||||
case '4k60':
|
||||
return { res: '4k', fps: fallback };
|
||||
}
|
||||
}
|
||||
|
||||
const THUMBNAIL_REFRESH_MS = 3500;
|
||||
|
||||
export function ScreenSharePickerModal({ onClose }: Props) {
|
||||
@@ -43,9 +95,14 @@ export function ScreenSharePickerModal({ onClose }: Props) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
const [preset, setPreset] = useState<ScreenSharePreset>(
|
||||
() => getScreenShareSettings().preset,
|
||||
);
|
||||
const [res, setRes] = useState<ResChoice>(() => {
|
||||
const s = getScreenShareSettings();
|
||||
return decomposePreset(s.preset, s.framerateOverride).res;
|
||||
});
|
||||
const [fps, setFps] = useState<FpsChoice>(() => {
|
||||
const s = getScreenShareSettings();
|
||||
return decomposePreset(s.preset, s.framerateOverride).fps;
|
||||
});
|
||||
const [audio, setAudio] = useState<boolean>(
|
||||
() => getScreenShareSettings().includeSystemAudio,
|
||||
);
|
||||
@@ -109,13 +166,14 @@ export function ScreenSharePickerModal({ onClose }: Props) {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
// Persist quality + audio toggle. Also force-clear any stale duck
|
||||
// setting users may have inherited from earlier builds — the
|
||||
// native loopback addon excludes the app's own audio at OS level
|
||||
// now, so JS-side ducking (which muted the user's incoming peer
|
||||
// audio) is no longer needed and was causing "I can't hear anyone".
|
||||
const preset = presetForResFps(res, fps);
|
||||
// Persist resolution + fps + audio. The duck flag is force-cleared
|
||||
// because the native loopback addon now excludes the app's own audio
|
||||
// at OS level; JS-side ducking (which also muted incoming peer audio)
|
||||
// was causing "I can't hear anyone" on earlier builds.
|
||||
updateScreenShareSettings({
|
||||
preset,
|
||||
framerateOverride: fps,
|
||||
includeSystemAudio: audio,
|
||||
duckRemoteAudioWhileSharing: false,
|
||||
});
|
||||
@@ -125,7 +183,7 @@ export function ScreenSharePickerModal({ onClose }: Props) {
|
||||
await startScreenShare({
|
||||
preset,
|
||||
displaySurface: tab === 'screen' ? 'monitor' : 'window',
|
||||
framerate: null,
|
||||
framerate: fps,
|
||||
// Forward the picked source id so the native loopback path can
|
||||
// switch into INCLUDE_TARGET_PROCESS_TREE for window-shares
|
||||
// (parses HWND from `window:<HWND>:0`). For screen-shares this
|
||||
@@ -271,16 +329,42 @@ export function ScreenSharePickerModal({ onClose }: Props) {
|
||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-fg-muted">
|
||||
Qualität
|
||||
Auflösung
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
{QUALITY_PILLS.map((q) => {
|
||||
const active = preset === q.id;
|
||||
{RES_PILLS.map((q) => {
|
||||
const active = res === q.id;
|
||||
return (
|
||||
<button
|
||||
key={q.id}
|
||||
type="button"
|
||||
onClick={() => setPreset(q.id)}
|
||||
onClick={() => setRes(q.id)}
|
||||
className={
|
||||
'cursor-pointer rounded-md border px-2.5 py-1 text-[11px] font-medium transition focus:outline-none ' +
|
||||
(active
|
||||
? 'border-accent bg-accent/10 text-fg'
|
||||
: 'border-line bg-surface-3 text-fg-muted hover:text-fg')
|
||||
}
|
||||
>
|
||||
{q.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-fg-muted">
|
||||
FPS
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
{FPS_PILLS.map((q) => {
|
||||
const active = fps === q.id;
|
||||
return (
|
||||
<button
|
||||
key={q.id}
|
||||
type="button"
|
||||
onClick={() => setFps(q.id)}
|
||||
className={
|
||||
'cursor-pointer rounded-md border px-2.5 py-1 text-[11px] font-medium transition focus:outline-none ' +
|
||||
(active
|
||||
|
||||
@@ -9,6 +9,11 @@ interface ScreenShareViewerProps {
|
||||
share: RemoteScreenShare;
|
||||
avatarUrl: string | null;
|
||||
displayName: string;
|
||||
/** Suppress the in-share fullscreen toggle button. Used inside cinema
|
||||
* mode where (a) the window is already OS-fullscreen and (b) the
|
||||
* toggle collides visually with FullscreenCall's strip-hidden button
|
||||
* at the same top-right corner. */
|
||||
hideFullscreenToggle?: boolean;
|
||||
}
|
||||
|
||||
// Click-to-watch gate, live <video> attach/detach, native fullscreen toggle.
|
||||
@@ -21,6 +26,7 @@ export function ScreenShareViewer({
|
||||
share,
|
||||
avatarUrl,
|
||||
displayName,
|
||||
hideFullscreenToggle = false,
|
||||
}: ScreenShareViewerProps) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
@@ -76,7 +82,7 @@ export function ScreenShareViewer({
|
||||
defaultValue: displayName + ' teilt den Bildschirm',
|
||||
})}
|
||||
</span>
|
||||
{watching && (
|
||||
{watching && !hideFullscreenToggle && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleFullscreen}
|
||||
@@ -110,8 +116,7 @@ export function ScreenShareViewer({
|
||||
type="button"
|
||||
onClick={() => watchShare(share.participantId)}
|
||||
aria-label={t('app:call.watch_screen', { defaultValue: 'Bildschirm anschauen' })}
|
||||
className="group relative block w-full cursor-pointer overflow-hidden bg-ink-900 focus:outline-none"
|
||||
style={{ aspectRatio: '16 / 9' }}
|
||||
className="group relative block h-full w-full flex-1 cursor-pointer overflow-hidden bg-ink-900 focus:outline-none"
|
||||
>
|
||||
<BlurredTile avatarUrl={avatarUrl} letter={letter} />
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/30 transition group-hover:bg-black/40">
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
type AutoLockMinutes,
|
||||
getAutoLockMinutes,
|
||||
notifyAutoLockChanged,
|
||||
setAutoLockMinutes,
|
||||
} from '../lib/autoLockSettings';
|
||||
import { isWipeOnCloseEnabled, setWipeOnClose } from '../lib/memoryWipeSettings';
|
||||
import {
|
||||
changePin,
|
||||
type LegacyMigrationReport,
|
||||
regenerateRecoveryCode,
|
||||
resetIdentity,
|
||||
retryLegacyMigration,
|
||||
} from '../lib/userIdentity';
|
||||
import { PinInput } from './PinInput';
|
||||
import { ShieldIcon, SpinnerIcon } from './icons';
|
||||
|
||||
interface Props { userId: string }
|
||||
|
||||
export function SecurityCenter({ userId }: Props) {
|
||||
const [pinOld, setPinOld] = useState('');
|
||||
const [pinNew, setPinNew] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [recovery, setRecovery] = useState<string | null>(null);
|
||||
const [migration, setMigration] = useState<LegacyMigrationReport | null>(null);
|
||||
const [wipeOnClose, setWipeOnCloseState] = useState<boolean>(() => isWipeOnCloseEnabled());
|
||||
const [autoLockMinutes, setAutoLockMinutesState] = useState<AutoLockMinutes>(() => getAutoLockMinutes());
|
||||
|
||||
async function handleRetryMigration() {
|
||||
setBusy(true); setMsg(null); setMigration(null);
|
||||
try {
|
||||
const report = await retryLegacyMigration(userId);
|
||||
setMigration(report);
|
||||
} catch (err) {
|
||||
setMsg(err instanceof Error ? err.message : String(err));
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function handleChangePin() {
|
||||
setBusy(true); setMsg(null);
|
||||
try {
|
||||
await changePin({ userId, oldPin: pinOld, newPin: pinNew });
|
||||
setMsg('PIN geändert.'); setPinOld(''); setPinNew('');
|
||||
} catch (err) {
|
||||
setMsg(err instanceof Error ? err.message : String(err));
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function handleRegenerateRecovery() {
|
||||
setBusy(true); setMsg(null);
|
||||
try { setRecovery(await regenerateRecoveryCode({ userId })); }
|
||||
catch (err) { setMsg(err instanceof Error ? err.message : String(err)); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function handleReset() {
|
||||
if (!window.confirm('Identität wirklich zurücksetzen? Alle bisherigen Chats werden für dich unlesbar.')) return;
|
||||
setBusy(true); setMsg(null);
|
||||
try {
|
||||
const code = await resetIdentity({ userId, pin: pinNew || pinOld });
|
||||
setRecovery(code);
|
||||
setMsg('Identität zurückgesetzt.');
|
||||
} catch (err) {
|
||||
setMsg(err instanceof Error ? err.message : String(err));
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 rounded-xl border border-line bg-surface-2 p-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldIcon className="h-4 w-4 text-accent" />
|
||||
<h2 className="text-sm font-semibold">Sicherheit</h2>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-fg-muted">PIN ändern</h3>
|
||||
<div className="space-y-2">
|
||||
<PinInput ariaLabel="Aktuelle PIN" value={pinOld} onChange={setPinOld} />
|
||||
<PinInput ariaLabel="Neue PIN" value={pinNew} onChange={setPinNew} />
|
||||
<button type="button"
|
||||
disabled={busy || pinOld.length !== 6 || pinNew.length !== 6}
|
||||
onClick={() => void handleChangePin()}
|
||||
className="rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg disabled:opacity-60"
|
||||
>
|
||||
{busy && <SpinnerIcon className="mr-1 inline h-4 w-4" />}PIN ändern
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-fg-muted">Recovery-Code</h3>
|
||||
<button type="button" disabled={busy} onClick={() => void handleRegenerateRecovery()}
|
||||
className="rounded-md border border-line bg-surface-3 px-3 py-2 text-sm hover:bg-surface-2"
|
||||
>Neuen Recovery-Code erzeugen</button>
|
||||
{recovery && (
|
||||
<div className="mt-2 select-all rounded bg-surface-3 px-2 py-1.5 font-mono text-sm tracking-widest">
|
||||
{recovery}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-fg-muted">Schlüssel-Migration reparieren</h3>
|
||||
<p className="mb-2 text-xs text-fg-muted">
|
||||
Versucht, alte Conversation-Schlüssel erneut für deine neue Identität zu re-wrappen.
|
||||
Sicher zu klicken wenn Nachrichten verschlüsselt bleiben oder du nicht senden kannst.
|
||||
</p>
|
||||
<button type="button" disabled={busy} onClick={() => void handleRetryMigration()}
|
||||
className="rounded-md border border-line bg-surface-3 px-3 py-2 text-sm hover:bg-surface-2"
|
||||
>
|
||||
{busy && <SpinnerIcon className="mr-1 inline h-4 w-4" />}Migration erneut ausführen
|
||||
</button>
|
||||
{migration && (
|
||||
<div className="mt-2 rounded border border-line bg-surface-3 p-3 text-xs text-fg-muted">
|
||||
<div>Geräte (Server): {migration.serverDevices}</div>
|
||||
<div>Lokale Schlüssel im Vault: {migration.strongholdKeysFromServerDevices}
|
||||
{migration.strongholdKeysFromBundleScan > 0 && (
|
||||
<> (+{migration.strongholdKeysFromBundleScan} aus Bundle-Scan)</>
|
||||
)}
|
||||
</div>
|
||||
<div>Versucht: {migration.attempted}, Erfolgreich: <span className="text-emerald-500">{migration.migrated}</span></div>
|
||||
<div>Übersprungen (kein lokaler Schlüssel): {migration.noStrongholdKey}</div>
|
||||
<div>Entschlüsselung gescheitert: {migration.decryptFailed}</div>
|
||||
<div>Server-Fehler: {migration.rpcFailed}</div>
|
||||
{migration.attempted > 0 && migration.migrated === 0 && (
|
||||
<p className="mt-2 text-rose-300">
|
||||
Keine Bundles migriert. Vermutlich hast du den ursprünglichen Geräteschlüssel nicht mehr lokal.
|
||||
Nutze "Identität zurücksetzen" wenn du neu starten willst (alte Chats gehen verloren).
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-fg-muted">
|
||||
Cache beim Schließen leeren
|
||||
</h3>
|
||||
<p className="mb-2 text-xs text-fg-muted">
|
||||
Beim Beenden der App werden alle entschlüsselten Caches gelöscht. Beim nächsten Start
|
||||
musst du wieder deine PIN eingeben. Empfohlen für gemeinsam genutzte Rechner.
|
||||
</p>
|
||||
<label className="inline-flex cursor-pointer items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={wipeOnClose}
|
||||
onChange={(e) => {
|
||||
setWipeOnClose(e.target.checked);
|
||||
setWipeOnCloseState(e.target.checked);
|
||||
}}
|
||||
/>
|
||||
<span>Aktivieren</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-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>
|
||||
<button type="button" disabled={busy} onClick={() => void handleReset()}
|
||||
className="rounded-md border border-rose-500/40 bg-rose-500/10 px-3 py-2 text-sm text-rose-200 hover:bg-rose-500/20"
|
||||
>Identität zurücksetzen</button>
|
||||
</section>
|
||||
|
||||
{msg && <p role="status" className="text-sm text-fg-muted">{msg}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,93 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { setupNewUserIdentity } from '../lib/userIdentity';
|
||||
import { PinInput } from './PinInput';
|
||||
import { ShieldIcon, SpinnerIcon } from './icons';
|
||||
|
||||
interface Props { userId: string; onComplete: () => void }
|
||||
type Step = 'enter' | 'recovery';
|
||||
|
||||
export function UserKeySetup({ userId, onComplete }: Props) {
|
||||
const [pin, setPin] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [withRecovery, setWithRecovery] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [step, setStep] = useState<Step>('enter');
|
||||
const [recoveryCode, setRecoveryCode] = useState<string | null>(null);
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
if (pin.length !== 6 || confirm.length !== 6) {
|
||||
setError('PIN muss 6 Ziffern haben.'); return;
|
||||
}
|
||||
if (pin !== confirm) {
|
||||
setError('PINs stimmen nicht überein.'); return;
|
||||
}
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
const r = await setupNewUserIdentity({ userId, pin, withRecovery });
|
||||
setRecoveryCode(r.recoveryCode);
|
||||
if (withRecovery) setStep('recovery');
|
||||
else onComplete();
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally { setBusy(false); }
|
||||
}, [pin, confirm, withRecovery, userId, onComplete]);
|
||||
|
||||
if (step === 'recovery') {
|
||||
return (
|
||||
<div className="space-y-4 rounded-2xl border border-white/10 bg-ink-900/70 p-6 text-neutral-100">
|
||||
<div className="flex items-center gap-2 text-amber-300">
|
||||
<ShieldIcon className="h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">Recovery-Code</h2>
|
||||
</div>
|
||||
<p className="text-sm text-neutral-300">
|
||||
Speichere diesen Code an einem sicheren Ort (Passwortmanager, ausgedruckt).
|
||||
Mit ihm kannst du deinen Account auch ohne PIN entsperren.
|
||||
</p>
|
||||
<div className="select-all rounded-lg bg-ink-800 p-4 text-center font-mono text-lg tracking-widest">
|
||||
{recoveryCode}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="button"
|
||||
className="flex-1 rounded-lg border border-white/10 bg-ink-800 px-3 py-2 text-sm text-neutral-200 hover:bg-white/5"
|
||||
onClick={onComplete}
|
||||
>Überspringen</button>
|
||||
<button type="button"
|
||||
className="flex-1 rounded-lg bg-brand-500 px-3 py-2 text-sm font-semibold text-white hover:bg-brand-400"
|
||||
onClick={onComplete}
|
||||
>Habe ich gespeichert</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); void submit(); }}
|
||||
className="space-y-4 rounded-2xl border border-white/10 bg-ink-900/70 p-6 text-neutral-100"
|
||||
>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium uppercase tracking-wide text-neutral-400">PIN eingeben</label>
|
||||
<PinInput value={pin} onChange={setPin} ariaLabel="PIN eingeben" autoFocus />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium uppercase tracking-wide text-neutral-400">PIN bestätigen</label>
|
||||
<PinInput value={confirm} onChange={setConfirm} ariaLabel="PIN bestätigen" />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs text-neutral-300">
|
||||
<input type="checkbox" checked={withRecovery} onChange={(e) => setWithRecovery(e.target.checked)} />
|
||||
Recovery-Code erstellen (empfohlen)
|
||||
</label>
|
||||
{error && <p role="alert" className="text-sm text-rose-300">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="inline-flex w-full items-center justify-center gap-2 rounded-lg bg-brand-500 px-4 py-3 text-sm font-semibold text-white hover:bg-brand-400 disabled:opacity-60"
|
||||
>
|
||||
{busy && <SpinnerIcon className="h-4 w-4" />}
|
||||
Weiter
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { loadOrUnlockUserKey } from '../lib/userIdentity';
|
||||
import { PinInput } from './PinInput';
|
||||
import { LockIcon, SpinnerIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
userId: string;
|
||||
hasRecovery: boolean;
|
||||
lockedUntil?: string | null;
|
||||
onUnlocked: () => void;
|
||||
}
|
||||
|
||||
type Mode = 'pin' | 'recovery';
|
||||
|
||||
export function UserKeyUnlock({ userId, hasRecovery, lockedUntil, onUnlocked }: Props) {
|
||||
const [mode, setMode] = useState<Mode>('pin');
|
||||
const [value, setValue] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
const res = await loadOrUnlockUserKey({
|
||||
userId, pin: value, isRecoveryCode: mode === 'recovery',
|
||||
});
|
||||
if (res.kind === 'unlocked') onUnlocked();
|
||||
else if (res.kind === 'locked') setError('Konto gesperrt bis ' + res.lockedUntil);
|
||||
else setError('Schlüssel auf dem Server nicht gefunden.');
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally { setBusy(false); }
|
||||
}, [userId, value, mode, onUnlocked]);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); void submit(); }}
|
||||
className="space-y-4 rounded-2xl border border-white/10 bg-ink-900/70 p-6 text-neutral-100"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<LockIcon className="h-5 w-5 text-brand-300" />
|
||||
<h2 className="text-lg font-semibold">Account entsperren</h2>
|
||||
</div>
|
||||
{lockedUntil && (
|
||||
<p role="alert" className="rounded-lg border border-rose-500/30 bg-rose-500/10 p-3 text-xs text-rose-100">
|
||||
Zu viele Fehlversuche. Gesperrt bis {lockedUntil}.
|
||||
</p>
|
||||
)}
|
||||
{mode === 'pin' ? (
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium uppercase tracking-wide text-neutral-400">PIN eingeben</label>
|
||||
<PinInput value={value} onChange={setValue} ariaLabel="PIN eingeben" autoFocus onSubmit={() => void submit()} />
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium uppercase tracking-wide text-neutral-400" htmlFor="recovery-input">Recovery-Code</label>
|
||||
<input
|
||||
id="recovery-input"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
spellCheck={false}
|
||||
className="w-full rounded-lg border border-white/10 bg-ink-800 px-3 py-2 font-mono tracking-widest text-white"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{hasRecovery && (
|
||||
<button type="button"
|
||||
onClick={() => { setMode((m) => (m === 'pin' ? 'recovery' : 'pin')); setValue(''); setError(null); }}
|
||||
className="text-xs text-brand-300 hover:underline"
|
||||
>
|
||||
{mode === 'pin' ? 'PIN vergessen? Recovery-Code nutzen' : 'Stattdessen PIN verwenden'}
|
||||
</button>
|
||||
)}
|
||||
{error && <p role="alert" className="text-sm text-rose-300">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || (mode === 'pin' ? value.length !== 6 : value.trim().length === 0)}
|
||||
className="inline-flex w-full items-center justify-center gap-2 rounded-lg bg-brand-500 px-4 py-3 text-sm font-semibold text-white hover:bg-brand-400 disabled:opacity-60"
|
||||
>
|
||||
{busy && <SpinnerIcon className="h-4 w-4" />}
|
||||
Entsperren
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { markAttachmentViewed } from '@chat-app/shared/chat';
|
||||
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { EyeOffIcon, LockIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
attachmentId: string;
|
||||
/** Already-viewed timestamp from the DB row. Renders tombstone immediately. */
|
||||
viewedAt: string | null;
|
||||
/** True iff the local user is the sender — they don't burn the view. */
|
||||
isSender: boolean;
|
||||
/** Decrypted image source; only fetched/displayed inside the lightbox. */
|
||||
src: string;
|
||||
}
|
||||
|
||||
// Three states:
|
||||
// 1. viewedAt is null AND user is recipient → blurred lock card; tap opens
|
||||
// fullscreen lightbox AND fires the mark-viewed RPC.
|
||||
// 2. viewedAt is set → tombstone "Angesehen am …".
|
||||
// 3. user is sender → normal image, tombstone update appears once recipient burns it.
|
||||
export function ViewOnceImage({ attachmentId, viewedAt, isSender, src }: Props) {
|
||||
const [revealedAt, setRevealedAt] = useState<string | null>(viewedAt);
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
|
||||
const burned = revealedAt !== null;
|
||||
|
||||
if (burned && !isSender) {
|
||||
return (
|
||||
<div className="flex h-32 w-48 items-center justify-center rounded-lg border border-dashed border-line bg-surface-3 text-xs text-fg-muted">
|
||||
<EyeOffIcon className="mr-2 h-4 w-4" />
|
||||
Angesehen am {new Date(revealedAt).toLocaleString()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isSender) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<img src={src} alt="" className="max-h-72 rounded-lg" />
|
||||
<span className="absolute left-2 top-2 inline-flex items-center gap-1 rounded-full bg-black/60 px-2 py-0.5 text-[10px] font-semibold text-white">
|
||||
<EyeOffIcon className="h-3 w-3" /> Einmal ansehen
|
||||
</span>
|
||||
{burned && (
|
||||
<span className="absolute right-2 bottom-2 rounded-full bg-emerald-500/80 px-2 py-0.5 text-[10px] font-semibold text-white">
|
||||
Angesehen
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Recipient, not yet viewed.
|
||||
const handleOpen = async (): Promise<void> => {
|
||||
try {
|
||||
const res = await markAttachmentViewed(supabase, attachmentId);
|
||||
if (res.viewedAt) setRevealedAt(res.viewedAt);
|
||||
} catch (err) {
|
||||
console.warn('mark-viewed failed', err);
|
||||
}
|
||||
setFullscreen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleOpen()}
|
||||
className="relative flex h-48 w-64 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-line bg-surface-3 text-fg-muted hover:border-accent/40"
|
||||
>
|
||||
<LockIcon className="h-6 w-6 text-accent" />
|
||||
<span className="text-xs font-medium">Einmal ansehen — antippen</span>
|
||||
</button>
|
||||
{fullscreen && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 p-6"
|
||||
onClick={() => setFullscreen(false)}
|
||||
>
|
||||
<img src={src} alt="" className="max-h-full max-w-full rounded-lg" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useCall } from '../context/CallContext';
|
||||
import { useCallPresence } from '../lib/useCallPresence';
|
||||
import { Avatar } from './Avatar';
|
||||
import { PhoneIcon, SpinnerIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
conversation: ConversationSummary;
|
||||
}
|
||||
|
||||
const MAX_AVATARS = 5;
|
||||
|
||||
/**
|
||||
* Discord-style persistent voice-channel band rendered at the top of the
|
||||
* message surface. Always visible for groups so any member can pop in
|
||||
* without an invite-ring; for 1:1 conversations only when someone's already
|
||||
* in (a soft "rejoin" affordance). Joining uses the existing joinActiveCall
|
||||
* path which sends no invite signals.
|
||||
*/
|
||||
export function VoiceChannelRail({ conversation }: Props) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { session } = useAuth();
|
||||
const { state, joinActiveCall } = useCall();
|
||||
const presentIds = useCallPresence(conversation.id);
|
||||
|
||||
const myId = session?.user.id ?? null;
|
||||
const iAmIn =
|
||||
(state.kind === 'connected' ||
|
||||
state.kind === 'connecting' ||
|
||||
state.kind === 'reconnecting') &&
|
||||
state.conversationId === conversation.id;
|
||||
|
||||
const others = presentIds.filter((u) => u !== myId);
|
||||
const isGroup = conversation.type === 'group';
|
||||
|
||||
// For groups: persistent channel feel — show even when empty.
|
||||
// For DMs: only when somebody is already in (matches Discord-DM behaviour).
|
||||
const visible = !iAmIn && (isGroup || others.length > 0);
|
||||
if (!visible) return null;
|
||||
|
||||
const showAvatars = others.slice(0, MAX_AVATARS);
|
||||
const overflow = Math.max(0, others.length - MAX_AVATARS);
|
||||
const busy = state.kind !== 'idle';
|
||||
|
||||
const handleJoin = () => {
|
||||
if (busy) return;
|
||||
void joinActiveCall(conversation.id, 'audio');
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleJoin}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleJoin();
|
||||
}
|
||||
}}
|
||||
className={
|
||||
'flex cursor-pointer items-center gap-3 border-b border-line px-5 py-2.5 text-sm transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
(others.length > 0
|
||||
? 'bg-emerald-500/10 hover:bg-emerald-500/15'
|
||||
: 'bg-surface-3/70 hover:bg-surface-3')
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
'flex h-8 w-8 shrink-0 items-center justify-center rounded-md ' +
|
||||
(others.length > 0 ? 'bg-emerald-500/20 text-emerald-500' : 'bg-accent/15 text-accent')
|
||||
}
|
||||
>
|
||||
<PhoneIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-fg-muted">
|
||||
{t('app:call.voice_channel', { defaultValue: 'Sprach-Channel' })}
|
||||
</p>
|
||||
<p className="truncate text-xs text-fg">
|
||||
{others.length === 0
|
||||
? t('app:call.voice_empty', {
|
||||
defaultValue: 'Niemand drin — sei der Erste.',
|
||||
})
|
||||
: t('app:call.voice_count', {
|
||||
defaultValue: '{{count}} im Channel',
|
||||
count: others.length,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
{showAvatars.length > 0 && (
|
||||
<div className="flex -space-x-2">
|
||||
{showAvatars.map((id) => {
|
||||
const member = conversation.members.find((m) => m.userId === id);
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
title={member?.profile?.displayName ?? '?'}
|
||||
className="relative h-7 w-7 overflow-hidden rounded-full border-2 border-surface-3 bg-surface-2"
|
||||
>
|
||||
<Avatar
|
||||
url={member?.profile?.avatarUrl ?? null}
|
||||
displayName={member?.profile?.displayName ?? '?'}
|
||||
className="h-full w-full text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{overflow > 0 && (
|
||||
<span className="inline-flex h-7 min-w-[1.75rem] items-center justify-center rounded-full border-2 border-surface-3 bg-surface-2 px-1.5 text-[10px] font-semibold text-fg-muted">
|
||||
+{overflow}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleJoin();
|
||||
}}
|
||||
disabled={busy}
|
||||
className="inline-flex shrink-0 cursor-pointer items-center gap-1.5 rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-emerald-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{busy ? (
|
||||
<SpinnerIcon className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<PhoneIcon className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span>
|
||||
{others.length === 0
|
||||
? t('app:call.voice_open', { defaultValue: 'Channel öffnen' })
|
||||
: t('app:call.join', { defaultValue: 'Beitreten' })}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -23,11 +23,14 @@ export function RequireAuth() {
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
// Forces a registered device on this install. Sends to /device otherwise.
|
||||
// Forces a usable per-user encrypted key blob on this install. Sends to
|
||||
// /device (the setup/unlock page) when the blob is missing or locked.
|
||||
export function RequireDevice() {
|
||||
const { device, deviceLookupDone } = useAuth();
|
||||
if (!deviceLookupDone) return <FullScreenSpinner />;
|
||||
if (!device) return <Navigate to="/device" replace />;
|
||||
const { userKeyState } = useAuth();
|
||||
if (userKeyState.status === 'loading') return <FullScreenSpinner />;
|
||||
if (userKeyState.status === 'needs-setup' || userKeyState.status === 'needs-unlock') {
|
||||
return <Navigate to="/device" replace />;
|
||||
}
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,12 +1,15 @@
|
||||
import {
|
||||
type DeviceRecord,
|
||||
fetchUserKeyBlob,
|
||||
getOwnProfile,
|
||||
listOwnDevices,
|
||||
registerDevice,
|
||||
signOut as supabaseSignOut,
|
||||
type Profile,
|
||||
touchDeviceLastSeen,
|
||||
type Profile,
|
||||
updateOwnProfile,
|
||||
} from '@chat-app/shared/auth';
|
||||
import { changeLocale, isSupportedLocale } from '@chat-app/shared/i18n';
|
||||
import type { DevicePlatform } from '@chat-app/shared/supabase';
|
||||
import type { Session } from '@supabase/supabase-js';
|
||||
import {
|
||||
createContext,
|
||||
@@ -20,24 +23,40 @@ import {
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { findExistingDevice } from '../lib/device';
|
||||
import { PRESENCE_HEARTBEAT_MS } from '../lib/presence';
|
||||
import { clearDeviceRowId, getDeviceRowId, setDeviceRowId } from '../lib/deviceRowId';
|
||||
import { ensureInstallId } from '../lib/installId';
|
||||
import { wipeLocalState } from '../lib/memoryWipe';
|
||||
import { isWipeOnCloseEnabled } from '../lib/memoryWipeSettings';
|
||||
import { setSecretStoreUser } from '../lib/secretStore';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { cachedUserKey, ensureLegacyMigrated } from '../lib/userIdentity';
|
||||
import { registerWebPush } from '../lib/webPush';
|
||||
|
||||
// Discriminated union describing the per-user encrypted key blob lifecycle:
|
||||
//
|
||||
// loading — initial state, or refresh in flight
|
||||
// needs-setup — no row exists on Supabase; user must pick a PIN
|
||||
// needs-unlock — row exists but local cache empty; PIN (or recovery code)
|
||||
// required. `lockedUntil` non-null means the server-side
|
||||
// rate limiter is currently rejecting attempts.
|
||||
// unlocked — private key is in the local secret store and ready to use
|
||||
export type UserKeyState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'needs-setup' }
|
||||
| { status: 'needs-unlock'; lockedUntil: string | null; hasRecovery: boolean }
|
||||
| { status: 'unlocked' };
|
||||
|
||||
interface AuthContextValue {
|
||||
session: Session | null;
|
||||
profile: Profile | null;
|
||||
device: DeviceRecord | null;
|
||||
userKeyState: UserKeyState;
|
||||
// null while we're still resolving the very first auth state.
|
||||
ready: boolean;
|
||||
// null until a device lookup has finished for the current session.
|
||||
deviceLookupDone: boolean;
|
||||
refreshProfile: () => Promise<void>;
|
||||
refreshDevice: () => Promise<void>;
|
||||
setDevice: (device: DeviceRecord | null) => void;
|
||||
refreshUserKeyState: () => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
revokedRemotely: boolean;
|
||||
acknowledgeRevocation: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
@@ -47,8 +66,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [profile, setProfile] = useState<Profile | null>(null);
|
||||
const [device, setDevice] = useState<DeviceRecord | null>(null);
|
||||
const [deviceLookupDone, setDeviceLookupDone] = useState(false);
|
||||
const [userKeyState, setUserKeyState] = useState<UserKeyState>({ status: 'loading' });
|
||||
const [revokedRemotely, setRevokedRemotely] = useState(false);
|
||||
const autoOnlineUserRef = useRef<string | null>(null);
|
||||
|
||||
// Initial session + auth subscription. We verify the cached JWT against the
|
||||
@@ -112,41 +131,155 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}, [session, i18n.resolvedLanguage]);
|
||||
|
||||
const refreshDevice = useCallback(async () => {
|
||||
// Resolves the current state of the per-user encrypted key blob:
|
||||
// 1. local cache hit → 'unlocked'
|
||||
// 2. no remote row → 'needs-setup'
|
||||
// 3. server rate-limit → 'needs-unlock' with lockedUntil set
|
||||
// 4. otherwise → 'needs-unlock'; hasRecovery reflects whether a
|
||||
// recovery-code blob is present so the UI can
|
||||
// conditionally offer the recovery affordance
|
||||
const refreshUserKeyState = useCallback(async () => {
|
||||
if (!session) {
|
||||
setDevice(null);
|
||||
setDeviceLookupDone(false);
|
||||
setUserKeyState({ status: 'loading' });
|
||||
return;
|
||||
}
|
||||
setDeviceLookupDone(false);
|
||||
const found = await findExistingDevice(session.user.id);
|
||||
setDevice(found);
|
||||
setDeviceLookupDone(true);
|
||||
setUserKeyState({ status: 'loading' });
|
||||
const cached = await cachedUserKey(session.user.id);
|
||||
if (cached) {
|
||||
setUserKeyState({ status: 'unlocked' });
|
||||
// Best-effort: re-wrap any unmigrated legacy bundles. Idempotent (RPC
|
||||
// uses ON CONFLICT DO NOTHING). Recovers users who set up under 0.18.0
|
||||
// where the migration query had a `.eq(null)` bug that made it a no-op.
|
||||
void ensureLegacyMigrated(session.user.id).catch((err) => {
|
||||
console.warn('legacy conv-key migration on auth-resume failed', err);
|
||||
});
|
||||
return;
|
||||
}
|
||||
const blob = await fetchUserKeyBlob(supabase, session.user.id);
|
||||
if (!blob || !blob.exists) {
|
||||
setUserKeyState({ status: 'needs-setup' });
|
||||
return;
|
||||
}
|
||||
if (blob.locked) {
|
||||
setUserKeyState({
|
||||
status: 'needs-unlock',
|
||||
lockedUntil: blob.lockedUntil,
|
||||
hasRecovery: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setUserKeyState({
|
||||
status: 'needs-unlock',
|
||||
lockedUntil: null,
|
||||
hasRecovery: blob.recoverySealedPrivateKey !== null,
|
||||
});
|
||||
}, [session]);
|
||||
|
||||
// Re-pull profile + device whenever session flips.
|
||||
// Re-pull profile + user-key state whenever session flips.
|
||||
useEffect(() => {
|
||||
if (!session) {
|
||||
setProfile(null);
|
||||
setDevice(null);
|
||||
setDeviceLookupDone(false);
|
||||
setUserKeyState({ status: 'loading' });
|
||||
return;
|
||||
}
|
||||
void refreshProfile().catch((err: unknown) => {
|
||||
console.error('refreshProfile failed', err);
|
||||
});
|
||||
void refreshDevice().catch((err: unknown) => {
|
||||
console.error('refreshDevice failed', err);
|
||||
setDeviceLookupDone(true);
|
||||
void refreshUserKeyState().catch((err: unknown) => {
|
||||
console.error('refreshUserKeyState failed', err);
|
||||
// Treat an unrecoverable lookup error as "needs-setup" so the UI at
|
||||
// least drives the user toward the setup/unlock page rather than
|
||||
// hanging forever on the spinner.
|
||||
setUserKeyState({ status: 'needs-setup' });
|
||||
});
|
||||
}, [session, refreshProfile, refreshDevice]);
|
||||
}, [session, refreshProfile, refreshUserKeyState]);
|
||||
|
||||
// Best-effort web-push registration once we know the device id. No-op on
|
||||
// Tauri (uses native notifications) or when VITE_VAPID_PUBLIC_KEY is unset.
|
||||
// Best-effort web-push registration once we have a session. Keyed by an
|
||||
// install-id (localStorage UUID) since there's no longer a per-device
|
||||
// crypto record to key by. No-op on Tauri (uses native notifications) or
|
||||
// when VITE_VAPID_PUBLIC_KEY is unset.
|
||||
useEffect(() => {
|
||||
if (!device?.id) return;
|
||||
void registerWebPush(device.id);
|
||||
}, [device?.id]);
|
||||
if (!session) return;
|
||||
const installId = ensureInstallId();
|
||||
void registerWebPush(installId);
|
||||
}, [session]);
|
||||
|
||||
// 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.
|
||||
//
|
||||
@@ -188,52 +321,78 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
};
|
||||
}, [session, profile, refreshProfile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session || !device?.id) return;
|
||||
|
||||
const touch = () => {
|
||||
void touchDeviceLastSeen(supabase, device.id).catch((err: unknown) => {
|
||||
console.warn('presence heartbeat failed', err);
|
||||
});
|
||||
};
|
||||
const touchWhenVisible = () => {
|
||||
if (document.visibilityState === 'visible') touch();
|
||||
};
|
||||
|
||||
touch();
|
||||
const heartbeat = window.setInterval(touch, PRESENCE_HEARTBEAT_MS);
|
||||
window.addEventListener('focus', touch);
|
||||
window.addEventListener('online', touch);
|
||||
document.addEventListener('visibilitychange', touchWhenVisible);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(heartbeat);
|
||||
window.removeEventListener('focus', touch);
|
||||
window.removeEventListener('online', touch);
|
||||
document.removeEventListener('visibilitychange', touchWhenVisible);
|
||||
};
|
||||
}, [session, device?.id]);
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
const uid = session?.user.id ?? null;
|
||||
await updateOwnProfile(supabase, { presenceState: 'offline' }).catch((err: unknown) => {
|
||||
console.warn('offline update before sign-out failed', err);
|
||||
});
|
||||
await supabaseSignOut(supabase);
|
||||
await wipeLocalState(uid);
|
||||
}, [session]);
|
||||
|
||||
const acknowledgeRevocation = useCallback(() => {
|
||||
setRevokedRemotely(false);
|
||||
}, []);
|
||||
|
||||
// Phase 3: listen for own-device revocations. The same channel also fires
|
||||
// when *another* of the user's installs is revoked — we ignore those (we
|
||||
// only force-sign-out when OUR row's revoked_at flips). The UI's device
|
||||
// list refetches independently via its own subscription in useOwnDevices.
|
||||
useEffect(() => {
|
||||
if (!session) return;
|
||||
const userId = session.user.id;
|
||||
const channel = supabase
|
||||
.channel('devices:self:' + userId)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'UPDATE',
|
||||
schema: 'public',
|
||||
table: 'devices',
|
||||
filter: 'user_id=eq.' + userId,
|
||||
},
|
||||
(payload) => {
|
||||
const ownId = getDeviceRowId();
|
||||
const row = payload.new as { id?: string; revoked_at?: string | null } | null;
|
||||
if (!row || !ownId) return;
|
||||
if (row.id !== ownId) return;
|
||||
if (row.revoked_at) {
|
||||
setRevokedRemotely(true);
|
||||
void signOut().catch((err) => {
|
||||
console.warn('forced signOut after revoke failed', err);
|
||||
});
|
||||
}
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
return () => {
|
||||
void supabase.removeChannel(channel);
|
||||
};
|
||||
}, [session, signOut]);
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({
|
||||
session,
|
||||
profile,
|
||||
device,
|
||||
userKeyState,
|
||||
ready,
|
||||
deviceLookupDone,
|
||||
refreshProfile,
|
||||
refreshDevice,
|
||||
setDevice,
|
||||
refreshUserKeyState,
|
||||
signOut,
|
||||
revokedRemotely,
|
||||
acknowledgeRevocation,
|
||||
}),
|
||||
[session, profile, device, ready, deviceLookupDone, refreshProfile, refreshDevice, signOut],
|
||||
[
|
||||
session,
|
||||
profile,
|
||||
userKeyState,
|
||||
ready,
|
||||
refreshProfile,
|
||||
refreshUserKeyState,
|
||||
signOut,
|
||||
revokedRemotely,
|
||||
acknowledgeRevocation,
|
||||
],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
|
||||
import { sendEncryptedMessage } from '@chat-app/shared/chat';
|
||||
import {
|
||||
type CallKind,
|
||||
@@ -40,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 {
|
||||
@@ -114,8 +112,9 @@ import {
|
||||
type ScreenSharePreset,
|
||||
updateScreenShareSettings,
|
||||
} from '../lib/screenShareSettings';
|
||||
import { devLocalSecretStore } from '../lib/secretStore';
|
||||
import { ensureInstallId } from '../lib/installId';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { cachedUserKey } from '../lib/userIdentity';
|
||||
import { setWindowFullscreen } from '../lib/windowFullscreen';
|
||||
|
||||
export type CallState =
|
||||
@@ -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
|
||||
@@ -325,7 +316,7 @@ function newCallId(): string {
|
||||
}
|
||||
|
||||
export function CallProvider({ children }: { children: ReactNode }) {
|
||||
const { session, device, profile } = useAuth();
|
||||
const { session, profile } = useAuth();
|
||||
const { conversations } = useConversationsContext();
|
||||
const myId = session?.user.id;
|
||||
|
||||
@@ -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);
|
||||
@@ -446,13 +434,9 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
mediaKind: CallKind,
|
||||
durationSec: number,
|
||||
) => {
|
||||
if (!session?.user.id || !device?.id) return;
|
||||
if (!session?.user.id) return;
|
||||
try {
|
||||
const priv = await loadDevicePrivateKey(
|
||||
devLocalSecretStore,
|
||||
session.user.id,
|
||||
device.id,
|
||||
);
|
||||
const priv = await cachedUserKey(session.user.id);
|
||||
if (!priv) return;
|
||||
const payload = JSON.stringify({
|
||||
v: 1,
|
||||
@@ -466,14 +450,14 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
conversationId,
|
||||
plaintext: payload,
|
||||
senderUserId: session.user.id,
|
||||
senderDeviceId: device.id,
|
||||
senderDeviceId: ensureInstallId(),
|
||||
senderPrivateKey: priv,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
console.error('emitCallEvent failed', err);
|
||||
}
|
||||
},
|
||||
[session?.user.id, device?.id],
|
||||
[session?.user.id],
|
||||
);
|
||||
|
||||
const clearRingTimer = useCallback(() => {
|
||||
@@ -832,7 +816,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
setRemoteScreenShares([]);
|
||||
setConnectionQualities({});
|
||||
setCallHostId(null);
|
||||
setCaptions({});
|
||||
setIsScreenSharing(false);
|
||||
setIsE2EEActive(false);
|
||||
}
|
||||
@@ -974,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') {
|
||||
@@ -995,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 */
|
||||
}
|
||||
@@ -1446,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 })
|
||||
@@ -1749,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;
|
||||
@@ -2008,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];
|
||||
@@ -2596,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,
|
||||
@@ -2619,8 +2582,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
remoteMute,
|
||||
connectionQualities,
|
||||
callHostId,
|
||||
captions,
|
||||
pushLocalCaption,
|
||||
remoteScreenShares,
|
||||
lastCallConversationId,
|
||||
callMode,
|
||||
@@ -2676,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[],
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
|
||||
import { type OwnDeviceCtx, shareConvKeyToDevice } from '@chat-app/shared/chat';
|
||||
import { type OwnUserCtx, shareConvKeyToUser } from '@chat-app/shared/chat';
|
||||
import { pgHexToBytes } from '@chat-app/shared/supabase';
|
||||
|
||||
import { devLocalSecretStore } from './secretStore';
|
||||
import { supabase } from './supabase';
|
||||
|
||||
// Watches the `devices` table for new entries AND, on mount, scans every
|
||||
// conversation we participate in for missing key bundles. Fills gaps by
|
||||
// re-wrapping our active conv-key for the missing recipient devices.
|
||||
// re-wrapping our active conv-key for the missing recipient users.
|
||||
//
|
||||
// This fixes the "cannot decrypt" cliff for devices that registered while
|
||||
// no other participant device was online to share the key with them.
|
||||
// This fixes the "cannot decrypt" cliff for users that joined while no
|
||||
// other participant was online to share the key with them.
|
||||
|
||||
export interface SyncCtx {
|
||||
myUserId: string;
|
||||
@@ -57,8 +55,6 @@ export function startConversationKeySync(
|
||||
void ownUserId;
|
||||
void ownDeviceId;
|
||||
void backfilledKey;
|
||||
void loadDevicePrivateKey;
|
||||
void devLocalSecretStore;
|
||||
void supabase;
|
||||
return () => {};
|
||||
}
|
||||
@@ -70,10 +66,10 @@ export function startConversationKeySync(
|
||||
// `wrapForOneDevice` and `syncOneConversationGaps` are exported below for
|
||||
// the device-approval module — once the user explicitly approves a new
|
||||
// device the approval flow re-uses these helpers to wrap conv-keys for
|
||||
// that specific deviceId.
|
||||
// that specific user.
|
||||
void (() => {
|
||||
void listMyConversationIds;
|
||||
void listConversationDevices;
|
||||
void listConversationMembers;
|
||||
void listExistingKeyRecipients;
|
||||
void getActiveKeyVersion;
|
||||
void syncAllExistingGaps;
|
||||
@@ -94,9 +90,9 @@ async function listMyConversationIds(myUserId: string): Promise<string[]> {
|
||||
return (data ?? []).map((r) => r.conversation_id as string);
|
||||
}
|
||||
|
||||
async function listConversationDevices(
|
||||
async function listConversationMembers(
|
||||
conversationId: string,
|
||||
): Promise<{ id: string; user_id: string; public_key: string }[]> {
|
||||
): Promise<{ user_id: string; public_key: string }[]> {
|
||||
const { data: members, error: mErr } = await supabase
|
||||
.from('conversation_members')
|
||||
.select('user_id')
|
||||
@@ -109,15 +105,14 @@ async function listConversationDevices(
|
||||
const userIds = (members ?? []).map((m) => m.user_id as string);
|
||||
if (userIds.length === 0) return [];
|
||||
|
||||
const { data: devices, error: dErr } = await supabase
|
||||
.from('devices')
|
||||
.select('id, user_id, public_key')
|
||||
const { data: keys, error: kErr } = await rawFrom('user_keys')
|
||||
.select('user_id, public_key')
|
||||
.in('user_id', userIds);
|
||||
if (dErr) {
|
||||
console.warn('keySync: devices lookup failed', dErr);
|
||||
if (kErr) {
|
||||
console.warn('keySync: user-keys lookup failed', kErr);
|
||||
return [];
|
||||
}
|
||||
return (devices ?? []) as { id: string; user_id: string; public_key: string }[];
|
||||
return (keys ?? []) as { user_id: string; public_key: string }[];
|
||||
}
|
||||
|
||||
async function listExistingKeyRecipients(
|
||||
@@ -125,14 +120,14 @@ async function listExistingKeyRecipients(
|
||||
keyVersion: number,
|
||||
): Promise<Set<string>> {
|
||||
const { data, error } = await rawFrom('conversation_keys')
|
||||
.select('recipient_device_id')
|
||||
.select('recipient_user_id')
|
||||
.eq('conversation_id', conversationId)
|
||||
.eq('key_version', keyVersion);
|
||||
if (error) {
|
||||
console.warn('keySync: existing keys lookup failed', error);
|
||||
return new Set();
|
||||
}
|
||||
return new Set((data ?? []).map((r: { recipient_device_id: string }) => r.recipient_device_id));
|
||||
return new Set((data ?? []).map((r: { recipient_user_id: string }) => r.recipient_user_id));
|
||||
}
|
||||
|
||||
async function getActiveKeyVersion(conversationId: string): Promise<number> {
|
||||
@@ -160,33 +155,32 @@ async function syncAllExistingGaps(ctx: SyncCtx): Promise<void> {
|
||||
|
||||
export async function syncOneConversationGaps(ctx: SyncCtx, convId: string): Promise<void> {
|
||||
const version = await getActiveKeyVersion(convId);
|
||||
const devices = await listConversationDevices(convId);
|
||||
if (devices.length === 0) return;
|
||||
const members = await listConversationMembers(convId);
|
||||
if (members.length === 0) return;
|
||||
|
||||
const recipients = await listExistingKeyRecipients(convId, version);
|
||||
const ownCtx: OwnDeviceCtx = {
|
||||
const ownCtx: OwnUserCtx = {
|
||||
userId: ctx.myUserId,
|
||||
deviceId: ctx.myDeviceId,
|
||||
privateKey: ctx.priv,
|
||||
};
|
||||
|
||||
for (const dev of devices) {
|
||||
if (recipients.has(dev.id)) continue;
|
||||
// Skip our own device — we already have the bundle if we're capable of
|
||||
for (const m of members) {
|
||||
if (recipients.has(m.user_id)) continue;
|
||||
// Skip our own user — we already have the bundle if we're capable of
|
||||
// sharing (or don't need it if we ourselves haven't been wrapped yet).
|
||||
if (dev.id === ctx.myDeviceId) continue;
|
||||
if (m.user_id === ctx.myUserId) continue;
|
||||
try {
|
||||
await shareConvKeyToDevice(supabase, convId, dev.id, pgHexToBytes(dev.public_key), ownCtx);
|
||||
await shareConvKeyToUser(supabase, convId, m.user_id, pgHexToBytes(m.public_key), ownCtx);
|
||||
} catch (err: unknown) {
|
||||
// Backfill is best-effort. Most common silent failures:
|
||||
// - tryGetConvKey couldn't unwrap (another peer will fill the gap).
|
||||
// - RLS rejects because the recipient's owner is a pending (not-yet
|
||||
// - RLS rejects because the recipient is a pending (not-yet
|
||||
// accepted) DM member, or was removed from the conv.
|
||||
// Both are recoverable / expected, swallow without spam.
|
||||
if (isExpectedShareFailure(err)) continue;
|
||||
console.warn('keySync: shareConvKeyToDevice gap-fill failed', {
|
||||
console.warn('keySync: shareConvKeyToUser gap-fill failed', {
|
||||
convId,
|
||||
recipient: dev.id,
|
||||
recipient: m.user_id,
|
||||
err,
|
||||
});
|
||||
}
|
||||
@@ -209,12 +203,20 @@ function isExpectedShareFailure(err: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
// Approval-flow helper. The legacy signature took (deviceId, userId,
|
||||
// devicePubHex) because conv-keys were wrapped per-device. In the per-user
|
||||
// model only the user dimension matters, so the device-id parameter is
|
||||
// ignored and the public key passed in MUST be the recipient user's
|
||||
// user_keys.public_key (callers will be updated alongside the broader
|
||||
// approval-flow rework).
|
||||
export async function wrapForOneDevice(
|
||||
ctx: SyncCtx,
|
||||
newDeviceId: string,
|
||||
newDeviceUserId: string,
|
||||
newDevicePubHex: string,
|
||||
): Promise<void> {
|
||||
void newDeviceId; // kept for API compat; no longer used
|
||||
|
||||
const myConvs = new Set(await listMyConversationIds(ctx.myUserId));
|
||||
const { data: peerMember, error: pErr } = await supabase
|
||||
.from('conversation_members')
|
||||
@@ -230,16 +232,15 @@ export async function wrapForOneDevice(
|
||||
if (sharedConvs.length === 0) return;
|
||||
|
||||
const newPub = pgHexToBytes(newDevicePubHex);
|
||||
const ownCtx: OwnDeviceCtx = {
|
||||
const ownCtx: OwnUserCtx = {
|
||||
userId: ctx.myUserId,
|
||||
deviceId: ctx.myDeviceId,
|
||||
privateKey: ctx.priv,
|
||||
};
|
||||
for (const convId of sharedConvs) {
|
||||
try {
|
||||
await shareConvKeyToDevice(supabase, convId, newDeviceId, newPub, ownCtx);
|
||||
await shareConvKeyToUser(supabase, convId, newDeviceUserId, newPub, ownCtx);
|
||||
} catch (err: unknown) {
|
||||
console.warn('keySync: shareConvKeyToDevice failed', { convId, err });
|
||||
console.warn('keySync: shareConvKeyToUser failed', { convId, err });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,5 +25,13 @@ export async function createLibsodiumBackend(): Promise<CryptoBackend> {
|
||||
s.crypto_box_open_easy(ciphertext, nonce, senderPublicKey, recipientPrivateKey),
|
||||
secretbox: (plaintext, nonce, key) => s.crypto_secretbox_easy(plaintext, nonce, key),
|
||||
secretboxOpen: (ciphertext, nonce, key) => s.crypto_secretbox_open_easy(ciphertext, nonce, key),
|
||||
pwhashConsts: {
|
||||
OPSLIMIT_MODERATE: s.crypto_pwhash_OPSLIMIT_MODERATE,
|
||||
MEMLIMIT_MODERATE: s.crypto_pwhash_MEMLIMIT_MODERATE,
|
||||
ALG_ARGON2ID13: s.crypto_pwhash_ALG_ARGON2ID13,
|
||||
},
|
||||
pwhash: (outLen, password, salt, opslimit, memlimit, alg) =>
|
||||
s.crypto_pwhash(outLen, password, salt, opslimit, memlimit, alg),
|
||||
scalarMultBase: (priv) => s.crypto_scalarmult_base(priv),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import {
|
||||
deviceIdStorageKey,
|
||||
listOwnDevices,
|
||||
loadDevicePrivateKey,
|
||||
provisionNewDevice,
|
||||
touchDeviceLastSeen,
|
||||
type DeviceRecord,
|
||||
} from '@chat-app/shared/auth';
|
||||
import type { DevicePlatform } from '@chat-app/shared/supabase';
|
||||
|
||||
import { devLocalSecretStore } from './secretStore';
|
||||
import { supabase } from './supabase';
|
||||
|
||||
export function detectDesktopPlatform(): DevicePlatform {
|
||||
const ua =
|
||||
typeof navigator !== 'undefined' && typeof navigator.userAgent === 'string'
|
||||
? navigator.userAgent.toLowerCase()
|
||||
: '';
|
||||
if (ua.includes('mac')) return 'macos';
|
||||
if (ua.includes('win')) return 'windows';
|
||||
return 'linux';
|
||||
}
|
||||
|
||||
export function readLocalDeviceId(userId: string): string | null {
|
||||
return window.localStorage.getItem(deviceIdStorageKey(userId));
|
||||
}
|
||||
|
||||
export function writeLocalDeviceId(userId: string, deviceId: string): void {
|
||||
window.localStorage.setItem(deviceIdStorageKey(userId), deviceId);
|
||||
}
|
||||
|
||||
export function clearLocalDeviceId(userId: string): void {
|
||||
window.localStorage.removeItem(deviceIdStorageKey(userId));
|
||||
}
|
||||
|
||||
// Look up the current install's device record. Returns null when either:
|
||||
// - no device id is cached locally, or
|
||||
// - the cached id was deleted server-side (e.g. wiped from Studio).
|
||||
// In both cases the UI should prompt the user to register a fresh device.
|
||||
export async function findExistingDevice(userId: string): Promise<DeviceRecord | null> {
|
||||
const cachedId = readLocalDeviceId(userId);
|
||||
if (!cachedId) return null;
|
||||
|
||||
const all = await listOwnDevices(supabase);
|
||||
const hit = all.find((d) => d.id === cachedId) ?? null;
|
||||
if (!hit) return null;
|
||||
|
||||
const priv = await loadDevicePrivateKey(devLocalSecretStore, userId, hit.id);
|
||||
if (!priv) {
|
||||
// Server row exists but we lost the private key locally — treat as fresh install.
|
||||
return null;
|
||||
}
|
||||
|
||||
void touchDeviceLastSeen(supabase, hit.id).catch(() => {
|
||||
/* non-fatal */
|
||||
});
|
||||
return hit;
|
||||
}
|
||||
|
||||
export async function registerCurrentDevice(params: {
|
||||
userId: string;
|
||||
name: string;
|
||||
}): Promise<DeviceRecord> {
|
||||
const device = await provisionNewDevice({
|
||||
client: supabase,
|
||||
secretStore: devLocalSecretStore,
|
||||
userId: params.userId,
|
||||
name: params.name,
|
||||
platform: detectDesktopPlatform(),
|
||||
});
|
||||
writeLocalDeviceId(params.userId, device.id);
|
||||
return device;
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
import { getCryptoBackend } from '@chat-app/shared/crypto';
|
||||
import sodium from 'libsodium-wrappers-sumo';
|
||||
|
||||
import { pwhashArgon2id } from './nativeCryptoOps';
|
||||
|
||||
// Encrypts/decrypts the device private key with a user-provided passphrase
|
||||
// so the backup string can be safely written down or stored in a password
|
||||
// manager. Uses Argon2id (libsodium crypto_pwhash) for the KDF and
|
||||
// XSalsa20-Poly1305 (crypto_secretbox) for the AEAD.
|
||||
//
|
||||
// Backup format (base64url-encoded blob, prefixed with a magic string so we
|
||||
// can version it):
|
||||
//
|
||||
// chatapp-backup-v1.<base64url(salt(16) | nonce(24) | ciphertext)>
|
||||
|
||||
const MAGIC = 'chatapp-backup-v1.';
|
||||
const SALT_LEN = 16; // crypto_pwhash_SALTBYTES
|
||||
const NONCE_LEN = 24; // crypto_secretbox_NONCEBYTES
|
||||
const KEY_LEN = 32; // crypto_secretbox_KEYBYTES
|
||||
|
||||
async function ensureSodium(): Promise<typeof sodium> {
|
||||
await sodium.ready;
|
||||
return sodium;
|
||||
}
|
||||
|
||||
function b64url(bytes: Uint8Array): string {
|
||||
let s = '';
|
||||
for (const b of bytes) s += String.fromCharCode(b);
|
||||
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
function unb64url(s: string): Uint8Array {
|
||||
let str = s.replace(/-/g, '+').replace(/_/g, '/');
|
||||
while (str.length % 4) str += '=';
|
||||
const bin = atob(str);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function deriveKey(passphrase: string, salt: Uint8Array, _sodiumLib: typeof sodium): Promise<Uint8Array> {
|
||||
return pwhashArgon2id({
|
||||
password: passphrase,
|
||||
salt,
|
||||
outLen: KEY_LEN,
|
||||
preset: 'moderate',
|
||||
});
|
||||
}
|
||||
|
||||
export async function exportDeviceKey(
|
||||
privateKey: Uint8Array,
|
||||
passphrase: string,
|
||||
): Promise<string> {
|
||||
if (passphrase.length < 8) throw new Error('Passphrase must be at least 8 characters.');
|
||||
const s = await ensureSodium();
|
||||
const salt = s.randombytes_buf(SALT_LEN);
|
||||
const nonce = s.randombytes_buf(NONCE_LEN);
|
||||
const key = await deriveKey(passphrase, salt, s);
|
||||
const backend = getCryptoBackend();
|
||||
const ciphertext = backend.secretbox(privateKey, nonce, key);
|
||||
s.memzero(key);
|
||||
const blob = new Uint8Array(SALT_LEN + NONCE_LEN + ciphertext.length);
|
||||
blob.set(salt, 0);
|
||||
blob.set(nonce, SALT_LEN);
|
||||
blob.set(ciphertext, SALT_LEN + NONCE_LEN);
|
||||
return MAGIC + b64url(blob);
|
||||
}
|
||||
|
||||
export async function importDeviceKey(
|
||||
backup: string,
|
||||
passphrase: string,
|
||||
): Promise<Uint8Array> {
|
||||
if (!backup.startsWith(MAGIC)) {
|
||||
throw new Error('Invalid backup format');
|
||||
}
|
||||
const blob = unb64url(backup.slice(MAGIC.length));
|
||||
if (blob.length < SALT_LEN + NONCE_LEN + 1) {
|
||||
throw new Error('Backup too short');
|
||||
}
|
||||
const salt = blob.slice(0, SALT_LEN);
|
||||
const nonce = blob.slice(SALT_LEN, SALT_LEN + NONCE_LEN);
|
||||
const ciphertext = blob.slice(SALT_LEN + NONCE_LEN);
|
||||
const s = await ensureSodium();
|
||||
const key = await deriveKey(passphrase, salt, s);
|
||||
const backend = getCryptoBackend();
|
||||
try {
|
||||
return backend.secretboxOpen(ciphertext, nonce, key);
|
||||
} catch {
|
||||
throw new Error('Wrong passphrase or corrupt backup');
|
||||
} finally {
|
||||
s.memzero(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Full-device backup. Wraps userId + deviceId + privateKey in a JSON payload
|
||||
// before encrypting, so a restore flow can re-seed localStorage + vault + server
|
||||
// device row without requiring the user to remember IDs.
|
||||
export interface DeviceBackupPayload {
|
||||
v: 2;
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
privateKeyB64: string;
|
||||
}
|
||||
|
||||
export async function exportDeviceBackup(
|
||||
params: {
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
privateKey: Uint8Array;
|
||||
passphrase: string;
|
||||
},
|
||||
): Promise<string> {
|
||||
const payload: DeviceBackupPayload = {
|
||||
v: 2,
|
||||
userId: params.userId,
|
||||
deviceId: params.deviceId,
|
||||
privateKeyB64: b64url(params.privateKey),
|
||||
};
|
||||
const bytes = new TextEncoder().encode(JSON.stringify(payload));
|
||||
return exportDeviceKey(bytes, params.passphrase);
|
||||
}
|
||||
|
||||
export async function importDeviceBackup(
|
||||
backup: string,
|
||||
passphrase: string,
|
||||
): Promise<DeviceBackupPayload> {
|
||||
const plain = await importDeviceKey(backup, passphrase);
|
||||
const text = new TextDecoder().decode(plain);
|
||||
try {
|
||||
const obj = JSON.parse(text) as DeviceBackupPayload;
|
||||
if (obj && obj.v === 2 && obj.userId && obj.deviceId && obj.privateKeyB64) {
|
||||
return obj;
|
||||
}
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
throw new Error('Backup format not supported — v2 expected');
|
||||
}
|
||||
|
||||
export function decodePrivateKeyFromBackup(payload: DeviceBackupPayload): Uint8Array {
|
||||
return unb64url(payload.privateKeyB64);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recovery code
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Generates a high-entropy code shown to the user once at backup time. The
|
||||
// same payload is encrypted twice — once with the user's passphrase, once
|
||||
// with the recovery code — so either string can decrypt the device key.
|
||||
//
|
||||
// The recovery code is 24 chars from a 32-symbol alphabet (no ambiguous
|
||||
// characters), grouped as 4×6. ~120 bits of entropy.
|
||||
|
||||
const RECOVERY_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
|
||||
export interface BackupBundle {
|
||||
passphraseBackup: string;
|
||||
recoveryBackup: string;
|
||||
recoveryCode: string;
|
||||
}
|
||||
|
||||
export async function exportDeviceBackupWithRecovery(params: {
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
privateKey: Uint8Array;
|
||||
passphrase: string;
|
||||
}): Promise<BackupBundle> {
|
||||
const recoveryCode = await generateRecoveryCode();
|
||||
const [passphraseBackup, recoveryBackup] = await Promise.all([
|
||||
exportDeviceBackup(params),
|
||||
exportDeviceBackup({ ...params, passphrase: recoveryCode }),
|
||||
]);
|
||||
return { passphraseBackup, recoveryBackup, recoveryCode };
|
||||
}
|
||||
|
||||
async function generateRecoveryCode(): Promise<string> {
|
||||
const s = await ensureSodium();
|
||||
const raw = s.randombytes_buf(24);
|
||||
let out = '';
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
out += RECOVERY_ALPHABET[raw[i]! % RECOVERY_ALPHABET.length];
|
||||
if ((i + 1) % 6 === 0 && i !== raw.length - 1) out += '-';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Normalizes a user-typed recovery code: strips dashes/spaces, uppercases,
|
||||
// maps look-alike characters. Lets users enter the code with imperfect
|
||||
// spacing without rejecting valid input.
|
||||
export function normalizeRecoveryCode(input: string): string {
|
||||
return input
|
||||
.toUpperCase()
|
||||
.replace(/[\s-]/g, '')
|
||||
.split('')
|
||||
.filter((c) => RECOVERY_ALPHABET.includes(c))
|
||||
.join('');
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// localStorage key for "the devices.id row that belongs to THIS install".
|
||||
// Reset on memory-wipe (NOT preserved) — a wiped install is conceptually a
|
||||
// fresh install, so registering a new row is correct.
|
||||
|
||||
const KEY = 'chatapp.deviceRowId.v1';
|
||||
|
||||
export function getDeviceRowId(): string | null {
|
||||
try {
|
||||
const v = window.localStorage.getItem(KEY);
|
||||
return v && v.length > 0 ? v : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setDeviceRowId(id: string): void {
|
||||
try {
|
||||
window.localStorage.setItem(KEY, id);
|
||||
} catch {
|
||||
/* quota */
|
||||
}
|
||||
}
|
||||
|
||||
export function clearDeviceRowId(): void {
|
||||
try {
|
||||
window.localStorage.removeItem(KEY);
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
// Local-only friend nickname overrides. Stored in localStorage keyed by the
|
||||
// peer's user-id. Empty/missing value = use the real display name.
|
||||
// Local-only by design — friends never see your nickname for them.
|
||||
|
||||
const STORAGE_KEY = 'chatapp.friendNicknames.v1';
|
||||
|
||||
let cache: Record<string, string> | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function load(): Record<string, string> {
|
||||
if (cache) return cache;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) { cache = {}; return cache; }
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
cache = {};
|
||||
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
|
||||
if (typeof v === 'string' && v.trim().length > 0) cache[k] = v;
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
} catch { /* corrupted; fall through */ }
|
||||
cache = {};
|
||||
return cache;
|
||||
}
|
||||
|
||||
function persist(): void {
|
||||
try { window.localStorage.setItem(STORAGE_KEY, JSON.stringify(cache ?? {})); }
|
||||
catch { /* quota / private mode */ }
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
|
||||
export function getNickname(userId: string): string | null {
|
||||
return load()[userId] ?? null;
|
||||
}
|
||||
|
||||
export function setNickname(userId: string, nickname: string | null): void {
|
||||
const store = load();
|
||||
const trimmed = nickname?.trim() ?? '';
|
||||
if (trimmed.length === 0) {
|
||||
if (!(userId in store)) return;
|
||||
delete store[userId];
|
||||
} else {
|
||||
if (store[userId] === trimmed) return;
|
||||
store[userId] = trimmed;
|
||||
}
|
||||
persist();
|
||||
}
|
||||
|
||||
// Reactive hook: returns the current nickname for a user, or `fallback`
|
||||
// when no nickname is set. Re-renders when ANY nickname changes (cheap,
|
||||
// the set is small).
|
||||
export function useNickname(userId: string | null | undefined, fallback: string): string {
|
||||
const subscribe = (cb: () => void) => {
|
||||
listeners.add(cb);
|
||||
return () => { listeners.delete(cb); };
|
||||
};
|
||||
const getSnapshot = () => (userId ? getNickname(userId) : null);
|
||||
const nickname = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
return nickname ?? fallback;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// Per-install browser identifier. Replaces the legacy per-device crypto
|
||||
// identity for non-crypto bookkeeping (push tokens, message sender_device_id
|
||||
// metadata) where we just need a stable, opaque id for this browser/install.
|
||||
//
|
||||
// The user-key model has no device-bound crypto; the `devices` table and
|
||||
// `messages.sender_device_id` column still exist but they're now plain
|
||||
// telemetry. Using a localStorage UUID keeps the column populated without
|
||||
// any of the old key-management overhead.
|
||||
|
||||
const KEY = 'chatapp.installId';
|
||||
|
||||
export function ensureInstallId(): string {
|
||||
let id = window.localStorage.getItem(KEY);
|
||||
if (!id) {
|
||||
id = crypto.randomUUID();
|
||||
window.localStorage.setItem(KEY, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import type { DecryptedMessage } from '@chat-app/shared/chat';
|
||||
|
||||
import {
|
||||
__resetForTests,
|
||||
getCachedMessages,
|
||||
hasCachedMessages,
|
||||
setCachedMessages,
|
||||
} from './messageMemoryCache';
|
||||
|
||||
function msg(id: string): DecryptedMessage {
|
||||
return {
|
||||
id,
|
||||
conversationId: 'conv-1',
|
||||
senderId: 'sender-1',
|
||||
senderDeviceId: null,
|
||||
replyToId: null,
|
||||
editedAt: null,
|
||||
deletedAt: null,
|
||||
createdAt: '2026-05-17T00:00:00Z',
|
||||
plaintext: 'hi ' + id,
|
||||
};
|
||||
}
|
||||
|
||||
describe('messageMemoryCache', () => {
|
||||
afterEach(() => {
|
||||
__resetForTests();
|
||||
});
|
||||
|
||||
it('returns empty array when nothing is cached', () => {
|
||||
expect(getCachedMessages('unknown')).toEqual([]);
|
||||
expect(hasCachedMessages('unknown')).toBe(false);
|
||||
});
|
||||
|
||||
it('stores and returns messages per conversation', () => {
|
||||
setCachedMessages('a', [msg('m1'), msg('m2')]);
|
||||
expect(getCachedMessages('a').map((m) => m.id)).toEqual(['m1', 'm2']);
|
||||
expect(hasCachedMessages('a')).toBe(true);
|
||||
});
|
||||
|
||||
it('isolates conversations', () => {
|
||||
setCachedMessages('a', [msg('m1')]);
|
||||
setCachedMessages('b', [msg('m9')]);
|
||||
expect(getCachedMessages('a').map((m) => m.id)).toEqual(['m1']);
|
||||
expect(getCachedMessages('b').map((m) => m.id)).toEqual(['m9']);
|
||||
});
|
||||
|
||||
it('overwrites prior cache when set again', () => {
|
||||
setCachedMessages('a', [msg('m1')]);
|
||||
setCachedMessages('a', [msg('m1'), msg('m2')]);
|
||||
expect(getCachedMessages('a').map((m) => m.id)).toEqual(['m1', 'm2']);
|
||||
});
|
||||
|
||||
it('treats an explicit empty list as "cached"', () => {
|
||||
// A conversation that genuinely has zero messages should still be
|
||||
// flagged as cached so the hook skips the loading spinner on re-entry.
|
||||
setCachedMessages('a', []);
|
||||
expect(hasCachedMessages('a')).toBe(true);
|
||||
expect(getCachedMessages('a')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
// In-memory cache of the most-recently-rendered messages for each
|
||||
// conversation. Survives React component unmount/remount (used by
|
||||
// `useConversationMessages` to initialize state synchronously when
|
||||
// ConversationPage is remounted on chat switch). Session-scoped — lost
|
||||
// on full app reload. The SQLite cache (`messageCache.ts`) is still the
|
||||
// source of truth for cross-session persistence; this layer just shaves
|
||||
// off the round-trip-to-disk spinner flash.
|
||||
//
|
||||
// Two-tier semantics:
|
||||
// * `hasCachedMessages(id)` returns true even for a known-empty chat
|
||||
// so the hook can suppress the loading spinner on re-entry.
|
||||
// * `getCachedMessages(id)` returns a defensive copy so callers can't
|
||||
// mutate the cached array.
|
||||
|
||||
import type { DecryptedMessage } from '@chat-app/shared/chat';
|
||||
|
||||
const cache = new Map<string, DecryptedMessage[]>();
|
||||
|
||||
export function getCachedMessages(conversationId: string): DecryptedMessage[] {
|
||||
const stored = cache.get(conversationId);
|
||||
return stored ? stored.slice() : [];
|
||||
}
|
||||
|
||||
export function hasCachedMessages(conversationId: string): boolean {
|
||||
return cache.has(conversationId);
|
||||
}
|
||||
|
||||
export function setCachedMessages(
|
||||
conversationId: string,
|
||||
messages: DecryptedMessage[],
|
||||
): void {
|
||||
cache.set(conversationId, messages.slice());
|
||||
}
|
||||
|
||||
export function __resetForTests(): void {
|
||||
cache.clear();
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,63 @@
|
||||
import { isTauriRuntime } from './globalShortcut';
|
||||
|
||||
// Pushes the current aggregate unread count to main, which updates the
|
||||
// Tray tooltip and (on Windows) the taskbar overlay icon. No-op
|
||||
// outside the Electron runtime (e.g. browser dev preview) so no guards
|
||||
// needed at call-sites.
|
||||
// Tray tooltip and (on Windows) the taskbar overlay icon. We render the
|
||||
// badge here in the renderer because main has no Canvas2D — painting a
|
||||
// red bubble with the count, encoding to PNG, and handing the buffer
|
||||
// to main keeps the implementation free of a native canvas dependency.
|
||||
//
|
||||
// No-op outside the Electron runtime (e.g. browser dev preview) so no
|
||||
// guards needed at call-sites.
|
||||
export async function updateTrayUnread(count: number): Promise<void> {
|
||||
if (!isTauriRuntime()) return;
|
||||
const n = Math.max(0, Math.floor(count));
|
||||
const badgeDataUrl = n > 0 ? renderBadgePng(n) : null;
|
||||
try {
|
||||
await window.electronAPI.setTrayUnread(Math.max(0, Math.floor(count)));
|
||||
await window.electronAPI.setTrayUnread(n, badgeDataUrl);
|
||||
} catch (err: unknown) {
|
||||
console.warn('updateTrayUnread failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Discord-style red bubble with white count.
|
||||
//
|
||||
// Source is rendered at 64×64 so Windows' high-quality downsample to the
|
||||
// 16×16 taskbar overlay slot retains crisp edges (4× supersampling).
|
||||
// Earlier 32×32 + 2px white ring blurred badly: the ring became a half
|
||||
// pixel at the target size, and the bigger font fell into AA mush. Now
|
||||
// no outer ring (Discord doesn't use one either) and a full-bleed circle.
|
||||
function renderBadgePng(count: number): string | null {
|
||||
if (typeof document === 'undefined') return null;
|
||||
const size = 64;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return null;
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
|
||||
// Solid red bubble, full bleed.
|
||||
const center = size / 2;
|
||||
ctx.beginPath();
|
||||
ctx.arc(center, center, center, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#ef4444';
|
||||
ctx.fill();
|
||||
|
||||
// Count label — Discord parity: cap at 99 with a "+" once we cross it.
|
||||
// Sizes are tuned per glyph count so each variant fills the bubble
|
||||
// without clipping when Windows downsamples to 16×16.
|
||||
const label = count > 99 ? '99+' : String(count);
|
||||
const fontPx = label.length >= 3 ? 30 : label.length === 2 ? 40 : 48;
|
||||
ctx.fillStyle = '#fff';
|
||||
// Segoe UI is the Windows system font; explicit weight 800 keeps the
|
||||
// glyph chunky after downsampling. Fallbacks cover macOS/Linux.
|
||||
ctx.font = `800 ${fontPx}px "Segoe UI", system-ui, -apple-system, sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
// +2 vertical nudge: most system fonts render numerals visually high
|
||||
// relative to the baseline mid-point; the offset re-centres them.
|
||||
ctx.fillText(label, center, center + 2);
|
||||
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
|
||||
import { fetchPeerPublicKeys } from '@chat-app/shared/auth';
|
||||
import {
|
||||
type AttachmentHandle,
|
||||
type DecryptedMessage,
|
||||
@@ -9,11 +9,14 @@ import {
|
||||
MAX_ATTACHMENT_BYTES,
|
||||
type MessageWithCipher,
|
||||
sendEncryptedMessage,
|
||||
shareConvKeyToUser,
|
||||
tryGetConvKey,
|
||||
} from '@chat-app/shared/chat';
|
||||
import { bytesToPgHex, pgBytesToBytes } from '@chat-app/shared/supabase';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { decryptBatch as decryptBatchWorker } from './decryptWorker';
|
||||
import { generateWebPThumb } from './imageCompress';
|
||||
import {
|
||||
loadCachedMessages,
|
||||
persistMessages,
|
||||
@@ -30,8 +33,13 @@ import {
|
||||
shouldGiveUp,
|
||||
subscribeOutbox,
|
||||
} from './messageOutbox';
|
||||
import { devLocalSecretStore } from './secretStore';
|
||||
import {
|
||||
getCachedMessages,
|
||||
hasCachedMessages,
|
||||
setCachedMessages,
|
||||
} from './messageMemoryCache';
|
||||
import { supabase } from './supabase';
|
||||
import { cachedUserKey } from './userIdentity';
|
||||
|
||||
interface State {
|
||||
messages: DecryptedMessage[];
|
||||
@@ -68,13 +76,31 @@ 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;
|
||||
cancelPending: (id: string) => void;
|
||||
} {
|
||||
const [state, setState] = useState<State>({ messages: [], loading: true, error: null });
|
||||
// Initialize from the in-memory cache so a previously-viewed chat shows
|
||||
// content on the very first render after the parent remounts on `:id`
|
||||
// change. `loading` stays true ONLY for never-seen conversations (cache
|
||||
// miss) so the spinner doesn't flash on every chat switch.
|
||||
const [state, setState] = useState<State>(() => {
|
||||
if (conversationId && hasCachedMessages(conversationId)) {
|
||||
return {
|
||||
messages: getCachedMessages(conversationId),
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
return { messages: [], loading: true, error: null };
|
||||
});
|
||||
const [pending, setPending] = useState<OutboxItem[]>(() =>
|
||||
conversationId ? getOutbox(conversationId) : [],
|
||||
);
|
||||
@@ -90,25 +116,115 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
});
|
||||
}, [conversationId]);
|
||||
|
||||
// Load own private key once per (user, device).
|
||||
// Load own user private key once per user.
|
||||
useEffect(() => {
|
||||
privateKeyRef.current = null;
|
||||
if (!userId || !deviceId) return;
|
||||
void loadDevicePrivateKey(devLocalSecretStore, userId, deviceId).then((pk) => {
|
||||
if (!userId) return;
|
||||
void cachedUserKey(userId).then((pk) => {
|
||||
privateKeyRef.current = pk;
|
||||
});
|
||||
}, [userId, deviceId]);
|
||||
}, [userId]);
|
||||
|
||||
// Proactive rewrap sweep: when a conversation opens, walk every accepted
|
||||
// member and ensure the active conv-key has a `recipient_user_id` bundle
|
||||
// for them. Members who are missing one (typically peers who haven't yet
|
||||
// migrated to the per-user key model) get a best-effort wrap from the
|
||||
// local conv-key handle. Closes the legacy migration gap so peer B can
|
||||
// read on first unlock without manual intervention from A.
|
||||
useEffect(() => {
|
||||
if (!conversationId || !userId) return;
|
||||
let cancelled = false;
|
||||
const run = async (): Promise<void> => {
|
||||
// Wait for the private key ref to be populated. Loop with backoff
|
||||
// because it's set asynchronously by another effect; bail if the
|
||||
// conversation switches.
|
||||
for (let i = 0; i < 20 && !privateKeyRef.current && !cancelled; i++) {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
const priv = privateKeyRef.current;
|
||||
if (!priv || cancelled) return;
|
||||
|
||||
try {
|
||||
const { data: convRow, error: convErr } = await supabase
|
||||
.from('conversations')
|
||||
.select('active_key_version')
|
||||
.eq('id', conversationId)
|
||||
.single();
|
||||
if (convErr || !convRow) return;
|
||||
// db-types snapshot predates the active_key_version column; cast via unknown.
|
||||
const version = (convRow as unknown as { active_key_version: number }).active_key_version;
|
||||
|
||||
const handle = await tryGetConvKey(supabase, conversationId, userId, priv, version);
|
||||
if (!handle || cancelled) return;
|
||||
|
||||
const { data: members, error: mErr } = await supabase
|
||||
.from('conversation_members')
|
||||
.select('user_id, accepted')
|
||||
.eq('conversation_id', conversationId);
|
||||
if (mErr || !members) return;
|
||||
const memberIds = (members as Array<{ user_id: string; accepted: boolean }>)
|
||||
.filter((m) => m.accepted && m.user_id !== userId)
|
||||
.map((m) => m.user_id);
|
||||
if (memberIds.length === 0) return;
|
||||
|
||||
const peers = await fetchPeerPublicKeys(supabase, memberIds);
|
||||
for (const peer of peers) {
|
||||
if (cancelled) return;
|
||||
const { count, error: cntErr } = await (
|
||||
supabase as unknown as {
|
||||
from: (t: string) => {
|
||||
select: (s: string, o?: object) => {
|
||||
eq: (...a: unknown[]) => {
|
||||
eq: (...a: unknown[]) => {
|
||||
eq: (
|
||||
...a: unknown[]
|
||||
) => Promise<{ count: number | null; error: unknown }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
)
|
||||
.from('conversation_keys')
|
||||
.select('recipient_user_id', { count: 'exact', head: true })
|
||||
.eq('conversation_id', conversationId)
|
||||
.eq('recipient_user_id', peer.userId)
|
||||
.eq('key_version', version);
|
||||
if (cntErr) continue;
|
||||
if ((count ?? 0) === 0) {
|
||||
try {
|
||||
await shareConvKeyToUser(
|
||||
supabase,
|
||||
conversationId,
|
||||
peer.userId,
|
||||
peer.publicKey,
|
||||
{ userId, privateKey: priv },
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn('proactive rewrap failed for', peer.userId, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('proactive rewrap sweep failed', err);
|
||||
}
|
||||
};
|
||||
void run();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [conversationId, userId]);
|
||||
|
||||
const decryptBatch = useCallback(
|
||||
async (messages: MessageWithCipher[]): Promise<DecryptedMessage[]> => {
|
||||
const priv = privateKeyRef.current;
|
||||
if (!priv || !deviceId || messages.length === 0) {
|
||||
if (!priv || !userId || messages.length === 0) {
|
||||
return messages.map((m) => ({ ...m, plaintext: null }));
|
||||
}
|
||||
return decryptMessages({
|
||||
client: supabase,
|
||||
messages,
|
||||
ownDeviceId: deviceId,
|
||||
ownUserId: userId,
|
||||
ownPrivateKey: priv,
|
||||
// Offload the symmetric decrypt + utf-8 decode to a Web Worker so
|
||||
// the main thread stays responsive during bulk operations (initial
|
||||
@@ -116,7 +232,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
aeadBatchDelegate: decryptBatchWorker,
|
||||
});
|
||||
},
|
||||
[deviceId],
|
||||
[userId],
|
||||
);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
@@ -131,6 +247,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
const rows = await fetchConversationMessages(supabase, conversationId);
|
||||
const decrypted = await decryptBatch(rows);
|
||||
setState({ messages: decrypted, loading: false, error: null });
|
||||
setCachedMessages(conversationId, decrypted);
|
||||
// Persist the fresh batch to the local cache so next conversation
|
||||
// switch / app start can hydrate instantly. Fire-and-forget — cache
|
||||
// write failure is never user-visible.
|
||||
@@ -150,12 +267,17 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
// when the server response lands. On cache-miss this is a ~5ms no-op.
|
||||
useEffect(() => {
|
||||
if (!conversationId) return;
|
||||
// Memory cache already populated state synchronously — skip the disk
|
||||
// round-trip entirely. The canonical data lands shortly via refresh();
|
||||
// the SQLite cache only matters for cold-start hydration.
|
||||
if (hasCachedMessages(conversationId)) return;
|
||||
let cancelled = false;
|
||||
void loadCachedMessages(conversationId).then((cached) => {
|
||||
if (cancelled || cached.length === 0) return;
|
||||
setState((prev) => {
|
||||
// Don't clobber a fresh server response that already landed.
|
||||
if (prev.messages.length > 0) return prev;
|
||||
setCachedMessages(conversationId, cached);
|
||||
return { messages: cached, loading: false, error: null };
|
||||
});
|
||||
});
|
||||
@@ -240,7 +362,9 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
if (!decrypted) return;
|
||||
setState((prev) => {
|
||||
if (prev.messages.some((m) => m.id === decrypted!.id)) return prev;
|
||||
return { ...prev, messages: [...prev.messages, decrypted!] };
|
||||
const next = [...prev.messages, decrypted!];
|
||||
if (conversationId) setCachedMessages(conversationId, next);
|
||||
return { ...prev, messages: next };
|
||||
});
|
||||
},
|
||||
[conversationId, deviceId, decryptBatch],
|
||||
@@ -260,6 +384,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
editedAt: partial.editedAt,
|
||||
deletedAt: partial.deletedAt,
|
||||
};
|
||||
if (conversationId) setCachedMessages(conversationId, next);
|
||||
return { ...prev, messages: next };
|
||||
});
|
||||
if (partial.editedAt && !partial.deletedAt) {
|
||||
@@ -323,6 +448,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
if (idx === -1) return prev;
|
||||
const next = [...prev.messages];
|
||||
next[idx] = decrypted!;
|
||||
if (conversationId) setCachedMessages(conversationId, next);
|
||||
return { ...prev, messages: next };
|
||||
});
|
||||
}
|
||||
@@ -330,14 +456,18 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
[conversationId, deviceId, decryptBatch],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback((row: Record<string, unknown>) => {
|
||||
const id = String(row.id);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
messages: prev.messages.filter((m) => m.id !== id),
|
||||
}));
|
||||
void deleteCachedMessage(id);
|
||||
}, []);
|
||||
const handleDelete = useCallback(
|
||||
(row: Record<string, unknown>) => {
|
||||
const id = String(row.id);
|
||||
setState((prev) => {
|
||||
const next = prev.messages.filter((m) => m.id !== id);
|
||||
if (conversationId) setCachedMessages(conversationId, next);
|
||||
return { ...prev, messages: next };
|
||||
});
|
||||
void deleteCachedMessage(id);
|
||||
},
|
||||
[conversationId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!conversationId || !userId || !deviceId) return;
|
||||
@@ -454,20 +584,24 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
});
|
||||
setState((prev) => {
|
||||
if (prev.messages.some((m) => m.id === msg.id)) return prev;
|
||||
return {
|
||||
...prev,
|
||||
messages: [
|
||||
...prev.messages,
|
||||
{ ...msg, plaintext: text } as DecryptedMessage,
|
||||
],
|
||||
};
|
||||
const next = [
|
||||
...prev.messages,
|
||||
{ ...msg, plaintext: text } as DecryptedMessage,
|
||||
];
|
||||
setCachedMessages(convId, next);
|
||||
return { ...prev, messages: next };
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
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;
|
||||
@@ -523,13 +657,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,
|
||||
@@ -538,7 +682,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));
|
||||
}
|
||||
@@ -564,16 +717,15 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
: JSON.stringify({ v: 1, text: trimmed, attachments: handles });
|
||||
setState((prev) => {
|
||||
if (prev.messages.some((m) => m.id === msg.id)) return prev;
|
||||
return {
|
||||
...prev,
|
||||
messages: [
|
||||
...prev.messages,
|
||||
{
|
||||
...msg,
|
||||
plaintext: attachmentsPayload,
|
||||
} as DecryptedMessage,
|
||||
],
|
||||
};
|
||||
const next = [
|
||||
...prev.messages,
|
||||
{
|
||||
...msg,
|
||||
plaintext: attachmentsPayload,
|
||||
} as DecryptedMessage,
|
||||
];
|
||||
setCachedMessages(conversationId, next);
|
||||
return { ...prev, messages: next };
|
||||
});
|
||||
|
||||
// 4. Insert public attachment metadata rows pointing at the new message.
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it, beforeEach, vi, beforeAll } from 'vitest';
|
||||
|
||||
import { setCryptoBackend } from '@chat-app/shared/crypto';
|
||||
import { makeWasmTestBackend } from '@chat-app/shared/crypto/testBackend';
|
||||
|
||||
beforeAll(async () => { setCryptoBackend(await makeWasmTestBackend()); });
|
||||
|
||||
const memStore: Record<string, Uint8Array> = {};
|
||||
const fakeStore = {
|
||||
getSecret: async (k: string) => memStore[k] ?? null,
|
||||
setSecret: async (k: string, v: Uint8Array) => { memStore[k] = v; },
|
||||
removeSecret: async (k: string) => { delete memStore[k]; },
|
||||
};
|
||||
|
||||
vi.mock('./secretStore', () => ({
|
||||
devLocalSecretStore: fakeStore,
|
||||
setSecretStoreUser: vi.fn(),
|
||||
isEncryptedVaultActive: () => false,
|
||||
}));
|
||||
|
||||
vi.mock('./supabase', () => {
|
||||
const rpcResponses = new Map<string, { data: unknown; error: unknown }>();
|
||||
const rpc = vi.fn((name: string, _params: unknown) =>
|
||||
Promise.resolve(rpcResponses.get(name) ?? { data: null, error: null }),
|
||||
);
|
||||
const supabase = {
|
||||
auth: { getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'me' } }, error: null }) },
|
||||
rpc,
|
||||
from: vi.fn(() => ({
|
||||
select: () => ({ in: () => Promise.resolve({ data: [], error: null }) }),
|
||||
})),
|
||||
};
|
||||
return { supabase, __setRpcResponse: (n: string, r: { data: unknown; error: unknown }) => rpcResponses.set(n, r) };
|
||||
});
|
||||
|
||||
describe('userIdentity', () => {
|
||||
beforeEach(() => { for (const k of Object.keys(memStore)) delete memStore[k]; });
|
||||
|
||||
it('setupNewUserIdentity uploads blob, caches private key in store', async () => {
|
||||
const supabaseMod = await import('./supabase');
|
||||
(supabaseMod as unknown as { __setRpcResponse: (n: string, r: { data: unknown; error: unknown }) => void })
|
||||
.__setRpcResponse('reset_user_key', { data: 0, error: null });
|
||||
const { setupNewUserIdentity, cachedUserKey } = await import('./userIdentity');
|
||||
const result = await setupNewUserIdentity({ userId: 'me', pin: '123456', withRecovery: true });
|
||||
expect(result.publicKey).toHaveLength(32);
|
||||
expect(result.recoveryCode).toMatch(/^[A-Z2-9]{6}-[A-Z2-9]{6}-[A-Z2-9]{6}-[A-Z2-9]{6}$/);
|
||||
const cached = await cachedUserKey('me');
|
||||
expect(cached).toBeInstanceOf(Uint8Array);
|
||||
expect(cached?.length).toBe(32);
|
||||
});
|
||||
|
||||
it('loadOrUnlockUserKey unlocks with correct PIN, throws on wrong PIN', async () => {
|
||||
const { setupNewUserIdentity, clearUserKeyCache, loadOrUnlockUserKey } =
|
||||
await import('./userIdentity');
|
||||
const supabaseMod = await import('./supabase');
|
||||
const setRpc = (supabaseMod as unknown as { __setRpcResponse: (n: string, r: { data: unknown; error: unknown }) => void }).__setRpcResponse;
|
||||
setRpc('reset_user_key', { data: 0, error: null });
|
||||
|
||||
await setupNewUserIdentity({ userId: 'me', pin: '123456', withRecovery: false });
|
||||
const rpc = (supabaseMod.supabase as unknown as { rpc: ReturnType<typeof vi.fn> }).rpc;
|
||||
const lastCall = rpc.mock.calls[rpc.mock.calls.length - 1]!;
|
||||
const params = lastCall[1] as Record<string, string>;
|
||||
|
||||
setRpc('try_unlock_user_key', {
|
||||
data: {
|
||||
exists: true, locked: false,
|
||||
sealed_private_key: params.p_sealed_private_b64,
|
||||
salt: params.p_salt_b64,
|
||||
kdf_params: params.p_kdf_params,
|
||||
recovery_sealed_private_key: null, recovery_salt: null,
|
||||
failed_attempts: 0, failed_recovery_attempts: 0,
|
||||
recovery_locked_until: null, key_version: 1,
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
setRpc('record_pin_attempt', { data: { failed_attempts: 0, locked_until: null }, error: null });
|
||||
|
||||
await clearUserKeyCache('me');
|
||||
const ok = await loadOrUnlockUserKey({ userId: 'me', pin: '123456' });
|
||||
expect(ok.kind).toBe('unlocked');
|
||||
|
||||
await clearUserKeyCache('me');
|
||||
await expect(loadOrUnlockUserKey({ userId: 'me', pin: '654321' })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
import {
|
||||
fetchUserKeyBlob, listOwnDevices, recordPinAttempt,
|
||||
resetUserKey, tryUnlockUserKey, uploadUserKeyBlob,
|
||||
} from '@chat-app/shared/auth';
|
||||
import {
|
||||
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';
|
||||
|
||||
const cacheKey = (userId: string) => `chatapp.userpriv.${userId}`;
|
||||
|
||||
export interface SetupParams { userId: string; pin: string; withRecovery: boolean }
|
||||
export interface SetupResult { publicKey: Uint8Array; recoveryCode: string | null }
|
||||
|
||||
export async function setupNewUserIdentity(p: SetupParams): Promise<SetupResult> {
|
||||
const kp = await generateUserKeyPair();
|
||||
const sealed = await sealUserKey({ privateKey: kp.privateKey, pin: p.pin });
|
||||
let recoveryCode: string | null = null;
|
||||
let recoverySealed: { sealedPrivateKey: Uint8Array; salt: Uint8Array } | null = null;
|
||||
if (p.withRecovery) {
|
||||
recoveryCode = await generateRecoveryCode();
|
||||
const r = await sealUserKey({ privateKey: kp.privateKey, pin: normalizeRecoveryCode(recoveryCode) });
|
||||
recoverySealed = { sealedPrivateKey: r.sealedPrivateKey, salt: r.salt };
|
||||
}
|
||||
await uploadUserKeyBlob(supabase, {
|
||||
userId: p.userId,
|
||||
publicKey: kp.publicKey,
|
||||
sealedPrivateKey: sealed.sealedPrivateKey,
|
||||
salt: sealed.salt,
|
||||
kdfParams: sealed.kdfParams,
|
||||
recoverySealedPrivateKey: recoverySealed?.sealedPrivateKey ?? null,
|
||||
recoverySalt: recoverySealed?.salt ?? null,
|
||||
});
|
||||
await devLocalSecretStore.setSecret(cacheKey(p.userId), kp.privateKey);
|
||||
void ensureLegacyMigrated(p.userId).catch((err) => {
|
||||
console.warn('legacy conv-key migration failed', err);
|
||||
});
|
||||
return { publicKey: kp.publicKey, recoveryCode };
|
||||
}
|
||||
|
||||
// Background, idempotent re-wrap of own legacy conv-key bundles for the new
|
||||
// per-user identity. Safe to call repeatedly: the underlying RPC uses
|
||||
// ON CONFLICT DO NOTHING. Triggered on every successful unlock so users who
|
||||
// upgraded to 0.18.0 (where the .eq(null) bug made setup-time migration a
|
||||
// no-op) auto-recover on the next launch.
|
||||
export async function ensureLegacyMigrated(userId: string): Promise<void> {
|
||||
const priv = await devLocalSecretStore.getSecret(cacheKey(userId));
|
||||
if (!priv) return;
|
||||
const pub = derivePublicKey(priv);
|
||||
await runLegacyMigration(userId, priv, pub);
|
||||
}
|
||||
|
||||
export interface UnlockParams { userId: string; pin: string; isRecoveryCode?: boolean }
|
||||
|
||||
export type UnlockOutcome =
|
||||
| { kind: 'unlocked' }
|
||||
| { kind: 'locked'; lockedUntil: string }
|
||||
| { kind: 'missing' };
|
||||
|
||||
export async function loadOrUnlockUserKey(p: UnlockParams): Promise<UnlockOutcome> {
|
||||
const remote = await tryUnlockUserKey(supabase, p.userId);
|
||||
if (!remote.exists) return { kind: 'missing' };
|
||||
if (remote.locked) return { kind: 'locked', lockedUntil: remote.lockedUntil };
|
||||
const secret = p.isRecoveryCode ? normalizeRecoveryCode(p.pin) : p.pin;
|
||||
const sealed = p.isRecoveryCode ? remote.recoverySealedPrivateKey : remote.sealedPrivateKey;
|
||||
const salt = p.isRecoveryCode ? remote.recoverySalt : remote.salt;
|
||||
if (!sealed || !salt) throw new Error('no recovery blob configured');
|
||||
let priv: Uint8Array;
|
||||
try {
|
||||
priv = await openUserKeyMaybeWorker({ sealed, pin: secret, salt, kdfParams: remote.kdfParams });
|
||||
} catch (err) {
|
||||
await recordPinAttempt(supabase, p.userId, false, p.isRecoveryCode === true).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
await recordPinAttempt(supabase, p.userId, true, p.isRecoveryCode === true).catch(() => {});
|
||||
await devLocalSecretStore.setSecret(cacheKey(p.userId), priv);
|
||||
void ensureLegacyMigrated(p.userId).catch((err) => {
|
||||
console.warn('legacy conv-key migration failed', err);
|
||||
});
|
||||
return { kind: 'unlocked' };
|
||||
}
|
||||
|
||||
export async function cachedUserKey(userId: string): Promise<Uint8Array | null> {
|
||||
return devLocalSecretStore.getSecret(cacheKey(userId));
|
||||
}
|
||||
|
||||
export async function clearUserKeyCache(userId: string): Promise<void> {
|
||||
await devLocalSecretStore.removeSecret(cacheKey(userId));
|
||||
}
|
||||
|
||||
export async function userKeyExistsRemotely(userId: string): Promise<boolean> {
|
||||
const blob = await fetchUserKeyBlob(supabase, userId);
|
||||
return blob !== null;
|
||||
}
|
||||
|
||||
export async function changePin(params: {
|
||||
userId: string; oldPin: string; newPin: string;
|
||||
}): Promise<void> {
|
||||
const cached = await cachedUserKey(params.userId);
|
||||
if (!cached) throw new Error('user key not cached locally — re-login required');
|
||||
const fresh = await sealUserKey({ privateKey: cached, pin: params.newPin });
|
||||
await uploadUserKeyBlob(supabase, {
|
||||
userId: params.userId,
|
||||
publicKey: derivePublicKey(cached),
|
||||
sealedPrivateKey: fresh.sealedPrivateKey,
|
||||
salt: fresh.salt,
|
||||
kdfParams: fresh.kdfParams,
|
||||
});
|
||||
void params.oldPin; // unused: cached key already proves old PIN was correct
|
||||
}
|
||||
|
||||
export async function regenerateRecoveryCode(params: { userId: string }): Promise<string> {
|
||||
const cached = await cachedUserKey(params.userId);
|
||||
if (!cached) throw new Error('user key not cached locally');
|
||||
const blob = await fetchUserKeyBlob(supabase, params.userId);
|
||||
if (!blob || !blob.exists || blob.locked) throw new Error('cannot regenerate recovery while locked');
|
||||
const recoveryCode = await generateRecoveryCode();
|
||||
const sealed = await sealUserKey({ privateKey: cached, pin: normalizeRecoveryCode(recoveryCode) });
|
||||
await uploadUserKeyBlob(supabase, {
|
||||
userId: params.userId,
|
||||
publicKey: derivePublicKey(cached),
|
||||
sealedPrivateKey: blob.sealedPrivateKey,
|
||||
salt: blob.salt,
|
||||
kdfParams: blob.kdfParams,
|
||||
recoverySealedPrivateKey: sealed.sealedPrivateKey,
|
||||
recoverySalt: sealed.salt,
|
||||
});
|
||||
return recoveryCode;
|
||||
}
|
||||
|
||||
export async function resetIdentity(params: { userId: string; pin: string }): Promise<string> {
|
||||
await clearUserKeyCache(params.userId);
|
||||
// resetUserKey already deletes legacy bundles; setupNewUserIdentity uploads
|
||||
// the brand-new blob via the same RPC (UPSERT). After this, no migration
|
||||
// pass runs because all legacy conv-keys are gone.
|
||||
await resetUserKey(supabase, {
|
||||
userId: params.userId,
|
||||
publicKey: new Uint8Array(32), // overwritten by next upload
|
||||
sealedPrivateKey: new Uint8Array(40),
|
||||
salt: new Uint8Array(16),
|
||||
kdfParams: { algo: 'argon2id', preset: 'moderate', opslimit: 1, memlimit: 1 },
|
||||
});
|
||||
const setup = await setupNewUserIdentity({ userId: params.userId, pin: params.pin, withRecovery: true });
|
||||
return setup.recoveryCode ?? '';
|
||||
}
|
||||
|
||||
// Returned by ensureLegacyMigrated and SecurityCenter's manual retry. Lets
|
||||
// the UI surface "X conv-keys re-wrapped, Y stuck because no key in vault."
|
||||
export interface LegacyMigrationReport {
|
||||
serverDevices: number;
|
||||
strongholdKeysFromServerDevices: number;
|
||||
strongholdKeysFromBundleScan: number;
|
||||
attempted: number;
|
||||
migrated: number;
|
||||
noStrongholdKey: number;
|
||||
decryptFailed: number;
|
||||
rpcFailed: number;
|
||||
}
|
||||
|
||||
async function runLegacyMigration(
|
||||
userId: string,
|
||||
ownNewPriv: Uint8Array,
|
||||
ownNewPub: Uint8Array,
|
||||
): Promise<LegacyMigrationReport> {
|
||||
const report: LegacyMigrationReport = {
|
||||
serverDevices: 0,
|
||||
strongholdKeysFromServerDevices: 0,
|
||||
strongholdKeysFromBundleScan: 0,
|
||||
attempted: 0,
|
||||
migrated: 0,
|
||||
noStrongholdKey: 0,
|
||||
decryptFailed: 0,
|
||||
rpcFailed: 0,
|
||||
};
|
||||
|
||||
const devices = await listOwnDevices(supabase);
|
||||
report.serverDevices = devices.length;
|
||||
const ownLegacyDevicePrivateKeys: Record<string, Uint8Array> = {};
|
||||
|
||||
// 1) Try every server-listed device first.
|
||||
for (const d of devices) {
|
||||
const k = await devLocalSecretStore.getSecret(`chatapp.priv.${userId}.${d.id}`);
|
||||
if (k) ownLegacyDevicePrivateKeys[d.id] = k;
|
||||
}
|
||||
report.strongholdKeysFromServerDevices = Object.keys(ownLegacyDevicePrivateKeys).length;
|
||||
|
||||
// 2) Scan our visible un-migrated conversation_keys for distinct
|
||||
// recipient_device_ids and probe stronghold for each. This catches the case
|
||||
// where a device row was deleted server-side but its key remains locally,
|
||||
// OR where listOwnDevices missed a device because of an RLS edge.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { data: scanRowsRaw } = await (supabase as any)
|
||||
.from('conversation_keys')
|
||||
.select('recipient_device_id')
|
||||
.is('recipient_user_id', null)
|
||||
.not('recipient_device_id', 'is', null);
|
||||
const scanIds = Array.from(new Set(
|
||||
((scanRowsRaw ?? []) as { recipient_device_id: string }[])
|
||||
.map((r) => r.recipient_device_id)
|
||||
.filter((id): id is string => !!id),
|
||||
));
|
||||
for (const id of scanIds) {
|
||||
if (ownLegacyDevicePrivateKeys[id]) continue;
|
||||
const k = await devLocalSecretStore.getSecret(`chatapp.priv.${userId}.${id}`);
|
||||
if (k) {
|
||||
ownLegacyDevicePrivateKeys[id] = k;
|
||||
report.strongholdKeysFromBundleScan += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.debug(
|
||||
'[crypto-migration] vault scan:',
|
||||
'serverDevices=' + report.serverDevices,
|
||||
'keysFromServerList=' + report.strongholdKeysFromServerDevices,
|
||||
'extraKeysFromBundleScan=' + report.strongholdKeysFromBundleScan,
|
||||
);
|
||||
|
||||
const ids = Object.keys(ownLegacyDevicePrivateKeys);
|
||||
if (ids.length === 0) {
|
||||
console.warn('[crypto-migration] no legacy private keys in vault — nothing to migrate');
|
||||
return report;
|
||||
}
|
||||
|
||||
const result = await migrateOwnLegacyBundles({
|
||||
client: supabase,
|
||||
ownUserId: userId,
|
||||
ownNewPublicKey: ownNewPub,
|
||||
ownNewPrivateKey: ownNewPriv,
|
||||
ownLegacyDeviceIds: ids,
|
||||
ownLegacyDevicePrivateKeys,
|
||||
});
|
||||
|
||||
report.attempted = result.attempted;
|
||||
report.migrated = result.migratedConversations;
|
||||
report.noStrongholdKey = result.noStrongholdKey;
|
||||
report.decryptFailed = result.decryptFailed;
|
||||
report.rpcFailed = result.rpcFailed;
|
||||
return report;
|
||||
}
|
||||
|
||||
// Public wrapper for SecurityCenter's "Migration erneut versuchen" button.
|
||||
// Returns a structured report so the UI can render numbers and reasons.
|
||||
export async function retryLegacyMigration(userId: string): Promise<LegacyMigrationReport> {
|
||||
const priv = await devLocalSecretStore.getSecret(cacheKey(userId));
|
||||
if (!priv) throw new Error('user key not cached locally — re-login required');
|
||||
const pub = derivePublicKey(priv);
|
||||
return runLegacyMigration(userId, priv, pub);
|
||||
}
|
||||
|
||||
function derivePublicKey(privateKey: Uint8Array): Uint8Array {
|
||||
return getCryptoBackend().scalarMultBase(privateKey);
|
||||
}
|
||||
@@ -20,6 +20,13 @@ export interface VoiceHotkeyBinding {
|
||||
shift: boolean;
|
||||
alt: boolean;
|
||||
enabled: boolean;
|
||||
/**
|
||||
* When true, the hotkey is registered as an OS-level shortcut and fires
|
||||
* even when the app isn't focused. When false (default) the binding only
|
||||
* fires from the window's keydown listener — so e.g. setting "M" as mute
|
||||
* doesn't break typing "m" everywhere else on the system.
|
||||
*/
|
||||
global: boolean;
|
||||
}
|
||||
|
||||
export interface VoiceHotkeys {
|
||||
@@ -36,46 +43,11 @@ export interface VoiceHotkeys {
|
||||
export type VoiceHotkeyKind = keyof VoiceHotkeys;
|
||||
|
||||
const DEFAULTS: VoiceHotkeys = {
|
||||
mute: {
|
||||
key: 'KeyM',
|
||||
keyLabel: 'Ctrl+Shift+M',
|
||||
ctrl: true,
|
||||
shift: true,
|
||||
alt: false,
|
||||
enabled: false,
|
||||
},
|
||||
deafen: {
|
||||
key: 'KeyD',
|
||||
keyLabel: 'Ctrl+Shift+D',
|
||||
ctrl: true,
|
||||
shift: true,
|
||||
alt: false,
|
||||
enabled: false,
|
||||
},
|
||||
hangup: {
|
||||
key: 'KeyH',
|
||||
keyLabel: 'Ctrl+Shift+H',
|
||||
ctrl: true,
|
||||
shift: true,
|
||||
alt: false,
|
||||
enabled: false,
|
||||
},
|
||||
screenShare: {
|
||||
key: 'KeyE',
|
||||
keyLabel: 'Ctrl+Shift+E',
|
||||
ctrl: true,
|
||||
shift: true,
|
||||
alt: false,
|
||||
enabled: false,
|
||||
},
|
||||
video: {
|
||||
key: 'KeyV',
|
||||
keyLabel: 'Ctrl+Shift+V',
|
||||
ctrl: true,
|
||||
shift: true,
|
||||
alt: false,
|
||||
enabled: false,
|
||||
},
|
||||
mute: { key: 'KeyM', keyLabel: 'Ctrl+Shift+M', ctrl: true, shift: true, alt: false, enabled: false, global: false },
|
||||
deafen: { key: 'KeyD', keyLabel: 'Ctrl+Shift+D', ctrl: true, shift: true, alt: false, enabled: false, global: false },
|
||||
hangup: { key: 'KeyH', keyLabel: 'Ctrl+Shift+H', ctrl: true, shift: true, alt: false, enabled: false, global: false },
|
||||
screenShare: { key: 'KeyE', keyLabel: 'Ctrl+Shift+E', ctrl: true, shift: true, alt: false, enabled: false, global: false },
|
||||
video: { key: 'KeyV', keyLabel: 'Ctrl+Shift+V', ctrl: true, shift: true, alt: false, enabled: false, global: false },
|
||||
};
|
||||
|
||||
type Listener = (s: VoiceHotkeys) => void;
|
||||
@@ -93,6 +65,7 @@ function validateBinding(raw: unknown, fallback: VoiceHotkeyBinding): VoiceHotke
|
||||
shift: typeof b.shift === 'boolean' ? b.shift : fallback.shift,
|
||||
alt: typeof b.alt === 'boolean' ? b.alt : fallback.alt,
|
||||
enabled: typeof b.enabled === 'boolean' ? b.enabled : fallback.enabled,
|
||||
global: typeof b.global === 'boolean' ? b.global : fallback.global,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,33 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { fetchChangelog, type ChangelogEntry } from '../lib/changelog';
|
||||
import { SparklesIcon, SpinnerIcon } from '../components/icons';
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
// Installed app version comes from the preload bridge (process.env.npm_-
|
||||
// package_version at preload build time). Falls back to '0.0.0' outside
|
||||
// Electron so the page still renders in a browser preview.
|
||||
const installedVersion = window.electronAPI?.appVersion ?? '0.0.0';
|
||||
|
||||
export function ChangelogPage() {
|
||||
const [entries, setEntries] = useState<ChangelogEntry[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [visible, setVisible] = useState(PAGE_SIZE);
|
||||
|
||||
// Compare the installed version against the top changelog entry. The
|
||||
// server-side changelog is sorted newest-first by the release script, so
|
||||
// entries[0] is always the published latest.
|
||||
const latestVersion = entries?.[0]?.version ?? null;
|
||||
const versionStatus = useMemo<'loading' | 'current' | 'outdated' | 'ahead'>(() => {
|
||||
if (entries === null) return 'loading';
|
||||
if (!latestVersion) return 'current';
|
||||
const cmp = compareSemver(installedVersion, latestVersion);
|
||||
if (cmp === 0) return 'current';
|
||||
if (cmp < 0) return 'outdated';
|
||||
return 'ahead';
|
||||
}, [entries, latestVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
@@ -34,7 +52,7 @@ export function ChangelogPage() {
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-accent/15 text-accent">
|
||||
<SparklesIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex-1">
|
||||
<h1 className="font-display text-2xl font-semibold tracking-tight text-fg">
|
||||
Was ist neu
|
||||
</h1>
|
||||
@@ -42,6 +60,11 @@ export function ChangelogPage() {
|
||||
Alle Änderungen in dieser App, neueste zuerst.
|
||||
</p>
|
||||
</div>
|
||||
<VersionBadge
|
||||
status={versionStatus}
|
||||
installed={installedVersion}
|
||||
latest={latestVersion}
|
||||
/>
|
||||
</header>
|
||||
|
||||
{entries === null && !error && (
|
||||
@@ -118,3 +141,75 @@ function formatDate(iso: string): string {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
// Compact status chip in the header that tells the user whether their
|
||||
// installed build matches the latest published version. Three visual
|
||||
// tones: emerald (current), amber (outdated → update available), neutral
|
||||
// (loading / unknown). The "ahead" case (dev build > released) shares the
|
||||
// neutral tone since users running it always know what they're doing.
|
||||
function VersionBadge({
|
||||
status,
|
||||
installed,
|
||||
latest,
|
||||
}: {
|
||||
status: 'loading' | 'current' | 'outdated' | 'ahead';
|
||||
installed: string;
|
||||
latest: string | null;
|
||||
}) {
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<span className="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-line bg-surface-2 px-2.5 py-1 text-[11px] font-medium tabular-nums text-fg-muted">
|
||||
v{installed}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === 'current') {
|
||||
return (
|
||||
<span
|
||||
title="Du läufst auf der neuesten Version."
|
||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-emerald-500/40 bg-emerald-500/10 px-2.5 py-1 text-[11px] font-semibold tabular-nums text-emerald-700 dark:text-emerald-300"
|
||||
>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
|
||||
v{installed} · aktuell
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === 'outdated' && latest) {
|
||||
return (
|
||||
<span
|
||||
title={`Update verfügbar — neueste Version: v${latest}.`}
|
||||
className="inline-flex shrink-0 flex-col items-end gap-0.5 rounded-md border border-amber-400/50 bg-amber-400/10 px-2.5 py-1 text-[11px] font-semibold tabular-nums text-amber-800 dark:text-amber-200"
|
||||
>
|
||||
<span>v{installed} · Update verfügbar</span>
|
||||
<span className="text-[10px] font-normal opacity-80">neueste: v{latest}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
title="Du läufst auf einer neueren Version als veröffentlicht (z.B. Dev-Build)."
|
||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-line bg-surface-2 px-2.5 py-1 text-[11px] font-medium tabular-nums text-fg-muted"
|
||||
>
|
||||
v{installed}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Lightweight semver comparator: parses major.minor.patch as ints and
|
||||
// compares numerically. Returns negative if a < b, zero if equal, positive
|
||||
// if a > b. Handles malformed inputs by treating non-numeric segments as
|
||||
// 0 so a typo doesn't flag a perfectly current install as outdated.
|
||||
function compareSemver(a: string, b: string): number {
|
||||
const parse = (s: string): [number, number, number] => {
|
||||
const parts = s.split('.').map((p) => {
|
||||
const n = parseInt(p, 10);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
});
|
||||
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
|
||||
};
|
||||
const [aMaj, aMin, aPat] = parse(a);
|
||||
const [bMaj, bMin, bPat] = parse(b);
|
||||
if (aMaj !== bMaj) return aMaj - bMaj;
|
||||
if (aMin !== bMin) return aMin - bMin;
|
||||
return aPat - bPat;
|
||||
}
|
||||
|
||||
@@ -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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user