Compare commits
294 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d5c54166a4 | |||
| f438018400 | |||
| 89003f71a4 | |||
| b364c53c61 | |||
| 9c5456b492 | |||
| b057795735 | |||
| c81a036c4e | |||
| bdc017e609 | |||
| 27160145f9 | |||
| 8ea2cb48e9 | |||
| c3ef995404 | |||
| b4ed3aced0 | |||
| 30d00194be | |||
| 31b394a6e0 | |||
| 271d6fff5c | |||
| 8b8d71bc4d | |||
| 40f36cb182 | |||
| 2372731504 | |||
| 43a99a8d6d | |||
| f73abbd860 | |||
| e822f6f58f | |||
| 255dbdc712 | |||
| 51a8630114 | |||
| d539656535 | |||
| 5bc30c950c | |||
| 588b843904 | |||
| dbf8030e93 | |||
| 615770722e | |||
| f1cba99b9e | |||
| f60c5c676a | |||
| 8e6be3256d | |||
| 508c53b451 | |||
| e2f86bc377 | |||
| 92a6e01a26 | |||
| 3d959aaadf | |||
| 787437c3f1 | |||
| be5647281d | |||
| fc8fc275cb | |||
| e19a71e892 | |||
| 11869be443 | |||
| 3fa8b6dbc1 | |||
| f9f8e3fdb8 | |||
| 4ed3f04300 | |||
| a7ffcbff83 | |||
| 82600915f1 | |||
| 93098a74ca | |||
| 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 |
@@ -7,7 +7,11 @@
|
|||||||
# stopped publishing Tauri releases to this host.
|
# stopped publishing Tauri releases to this host.
|
||||||
|
|
||||||
# Host serving latest.yml + installer artifacts over HTTPS.
|
# Host serving latest.yml + installer artifacts over HTTPS.
|
||||||
UPDATE_HOST=update.netralax.cloud
|
# NOTE: during the .cloud→.de transition the new VPS must ALSO serve the same
|
||||||
|
# artifacts under update.netralax.cloud (point its DNS at the new IP) so that
|
||||||
|
# already-installed clients — which have update.netralax.cloud baked in — can
|
||||||
|
# still pull the release that switches them over to .de.
|
||||||
|
UPDATE_HOST=update.netralax.de
|
||||||
|
|
||||||
# SSH user on UPDATE_HOST with write access to UPDATE_REMOTE_PATH.
|
# SSH user on UPDATE_HOST with write access to UPDATE_REMOTE_PATH.
|
||||||
UPDATE_SSH_USER=chatapp-deploy
|
UPDATE_SSH_USER=chatapp-deploy
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ web-build/
|
|||||||
apps/desktop/out/
|
apps/desktop/out/
|
||||||
apps/desktop/release/
|
apps/desktop/release/
|
||||||
|
|
||||||
|
# Bundle visualizer reports
|
||||||
|
apps/desktop/stats.html
|
||||||
|
apps/desktop/stats.json
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
npm-debug.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 react from '@vitejs/plugin-react';
|
||||||
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
|
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { visualizer } from 'rollup-plugin-visualizer';
|
||||||
|
|
||||||
|
const ANALYZE = process.env.ANALYZE === 'true';
|
||||||
|
|
||||||
const rendererAliases = {
|
const rendererAliases = {
|
||||||
'@': path.resolve(__dirname, './src'),
|
'@': path.resolve(__dirname, './src'),
|
||||||
@@ -44,7 +47,30 @@ export default defineConfig({
|
|||||||
// bundle. Relative base produces `./assets/...` which works in both
|
// bundle. Relative base produces `./assets/...` which works in both
|
||||||
// dev (served from /) and packaged builds.
|
// dev (served from /) and packaged builds.
|
||||||
base: './',
|
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: {
|
resolve: {
|
||||||
alias: rendererAliases,
|
alias: rendererAliases,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -106,6 +106,24 @@ export const CHANNELS = {
|
|||||||
// Used by the call cinema mode to flip the host BrowserWindow into real
|
// Used by the call cinema mode to flip the host BrowserWindow into real
|
||||||
// OS fullscreen so the Windows taskbar / macOS menubar gets covered.
|
// OS fullscreen so the Windows taskbar / macOS menubar gets covered.
|
||||||
WINDOW_SET_FULLSCREEN: 'window:set-fullscreen',
|
WINDOW_SET_FULLSCREEN: 'window:set-fullscreen',
|
||||||
|
|
||||||
|
// Content-protection toggle. Enables/disables OS-level screenshot/screen-
|
||||||
|
// recording block (WDA_MONITOR on Windows, NSWindowSharingNone on macOS)
|
||||||
|
// while a view-once image is being revealed. No-op on Linux X11.
|
||||||
|
WINDOW_SET_CONTENT_PROTECTION: 'window:set-content-protection',
|
||||||
|
|
||||||
|
// 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;
|
} as const;
|
||||||
|
|
||||||
// ---- Screen sources ------------------------------------------------------
|
// ---- Screen sources ------------------------------------------------------
|
||||||
@@ -258,6 +276,12 @@ export interface UpdateProgress {
|
|||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Window content protection -------------------------------------------
|
||||||
|
|
||||||
|
export interface WindowSetContentProtectionArgs {
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Runtime marker ------------------------------------------------------
|
// ---- Runtime marker ------------------------------------------------------
|
||||||
|
|
||||||
/** Value exposed on `window.electronAPI.platform`. Used by the renderer
|
/** Value exposed on `window.electronAPI.platform`. Used by the renderer
|
||||||
|
|||||||
@@ -6,11 +6,13 @@
|
|||||||
// the old Tauri devUrl so nothing in the renderer code needs to change)
|
// the old Tauri devUrl so nothing in the renderer code needs to change)
|
||||||
// and from the built dist in packaged mode.
|
// 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 path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import { CHANNELS } from './ipc-types';
|
||||||
import { register as registerAudioLoopback } from './modules/audio-loopback';
|
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 registerAutostart } from './modules/autostart';
|
||||||
import { register as registerFsScoped } from './modules/fs-scoped';
|
import { register as registerFsScoped } from './modules/fs-scoped';
|
||||||
import { register as registerNotifications } from './modules/notifications';
|
import { register as registerNotifications } from './modules/notifications';
|
||||||
@@ -24,6 +26,7 @@ import { register as registerShortcuts } from './modules/shortcuts';
|
|||||||
import { register as registerSql } from './modules/sql';
|
import { register as registerSql } from './modules/sql';
|
||||||
import { register as registerTray } from './modules/tray';
|
import { register as registerTray } from './modules/tray';
|
||||||
import { register as registerUpdater } from './modules/updater';
|
import { register as registerUpdater } from './modules/updater';
|
||||||
|
import { register as registerWindowContentProtection } from './modules/window-content-protection';
|
||||||
import { register as registerWindowFullscreen } from './modules/window-fullscreen';
|
import { register as registerWindowFullscreen } from './modules/window-fullscreen';
|
||||||
import { attach as attachWindowState, loadState } from './window-state';
|
import { attach as attachWindowState, loadState } from './window-state';
|
||||||
|
|
||||||
@@ -145,6 +148,17 @@ async function createWindow(): Promise<BrowserWindow> {
|
|||||||
attachWindowState(win, WINDOW_STATE_FILE);
|
attachWindowState(win, WINDOW_STATE_FILE);
|
||||||
|
|
||||||
if (!app.isPackaged) {
|
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);
|
await win.loadURL(DEV_URL);
|
||||||
} else {
|
} else {
|
||||||
await win.loadFile(resolveRendererIndex());
|
await win.loadFile(resolveRendererIndex());
|
||||||
@@ -253,6 +267,7 @@ if (!gotLock) {
|
|||||||
registerFsScoped();
|
registerFsScoped();
|
||||||
registerSql();
|
registerSql();
|
||||||
registerAutostart();
|
registerAutostart();
|
||||||
|
registerAppHostname();
|
||||||
|
|
||||||
// Window-dependent registrars — only call once mainWindow exists so
|
// Window-dependent registrars — only call once mainWindow exists so
|
||||||
// event emitters have somewhere to send.
|
// event emitters have somewhere to send.
|
||||||
@@ -260,9 +275,36 @@ if (!gotLock) {
|
|||||||
registerTray(mainWindow);
|
registerTray(mainWindow);
|
||||||
registerUpdater(mainWindow);
|
registerUpdater(mainWindow);
|
||||||
registerWindowFullscreen(mainWindow);
|
registerWindowFullscreen(mainWindow);
|
||||||
|
registerWindowContentProtection(mainWindow);
|
||||||
registerAudioLoopback(mainWindow);
|
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', () => {
|
app.on('window-all-closed', () => {
|
||||||
if (process.platform !== 'darwin') app.quit();
|
if (process.platform !== 'darwin') app.quit();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// app:hostname adapter — returns the OS hostname via Node's `os.hostname()`.
|
||||||
|
// The renderer has no Node access (contextIsolation is ON), so it must ask
|
||||||
|
// main for this value. Used by AuthContext on first launch to populate the
|
||||||
|
// `devices` row with a human-readable default device name.
|
||||||
|
|
||||||
|
import os from 'node:os';
|
||||||
|
|
||||||
|
import { ipcMain } from 'electron';
|
||||||
|
|
||||||
|
import { CHANNELS } from '../ipc-types';
|
||||||
|
|
||||||
|
export function register(): void {
|
||||||
|
ipcMain.handle(CHANNELS.APP_HOSTNAME, (): string | null => {
|
||||||
|
try {
|
||||||
|
const h = os.hostname();
|
||||||
|
return typeof h === 'string' && h.length > 0 ? h : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -6,8 +6,10 @@
|
|||||||
// (<1ms per op for the current workload).
|
// (<1ms per op for the current workload).
|
||||||
//
|
//
|
||||||
// Binding param style: Tauri's plugin-sql used $1, $2... positionals with
|
// Binding param style: Tauri's plugin-sql used $1, $2... positionals with
|
||||||
// bindings as an array. SQLite natively accepts $N so existing queries
|
// bindings as an array. SQLite parses `$NAME` as a NAMED parameter
|
||||||
// keep working unmodified.
|
// (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 { app, ipcMain } from 'electron';
|
||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
@@ -40,6 +42,20 @@ function requireHandle(h: string): Handle {
|
|||||||
return entry;
|
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 {
|
export function register(): void {
|
||||||
ipcMain.handle(CHANNELS.SQL_LOAD, async (_evt, args: SqlLoadArgs): Promise<string> => {
|
ipcMain.handle(CHANNELS.SQL_LOAD, async (_evt, args: SqlLoadArgs): Promise<string> => {
|
||||||
const rawName = stripPrefix(args.name);
|
const rawName = stripPrefix(args.name);
|
||||||
@@ -59,7 +75,8 @@ export function register(): void {
|
|||||||
async (_evt, args: SqlExecuteArgs): Promise<SqlExecuteResult> => {
|
async (_evt, args: SqlExecuteArgs): Promise<SqlExecuteResult> => {
|
||||||
const entry = requireHandle(args.handle);
|
const entry = requireHandle(args.handle);
|
||||||
const stmt = entry.db.prepare(args.query);
|
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 {
|
return {
|
||||||
rowsAffected: info.changes,
|
rowsAffected: info.changes,
|
||||||
lastInsertId:
|
lastInsertId:
|
||||||
@@ -75,7 +92,11 @@ export function register(): void {
|
|||||||
async (_evt, args: SqlSelectArgs): Promise<SqlSelectResult> => {
|
async (_evt, args: SqlSelectArgs): Promise<SqlSelectResult> => {
|
||||||
const entry = requireHandle(args.handle);
|
const entry = requireHandle(args.handle);
|
||||||
const stmt = entry.db.prepare(args.query);
|
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;
|
return rows;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -30,6 +30,17 @@ function resolveIconPathDev(): string {
|
|||||||
return path.join(app.getAppPath(), 'resources', 'icon.ico');
|
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 {
|
function loadTrayIcon(): NativeImage {
|
||||||
for (const p of [resolveIconPath(), resolveIconPathDev()]) {
|
for (const p of [resolveIconPath(), resolveIconPathDev()]) {
|
||||||
try {
|
try {
|
||||||
@@ -39,7 +50,7 @@ function loadTrayIcon(): NativeImage {
|
|||||||
/* try next */
|
/* try next */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nativeImage.createEmpty();
|
return nativeImage.createFromBuffer(Buffer.from(FALLBACK_TRAY_PNG_BASE64, 'base64'));
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildOverlay(): NativeImage {
|
function buildOverlay(): NativeImage {
|
||||||
@@ -53,35 +64,49 @@ function buildOverlay(): NativeImage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function register(mainWindow: BrowserWindow): void {
|
export function register(mainWindow: BrowserWindow): void {
|
||||||
const icon = loadTrayIcon();
|
// Tray icon is best-effort: if neither the packaged resource nor the
|
||||||
trayRef = new Tray(icon.isEmpty() ? nativeImage.createEmpty() : icon);
|
// dev path resolves (e.g. icon.ico isn't shipped under resourcesPath
|
||||||
trayRef.setToolTip('Netralax');
|
// 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([
|
const menu = Menu.buildFromTemplate([
|
||||||
{
|
{
|
||||||
label: 'Open',
|
label: 'Open',
|
||||||
click: (): void => {
|
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.isDestroyed()) return;
|
||||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||||
mainWindow.show();
|
if (mainWindow.isVisible()) mainWindow.focus();
|
||||||
mainWindow.focus();
|
else mainWindow.show();
|
||||||
},
|
});
|
||||||
},
|
}
|
||||||
{ type: 'separator' },
|
} catch (err: unknown) {
|
||||||
{
|
// Swallow: the overlay badge below is the user-visible bit. A missing
|
||||||
label: 'Quit',
|
// systray icon is cosmetic and shouldn't take the unread handler with it.
|
||||||
click: (): void => {
|
console.warn('[tray] systray init failed, overlay badge still active', err);
|
||||||
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();
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
CHANNELS.TRAY_UNREAD,
|
CHANNELS.TRAY_UNREAD,
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// Window content-protection adapter. Enables / disables OS-level
|
||||||
|
// screenshot and screen-recording blocking on the host BrowserWindow.
|
||||||
|
//
|
||||||
|
// Windows: WDA_MONITOR (SetWindowDisplayAffinity) — the window surface
|
||||||
|
// appears black in any screen capture tool (OBS, Snipping Tool,
|
||||||
|
// Win+PrtScr, etc.) while protection is enabled.
|
||||||
|
// macOS: NSWindowSharingNone — equivalent coverage for QuickTime,
|
||||||
|
// Cmd+Shift+3/4, and external recorders.
|
||||||
|
// Linux: No-op. Electron exposes the API on all platforms but the
|
||||||
|
// X11/Wayland compositors don't honour it in Electron 33.
|
||||||
|
//
|
||||||
|
// Called by the renderer during view-once image reveals so the image
|
||||||
|
// cannot be captured by an OS-level screenshot while it is on screen.
|
||||||
|
|
||||||
|
import { BrowserWindow, ipcMain } from 'electron';
|
||||||
|
|
||||||
|
import { CHANNELS, type WindowSetContentProtectionArgs } from '../ipc-types';
|
||||||
|
|
||||||
|
export function register(mainWindow: BrowserWindow): void {
|
||||||
|
ipcMain.handle(
|
||||||
|
CHANNELS.WINDOW_SET_CONTENT_PROTECTION,
|
||||||
|
(_evt, args: WindowSetContentProtectionArgs) => {
|
||||||
|
// Electron's setContentProtection covers Windows (WDA_MONITOR) and
|
||||||
|
// macOS (NSWindowSharingNone) in one call. No-op on Linux X11.
|
||||||
|
// Wrapped in try/catch because the window can already be destroyed
|
||||||
|
// by the time this fires during a teardown.
|
||||||
|
try {
|
||||||
|
const win = BrowserWindow.fromWebContents(_evt.sender) ?? mainWindow;
|
||||||
|
if (!win || win.isDestroyed()) return;
|
||||||
|
win.setContentProtection(args.enabled);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('setContentProtection failed', err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
+17
@@ -95,6 +95,23 @@ export interface ElectronAPI {
|
|||||||
setAutoStart: (enabled: boolean) => Promise<void>;
|
setAutoStart: (enabled: boolean) => Promise<void>;
|
||||||
|
|
||||||
setFullscreen: (enabled: boolean) => Promise<void>;
|
setFullscreen: (enabled: boolean) => Promise<void>;
|
||||||
|
|
||||||
|
/** Block OS-level screen capture (Win+PrtScr, OBS, etc.) while a
|
||||||
|
* view-once image is being revealed. Covers Windows (WDA_MONITOR) and
|
||||||
|
* macOS (NSWindowSharingNone). No-op on Linux X11. Optional: always
|
||||||
|
* feature-check because the web build has no preload bridge. */
|
||||||
|
setContentProtection?: (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 {
|
declare global {
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ import {
|
|||||||
type UpdateCheckResult,
|
type UpdateCheckResult,
|
||||||
type UpdateProgress,
|
type UpdateProgress,
|
||||||
} from './ipc-types';
|
} 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;
|
type Unsubscribe = () => void;
|
||||||
|
|
||||||
@@ -40,7 +47,7 @@ function on<T>(channel: string, cb: (payload: T) => void): Unsubscribe {
|
|||||||
const api = {
|
const api = {
|
||||||
platform: ELECTRON_RUNTIME_MARKER,
|
platform: ELECTRON_RUNTIME_MARKER,
|
||||||
osPlatform: process.platform as NodeJS.Platform,
|
osPlatform: process.platform as NodeJS.Platform,
|
||||||
appVersion: process.env.npm_package_version ?? '0.0.0',
|
appVersion: pkg.version,
|
||||||
|
|
||||||
// Screen sources ---------------------------------------------------------
|
// Screen sources ---------------------------------------------------------
|
||||||
getScreenSources: (): Promise<ScreenSource[]> => ipcRenderer.invoke(CHANNELS.SCREEN_GET_SOURCES),
|
getScreenSources: (): Promise<ScreenSource[]> => ipcRenderer.invoke(CHANNELS.SCREEN_GET_SOURCES),
|
||||||
@@ -152,6 +159,31 @@ const api = {
|
|||||||
// Window fullscreen ------------------------------------------------------
|
// Window fullscreen ------------------------------------------------------
|
||||||
setFullscreen: (enabled: boolean): Promise<void> =>
|
setFullscreen: (enabled: boolean): Promise<void> =>
|
||||||
ipcRenderer.invoke(CHANNELS.WINDOW_SET_FULLSCREEN, enabled),
|
ipcRenderer.invoke(CHANNELS.WINDOW_SET_FULLSCREEN, enabled),
|
||||||
|
|
||||||
|
// Window content protection ----------------------------------------------
|
||||||
|
setContentProtection: (enabled: boolean): Promise<void> =>
|
||||||
|
ipcRenderer.invoke(CHANNELS.WINDOW_SET_CONTENT_PROTECTION, { 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;
|
} as const;
|
||||||
|
|
||||||
export type ElectronAPI = typeof api;
|
export type ElectronAPI = typeof api;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@chat-app/desktop",
|
"name": "@chat-app/desktop",
|
||||||
"version": "0.17.1",
|
"version": "0.21.11",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Electron desktop client (Windows / macOS / Linux)",
|
"description": "Electron desktop client (Windows / macOS / Linux)",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -24,7 +24,9 @@
|
|||||||
"@livekit/components-react": "^2.9.0",
|
"@livekit/components-react": "^2.9.0",
|
||||||
"@livekit/track-processors": "^0.7.2",
|
"@livekit/track-processors": "^0.7.2",
|
||||||
"@supabase/supabase-js": "^2.46.0",
|
"@supabase/supabase-js": "^2.46.0",
|
||||||
|
"@tanstack/react-virtual": "^3.10.0",
|
||||||
"better-sqlite3": "^11.3.0",
|
"better-sqlite3": "^11.3.0",
|
||||||
|
"canvas-confetti": "^1.9.4",
|
||||||
"electron-updater": "^6.3.0",
|
"electron-updater": "^6.3.0",
|
||||||
"i18next": "^23.16.4",
|
"i18next": "^23.16.4",
|
||||||
"libsodium-wrappers-sumo": "0.7.15",
|
"libsodium-wrappers-sumo": "0.7.15",
|
||||||
@@ -38,6 +40,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.0",
|
"@types/better-sqlite3": "^7.6.0",
|
||||||
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@types/libsodium-wrappers": "^0.7.14",
|
"@types/libsodium-wrappers": "^0.7.14",
|
||||||
"@types/libsodium-wrappers-sumo": "^0.8.2",
|
"@types/libsodium-wrappers-sumo": "^0.8.2",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
@@ -49,6 +52,7 @@
|
|||||||
"electron-vite": "^2.3.0",
|
"electron-vite": "^2.3.0",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
"rimraf": "^6.0.0",
|
"rimraf": "^6.0.0",
|
||||||
|
"rollup-plugin-visualizer": "^7.0.1",
|
||||||
"tailwindcss": "^3.4.15",
|
"tailwindcss": "^3.4.15",
|
||||||
"vite": "^5.4.11"
|
"vite": "^5.4.11"
|
||||||
},
|
},
|
||||||
@@ -91,7 +95,7 @@
|
|||||||
"publish": [
|
"publish": [
|
||||||
{
|
{
|
||||||
"provider": "generic",
|
"provider": "generic",
|
||||||
"url": "https://update.netralax.cloud/windows/"
|
"url": "https://update.netralax.de/windows/"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { lazy, Suspense } from 'react';
|
import { lazy, Suspense, useEffect } 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 { AppShell } from './components/AppShell';
|
||||||
import { CrashToast } from './components/CrashToast';
|
import { CrashToast } from './components/CrashToast';
|
||||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||||
|
import { RemoteRevokedScreen } from './components/RemoteRevokedScreen';
|
||||||
import { SpinnerIcon } from './components/icons';
|
import { SpinnerIcon } from './components/icons';
|
||||||
import { UpdateToast } from './components/UpdateToast';
|
import { UpdateToast } from './components/UpdateToast';
|
||||||
import { RequireAdmin, RequireAuth, RequireDevice } from './components/guards';
|
import { RequireAdmin, RequireAuth, RequireDevice } from './components/guards';
|
||||||
@@ -12,6 +13,7 @@ import { CallProvider } from './context/CallContext';
|
|||||||
import { ConversationsProvider } from './context/ConversationsContext';
|
import { ConversationsProvider } from './context/ConversationsContext';
|
||||||
import { FriendshipsProvider } from './context/FriendshipsContext';
|
import { FriendshipsProvider } from './context/FriendshipsContext';
|
||||||
import { ThemeProvider } from './context/ThemeContext';
|
import { ThemeProvider } from './context/ThemeContext';
|
||||||
|
import { hydrateDrafts } from './lib/composerDraftStore';
|
||||||
import { AuthPage } from './pages/AuthPage';
|
import { AuthPage } from './pages/AuthPage';
|
||||||
import { ChatsEmptyState, ChatsPage } from './pages/ChatsPage';
|
import { ChatsEmptyState, ChatsPage } from './pages/ChatsPage';
|
||||||
import { ConversationPage } from './pages/ConversationPage';
|
import { ConversationPage } from './pages/ConversationPage';
|
||||||
@@ -59,7 +61,23 @@ 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() {
|
export function App() {
|
||||||
|
useEffect(() => {
|
||||||
|
void hydrateDrafts();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary scope="root">
|
<ErrorBoundary scope="root">
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
@@ -113,7 +131,7 @@ export function App() {
|
|||||||
path=":id"
|
path=":id"
|
||||||
element={
|
element={
|
||||||
<ErrorBoundary scope="conversation">
|
<ErrorBoundary scope="conversation">
|
||||||
<ConversationPage />
|
<ConversationRoute />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -168,6 +186,7 @@ export function App() {
|
|||||||
</Routes>
|
</Routes>
|
||||||
<UpdateToast />
|
<UpdateToast />
|
||||||
<CrashToast />
|
<CrashToast />
|
||||||
|
<RemoteRevokedScreen />
|
||||||
</HashRouter>
|
</HashRouter>
|
||||||
</CallProvider>
|
</CallProvider>
|
||||||
</ConversationsProvider>
|
</ConversationsProvider>
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
|
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { useIdleAutoLock } from '../hooks/useIdleAutoLock';
|
||||||
import { startConversationKeySync } from '../lib/conversationKeySync';
|
import { startConversationKeySync } from '../lib/conversationKeySync';
|
||||||
import { startDeviceApprovalListener } from '../lib/deviceApproval';
|
import { startDeviceApprovalListener } from '../lib/deviceApproval';
|
||||||
|
import { ensureInstallId } from '../lib/installId';
|
||||||
import { ensureNotificationPermission } from '../lib/osNotify';
|
import { ensureNotificationPermission } from '../lib/osNotify';
|
||||||
import { devLocalSecretStore } from '../lib/secretStore';
|
import { useMentionNotifications } from '../lib/useMentionNotifications';
|
||||||
import { BackupPromptBanner } from './BackupPromptBanner';
|
import { cachedUserKey } from '../lib/userIdentity';
|
||||||
import { CallUI } from './CallUI';
|
import { CallUI } from './CallUI';
|
||||||
import { DeviceApprovalBanner } from './DeviceApprovalBanner';
|
import { DeviceApprovalBanner } from './DeviceApprovalBanner';
|
||||||
import { Sidebar } from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
const { session, device } = useAuth();
|
const { session } = useAuth();
|
||||||
|
useIdleAutoLock();
|
||||||
|
useMentionNotifications(session?.user.id);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Prompt once per authenticated shell mount. Module-level guard prevents
|
// Prompt once per authenticated shell mount. Module-level guard prevents
|
||||||
// re-asking if the user already responded this session.
|
// re-asking if the user already responded this session.
|
||||||
@@ -21,20 +24,25 @@ export function AppShell() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!session?.user.id || !device?.id) return;
|
if (!session?.user.id) return;
|
||||||
const userId = session.user.id;
|
const userId = session.user.id;
|
||||||
const deviceId = device.id;
|
// Per-install id replaces the per-device record now that crypto is
|
||||||
const stopKeySync = startConversationKeySync(userId, deviceId);
|
// 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({
|
const stopApproval = startDeviceApprovalListener({
|
||||||
ownUserId: userId,
|
ownUserId: userId,
|
||||||
ownDeviceId: deviceId,
|
ownDeviceId: installId,
|
||||||
getPriv: () => loadDevicePrivateKey(devLocalSecretStore, userId, deviceId),
|
getPriv: () => cachedUserKey(userId),
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
stopKeySync();
|
stopKeySync();
|
||||||
stopApproval();
|
stopApproval();
|
||||||
};
|
};
|
||||||
}, [session?.user.id, device?.id]);
|
}, [session?.user.id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex min-h-screen overflow-hidden bg-surface text-fg">
|
<div className="relative flex min-h-screen overflow-hidden bg-surface text-fg">
|
||||||
@@ -48,7 +56,6 @@ export function AppShell() {
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<CallUI />
|
<CallUI />
|
||||||
<BackupPromptBanner />
|
|
||||||
<DeviceApprovalBanner />
|
<DeviceApprovalBanner />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/s
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
|
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
|
||||||
|
import { getVoiceSpeed, setVoiceSpeed, VOICE_SPEEDS, type VoiceSpeed } from '../lib/voiceSpeedSettings';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
import { AlertIcon, MicIcon, SpinnerIcon } from './icons';
|
import { AlertIcon, MicIcon, SpinnerIcon } from './icons';
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ export function AttachmentAudio({ handle }: Props) {
|
|||||||
const [duration, setDuration] = useState<number>(0);
|
const [duration, setDuration] = useState<number>(0);
|
||||||
const [position, setPosition] = useState<number>(0);
|
const [position, setPosition] = useState<number>(0);
|
||||||
const [playing, setPlaying] = useState(false);
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const [speed, setSpeed] = useState<VoiceSpeed>(() => getVoiceSpeed());
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -105,6 +107,12 @@ export function AttachmentAudio({ handle }: Props) {
|
|||||||
};
|
};
|
||||||
}, [arrayBuf]);
|
}, [arrayBuf]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = audioRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.playbackRate = speed;
|
||||||
|
}, [speed, blobUrl]);
|
||||||
|
|
||||||
const fallbackPeaks = useMemo(
|
const fallbackPeaks = useMemo(
|
||||||
() => (peaks ? null : Array.from({ length: BAR_COUNT }, () => 0.5)),
|
() => (peaks ? null : Array.from({ length: BAR_COUNT }, () => 0.5)),
|
||||||
[peaks],
|
[peaks],
|
||||||
@@ -154,6 +162,29 @@ export function AttachmentAudio({ handle }: Props) {
|
|||||||
<PlayGlyph />
|
<PlayGlyph />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
<div className="flex shrink-0 items-center gap-0.5 rounded-md bg-surface-3 p-0.5 text-[10px] font-semibold text-fg-muted">
|
||||||
|
{VOICE_SPEEDS.map((s) => {
|
||||||
|
const active = s === speed;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSpeed(s);
|
||||||
|
setVoiceSpeed(s);
|
||||||
|
}}
|
||||||
|
className={
|
||||||
|
'flex h-6 w-7 cursor-pointer items-center justify-center rounded transition ' +
|
||||||
|
(active ? 'bg-accent text-accent-fg' : 'hover:bg-surface hover:text-fg')
|
||||||
|
}
|
||||||
|
aria-pressed={active}
|
||||||
|
title={'Wiedergabegeschwindigkeit ' + s + '×'}
|
||||||
|
>
|
||||||
|
{s}×
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||||
<div
|
<div
|
||||||
role="slider"
|
role="slider"
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
|
import {
|
||||||
import { useEffect, useState } from 'react';
|
type AttachmentHandle,
|
||||||
|
downloadAndDecryptAttachment,
|
||||||
|
downloadAndDecryptAttachmentThumb,
|
||||||
|
} from '@chat-app/shared/chat';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
|
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
|
||||||
import { supabase } from '../lib/supabase';
|
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 {
|
interface Props {
|
||||||
handle: AttachmentHandle;
|
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
|
// 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 [fullUrl, setFullUrl] = useState<string | null>(null);
|
||||||
const [thumbUrl, setThumbUrl] = useState<string | null>(null);
|
const [thumbUrl, setThumbUrl] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const created: string[] = [];
|
const created: string[] = [];
|
||||||
@@ -62,9 +84,39 @@ export function AttachmentImage({ handle }: Props) {
|
|||||||
return u;
|
return u;
|
||||||
};
|
};
|
||||||
|
|
||||||
// OPFS cache → decrypt → generate thumbnail for inline display.
|
const thumbCacheId = handle.id + '-thumb';
|
||||||
// Lightbox swaps to the full blob when opened.
|
|
||||||
void (async () => {
|
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);
|
const cached = await getCachedAttachment(handle.id);
|
||||||
let blob: Blob;
|
let blob: Blob;
|
||||||
if (cached) {
|
if (cached) {
|
||||||
@@ -83,10 +135,14 @@ export function AttachmentImage({ handle }: Props) {
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const full = take(blob);
|
const full = take(blob);
|
||||||
setFullUrl(full);
|
setFullUrl(full);
|
||||||
const thumb = await makeThumbnail(blob);
|
// Pre-Phase-6B fallback: no server thumb shipped, so re-derive a
|
||||||
if (cancelled) return;
|
// smaller preview on the client to keep memory pressure down.
|
||||||
if (thumb) {
|
if (!hasServerThumb) {
|
||||||
setThumbUrl(take(thumb));
|
const thumb = await makeThumbnail(blob);
|
||||||
|
if (cancelled) return;
|
||||||
|
if (thumb) {
|
||||||
|
setThumbUrl(take(thumb));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -94,7 +150,74 @@ export function AttachmentImage({ handle }: Props) {
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
for (const u of created) URL.revokeObjectURL(u);
|
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;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -131,47 +273,18 @@ export function AttachmentImage({ handle }: Props) {
|
|||||||
className="block h-auto max-h-80 w-auto max-w-full object-contain"
|
className="block h-auto max-h-80 w-auto max-w-full object-contain"
|
||||||
/>
|
/>
|
||||||
</button>
|
</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 }) {
|
// Lightbox extracted to ./Lightbox.tsx so the settings avatar preview and
|
||||||
useEffect(() => {
|
// any future surface can reuse the same dialog without duplication.
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
// Reusable avatar that prefers an uploaded image and falls back to a coloured
|
// Reusable avatar that prefers an uploaded image and falls back to a coloured
|
||||||
// letter circle. Use this everywhere the app needs to render a profile.
|
// letter circle. Use this everywhere the app needs to render a profile.
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { useCachedAvatarUrl } from '../lib/avatarCache';
|
import { useCachedAvatarUrl } from '../lib/avatarCache';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -11,6 +13,11 @@ interface Props {
|
|||||||
// brand-tinted fallback; callers can override (e.g. to colour-by-id).
|
// brand-tinted fallback; callers can override (e.g. to colour-by-id).
|
||||||
fallbackClass?: string;
|
fallbackClass?: string;
|
||||||
alt?: 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({
|
export function Avatar({
|
||||||
@@ -19,15 +26,24 @@ export function Avatar({
|
|||||||
className = 'h-10 w-10',
|
className = 'h-10 w-10',
|
||||||
fallbackClass = 'bg-accent/20 text-accent',
|
fallbackClass = 'bg-accent/20 text-accent',
|
||||||
alt,
|
alt,
|
||||||
|
loading = 'lazy',
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const effectiveUrl = useCachedAvatarUrl(url);
|
const effectiveUrl = useCachedAvatarUrl(url);
|
||||||
if (effectiveUrl) {
|
// If the image URL is non-empty but unreachable (e.g. the storage object is
|
||||||
|
// missing / 404s), the bare <img> would render broken with no fallback.
|
||||||
|
// Track a load error and degrade to the letter circle instead. Reset on URL
|
||||||
|
// change so a fresh, valid avatar is retried.
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
useEffect(() => setFailed(false), [effectiveUrl]);
|
||||||
|
if (effectiveUrl && !failed) {
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src={effectiveUrl}
|
src={effectiveUrl}
|
||||||
alt={alt ?? displayName ?? ''}
|
alt={alt ?? displayName ?? ''}
|
||||||
className={'shrink-0 rounded-full object-cover ' + className}
|
className={'shrink-0 rounded-full object-cover ' + className}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
|
loading={loading}
|
||||||
|
onError={() => setFailed(true)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CaptionsIcon,
|
|
||||||
HeadphonesIcon,
|
HeadphonesIcon,
|
||||||
HeadphonesOffIcon,
|
HeadphonesOffIcon,
|
||||||
MicIcon,
|
MicIcon,
|
||||||
@@ -32,10 +31,6 @@ interface Props {
|
|||||||
/** Toggle the in-call soundboard popover. Active = panel currently open. */
|
/** Toggle the in-call soundboard popover. Active = panel currently open. */
|
||||||
onToggleSoundboard?: () => void;
|
onToggleSoundboard?: () => void;
|
||||||
soundboardOpen?: boolean;
|
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;
|
participantsOpen?: boolean;
|
||||||
/** Compact variant used inside the docked call (36px buttons). */
|
/** Compact variant used inside the docked call (36px buttons). */
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
@@ -59,8 +54,6 @@ export function CallControls({
|
|||||||
onOpenParticipants,
|
onOpenParticipants,
|
||||||
onToggleSoundboard,
|
onToggleSoundboard,
|
||||||
soundboardOpen = false,
|
soundboardOpen = false,
|
||||||
onToggleCaptions,
|
|
||||||
captionsOn = false,
|
|
||||||
participantsOpen = false,
|
participantsOpen = false,
|
||||||
compact = false,
|
compact = false,
|
||||||
glass = false,
|
glass = false,
|
||||||
@@ -148,22 +141,6 @@ export function CallControls({
|
|||||||
<MusicIcon className="h-5 w-5" />
|
<MusicIcon className="h-5 w-5" />
|
||||||
</CallButton>
|
</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 && (
|
{onOpenParticipants && (
|
||||||
<CallButton
|
<CallButton
|
||||||
label={t('app:call.participants', { defaultValue: 'Teilnehmer' })}
|
label={t('app:call.participants', { defaultValue: 'Teilnehmer' })}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
import { useNickname } from '../lib/friendNicknames';
|
||||||
import {
|
import {
|
||||||
CrownIcon,
|
CrownIcon,
|
||||||
HeadphonesOffIcon,
|
HeadphonesOffIcon,
|
||||||
@@ -89,6 +90,7 @@ export interface ParticipantTileProps {
|
|||||||
|
|
||||||
export function CallParticipantTile(props: ParticipantTileProps) {
|
export function CallParticipantTile(props: ParticipantTileProps) {
|
||||||
const {
|
const {
|
||||||
|
userId,
|
||||||
displayName,
|
displayName,
|
||||||
me,
|
me,
|
||||||
muted,
|
muted,
|
||||||
@@ -107,6 +109,13 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
|||||||
onContextMenu,
|
onContextMenu,
|
||||||
} = props;
|
} = 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';
|
const small = size === 'small';
|
||||||
// Discord-style: full tile border switches to emerald the whole time the
|
// Discord-style: full tile border switches to emerald the whole time the
|
||||||
// user is speaking. Same treatment for audio + video tiles so the visual
|
// user is speaking. Same treatment for audio + video tiles so the visual
|
||||||
@@ -132,9 +141,9 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{video ? (
|
{video ? (
|
||||||
<VideoStub {...props} small={small} fit={focused ? 'contain' : 'cover'} />
|
<VideoStub {...tileProps} small={small} fit={focused ? 'contain' : 'cover'} />
|
||||||
) : (
|
) : (
|
||||||
<AudioContent {...props} small={small} />
|
<AudioContent {...tileProps} small={small} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{speaking && (
|
{speaking && (
|
||||||
@@ -187,7 +196,7 @@ export function CallParticipantTile(props: ParticipantTileProps) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{displayName}
|
{resolvedName}
|
||||||
{me ? ' (du)' : ''}
|
{me ? ' (du)' : ''}
|
||||||
</span>
|
</span>
|
||||||
{e2ee && (
|
{e2ee && (
|
||||||
@@ -282,6 +291,7 @@ function AudioContent({
|
|||||||
src={avatarUrl}
|
src={avatarUrl}
|
||||||
alt=""
|
alt=""
|
||||||
className="relative h-full w-full rounded-full object-cover"
|
className="relative h-full w-full rounded-full object-cover"
|
||||||
|
loading="eager"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span
|
<span
|
||||||
@@ -357,7 +367,7 @@ function VideoStub({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{avatarUrl ? (
|
{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
|
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 { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useCall } from '../context/CallContext';
|
import { useCall } from '../context/CallContext';
|
||||||
|
import { useNickname } from '../lib/friendNicknames';
|
||||||
import type { PeerPresence } from '../lib/usePeerPresence';
|
import type { PeerPresence } from '../lib/usePeerPresence';
|
||||||
import { Avatar } from './Avatar';
|
import { Avatar } from './Avatar';
|
||||||
import {
|
import {
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
UsersIcon,
|
UsersIcon,
|
||||||
VideoIcon,
|
VideoIcon,
|
||||||
} from './icons';
|
} from './icons';
|
||||||
|
import { PinnedMessagesPill } from './PinnedMessagesPill';
|
||||||
|
|
||||||
const PRESENCE_DOT: Record<PresenceState, string> = {
|
const PRESENCE_DOT: Record<PresenceState, string> = {
|
||||||
online: 'bg-emerald-500',
|
online: 'bg-emerald-500',
|
||||||
@@ -29,6 +31,8 @@ interface Props {
|
|||||||
onMediaClick?: () => void;
|
onMediaClick?: () => void;
|
||||||
onProfileClick?: (ev: React.MouseEvent) => void;
|
onProfileClick?: (ev: React.MouseEvent) => void;
|
||||||
onSearchClick?: () => void;
|
onSearchClick?: () => void;
|
||||||
|
pinnedCount?: number;
|
||||||
|
onOpenPinned?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConversationHeader({
|
export function ConversationHeader({
|
||||||
@@ -38,6 +42,8 @@ export function ConversationHeader({
|
|||||||
onMediaClick,
|
onMediaClick,
|
||||||
onProfileClick,
|
onProfileClick,
|
||||||
onSearchClick,
|
onSearchClick,
|
||||||
|
pinnedCount = 0,
|
||||||
|
onOpenPinned,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
if (!conversation) {
|
if (!conversation) {
|
||||||
return <header className="h-[65px] border-b border-line px-6 py-3" aria-busy="true" />;
|
return <header className="h-[65px] border-b border-line px-6 py-3" aria-busy="true" />;
|
||||||
@@ -48,10 +54,12 @@ export function ConversationHeader({
|
|||||||
<HeaderBar
|
<HeaderBar
|
||||||
conversation={conversation}
|
conversation={conversation}
|
||||||
peerPresence={peerPresence}
|
peerPresence={peerPresence}
|
||||||
|
pinnedCount={pinnedCount}
|
||||||
{...(onInfoClick ? { onInfoClick } : {})}
|
{...(onInfoClick ? { onInfoClick } : {})}
|
||||||
{...(onMediaClick ? { onMediaClick } : {})}
|
{...(onMediaClick ? { onMediaClick } : {})}
|
||||||
{...(onProfileClick ? { onProfileClick } : {})}
|
{...(onProfileClick ? { onProfileClick } : {})}
|
||||||
{...(onSearchClick ? { onSearchClick } : {})}
|
{...(onSearchClick ? { onSearchClick } : {})}
|
||||||
|
{...(onOpenPinned ? { onOpenPinned } : {})}
|
||||||
/>
|
/>
|
||||||
{/* Voice-channel rail rendered separately in ConversationPage so it
|
{/* Voice-channel rail rendered separately in ConversationPage so it
|
||||||
can sit between the message surface and the in-call dock. */}
|
can sit between the message surface and the in-call dock. */}
|
||||||
@@ -66,6 +74,8 @@ interface HeaderBarProps {
|
|||||||
onMediaClick?: () => void;
|
onMediaClick?: () => void;
|
||||||
onProfileClick?: (ev: React.MouseEvent) => void;
|
onProfileClick?: (ev: React.MouseEvent) => void;
|
||||||
onSearchClick?: () => void;
|
onSearchClick?: () => void;
|
||||||
|
pinnedCount?: number;
|
||||||
|
onOpenPinned?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function HeaderBar({
|
function HeaderBar({
|
||||||
@@ -75,11 +85,19 @@ function HeaderBar({
|
|||||||
onMediaClick,
|
onMediaClick,
|
||||||
onProfileClick,
|
onProfileClick,
|
||||||
onSearchClick,
|
onSearchClick,
|
||||||
|
pinnedCount = 0,
|
||||||
|
onOpenPinned,
|
||||||
}: HeaderBarProps) {
|
}: HeaderBarProps) {
|
||||||
const { t } = useTranslation(['app']);
|
const { t } = useTranslation(['app']);
|
||||||
|
|
||||||
const isDm = conversation.type === 'dm';
|
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 handle = isDm ? '@' + (conversation.peer?.username ?? '?') : '';
|
||||||
const peerAvatar = isDm
|
const peerAvatar = isDm
|
||||||
? (conversation.peer?.avatarUrl ?? null)
|
? (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"
|
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 ? (
|
{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 ? (
|
) : 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">
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-accent/20 text-accent">
|
||||||
<UsersIcon className="h-5 w-5" />
|
<UsersIcon className="h-5 w-5" />
|
||||||
@@ -133,6 +151,7 @@ function HeaderBar({
|
|||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
{!isDm && <UsersIcon className="h-4 w-4 shrink-0 text-fg-muted" />}
|
{!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>
|
<p className="truncate font-display text-base font-semibold text-fg">{title}</p>
|
||||||
|
<PinnedMessagesPill count={pinnedCount} onClick={() => onOpenPinned?.()} />
|
||||||
</div>
|
</div>
|
||||||
<p className="truncate text-xs text-fg-muted">
|
<p className="truncate text-xs text-fg-muted">
|
||||||
{isDm ? (
|
{isDm ? (
|
||||||
@@ -178,7 +197,7 @@ function HeaderBar({
|
|||||||
|
|
||||||
interface HeaderActionButtonProps {
|
interface HeaderActionButtonProps {
|
||||||
label: string;
|
label: string;
|
||||||
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
|
icon: React.ComponentType<React.ComponentPropsWithoutRef<'svg'>>;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
tone?: 'default' | 'accent';
|
tone?: 'default' | 'accent';
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
import {
|
import {
|
||||||
muteDurationToIso,
|
muteDurationToIso,
|
||||||
setConversationArchived,
|
setConversationArchived,
|
||||||
|
setConversationMentionsOnly,
|
||||||
setConversationMutedUntil,
|
setConversationMutedUntil,
|
||||||
} from '@chat-app/shared/chat';
|
} from '@chat-app/shared/chat';
|
||||||
import { forwardRef, useCallback, useEffect, useRef, useState } from 'react';
|
import { forwardRef, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useConversationsContext } from '../context/ConversationsContext';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
import { ArchiveIcon, BellIcon, BellOffIcon, MoreVerticalIcon } from './icons';
|
import { ArchiveIcon, AtIcon, BellIcon, BellOffIcon, MoreVerticalIcon } from './icons';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
archived: boolean;
|
archived: boolean;
|
||||||
mutedUntil: string | null;
|
mutedUntil: string | null;
|
||||||
|
mentionsOnly: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MuteOption {
|
interface MuteOption {
|
||||||
@@ -50,8 +53,9 @@ interface MenuPos {
|
|||||||
// escape the sidebar's `overflow-y-auto` clipping context. Position is
|
// escape the sidebar's `overflow-y-auto` clipping context. Position is
|
||||||
// computed from the trigger's bounding rect — menu anchors right-aligned
|
// computed from the trigger's bounding rect — menu anchors right-aligned
|
||||||
// under the trigger so it doesn't push off-screen on narrow windows.
|
// 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 { t } = useTranslation(['app']);
|
||||||
|
const { patchConversation } = useConversationsContext();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [submenuOpen, setSubmenuOpen] = useState<'mute' | null>(null);
|
const [submenuOpen, setSubmenuOpen] = useState<'mute' | null>(null);
|
||||||
const [menuPos, setMenuPos] = useState<MenuPos | null>(null);
|
const [menuPos, setMenuPos] = useState<MenuPos | null>(null);
|
||||||
@@ -120,29 +124,59 @@ export function ConversationRowMenu({ conversationId, archived, mutedUntil }: Pr
|
|||||||
const isMuted =
|
const isMuted =
|
||||||
mutedUntil !== null && new Date(mutedUntil).getTime() > Date.now();
|
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(
|
const handleArchive = useCallback(
|
||||||
async (next: boolean) => {
|
async (next: boolean) => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
const previous = archived;
|
||||||
|
patchConversation(conversationId, { archived: next });
|
||||||
try {
|
try {
|
||||||
await setConversationArchived(supabase, conversationId, next);
|
await setConversationArchived(supabase, conversationId, next);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
patchConversation(conversationId, { archived: previous });
|
||||||
console.error('archive toggle failed', err);
|
console.error('archive toggle failed', err);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[conversationId],
|
[conversationId, archived, patchConversation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleMute = useCallback(
|
const handleMute = useCallback(
|
||||||
async (minutes: number | null) => {
|
async (minutes: number | null) => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setSubmenuOpen(null);
|
setSubmenuOpen(null);
|
||||||
|
const previous = mutedUntil;
|
||||||
|
const nextIso = muteDurationToIso(minutes);
|
||||||
|
patchConversation(conversationId, { mutedUntil: nextIso });
|
||||||
try {
|
try {
|
||||||
await setConversationMutedUntil(supabase, conversationId, muteDurationToIso(minutes));
|
await setConversationMutedUntil(supabase, conversationId, nextIso);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
patchConversation(conversationId, { mutedUntil: previous });
|
||||||
console.error('mute toggle failed', err);
|
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 (
|
return (
|
||||||
@@ -196,6 +230,16 @@ export function ConversationRowMenu({ conversationId, archived, mutedUntil }: Pr
|
|||||||
}}
|
}}
|
||||||
hasSubmenu={!isMuted}
|
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>,
|
</div>,
|
||||||
document.body,
|
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 {
|
import {
|
||||||
type AttachmentHandle,
|
type AttachmentHandle,
|
||||||
type DecryptedMessage,
|
type DecryptedMessage,
|
||||||
@@ -15,8 +14,9 @@ import { useTranslation } from 'react-i18next';
|
|||||||
|
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { useConversationsContext } from '../context/ConversationsContext';
|
import { useConversationsContext } from '../context/ConversationsContext';
|
||||||
import { devLocalSecretStore } from '../lib/secretStore';
|
import { ensureInstallId } from '../lib/installId';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
|
import { cachedUserKey } from '../lib/userIdentity';
|
||||||
import { Avatar } from './Avatar';
|
import { Avatar } from './Avatar';
|
||||||
import { ForwardIcon, SpinnerIcon, UsersIcon, XIcon } from './icons';
|
import { ForwardIcon, SpinnerIcon, UsersIcon, XIcon } from './icons';
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ interface Props {
|
|||||||
// preview hints at the dropped attachment.
|
// preview hints at the dropped attachment.
|
||||||
export function ForwardDialog({ open, message, currentConversationId, onClose }: Props) {
|
export function ForwardDialog({ open, message, currentConversationId, onClose }: Props) {
|
||||||
const { t } = useTranslation(['app', 'errors']);
|
const { t } = useTranslation(['app', 'errors']);
|
||||||
const { session, device } = useAuth();
|
const { session } = useAuth();
|
||||||
const { conversations } = useConversationsContext();
|
const { conversations } = useConversationsContext();
|
||||||
|
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
@@ -84,13 +84,13 @@ export function ForwardDialog({ open, message, currentConversationId, onClose }:
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleSend() {
|
async function handleSend() {
|
||||||
if (!session?.user.id || !device?.id || !message) return;
|
if (!session?.user.id || !message) return;
|
||||||
if (selected.size === 0) return;
|
if (selected.size === 0) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const priv = await loadDevicePrivateKey(devLocalSecretStore, session.user.id, device.id);
|
const priv = await cachedUserKey(session.user.id);
|
||||||
if (!priv) throw new Error('private key not loaded');
|
if (!priv) throw new Error('user key not unlocked');
|
||||||
|
|
||||||
const hasAttachments = sourceAttachments.length > 0;
|
const hasAttachments = sourceAttachments.length > 0;
|
||||||
const text = preview || (hasAttachments ? '' : '');
|
const text = preview || (hasAttachments ? '' : '');
|
||||||
@@ -138,7 +138,7 @@ export function ForwardDialog({ open, message, currentConversationId, onClose }:
|
|||||||
conversationId: convId,
|
conversationId: convId,
|
||||||
plaintext: text,
|
plaintext: text,
|
||||||
senderUserId: session.user.id,
|
senderUserId: session.user.id,
|
||||||
senderDeviceId: device.id,
|
senderDeviceId: ensureInstallId(),
|
||||||
senderPrivateKey: priv,
|
senderPrivateKey: priv,
|
||||||
...(newHandles.length > 0 ? { attachmentHandles: newHandles } : {}),
|
...(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
|
<aside
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-label={t('app:group.info_title')}
|
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">
|
<header className="flex items-center justify-between border-b border-white/5 px-5 py-4">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -0,0 +1,490 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { XIcon } from './icons';
|
||||||
|
|
||||||
|
export type AnnotatorTool = 'pen' | 'arrow' | 'rect' | 'circle' | 'text' | 'highlighter';
|
||||||
|
export type AnnotatorColor = '#ef4444' | '#3b82f6' | '#22c55e' | '#facc15' | '#a855f7' | '#ffffff';
|
||||||
|
export type AnnotatorWidth = 2 | 4 | 8;
|
||||||
|
|
||||||
|
export interface AnnotatorOp {
|
||||||
|
tool: AnnotatorTool;
|
||||||
|
color: AnnotatorColor;
|
||||||
|
width: AnnotatorWidth;
|
||||||
|
points?: Array<{ x: number; y: number }>;
|
||||||
|
from?: { x: number; y: number };
|
||||||
|
to?: { x: number; y: number };
|
||||||
|
text?: string;
|
||||||
|
at?: { x: number; y: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
file: File;
|
||||||
|
onCancel: () => void;
|
||||||
|
onSave: (next: File) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageAnnotator({ file, onCancel, onSave }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||||
|
const [imageLoaded, setImageLoaded] = useState(false);
|
||||||
|
const [ops, setOps] = useState<AnnotatorOp[]>([]);
|
||||||
|
const [redoStack, setRedoStack] = useState<AnnotatorOp[]>([]);
|
||||||
|
// tool/color/width are read by Task 2 (drawing engine) and Task 3 (toolbar).
|
||||||
|
// Defaults shown here are intentional so T2's pointer handlers work
|
||||||
|
// immediately with sensible behavior before T3's UI is wired.
|
||||||
|
const [tool, setTool] = useState<AnnotatorTool>('pen');
|
||||||
|
const [color, setColor] = useState<AnnotatorColor>('#ef4444');
|
||||||
|
const [width, setWidth] = useState<AnnotatorWidth>(4);
|
||||||
|
|
||||||
|
const draftRef = useRef<AnnotatorOp | null>(null);
|
||||||
|
const [draftTick, setDraftTick] = useState(0);
|
||||||
|
|
||||||
|
// Hold a stable ref to onCancel so the image-load effect doesn't depend
|
||||||
|
// on its identity. Without this, parents that pass an inline `() => …`
|
||||||
|
// re-render the modal on every keystroke / state change, the effect re-
|
||||||
|
// runs, the previous URL.createObjectURL gets revoked WHILE the new img
|
||||||
|
// is still decoding → img.onerror fires ("file not found") → onCancel →
|
||||||
|
// modal flashes open + closes instantly.
|
||||||
|
const onCancelRef = useRef(onCancel);
|
||||||
|
useEffect(() => { onCancelRef.current = onCancel; }, [onCancel]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// React 18 strict mode in dev double-mounts effects to test idempotency.
|
||||||
|
// The first run creates a blob URL, sets img.src, returns a cleanup
|
||||||
|
// that revokes — and the cleanup fires BEFORE the (still-in-flight)
|
||||||
|
// image fetch completes. The browser then emits ERR_FILE_NOT_FOUND for
|
||||||
|
// the revoked URL → img.onerror → modal closes instantly. The
|
||||||
|
// `cancelled` flag guards every callback so a torn-down run can't
|
||||||
|
// close the modal that the second mount just opened.
|
||||||
|
let cancelled = false;
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
imageRef.current = img;
|
||||||
|
setImageLoaded(true);
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
console.error('ImageAnnotator: failed to decode source image');
|
||||||
|
onCancelRef.current();
|
||||||
|
};
|
||||||
|
img.src = url;
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
}, [file]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!imageLoaded) return;
|
||||||
|
const cv = canvasRef.current;
|
||||||
|
const img = imageRef.current;
|
||||||
|
if (!cv || !img) return;
|
||||||
|
cv.width = img.naturalWidth;
|
||||||
|
cv.height = img.naturalHeight;
|
||||||
|
const ctx = cv.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
for (const op of ops) {
|
||||||
|
renderOp(ctx, op);
|
||||||
|
}
|
||||||
|
if (draftRef.current) {
|
||||||
|
renderOp(ctx, draftRef.current);
|
||||||
|
}
|
||||||
|
}, [imageLoaded, ops, draftTick]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onCancel();
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleUndo();
|
||||||
|
} else if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleRedo();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleUndo = () => {
|
||||||
|
setOps((cur) => {
|
||||||
|
if (cur.length === 0) return cur;
|
||||||
|
const next = cur.slice(0, -1);
|
||||||
|
setRedoStack((r) => [...r, cur[cur.length - 1]!]);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRedo = () => {
|
||||||
|
setRedoStack((r) => {
|
||||||
|
if (r.length === 0) return r;
|
||||||
|
const top = r[r.length - 1]!;
|
||||||
|
setOps((cur) => [...cur, top]);
|
||||||
|
return r.slice(0, -1);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setOps([]);
|
||||||
|
setRedoStack([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
const cv = canvasRef.current;
|
||||||
|
if (!cv) return;
|
||||||
|
cv.toBlob((blob) => {
|
||||||
|
if (!blob) {
|
||||||
|
console.error('ImageAnnotator: toBlob returned null');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const baseName = file.name.replace(/\.[^.]+$/, '');
|
||||||
|
const next = new File([blob], baseName + '-annotated.png', {
|
||||||
|
type: 'image/png',
|
||||||
|
lastModified: Date.now(),
|
||||||
|
});
|
||||||
|
onSave(next);
|
||||||
|
}, 'image/png');
|
||||||
|
};
|
||||||
|
|
||||||
|
function canvasPoint(e: React.PointerEvent<HTMLCanvasElement>): { x: number; y: number } {
|
||||||
|
const cv = canvasRef.current!;
|
||||||
|
const rect = cv.getBoundingClientRect();
|
||||||
|
const scaleX = cv.width / rect.width;
|
||||||
|
const scaleY = cv.height / rect.height;
|
||||||
|
return {
|
||||||
|
x: (e.clientX - rect.left) * scaleX,
|
||||||
|
y: (e.clientY - rect.top) * scaleY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||||
|
if (!imageLoaded) return;
|
||||||
|
const cv = canvasRef.current;
|
||||||
|
if (!cv) return;
|
||||||
|
cv.setPointerCapture(e.pointerId);
|
||||||
|
const p = canvasPoint(e);
|
||||||
|
|
||||||
|
if (tool === 'text') {
|
||||||
|
const value = window.prompt(
|
||||||
|
t('app:annotator.text_prompt', { defaultValue: 'Text eingeben:' }),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
if (value !== null && value.trim().length > 0) {
|
||||||
|
setOps((cur) => [...cur, { tool: 'text', color, width, text: value, at: p }]);
|
||||||
|
setRedoStack([]);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tool === 'pen' || tool === 'highlighter') {
|
||||||
|
draftRef.current = { tool, color, width, points: [p] };
|
||||||
|
} else {
|
||||||
|
draftRef.current = { tool, color, width, from: p, to: p };
|
||||||
|
}
|
||||||
|
setDraftTick((n) => n + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||||
|
if (!draftRef.current) return;
|
||||||
|
const p = canvasPoint(e);
|
||||||
|
const cur = draftRef.current;
|
||||||
|
if (cur.tool === 'pen' || cur.tool === 'highlighter') {
|
||||||
|
cur.points = [...(cur.points ?? []), p];
|
||||||
|
} else {
|
||||||
|
cur.to = p;
|
||||||
|
}
|
||||||
|
setDraftTick((n) => n + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerUp = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||||
|
const cv = canvasRef.current;
|
||||||
|
if (cv && cv.hasPointerCapture(e.pointerId)) cv.releasePointerCapture(e.pointerId);
|
||||||
|
const cur = draftRef.current;
|
||||||
|
draftRef.current = null;
|
||||||
|
if (!cur) return;
|
||||||
|
const hasContent =
|
||||||
|
(cur.tool === 'pen' || cur.tool === 'highlighter')
|
||||||
|
? (cur.points?.length ?? 0) >= 2
|
||||||
|
: !!(cur.from && cur.to && (cur.from.x !== cur.to.x || cur.from.y !== cur.to.y));
|
||||||
|
if (hasContent) {
|
||||||
|
setOps((p) => [...p, cur]);
|
||||||
|
setRedoStack([]);
|
||||||
|
}
|
||||||
|
setDraftTick((n) => n + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t('app:annotator.title', { defaultValue: 'Bild bearbeiten' })}
|
||||||
|
className="fixed inset-0 z-[80] flex flex-col bg-ink-950/95"
|
||||||
|
>
|
||||||
|
<header className="flex shrink-0 items-center justify-between border-b border-line/40 bg-surface-2 px-4 py-2">
|
||||||
|
<h2 className="font-display text-sm font-semibold text-fg">
|
||||||
|
{t('app:annotator.title', { defaultValue: 'Bild bearbeiten' })}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
aria-label={t('app:annotator.cancel', { defaultValue: 'Abbrechen' })}
|
||||||
|
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
|
||||||
|
>
|
||||||
|
<XIcon className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1 items-center justify-center overflow-hidden p-6">
|
||||||
|
{imageLoaded ? (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerLeave={handlePointerUp}
|
||||||
|
className="max-h-full max-w-full cursor-crosshair touch-none rounded-lg border border-line/40 bg-black shadow-2xl"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-fg-muted">
|
||||||
|
{t('app:annotator.loading', { defaultValue: 'Bild wird geladen…' })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="flex shrink-0 flex-wrap items-center gap-4 border-t border-line/40 bg-surface-2 px-4 py-3">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{(['pen', 'highlighter', 'arrow', 'rect', 'circle', 'text'] as AnnotatorTool[]).map((id) => {
|
||||||
|
const label = t('app:annotator.tool.' + id, {
|
||||||
|
defaultValue:
|
||||||
|
id === 'pen' ? 'Stift'
|
||||||
|
: id === 'highlighter' ? 'Marker'
|
||||||
|
: id === 'arrow' ? 'Pfeil'
|
||||||
|
: id === 'rect' ? 'Rechteck'
|
||||||
|
: id === 'circle' ? 'Kreis'
|
||||||
|
: 'Text',
|
||||||
|
});
|
||||||
|
const active = tool === id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTool(id)}
|
||||||
|
aria-pressed={active}
|
||||||
|
title={label}
|
||||||
|
className={
|
||||||
|
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-xs font-semibold transition ' +
|
||||||
|
(active
|
||||||
|
? 'bg-accent/20 text-accent ring-2 ring-accent/40'
|
||||||
|
: 'bg-surface-3 text-fg-muted hover:bg-surface hover:text-fg')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{toolGlyph(id)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-6 w-px bg-line/40" aria-hidden />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{(['#ef4444', '#3b82f6', '#22c55e', '#facc15', '#a855f7', '#ffffff'] as AnnotatorColor[]).map((c) => {
|
||||||
|
const active = color === c;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setColor(c)}
|
||||||
|
aria-pressed={active}
|
||||||
|
aria-label={c}
|
||||||
|
className={
|
||||||
|
'h-6 w-6 cursor-pointer rounded-full border-2 transition ' +
|
||||||
|
(active ? 'border-fg scale-110' : 'border-line/40 hover:scale-105')
|
||||||
|
}
|
||||||
|
style={{ backgroundColor: c }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-6 w-px bg-line/40" aria-hidden />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{([2, 4, 8] as AnnotatorWidth[]).map((w) => {
|
||||||
|
const active = width === w;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={w}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setWidth(w)}
|
||||||
|
aria-pressed={active}
|
||||||
|
title={w + 'px'}
|
||||||
|
className={
|
||||||
|
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-md transition ' +
|
||||||
|
(active
|
||||||
|
? 'bg-accent/20 ring-2 ring-accent/40'
|
||||||
|
: 'bg-surface-3 hover:bg-surface')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="rounded-full bg-fg"
|
||||||
|
style={{ width: w + 2 + 'px', height: w + 2 + 'px' }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-6 w-px bg-line/40" aria-hidden />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleUndo}
|
||||||
|
disabled={ops.length === 0}
|
||||||
|
title={t('app:annotator.undo', { defaultValue: 'Rückgängig (Ctrl+Z)' })}
|
||||||
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md bg-surface-3 text-fg-muted transition hover:bg-surface hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
↶
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRedo}
|
||||||
|
disabled={redoStack.length === 0}
|
||||||
|
title={t('app:annotator.redo', { defaultValue: 'Wiederholen (Ctrl+Y)' })}
|
||||||
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md bg-surface-3 text-fg-muted transition hover:bg-surface hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
↷
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleReset}
|
||||||
|
disabled={ops.length === 0}
|
||||||
|
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-1.5 text-xs font-medium text-fg-muted transition hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('app:annotator.reset', { defaultValue: 'Zurücksetzen' })}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!imageLoaded}
|
||||||
|
className="cursor-pointer rounded-md bg-accent px-4 py-1.5 text-xs font-semibold text-accent-fg transition hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('app:annotator.save', { defaultValue: 'Speichern' })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolGlyph(t: AnnotatorTool): string {
|
||||||
|
switch (t) {
|
||||||
|
case 'pen': return '✎';
|
||||||
|
case 'highlighter': return '🖍';
|
||||||
|
case 'arrow': return '↗';
|
||||||
|
case 'rect': return '▭';
|
||||||
|
case 'circle': return '◯';
|
||||||
|
case 'text': return 'T';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOp(ctx: CanvasRenderingContext2D, op: AnnotatorOp): void {
|
||||||
|
ctx.save();
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
ctx.strokeStyle = op.color;
|
||||||
|
ctx.fillStyle = op.color;
|
||||||
|
ctx.lineWidth = op.width;
|
||||||
|
|
||||||
|
switch (op.tool) {
|
||||||
|
case 'pen': {
|
||||||
|
const pts = op.points;
|
||||||
|
if (!pts || pts.length < 1) break;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(pts[0]!.x, pts[0]!.y);
|
||||||
|
for (let i = 1; i < pts.length; i++) {
|
||||||
|
ctx.lineTo(pts[i]!.x, pts[i]!.y);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'highlighter': {
|
||||||
|
const pts = op.points;
|
||||||
|
if (!pts || pts.length < 1) break;
|
||||||
|
ctx.globalAlpha = 0.35;
|
||||||
|
ctx.lineWidth = op.width * 4;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(pts[0]!.x, pts[0]!.y);
|
||||||
|
for (let i = 1; i < pts.length; i++) {
|
||||||
|
ctx.lineTo(pts[i]!.x, pts[i]!.y);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'rect': {
|
||||||
|
const { from, to } = op;
|
||||||
|
if (!from || !to) break;
|
||||||
|
ctx.strokeRect(
|
||||||
|
Math.min(from.x, to.x),
|
||||||
|
Math.min(from.y, to.y),
|
||||||
|
Math.abs(to.x - from.x),
|
||||||
|
Math.abs(to.y - from.y),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'circle': {
|
||||||
|
const { from, to } = op;
|
||||||
|
if (!from || !to) break;
|
||||||
|
const cx = (from.x + to.x) / 2;
|
||||||
|
const cy = (from.y + to.y) / 2;
|
||||||
|
const rx = Math.abs(to.x - from.x) / 2;
|
||||||
|
const ry = Math.abs(to.y - from.y) / 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'arrow': {
|
||||||
|
const { from, to } = op;
|
||||||
|
if (!from || !to) break;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(from.x, from.y);
|
||||||
|
ctx.lineTo(to.x, to.y);
|
||||||
|
ctx.stroke();
|
||||||
|
const angle = Math.atan2(to.y - from.y, to.x - from.x);
|
||||||
|
const head = Math.max(12, op.width * 3);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(to.x, to.y);
|
||||||
|
ctx.lineTo(
|
||||||
|
to.x - head * Math.cos(angle - Math.PI / 6),
|
||||||
|
to.y - head * Math.sin(angle - Math.PI / 6),
|
||||||
|
);
|
||||||
|
ctx.moveTo(to.x, to.y);
|
||||||
|
ctx.lineTo(
|
||||||
|
to.x - head * Math.cos(angle + Math.PI / 6),
|
||||||
|
to.y - head * Math.sin(angle + Math.PI / 6),
|
||||||
|
);
|
||||||
|
ctx.stroke();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'text': {
|
||||||
|
const { at, text } = op;
|
||||||
|
if (!at || !text) break;
|
||||||
|
const fontSize = Math.max(14, op.width * 6);
|
||||||
|
ctx.font = '600 ' + fontSize + 'px Inter, system-ui, sans-serif';
|
||||||
|
ctx.textBaseline = 'top';
|
||||||
|
ctx.fillText(text, at.x, at.y);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
@@ -15,14 +15,7 @@ import {
|
|||||||
listSounds,
|
listSounds,
|
||||||
subscribeSoundboardChanges,
|
subscribeSoundboardChanges,
|
||||||
} from '../lib/soundboardStorage';
|
} from '../lib/soundboardStorage';
|
||||||
import {
|
|
||||||
getLiveCaptionsSettings,
|
|
||||||
isLiveCaptionsSupported,
|
|
||||||
subscribeLiveCaptionsSettings,
|
|
||||||
updateLiveCaptionsSettings,
|
|
||||||
} from '../lib/liveCaptions';
|
|
||||||
import { useActiveSpeakers } from '../lib/useActiveSpeakers';
|
import { useActiveSpeakers } from '../lib/useActiveSpeakers';
|
||||||
import { CallCaptionsOverlay } from './CallCaptionsOverlay';
|
|
||||||
import { CallControls } from './CallControls';
|
import { CallControls } from './CallControls';
|
||||||
import { CallParticipantTile, type TileConnectionQuality } from './CallParticipantTile';
|
import { CallParticipantTile, type TileConnectionQuality } from './CallParticipantTile';
|
||||||
import { CallStatsOverlay } from './CallStatsOverlay';
|
import { CallStatsOverlay } from './CallStatsOverlay';
|
||||||
@@ -119,17 +112,6 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
const [sharePickerOpen, setSharePickerOpen] = useState(false);
|
const [sharePickerOpen, setSharePickerOpen] = useState(false);
|
||||||
// Discord-style debug stats overlay (Ctrl+Shift+S toggles).
|
// Discord-style debug stats overlay (Ctrl+Shift+S toggles).
|
||||||
const [statsOverlayOpen, setStatsOverlayOpen] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
// Match the modifier exactly to avoid clobbering other Ctrl+Shift combos.
|
// Match the modifier exactly to avoid clobbering other Ctrl+Shift combos.
|
||||||
@@ -362,15 +344,6 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
soundboardOpen,
|
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()}
|
onHangup={() => void hangup()}
|
||||||
compact={callMode !== 'fullscreen'}
|
compact={callMode !== 'fullscreen'}
|
||||||
glass={callMode === 'fullscreen'}
|
glass={callMode === 'fullscreen'}
|
||||||
@@ -499,7 +472,6 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
onClose={() => setStatsOverlayOpen(false)}
|
onClose={() => setStatsOverlayOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{captionsEnabled && <CallCaptionsOverlay conversation={conversation} />}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -644,8 +616,6 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
onClose={() => setStatsOverlayOpen(false)}
|
onClose={() => setStatsOverlayOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{captionsEnabled && <CallCaptionsOverlay conversation={conversation} />}
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1542,8 +1512,8 @@ function FullscreenCall({
|
|||||||
function StripToggleIcon({ hidden }: { hidden: boolean }) {
|
function StripToggleIcon({ hidden }: { hidden: boolean }) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
width="18"
|
width="14"
|
||||||
height="18"
|
height="14"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
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;
|
if (!open) return null;
|
||||||
|
|
||||||
return (
|
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">
|
<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">
|
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-accent/10 text-accent">
|
||||||
<ImageIcon className="h-5 w-5" />
|
<ImageIcon className="h-5 w-5" />
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import type { ConversationSummary } from '@chat-app/shared/chat';
|
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { useNickname } from '../lib/friendNicknames';
|
||||||
import { Avatar } from './Avatar';
|
import { Avatar } from './Avatar';
|
||||||
|
|
||||||
|
type Member = ConversationSummary['members'][number];
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
members: ConversationSummary['members'];
|
members: ConversationSummary['members'];
|
||||||
query: string;
|
query: string;
|
||||||
@@ -63,37 +66,58 @@ export function MentionAutocomplete({ members, query, excludeUserId, onSelect, o
|
|||||||
aria-label="Mitglieder"
|
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]"
|
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) => {
|
{matches.map((m, idx) => (
|
||||||
const name = m.profile?.displayName ?? m.profile?.username ?? '?';
|
<MemberRow
|
||||||
const handle = m.profile?.username ?? '';
|
key={m.userId}
|
||||||
const isActive = idx === active;
|
member={m}
|
||||||
return (
|
isActive={idx === active}
|
||||||
<button
|
onActivate={() => setActive(idx)}
|
||||||
key={m.userId}
|
onSelect={onSelect}
|
||||||
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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</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 {
|
import {
|
||||||
type DecryptedMessage,
|
type DecryptedMessage,
|
||||||
editEncryptedMessage,
|
editEncryptedMessage,
|
||||||
@@ -6,13 +5,15 @@ import {
|
|||||||
softDeleteMessage,
|
softDeleteMessage,
|
||||||
} from '@chat-app/shared/chat';
|
} from '@chat-app/shared/chat';
|
||||||
import { extractErrorCode } from '@chat-app/shared/i18n';
|
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 { createPortal } from 'react-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useAuth } from '../context/AuthContext';
|
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 { supabase } from '../lib/supabase';
|
||||||
|
import { cachedUserKey } from '../lib/userIdentity';
|
||||||
import type { AggregatedReaction } from '../lib/useMessageReactions';
|
import type { AggregatedReaction } from '../lib/useMessageReactions';
|
||||||
import { summarizePollVotes } from '../lib/conversationFeatures';
|
import { summarizePollVotes } from '../lib/conversationFeatures';
|
||||||
import { extractFirstUrl } from '../lib/useLinkPreview';
|
import { extractFirstUrl } from '../lib/useLinkPreview';
|
||||||
@@ -30,6 +31,7 @@ import {
|
|||||||
PencilIcon,
|
PencilIcon,
|
||||||
PhoneIcon,
|
PhoneIcon,
|
||||||
PhoneOffIcon,
|
PhoneOffIcon,
|
||||||
|
PinIcon,
|
||||||
ReplyIcon,
|
ReplyIcon,
|
||||||
SmileIcon,
|
SmileIcon,
|
||||||
SpinnerIcon,
|
SpinnerIcon,
|
||||||
@@ -58,8 +60,17 @@ interface Props {
|
|||||||
senderAvatarUrl?: string | null | undefined;
|
senderAvatarUrl?: string | null | undefined;
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
reactions: AggregatedReaction[];
|
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;
|
showSeen?: boolean;
|
||||||
/** Delivery state for own messages: 'sent' / 'delivered' / 'read'. */
|
/** Delivery state for own messages: 'sent' / 'delivered' / 'read'. */
|
||||||
deliveryState?: 'sent' | 'delivered' | 'read';
|
deliveryState?: 'sent' | 'delivered' | 'read';
|
||||||
@@ -75,9 +86,13 @@ interface Props {
|
|||||||
onAvatarClick?: (userId: string, ev: React.MouseEvent) => void;
|
onAvatarClick?: (userId: string, ev: React.MouseEvent) => void;
|
||||||
/** Highlighted state — set briefly after a jump. */
|
/** Highlighted state — set briefly after a jump. */
|
||||||
highlighted?: boolean;
|
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,
|
message,
|
||||||
mine,
|
mine,
|
||||||
groupedWithPrev,
|
groupedWithPrev,
|
||||||
@@ -96,9 +111,17 @@ export function MessageBubble({
|
|||||||
onForward,
|
onForward,
|
||||||
onAvatarClick,
|
onAvatarClick,
|
||||||
highlighted = false,
|
highlighted = false,
|
||||||
|
isPinned = false,
|
||||||
|
onTogglePin,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation(['app']);
|
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 parsed = parseMessagePayload(message.plaintext);
|
||||||
const initialText = parsed.kind === 'text' ? parsed.text : '';
|
const initialText = parsed.kind === 'text' ? parsed.text : '';
|
||||||
@@ -191,7 +214,7 @@ export function MessageBubble({
|
|||||||
}, [pickerOpen]);
|
}, [pickerOpen]);
|
||||||
|
|
||||||
const handleEditSave = useCallback(async () => {
|
const handleEditSave = useCallback(async () => {
|
||||||
if (!session || !device) return;
|
if (!session) return;
|
||||||
const trimmed = editText.trim();
|
const trimmed = editText.trim();
|
||||||
if (!trimmed || trimmed === bodyText) {
|
if (!trimmed || trimmed === bodyText) {
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
@@ -201,15 +224,15 @@ export function MessageBubble({
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setEditError(null);
|
setEditError(null);
|
||||||
try {
|
try {
|
||||||
const priv = await loadDevicePrivateKey(devLocalSecretStore, session.user.id, device.id);
|
const priv = await cachedUserKey(session.user.id);
|
||||||
if (!priv) throw new Error('private key not loaded');
|
if (!priv) throw new Error('user key not unlocked');
|
||||||
await editEncryptedMessage({
|
await editEncryptedMessage({
|
||||||
client: supabase,
|
client: supabase,
|
||||||
messageId: message.id,
|
messageId: message.id,
|
||||||
conversationId,
|
conversationId,
|
||||||
newPlaintext: trimmed,
|
newPlaintext: trimmed,
|
||||||
senderUserId: session.user.id,
|
senderUserId: session.user.id,
|
||||||
senderDeviceId: device.id,
|
senderDeviceId: ensureInstallId(),
|
||||||
senderPrivateKey: priv,
|
senderPrivateKey: priv,
|
||||||
});
|
});
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
@@ -225,7 +248,7 @@ export function MessageBubble({
|
|||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}, [editText, message.id, message.plaintext, conversationId, session, device, t]);
|
}, [editText, message.id, message.plaintext, conversationId, session, t]);
|
||||||
|
|
||||||
const handleDelete = useCallback(async () => {
|
const handleDelete = useCallback(async () => {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
@@ -243,12 +266,12 @@ export function MessageBubble({
|
|||||||
async (emoji: string) => {
|
async (emoji: string) => {
|
||||||
setPickerOpen(false);
|
setPickerOpen(false);
|
||||||
try {
|
try {
|
||||||
await onToggleReaction(emoji);
|
await onToggleReaction(message.id, emoji);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('toggleReaction failed', err);
|
console.error('toggleReaction failed', err);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onToggleReaction],
|
[onToggleReaction, message.id],
|
||||||
);
|
);
|
||||||
|
|
||||||
const copyableText =
|
const copyableText =
|
||||||
@@ -281,7 +304,7 @@ export function MessageBubble({
|
|||||||
<AvatarSlot
|
<AvatarSlot
|
||||||
show={isLastOfRun}
|
show={isLastOfRun}
|
||||||
url={senderAvatarUrl ?? null}
|
url={senderAvatarUrl ?? null}
|
||||||
displayName={senderDisplayName ?? null}
|
displayName={resolvedSenderDisplayName}
|
||||||
{...(onAvatarClick
|
{...(onAvatarClick
|
||||||
? { onClick: (ev: React.MouseEvent) => onAvatarClick(message.senderId, ev) }
|
? { onClick: (ev: React.MouseEvent) => onAvatarClick(message.senderId, ev) }
|
||||||
: {})}
|
: {})}
|
||||||
@@ -294,7 +317,14 @@ export function MessageBubble({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (parsed.kind === 'call_event') {
|
if (parsed.kind === 'call_event') {
|
||||||
return <CallEventRow parsed={parsed} mine={mine} time={time} />;
|
return (
|
||||||
|
<CallEventRow
|
||||||
|
parsed={parsed}
|
||||||
|
mine={mine}
|
||||||
|
time={time}
|
||||||
|
senderDisplayName={resolvedSenderDisplayName}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -308,7 +338,7 @@ export function MessageBubble({
|
|||||||
<AvatarSlot
|
<AvatarSlot
|
||||||
show={isLastOfRun}
|
show={isLastOfRun}
|
||||||
url={senderAvatarUrl ?? null}
|
url={senderAvatarUrl ?? null}
|
||||||
displayName={senderDisplayName ?? null}
|
displayName={resolvedSenderDisplayName}
|
||||||
{...(onAvatarClick
|
{...(onAvatarClick
|
||||||
? { onClick: (ev: React.MouseEvent) => onAvatarClick(message.senderId, ev) }
|
? { onClick: (ev: React.MouseEvent) => onAvatarClick(message.senderId, ev) }
|
||||||
: {})}
|
: {})}
|
||||||
@@ -409,7 +439,96 @@ export function MessageBubble({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{message.plaintext === null ? (
|
{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' ? (
|
) : parsed.kind === 'poll' ? (
|
||||||
<PollCard
|
<PollCard
|
||||||
question={parsed.question}
|
question={parsed.question}
|
||||||
@@ -417,7 +536,9 @@ export function MessageBubble({
|
|||||||
reactions={reactions}
|
reactions={reactions}
|
||||||
mine={mine}
|
mine={mine}
|
||||||
onVote={(emoji) =>
|
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} />;
|
return <AttachmentAudio key={a.id} handle={a} />;
|
||||||
}
|
}
|
||||||
if (a.mimeType.startsWith('image/')) {
|
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/')) {
|
if (a.mimeType.startsWith('video/')) {
|
||||||
return <AttachmentVideo key={a.id} handle={a} />;
|
return <AttachmentVideo key={a.id} handle={a} />;
|
||||||
@@ -483,7 +604,7 @@ export function MessageBubble({
|
|||||||
<button
|
<button
|
||||||
key={r.emoji + ':' + r.count}
|
key={r.emoji + ':' + r.count}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void onToggleReaction(r.emoji)}
|
onClick={() => void onToggleReaction(message.id, r.emoji)}
|
||||||
className={
|
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 ' +
|
'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
|
(r.mine
|
||||||
@@ -617,6 +738,15 @@ export function MessageBubble({
|
|||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
void handleDelete();
|
void handleDelete();
|
||||||
}}
|
}}
|
||||||
|
isPinned={isPinned}
|
||||||
|
{...(onTogglePin
|
||||||
|
? {
|
||||||
|
onTogglePin: () => {
|
||||||
|
setContextMenu(null);
|
||||||
|
onTogglePin(message.id);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {})}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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({
|
function PollCard({
|
||||||
question,
|
question,
|
||||||
options,
|
options,
|
||||||
@@ -714,6 +852,8 @@ function MessageContextMenu({
|
|||||||
onEdit,
|
onEdit,
|
||||||
onCopy,
|
onCopy,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
isPinned,
|
||||||
|
onTogglePin,
|
||||||
}: {
|
}: {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
@@ -728,6 +868,8 @@ function MessageContextMenu({
|
|||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onCopy: () => void;
|
onCopy: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
isPinned?: boolean;
|
||||||
|
onTogglePin?: () => void;
|
||||||
}) {
|
}) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onKey(e: KeyboardEvent) {
|
function onKey(e: KeyboardEvent) {
|
||||||
@@ -794,6 +936,13 @@ function MessageContextMenu({
|
|||||||
tone="danger"
|
tone="danger"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{onTogglePin && (
|
||||||
|
<MenuItem
|
||||||
|
label={isPinned ? 'Anheftung entfernen' : 'Anpinnen'}
|
||||||
|
icon={<PinIcon className="h-4 w-4" />}
|
||||||
|
onClick={onTogglePin}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
);
|
);
|
||||||
@@ -866,10 +1015,15 @@ function CallEventRow({
|
|||||||
parsed,
|
parsed,
|
||||||
mine,
|
mine,
|
||||||
time,
|
time,
|
||||||
|
senderDisplayName,
|
||||||
}: {
|
}: {
|
||||||
parsed: { status: string; mediaKind: string; durationSec: number };
|
parsed: { status: string; mediaKind: string; durationSec: number };
|
||||||
mine: boolean;
|
mine: boolean;
|
||||||
time: string;
|
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 { t } = useTranslation(['app']);
|
||||||
const status = parsed.status;
|
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-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';
|
: '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 =
|
const label =
|
||||||
status === 'ended'
|
status === 'ended'
|
||||||
? mine
|
? mine
|
||||||
? t('app:chats.call_outgoing', { defaultValue: 'Outgoing call' })
|
? 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'
|
: status === 'missed'
|
||||||
? mine
|
? mine
|
||||||
? t('app:chats.call_no_answer', { defaultValue: 'No answer' })
|
? t('app:chats.call_no_answer', { defaultValue: 'No answer' })
|
||||||
: t('app:chats.call_missed', { defaultValue: 'Missed call' })
|
: hasName
|
||||||
: t('app:chats.call_declined', { defaultValue: 'Call declined' });
|
? 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;
|
const duration = parsed.durationSec > 0 ? formatDuration(parsed.durationSec) : null;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,325 @@
|
|||||||
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
|
import {
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
import { isNearBottom, isNearTop, nextStickIntent } from '../lib/scrollController';
|
||||||
|
import type { VirtuosoRow } from '../pages/ConversationPage';
|
||||||
|
|
||||||
|
export interface MessageListHandle {
|
||||||
|
scrollToBottom(behavior?: ScrollBehavior): void;
|
||||||
|
scrollToRow(index: number, align?: 'center' | 'end', behavior?: ScrollBehavior): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageListProps {
|
||||||
|
rows: VirtuosoRow[];
|
||||||
|
renderRow: (index: number, row: VirtuosoRow) => ReactNode;
|
||||||
|
computeKey: (row: VirtuosoRow) => string;
|
||||||
|
/** Initial scroll target for a freshly-mounted list. */
|
||||||
|
initialAnchor: { type: 'bottom' } | { type: 'row'; index: number };
|
||||||
|
/** Reveal gate — the list stays hidden until reactions/heights are loaded, so
|
||||||
|
* the post-paint height cascade is never visible. */
|
||||||
|
ready: boolean;
|
||||||
|
estimateRowHeight?: number;
|
||||||
|
atBottomThreshold?: number;
|
||||||
|
onReachTop?: () => void;
|
||||||
|
onAtBottomChange?: (atBottom: boolean) => void;
|
||||||
|
onTopRowChange?: (topIndex: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MessageList = forwardRef<MessageListHandle, MessageListProps>(function MessageList(
|
||||||
|
{
|
||||||
|
rows,
|
||||||
|
renderRow,
|
||||||
|
computeKey,
|
||||||
|
initialAnchor,
|
||||||
|
ready,
|
||||||
|
estimateRowHeight = 64,
|
||||||
|
atBottomThreshold = 64,
|
||||||
|
onReachTop,
|
||||||
|
onAtBottomChange,
|
||||||
|
onTopRowChange,
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const scrollElRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [revealed, setRevealed] = useState(false);
|
||||||
|
|
||||||
|
// THE single source of truth: should the view stay pinned to the bottom?
|
||||||
|
// Only a genuine user up-input (wheel / key / touch / scrollbar drag) turns
|
||||||
|
// this OFF; only reaching the bottom turns it ON. A measurement reflow must
|
||||||
|
// never flip it — that was the root cause of the chat-switch bug.
|
||||||
|
const stickRef = useRef(true);
|
||||||
|
// Debounce for onAtBottomChange — fire the parent only on a real transition.
|
||||||
|
const lastReportedAtBottomRef = useRef<boolean | null>(null);
|
||||||
|
// Guard: scrolls WE cause (pin / measure re-pin / scrollToIndex) fire onScroll
|
||||||
|
// a tick later. Within this window we don't treat a scrollTop decrease as the
|
||||||
|
// user dragging up.
|
||||||
|
const programmaticRef = useRef(0);
|
||||||
|
// Previous scrollTop, to detect a genuine scrollbar/keyboard up-drag.
|
||||||
|
const lastScrollTopRef = useRef(0);
|
||||||
|
// Load-older preservation: remember the first row key + scrollHeight so a
|
||||||
|
// prepend can be detected and the viewport restored.
|
||||||
|
const prevFirstKeyRef = useRef<string | null>(null);
|
||||||
|
const prevScrollHeightRef = useRef(0);
|
||||||
|
|
||||||
|
// Latest onAtBottomChange, read through a ref so the input-listener effect
|
||||||
|
// can stay mounted once (deps []) without capturing a stale callback.
|
||||||
|
const onAtBottomChangeRef = useRef(onAtBottomChange);
|
||||||
|
onAtBottomChangeRef.current = onAtBottomChange;
|
||||||
|
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: rows.length,
|
||||||
|
getScrollElement: () => scrollElRef.current,
|
||||||
|
estimateSize: () => estimateRowHeight,
|
||||||
|
overscan: 8,
|
||||||
|
getItemKey: (index) => computeKey(rows[index]!),
|
||||||
|
});
|
||||||
|
|
||||||
|
const readMetrics = useCallback(() => {
|
||||||
|
const el = scrollElRef.current;
|
||||||
|
return el
|
||||||
|
? { scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight }
|
||||||
|
: { scrollTop: 0, scrollHeight: 0, clientHeight: 0 };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const pinToBottom = useCallback(() => {
|
||||||
|
const el = scrollElRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
programmaticRef.current = performance.now();
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
lastScrollTopRef.current = el.scrollTop;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Report at-bottom to the parent only on a true transition, always driven by
|
||||||
|
// the INTENT (stickRef) — never the raw position. This is what kills the
|
||||||
|
// feedback loop: a transient "not at bottom" mid-reflow is never persisted.
|
||||||
|
const reportAtBottom = useCallback((atBottom: boolean) => {
|
||||||
|
if (lastReportedAtBottomRef.current === atBottom) return;
|
||||||
|
lastReportedAtBottomRef.current = atBottom;
|
||||||
|
onAtBottomChangeRef.current?.(atBottom);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// A genuine user up-input: drop the stick intent immediately.
|
||||||
|
const markUserMovedUp = useCallback(() => {
|
||||||
|
if (!stickRef.current) return;
|
||||||
|
stickRef.current = false;
|
||||||
|
reportAtBottom(false);
|
||||||
|
}, [reportAtBottom]);
|
||||||
|
|
||||||
|
// Re-pin to the true bottom whenever the content (or viewport) resizes while
|
||||||
|
// sticking. ResizeObserver fires after layout / before paint, so as rows
|
||||||
|
// measure and the list grows the bottom stays pinned with no stale frame.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scrollElRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const ro = new ResizeObserver(() => {
|
||||||
|
const e = scrollElRef.current;
|
||||||
|
if (stickRef.current && e) {
|
||||||
|
programmaticRef.current = performance.now();
|
||||||
|
e.scrollTop = e.scrollHeight;
|
||||||
|
lastScrollTopRef.current = e.scrollTop;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ro.observe(el);
|
||||||
|
const inner = el.firstElementChild;
|
||||||
|
if (inner) ro.observe(inner);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Genuine-user-intent listeners. These are the ONLY way (besides reaching the
|
||||||
|
// bottom) the stick intent turns off, so a reflow can never unstick the list.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scrollElRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const onWheel = (e: WheelEvent) => {
|
||||||
|
if (e.deltaY < 0) markUserMovedUp();
|
||||||
|
};
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'PageUp' || e.key === 'Home' || e.key === 'ArrowUp') markUserMovedUp();
|
||||||
|
};
|
||||||
|
let touchStartY = 0;
|
||||||
|
const onTouchStart = (e: TouchEvent) => {
|
||||||
|
touchStartY = e.touches[0]?.clientY ?? 0;
|
||||||
|
};
|
||||||
|
const onTouchMove = (e: TouchEvent) => {
|
||||||
|
const y = e.touches[0]?.clientY ?? 0;
|
||||||
|
// Finger dragged DOWN (content scrolls up toward older messages). Guard on
|
||||||
|
// scrollTop>0 so an overscroll bounce at the bottom doesn't unstick.
|
||||||
|
if (y - touchStartY > 8 && (scrollElRef.current?.scrollTop ?? 0) > 0) markUserMovedUp();
|
||||||
|
};
|
||||||
|
el.addEventListener('wheel', onWheel, { passive: true });
|
||||||
|
el.addEventListener('keydown', onKeyDown);
|
||||||
|
el.addEventListener('touchstart', onTouchStart, { passive: true });
|
||||||
|
el.addEventListener('touchmove', onTouchMove, { passive: true });
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener('wheel', onWheel);
|
||||||
|
el.removeEventListener('keydown', onKeyDown);
|
||||||
|
el.removeEventListener('touchstart', onTouchStart);
|
||||||
|
el.removeEventListener('touchmove', onTouchMove);
|
||||||
|
};
|
||||||
|
}, [markUserMovedUp]);
|
||||||
|
|
||||||
|
// Deferred reveal: when ready, pin to the anchor and keep pinning each frame
|
||||||
|
// until the list height has SETTLED over two consecutive frames, THEN reveal —
|
||||||
|
// so what appears is already at its final position with no top-then-jump.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!ready || revealed || rows.length === 0) return;
|
||||||
|
const el = scrollElRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const rowIdx = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(initialAnchor.type === 'row' ? initialAnchor.index : 0, rows.length - 1),
|
||||||
|
);
|
||||||
|
if (initialAnchor.type === 'bottom') {
|
||||||
|
stickRef.current = true;
|
||||||
|
pinToBottom();
|
||||||
|
} else {
|
||||||
|
stickRef.current = false;
|
||||||
|
programmaticRef.current = performance.now();
|
||||||
|
virtualizer.scrollToIndex(rowIdx, { align: 'start' });
|
||||||
|
}
|
||||||
|
reportAtBottom(stickRef.current);
|
||||||
|
|
||||||
|
let prevSH = -1;
|
||||||
|
let stableFrames = 0;
|
||||||
|
const settle = (attempts: number): void => {
|
||||||
|
const e = scrollElRef.current;
|
||||||
|
if (!e) {
|
||||||
|
setRevealed(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
programmaticRef.current = performance.now();
|
||||||
|
if (stickRef.current) {
|
||||||
|
e.scrollTop = e.scrollHeight;
|
||||||
|
lastScrollTopRef.current = e.scrollTop;
|
||||||
|
} else {
|
||||||
|
virtualizer.scrollToIndex(rowIdx, { align: 'start' });
|
||||||
|
}
|
||||||
|
const sh = e.scrollHeight;
|
||||||
|
// Require TWO consecutive stable-height frames: a single stable frame can
|
||||||
|
// land mid-cascade (between reactions and the unread divider measuring)
|
||||||
|
// and reveal a not-yet-final layout that then jumps.
|
||||||
|
stableFrames = sh === prevSH ? stableFrames + 1 : 0;
|
||||||
|
prevSH = sh;
|
||||||
|
if (stableFrames >= 2 || attempts <= 0) {
|
||||||
|
setRevealed(true);
|
||||||
|
} else {
|
||||||
|
requestAnimationFrame(() => settle(attempts - 1));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
requestAnimationFrame(() => settle(12));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [ready, rows.length]);
|
||||||
|
|
||||||
|
// Load-older preservation: if rows were prepended (first key changed and the
|
||||||
|
// user is near the top), restore scrollTop by the height delta so the viewport
|
||||||
|
// stays put instead of jumping.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const firstKey = rows.length > 0 ? computeKey(rows[0]!) : null;
|
||||||
|
const el = scrollElRef.current;
|
||||||
|
if (el && revealed && prevFirstKeyRef.current && firstKey !== prevFirstKeyRef.current) {
|
||||||
|
const delta = el.scrollHeight - prevScrollHeightRef.current;
|
||||||
|
if (delta > 0 && el.scrollTop < atBottomThreshold * 4) {
|
||||||
|
el.scrollTop += delta;
|
||||||
|
lastScrollTopRef.current = el.scrollTop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prevFirstKeyRef.current = firstKey;
|
||||||
|
prevScrollHeightRef.current = el?.scrollHeight ?? 0;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [rows]);
|
||||||
|
|
||||||
|
// Re-pin on any rows change while sticking. Covers the two-phase data swap
|
||||||
|
// (the cached array is replaced by the freshly-decrypted one ~100ms after
|
||||||
|
// reveal) which the ResizeObserver can miss when the new content happens to
|
||||||
|
// measure to the same height.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (revealed && stickRef.current) pinToBottom();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [rows]);
|
||||||
|
|
||||||
|
const handleScroll = useCallback(() => {
|
||||||
|
const m = readMetrics();
|
||||||
|
const programmatic = performance.now() - programmaticRef.current < 120;
|
||||||
|
const nearBottom = isNearBottom(m, atBottomThreshold);
|
||||||
|
// A scrollbar drag or keyboard scroll surfaces here as a scrollTop decrease.
|
||||||
|
// Suppress it inside the programmatic window so our own re-pin / settle is
|
||||||
|
// never mistaken for the user moving up. 2px deadzone absorbs sub-pixel jitter.
|
||||||
|
const userMovedUp = !programmatic && m.scrollTop < lastScrollTopRef.current - 2;
|
||||||
|
lastScrollTopRef.current = m.scrollTop;
|
||||||
|
|
||||||
|
stickRef.current = nextStickIntent(stickRef.current, { nearBottom, userMovedUp });
|
||||||
|
reportAtBottom(stickRef.current);
|
||||||
|
|
||||||
|
if (isNearTop(m, atBottomThreshold * 4)) onReachTop?.();
|
||||||
|
const first = virtualizer.getVirtualItems()[0];
|
||||||
|
if (first) onTopRowChange?.(first.index);
|
||||||
|
}, [atBottomThreshold, onReachTop, onTopRowChange, readMetrics, reportAtBottom, virtualizer]);
|
||||||
|
|
||||||
|
useImperativeHandle(
|
||||||
|
ref,
|
||||||
|
() => ({
|
||||||
|
scrollToBottom: () => {
|
||||||
|
stickRef.current = true;
|
||||||
|
reportAtBottom(true);
|
||||||
|
pinToBottom();
|
||||||
|
},
|
||||||
|
scrollToRow: (index, align = 'center') => {
|
||||||
|
// The user is jumping to a specific row — drop the stick intent first so
|
||||||
|
// the ResizeObserver doesn't immediately drag the target back to the bottom.
|
||||||
|
stickRef.current = false;
|
||||||
|
reportAtBottom(false);
|
||||||
|
programmaticRef.current = performance.now();
|
||||||
|
virtualizer.scrollToIndex(index, { align });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[virtualizer, rows.length, reportAtBottom, pinToBottom],
|
||||||
|
);
|
||||||
|
|
||||||
|
const items = virtualizer.getVirtualItems();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={scrollElRef}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
tabIndex={0}
|
||||||
|
className="min-h-0 flex-1 overflow-y-auto"
|
||||||
|
style={{
|
||||||
|
opacity: revealed ? 1 : 0,
|
||||||
|
position: 'relative',
|
||||||
|
overflowAnchor: 'none',
|
||||||
|
outline: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ height: virtualizer.getTotalSize(), position: 'relative', width: '100%' }}>
|
||||||
|
{items.map((vi) => (
|
||||||
|
<div
|
||||||
|
key={vi.key}
|
||||||
|
data-index={vi.index}
|
||||||
|
ref={virtualizer.measureElement}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
transform: `translateY(${vi.start}px)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderRow(vi.index, rows[vi.index]!)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* 12px bottom breathing space (matches the old Virtuoso Footer). */}
|
||||||
|
<div style={{ height: 12 }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { getNickname, setNickname } from '../lib/friendNicknames';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
userId: string;
|
||||||
|
displayName: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NicknameDialog({ open, userId, displayName, onClose }: Props) {
|
||||||
|
const [value, setValue] = useState('');
|
||||||
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setValue(getNickname(userId) ?? '');
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 0);
|
||||||
|
}, [open, userId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const submit = (): void => {
|
||||||
|
setNickname(userId, value);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Spitzname setzen"
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="w-full max-w-sm rounded-2xl border border-line bg-surface-2 p-5 shadow-xl"
|
||||||
|
>
|
||||||
|
<h3 className="font-display text-base font-semibold text-fg">Spitzname für {displayName}</h3>
|
||||||
|
<p className="mt-1 text-xs text-fg-muted">
|
||||||
|
Nur du siehst diesen Namen. Leer lassen = den richtigen Namen verwenden.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') submit(); }}
|
||||||
|
maxLength={32}
|
||||||
|
placeholder={displayName}
|
||||||
|
className="mt-4 w-full rounded-lg border border-line bg-surface-3 px-3 py-2 text-sm text-fg placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
|
||||||
|
/>
|
||||||
|
<div className="mt-4 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg hover:bg-surface-2"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={submit}
|
||||||
|
className="cursor-pointer rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg hover:brightness-110"
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: string;
|
||||||
|
onChange: (next: string) => void;
|
||||||
|
length?: number;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
ariaLabel: string;
|
||||||
|
onSubmit?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PinInput({ value, onChange, length = 6, autoFocus, disabled, ariaLabel, onSubmit }: Props) {
|
||||||
|
const ref = useRef<HTMLInputElement | null>(null);
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
useEffect(() => { if (autoFocus) ref.current?.focus(); }, [autoFocus]);
|
||||||
|
// Index of the next empty slot the next keystroke will fill. When the user
|
||||||
|
// has typed all `length` digits, no slot is "active" — the form should
|
||||||
|
// submit instead of pretending one is still focused.
|
||||||
|
const activeIndex = value.length < length ? value.length : -1;
|
||||||
|
return (
|
||||||
|
<div className="relative flex justify-center" onClick={() => ref.current?.focus()}>
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
pattern="\d*"
|
||||||
|
maxLength={length}
|
||||||
|
disabled={disabled}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value.replace(/\D/g, '').slice(0, length))}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter' && value.length === length) onSubmit?.(); }}
|
||||||
|
onFocus={() => setFocused(true)}
|
||||||
|
onBlur={() => setFocused(false)}
|
||||||
|
className="absolute h-px w-px overflow-hidden p-0 opacity-0"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{Array.from({ length }).map((_, i) => {
|
||||||
|
const filled = i < value.length;
|
||||||
|
const active = focused && i === activeIndex;
|
||||||
|
let classes =
|
||||||
|
'flex h-12 w-10 items-center justify-center rounded-lg border text-lg font-semibold transition ';
|
||||||
|
if (filled) {
|
||||||
|
classes += 'border-brand-400 bg-brand-500/10 text-white';
|
||||||
|
} else if (active) {
|
||||||
|
// Brand-coloured ring + glow so the user immediately sees where
|
||||||
|
// the next keystroke lands. The animated cursor inside reinforces
|
||||||
|
// the "input is alive" feeling.
|
||||||
|
classes += 'border-brand-400 bg-brand-500/10 text-brand-300 ring-2 ring-brand-400/40 shadow-[0_0_12px_-2px] shadow-brand-500/40';
|
||||||
|
} else {
|
||||||
|
classes += 'border-white/10 bg-ink-800 text-neutral-500';
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span key={i} className={classes}>
|
||||||
|
{filled ? '•' : active ? <span className="animate-pulse">|</span> : ''}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import type { PinnedMessage } from '@chat-app/shared/chat';
|
||||||
|
|
||||||
|
import { PinIcon, XIcon } from './icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
pins: PinnedMessage[];
|
||||||
|
onClose: () => void;
|
||||||
|
onJump: (messageId: string) => void;
|
||||||
|
onUnpin: (messageId: string) => void;
|
||||||
|
// Optional preview-renderer: parent resolves messageId → short text/snippet
|
||||||
|
// since the panel itself doesn't decrypt. If absent, the panel just shows
|
||||||
|
// the message-id stub.
|
||||||
|
renderPreview?: (messageId: string) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PinnedMessagesPanel({ open, pins, onClose, onJump, onUnpin, renderPreview }: Props) {
|
||||||
|
if (!open) return null;
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
role="complementary"
|
||||||
|
aria-label="Angepinnte Nachrichten"
|
||||||
|
className="absolute right-0 top-0 z-30 flex h-full w-80 flex-col border-l border-line bg-surface-2 shadow-xl"
|
||||||
|
>
|
||||||
|
<header className="flex items-center justify-between border-b border-line px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<PinIcon className="h-4 w-4 text-accent" />
|
||||||
|
<h3 className="text-sm font-semibold text-fg">Angepinnt · {pins.length}</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Schließen"
|
||||||
|
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
|
||||||
|
>
|
||||||
|
<XIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
{pins.length === 0 ? (
|
||||||
|
<p className="p-6 text-center text-xs text-fg-muted">Noch nichts angepinnt.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex-1 overflow-y-auto">
|
||||||
|
{pins.map((p) => (
|
||||||
|
<li key={p.messageId} className="border-b border-line/60 px-4 py-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onJump(p.messageId)}
|
||||||
|
className="block w-full cursor-pointer text-left text-sm text-fg hover:text-accent"
|
||||||
|
>
|
||||||
|
{renderPreview ? renderPreview(p.messageId) : <span className="font-mono text-xs">{p.messageId.slice(0, 8)}</span>}
|
||||||
|
</button>
|
||||||
|
<div className="mt-1 flex items-center justify-between text-[11px] text-fg-muted">
|
||||||
|
<time dateTime={p.pinnedAt}>{new Date(p.pinnedAt).toLocaleString()}</time>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onUnpin(p.messageId)}
|
||||||
|
className="cursor-pointer text-rose-400 hover:underline"
|
||||||
|
>
|
||||||
|
Anheftung entfernen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { PinIcon } from './icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
count: number;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact chip rendered in the conv header that opens the pinned panel.
|
||||||
|
// Renders nothing when count is 0 so a fresh conv shows no clutter.
|
||||||
|
export function PinnedMessagesPill({ count, onClick }: Props) {
|
||||||
|
if (count === 0) return null;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-full border border-line bg-surface-3 px-2.5 text-xs font-medium text-fg-muted transition hover:bg-surface-2 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||||
|
title="Angepinnte Nachrichten anzeigen"
|
||||||
|
>
|
||||||
|
<PinIcon className="h-3 w-3 text-accent" />
|
||||||
|
<span>{count} angepinnt</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
|
export function RemoteRevokedScreen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { revokedRemotely, acknowledgeRevocation } = useAuth();
|
||||||
|
|
||||||
|
if (!revokedRemotely) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alertdialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="remote-revoked-title"
|
||||||
|
className="fixed inset-0 z-[1000] flex items-center justify-center bg-ink-950/95 p-6"
|
||||||
|
>
|
||||||
|
<div className="max-w-sm rounded-2xl border border-line bg-surface-2 p-6 text-center shadow-xl">
|
||||||
|
<h2
|
||||||
|
id="remote-revoked-title"
|
||||||
|
className="mb-2 font-display text-xl font-semibold text-fg"
|
||||||
|
>
|
||||||
|
{t('app:auth.revoked_title', { defaultValue: 'Du wurdest remote abgemeldet' })}
|
||||||
|
</h2>
|
||||||
|
<p className="mb-5 text-sm text-fg-muted">
|
||||||
|
{t('app:auth.revoked_body', {
|
||||||
|
defaultValue:
|
||||||
|
'Ein anderes deiner Geräte hat diesen Login beendet. Aus Sicherheitsgründen wurden alle lokalen Daten gelöscht.',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={acknowledgeRevocation}
|
||||||
|
className="inline-flex cursor-pointer items-center justify-center rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||||
|
>
|
||||||
|
{t('app:auth.revoked_acknowledge', { defaultValue: 'Verstanden' })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { supabase } from '../lib/supabase';
|
||||||
|
import { PencilIcon, XIcon } from './icons';
|
||||||
|
|
||||||
|
interface Stroke {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
color: string;
|
||||||
|
// normalized 0..1 coordinates so any viewer's canvas size renders consistently
|
||||||
|
points: Array<[number, number]>;
|
||||||
|
bornAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Stable per-share key. Use the share's participantId. */
|
||||||
|
shareKey: string;
|
||||||
|
/** Render annotations transparently (off when the toolbar is closed). */
|
||||||
|
enabled: boolean;
|
||||||
|
onToggleEnabled: (next: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FADE_MS = 8000;
|
||||||
|
const COLORS = ['#ef4444', '#3b82f6', '#22c55e', '#facc15', '#a855f7', '#000000'] as const;
|
||||||
|
|
||||||
|
export function ScreenShareAnnotations({ shareKey, enabled, onToggleEnabled }: Props) {
|
||||||
|
const { session } = useAuth();
|
||||||
|
const userId = session?.user.id ?? 'anon';
|
||||||
|
const [color, setColor] = useState<string>(COLORS[0]);
|
||||||
|
const [strokes, setStrokes] = useState<Stroke[]>([]);
|
||||||
|
const draftRef = useRef<Stroke | null>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
|
||||||
|
const broadcastRef = useRef<((s: Stroke) => void) | null>(null);
|
||||||
|
|
||||||
|
// Subscribe to remote strokes.
|
||||||
|
useEffect(() => {
|
||||||
|
const channel = supabase.channel('screen-annotation:' + shareKey, {
|
||||||
|
config: { broadcast: { self: false } },
|
||||||
|
});
|
||||||
|
channel.on('broadcast', { event: 'stroke' }, (payload) => {
|
||||||
|
const s = payload.payload as Stroke | undefined;
|
||||||
|
if (!s || s.userId === userId) return;
|
||||||
|
setStrokes((prev) => [...prev, { ...s, bornAt: Date.now() }]);
|
||||||
|
});
|
||||||
|
channel.subscribe();
|
||||||
|
broadcastRef.current = (s: Stroke) => {
|
||||||
|
void channel.send({
|
||||||
|
type: 'broadcast',
|
||||||
|
event: 'stroke',
|
||||||
|
payload: s,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return () => {
|
||||||
|
broadcastRef.current = null;
|
||||||
|
void supabase.removeChannel(channel);
|
||||||
|
};
|
||||||
|
}, [shareKey, userId]);
|
||||||
|
|
||||||
|
// Garbage-collect faded strokes after FADE_MS + a small grace window.
|
||||||
|
useEffect(() => {
|
||||||
|
if (strokes.length === 0) return;
|
||||||
|
const id = setInterval(() => {
|
||||||
|
const cutoff = Date.now() - FADE_MS - 500;
|
||||||
|
setStrokes((prev) => {
|
||||||
|
const next = prev.filter((s) => s.bornAt > cutoff);
|
||||||
|
return next.length === prev.length ? prev : next;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [strokes.length]);
|
||||||
|
|
||||||
|
// Paint the canvas on every render tick.
|
||||||
|
useEffect(() => {
|
||||||
|
const cv = canvasRef.current;
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!cv || !container) return;
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
if (cv.width !== rect.width || cv.height !== rect.height) {
|
||||||
|
cv.width = Math.max(1, Math.floor(rect.width));
|
||||||
|
cv.height = Math.max(1, Math.floor(rect.height));
|
||||||
|
}
|
||||||
|
const ctx = cv.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
ctx.clearRect(0, 0, cv.width, cv.height);
|
||||||
|
const now = Date.now();
|
||||||
|
const drawStroke = (s: Stroke) => {
|
||||||
|
const age = now - s.bornAt;
|
||||||
|
const alpha = Math.max(0, 1 - age / FADE_MS);
|
||||||
|
if (alpha <= 0) return;
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalAlpha = alpha;
|
||||||
|
ctx.strokeStyle = s.color;
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
ctx.beginPath();
|
||||||
|
for (let i = 0; i < s.points.length; i++) {
|
||||||
|
const [nx, ny] = s.points[i]!;
|
||||||
|
const x = nx * cv.width;
|
||||||
|
const y = ny * cv.height;
|
||||||
|
if (i === 0) ctx.moveTo(x, y);
|
||||||
|
else ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
};
|
||||||
|
for (const s of strokes) drawStroke(s);
|
||||||
|
if (draftRef.current) drawStroke(draftRef.current);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Animation frame loop so faded strokes visually decay between paints.
|
||||||
|
useEffect(() => {
|
||||||
|
if (strokes.length === 0 && !draftRef.current) return;
|
||||||
|
let raf = 0;
|
||||||
|
const tick = () => {
|
||||||
|
// Nudge state to force a re-paint. Slightly hacky but cheaper than a
|
||||||
|
// dedicated refresh state.
|
||||||
|
setStrokes((prev) => prev.slice());
|
||||||
|
raf = window.requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
raf = window.requestAnimationFrame(tick);
|
||||||
|
return () => window.cancelAnimationFrame(raf);
|
||||||
|
}, [strokes.length]);
|
||||||
|
|
||||||
|
const normalized = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return [0, 0] as [number, number];
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
const nx = (e.clientX - rect.left) / rect.width;
|
||||||
|
const ny = (e.clientY - rect.top) / rect.height;
|
||||||
|
return [Math.min(1, Math.max(0, nx)), Math.min(1, Math.max(0, ny))] as [number, number];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (!enabled) return;
|
||||||
|
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
||||||
|
draftRef.current = {
|
||||||
|
id: Math.random().toString(36).slice(2),
|
||||||
|
userId,
|
||||||
|
color,
|
||||||
|
points: [normalized(e)],
|
||||||
|
bornAt: Date.now(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (!draftRef.current) return;
|
||||||
|
draftRef.current.points.push(normalized(e));
|
||||||
|
setStrokes((prev) => prev.slice()); // cheap re-render trigger
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
const target = e.currentTarget as HTMLDivElement;
|
||||||
|
if (target.hasPointerCapture(e.pointerId)) target.releasePointerCapture(e.pointerId);
|
||||||
|
const draft = draftRef.current;
|
||||||
|
draftRef.current = null;
|
||||||
|
if (!draft || draft.points.length < 2) {
|
||||||
|
setStrokes((prev) => prev.slice());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStrokes((prev) => [...prev, draft]);
|
||||||
|
broadcastRef.current?.(draft);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={onPointerUp}
|
||||||
|
onPointerLeave={onPointerUp}
|
||||||
|
className={
|
||||||
|
'absolute inset-0 z-10 ' +
|
||||||
|
(enabled ? 'cursor-crosshair touch-none' : 'pointer-events-none')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="pointer-events-none absolute inset-0 h-full w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="absolute right-3 top-12 z-20 flex flex-col items-end gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggleEnabled(!enabled)}
|
||||||
|
aria-pressed={enabled}
|
||||||
|
title={enabled ? 'Annotation aus' : 'Annotation an'}
|
||||||
|
className={
|
||||||
|
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-full text-white shadow-lg transition ' +
|
||||||
|
(enabled ? 'bg-accent' : 'bg-black/60 hover:bg-black/80')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{enabled ? <XIcon className="h-3.5 w-3.5" /> : <PencilIcon className="h-3.5 w-3.5" />}
|
||||||
|
</button>
|
||||||
|
{enabled && (
|
||||||
|
<div className="flex items-center gap-1 rounded-full bg-black/60 p-1 shadow-lg">
|
||||||
|
{COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setColor(c)}
|
||||||
|
aria-label={c}
|
||||||
|
aria-pressed={c === color}
|
||||||
|
className={
|
||||||
|
'h-5 w-5 cursor-pointer rounded-full border-2 transition ' +
|
||||||
|
(c === color ? 'border-white scale-110' : 'border-white/30 hover:scale-105')
|
||||||
|
}
|
||||||
|
style={{ backgroundColor: c }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
|
|
||||||
import { type RemoteScreenShare, useCall } from '../context/CallContext';
|
import { type RemoteScreenShare, useCall } from '../context/CallContext';
|
||||||
import { MonitorShareIcon } from './icons';
|
import { MonitorShareIcon } from './icons';
|
||||||
|
import { ScreenShareAnnotations } from './ScreenShareAnnotations';
|
||||||
|
|
||||||
interface ScreenShareViewerProps {
|
interface ScreenShareViewerProps {
|
||||||
share: RemoteScreenShare;
|
share: RemoteScreenShare;
|
||||||
@@ -34,6 +35,7 @@ export function ScreenShareViewer({
|
|||||||
const { watchingShareUserIds, watchShare } = useCall();
|
const { watchingShareUserIds, watchShare } = useCall();
|
||||||
const watching = watchingShareUserIds.has(share.participantId);
|
const watching = watchingShareUserIds.has(share.participantId);
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
const [annotateEnabled, setAnnotateEnabled] = useState(false);
|
||||||
const letter = displayName.trim().charAt(0).toUpperCase() || '?';
|
const letter = displayName.trim().charAt(0).toUpperCase() || '?';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -96,21 +98,28 @@ export function ScreenShareViewer({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{watching ? (
|
{watching ? (
|
||||||
<video
|
<div className="relative h-full w-full flex-1">
|
||||||
ref={videoRef}
|
<video
|
||||||
autoPlay
|
ref={videoRef}
|
||||||
playsInline
|
autoPlay
|
||||||
muted
|
playsInline
|
||||||
// Suppress the browser's built-in <video> context menu
|
muted
|
||||||
// ("Save Video As…", PiP, …) so the right-click event bubbles
|
// Suppress the browser's built-in <video> context menu
|
||||||
// to the wrapping tile div in InCallPanel — that's where the
|
// ("Save Video As…", PiP, …) so the right-click event bubbles
|
||||||
// app's volume / mute menu is wired up. Without this, the
|
// to the wrapping tile div in InCallPanel — that's where the
|
||||||
// native menu opens on top of ours in focus + fullscreen
|
// app's volume / mute menu is wired up. Without this, the
|
||||||
// modes (where the video covers the whole tile).
|
// native menu opens on top of ours in focus + fullscreen
|
||||||
onContextMenu={(e) => e.preventDefault()}
|
// modes (where the video covers the whole tile).
|
||||||
onDoubleClick={toggleFullscreen}
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
className="block h-full w-full flex-1 cursor-zoom-in bg-black object-contain"
|
onDoubleClick={toggleFullscreen}
|
||||||
/>
|
className="block h-full w-full cursor-zoom-in bg-black object-contain"
|
||||||
|
/>
|
||||||
|
<ScreenShareAnnotations
|
||||||
|
shareKey={share.participantId}
|
||||||
|
enabled={annotateEnabled}
|
||||||
|
onToggleEnabled={setAnnotateEnabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -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 {
|
interface NavItem {
|
||||||
to: string;
|
to: string;
|
||||||
labelKey: string;
|
labelKey: string;
|
||||||
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
|
icon: React.ComponentType<React.ComponentPropsWithoutRef<'svg'>>;
|
||||||
badge?: 'friends' | 'chats';
|
badge?: 'friends' | 'chats';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ export function Sidebar() {
|
|||||||
interface RailNavLinkProps {
|
interface RailNavLinkProps {
|
||||||
to: string;
|
to: string;
|
||||||
label: string;
|
label: string;
|
||||||
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
|
icon: React.ComponentType<React.ComponentPropsWithoutRef<'svg'>>;
|
||||||
badge?: number;
|
badge?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +135,7 @@ function RailNavLink({ to, label, icon: Icon, badge = 0 }: RailNavLinkProps) {
|
|||||||
interface RailIconButtonProps {
|
interface RailIconButtonProps {
|
||||||
label: string;
|
label: string;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
|
icon: React.ComponentType<React.ComponentPropsWithoutRef<'svg'>>;
|
||||||
tone?: 'default' | 'danger';
|
tone?: 'default' | 'danger';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { deleteSound as deleteRemoteSound } from '@chat-app/shared/chat';
|
||||||
import { getPttSettings } from '../lib/pttSettings';
|
import { getPttSettings } from '../lib/pttSettings';
|
||||||
import { codeToShortcut } from '../lib/globalShortcut';
|
import { codeToShortcut } from '../lib/globalShortcut';
|
||||||
import { invalidate as invalidateSoundCache, preload } from '../lib/soundboardPlayback';
|
import { invalidate as invalidateSoundCache, preload } from '../lib/soundboardPlayback';
|
||||||
@@ -15,6 +16,8 @@ import {
|
|||||||
subscribeSoundboardChanges,
|
subscribeSoundboardChanges,
|
||||||
updateSound,
|
updateSound,
|
||||||
} from '../lib/soundboardStorage';
|
} from '../lib/soundboardStorage';
|
||||||
|
import { supabase } from '../lib/supabase';
|
||||||
|
import { useSoundboardSync, type SyncBadge } from '../hooks/useSoundboardSync';
|
||||||
import { Modal } from './Modal';
|
import { Modal } from './Modal';
|
||||||
import {
|
import {
|
||||||
AlertIcon,
|
AlertIcon,
|
||||||
@@ -46,6 +49,7 @@ export function SoundboardManagerDialog({ open, onClose }: Props) {
|
|||||||
const [busyId, setBusyId] = useState<string | null>(null);
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [previewingId, setPreviewingId] = useState<string | null>(null);
|
const [previewingId, setPreviewingId] = useState<string | null>(null);
|
||||||
|
const { badges } = useSoundboardSync();
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -167,6 +171,11 @@ export function SoundboardManagerDialog({ open, onClose }: Props) {
|
|||||||
}
|
}
|
||||||
setBusyId(id);
|
setBusyId(id);
|
||||||
try {
|
try {
|
||||||
|
try {
|
||||||
|
await deleteRemoteSound(supabase, id);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('remote sound delete failed (local delete proceeds)', err);
|
||||||
|
}
|
||||||
await deleteSound(id);
|
await deleteSound(id);
|
||||||
invalidateSoundCache(id);
|
invalidateSoundCache(id);
|
||||||
if (previewingId === id) stopPreview();
|
if (previewingId === id) stopPreview();
|
||||||
@@ -301,6 +310,7 @@ export function SoundboardManagerDialog({ open, onClose }: Props) {
|
|||||||
entriesTotal={entries}
|
entriesTotal={entries}
|
||||||
busyId={busyId}
|
busyId={busyId}
|
||||||
previewingId={previewingId}
|
previewingId={previewingId}
|
||||||
|
badges={badges}
|
||||||
onPatch={handlePatch}
|
onPatch={handlePatch}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onPreview={handlePreview}
|
onPreview={handlePreview}
|
||||||
@@ -323,6 +333,7 @@ interface GroupProps {
|
|||||||
entriesTotal: SoundboardEntry[];
|
entriesTotal: SoundboardEntry[];
|
||||||
busyId: string | null;
|
busyId: string | null;
|
||||||
previewingId: string | null;
|
previewingId: string | null;
|
||||||
|
badges: Map<string, SyncBadge>;
|
||||||
onPatch: (id: string, patch: Parameters<typeof updateSound>[1]) => Promise<void>;
|
onPatch: (id: string, patch: Parameters<typeof updateSound>[1]) => Promise<void>;
|
||||||
onDelete: (id: string) => Promise<void>;
|
onDelete: (id: string) => Promise<void>;
|
||||||
onPreview: (entry: SoundboardEntry) => Promise<void>;
|
onPreview: (entry: SoundboardEntry) => Promise<void>;
|
||||||
@@ -335,6 +346,7 @@ function SoundboardCategoryGroup({
|
|||||||
entriesTotal,
|
entriesTotal,
|
||||||
busyId,
|
busyId,
|
||||||
previewingId,
|
previewingId,
|
||||||
|
badges,
|
||||||
onPatch,
|
onPatch,
|
||||||
onDelete,
|
onDelete,
|
||||||
onPreview,
|
onPreview,
|
||||||
@@ -370,6 +382,7 @@ function SoundboardCategoryGroup({
|
|||||||
isLast={idx === entries.length - 1}
|
isLast={idx === entries.length - 1}
|
||||||
busy={busyId === entry.id}
|
busy={busyId === entry.id}
|
||||||
previewing={previewingId === entry.id}
|
previewing={previewingId === entry.id}
|
||||||
|
badge={badges.get(entry.id)}
|
||||||
onPatch={onPatch}
|
onPatch={onPatch}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
onPreview={onPreview}
|
onPreview={onPreview}
|
||||||
@@ -392,6 +405,7 @@ interface RowProps {
|
|||||||
isLast: boolean;
|
isLast: boolean;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
previewing: boolean;
|
previewing: boolean;
|
||||||
|
badge: SyncBadge | undefined;
|
||||||
onPatch: (id: string, patch: Parameters<typeof updateSound>[1]) => Promise<void>;
|
onPatch: (id: string, patch: Parameters<typeof updateSound>[1]) => Promise<void>;
|
||||||
onDelete: (id: string) => Promise<void>;
|
onDelete: (id: string) => Promise<void>;
|
||||||
onPreview: (entry: SoundboardEntry) => Promise<void>;
|
onPreview: (entry: SoundboardEntry) => Promise<void>;
|
||||||
@@ -406,6 +420,7 @@ function SoundboardRow({
|
|||||||
isLast,
|
isLast,
|
||||||
busy,
|
busy,
|
||||||
previewing,
|
previewing,
|
||||||
|
badge,
|
||||||
onPatch,
|
onPatch,
|
||||||
onDelete,
|
onDelete,
|
||||||
onPreview,
|
onPreview,
|
||||||
@@ -503,6 +518,13 @@ function SoundboardRow({
|
|||||||
)}
|
)}
|
||||||
<p className="text-[10px] text-fg-muted">
|
<p className="text-[10px] text-fg-muted">
|
||||||
{(entry.size / BYTES_PER_MB).toFixed(2)} MB · {entry.mime}
|
{(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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -623,3 +645,25 @@ function SoundboardRow({
|
|||||||
</li>
|
</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,148 @@
|
|||||||
|
import { useEffect, useRef, 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. Press-and-
|
||||||
|
// hold reveals the image fullscreen; release closes it 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.
|
||||||
|
//
|
||||||
|
// While revealed, the renderer window enables content-protection
|
||||||
|
// (`win.setContentProtection(true)`) so OS-level screen capture (OBS, Win/Cmd
|
||||||
|
// snipping tools, screen recorders) sees a black/empty surface. Re-enabled
|
||||||
|
// on release / unmount.
|
||||||
|
export function ViewOnceImage({ attachmentId, viewedAt, isSender, src }: Props) {
|
||||||
|
const [revealedAt, setRevealedAt] = useState<string | null>(viewedAt);
|
||||||
|
const [revealing, setRevealing] = useState(false);
|
||||||
|
const burnedRef = useRef(false);
|
||||||
|
const holdingRef = useRef(false);
|
||||||
|
|
||||||
|
const burned = revealedAt !== null;
|
||||||
|
|
||||||
|
// Tear down screen-capture protection if the component unmounts mid-reveal.
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (revealing || holdingRef.current) {
|
||||||
|
void window.electronAPI?.setContentProtection?.(false).catch(() => {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [revealing]);
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startReveal = async (): Promise<void> => {
|
||||||
|
if (burnedRef.current) return;
|
||||||
|
burnedRef.current = true;
|
||||||
|
holdingRef.current = true;
|
||||||
|
try {
|
||||||
|
await window.electronAPI?.setContentProtection?.(true);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('setContentProtection enable failed', err);
|
||||||
|
}
|
||||||
|
// The user may have released during the await. If so, skip showing the
|
||||||
|
// dialog and run the close-path directly so we don't leave the renderer
|
||||||
|
// in protected mode with no visible UI.
|
||||||
|
if (!holdingRef.current) {
|
||||||
|
// User released during the IPC await — endReveal already fired and is
|
||||||
|
// responsible for teardown (setContentProtection(false) + mark-viewed).
|
||||||
|
// Skipping teardown here avoids a duplicate markAttachmentViewed RPC.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRevealing(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const endReveal = async (): Promise<void> => {
|
||||||
|
if (!holdingRef.current && !revealing) return;
|
||||||
|
holdingRef.current = false;
|
||||||
|
if (revealing) setRevealing(false);
|
||||||
|
await teardownReveal();
|
||||||
|
};
|
||||||
|
|
||||||
|
const teardownReveal = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await window.electronAPI?.setContentProtection?.(false);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('setContentProtection disable failed', err);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await markAttachmentViewed(supabase, attachmentId);
|
||||||
|
if (res.viewedAt) setRevealedAt(res.viewedAt);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('mark-viewed failed', err);
|
||||||
|
burnedRef.current = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onPointerDown={() => void startReveal()}
|
||||||
|
onPointerUp={() => void endReveal()}
|
||||||
|
onPointerLeave={() => void endReveal()}
|
||||||
|
onPointerCancel={() => void endReveal()}
|
||||||
|
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 select-none"
|
||||||
|
>
|
||||||
|
<LockIcon className="h-6 w-6 text-accent" />
|
||||||
|
<span className="text-xs font-medium">Gedrückt halten zum Ansehen</span>
|
||||||
|
</button>
|
||||||
|
{revealing && (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Einmal-ansehen Bild"
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/95 p-6"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt=""
|
||||||
|
className="max-h-full max-w-full select-none rounded-lg"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
<span className="absolute bottom-6 left-1/2 -translate-x-1/2 rounded-full bg-white/10 px-3 py-1 text-xs font-semibold text-white">
|
||||||
|
Loslassen zum Schließen — Aufnahme blockiert
|
||||||
|
</span>
|
||||||
|
</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,234 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import type { WhiteboardStroke } from '@chat-app/shared/chat';
|
||||||
|
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { openCursorSession, type CursorEvent, type CursorSession } from '../lib/whiteboardCursors';
|
||||||
|
|
||||||
|
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;
|
||||||
|
/** Enables live-cursor broadcast when set. */
|
||||||
|
whiteboardId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
whiteboardId,
|
||||||
|
}: Props) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
const draftRef = useRef<WhiteboardStrokePayload | null>(null);
|
||||||
|
const strokeStartRef = useRef<number>(0);
|
||||||
|
const [, forceTick] = useState(0);
|
||||||
|
|
||||||
|
const { session, profile } = useAuth();
|
||||||
|
const [remoteCursors, setRemoteCursors] = useState<Map<string, CursorEvent & { lastSeen: number }>>(
|
||||||
|
() => new Map(),
|
||||||
|
);
|
||||||
|
const cursorSessionRef = useRef<CursorSession | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!whiteboardId) return;
|
||||||
|
const me = session?.user;
|
||||||
|
if (!me) return;
|
||||||
|
const displayName = profile?.displayName ?? me.email ?? me.id.slice(0, 8);
|
||||||
|
const s = openCursorSession(
|
||||||
|
whiteboardId,
|
||||||
|
{ userId: me.id, displayName },
|
||||||
|
(ev) => {
|
||||||
|
setRemoteCursors((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(ev.userId, { ...ev, lastSeen: Date.now() });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
cursorSessionRef.current = s;
|
||||||
|
return () => {
|
||||||
|
s.close();
|
||||||
|
cursorSessionRef.current = null;
|
||||||
|
};
|
||||||
|
}, [whiteboardId, session?.user, profile?.displayName]);
|
||||||
|
|
||||||
|
// Stale-cursor sweep: drop cursors that haven't been heard from in 2s. Cheap
|
||||||
|
// poll because the Map is tiny (at most one entry per active collaborator).
|
||||||
|
useEffect(() => {
|
||||||
|
if (remoteCursors.size === 0) return;
|
||||||
|
const id = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
setRemoteCursors((prev) => {
|
||||||
|
let changed = false;
|
||||||
|
const next = new Map(prev);
|
||||||
|
for (const [k, v] of next) {
|
||||||
|
if (now - v.lastSeen > 2000) {
|
||||||
|
next.delete(k);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed ? next : prev;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [remoteCursors.size]);
|
||||||
|
|
||||||
|
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>) => {
|
||||||
|
const [x, y] = canvasPoint(e);
|
||||||
|
cursorSessionRef.current?.send(x, y);
|
||||||
|
if (!draftRef.current) return;
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className="relative"
|
||||||
|
style={{ aspectRatio: logicalWidth + ' / ' + logicalHeight, width: '100%' }}
|
||||||
|
>
|
||||||
|
<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={{ width: '100%', height: '100%' }}
|
||||||
|
/>
|
||||||
|
{Array.from(remoteCursors.values()).map((c) => {
|
||||||
|
const pctX = (c.x / logicalWidth) * 100;
|
||||||
|
const pctY = (c.y / logicalHeight) * 100;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={c.userId}
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute"
|
||||||
|
style={{ left: pctX + '%', top: pctY + '%', transform: 'translate(-2px, -2px)' }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="block h-2 w-2 rounded-full border-2 border-white shadow"
|
||||||
|
style={{ backgroundColor: colorForUserId(c.userId) }}
|
||||||
|
/>
|
||||||
|
<span className="ml-2 inline-block translate-y-[-2px] rounded-full bg-black/60 px-1.5 py-0.5 text-[9px] font-semibold text-white">
|
||||||
|
{c.displayName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorForUserId(userId: string): string {
|
||||||
|
// Deterministic hue from the user id so each collaborator gets a stable
|
||||||
|
// colour across sessions. Saturation/lightness fixed to keep the cursor
|
||||||
|
// legible against the white canvas.
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < userId.length; i++) hash = (hash * 31 + userId.charCodeAt(i)) | 0;
|
||||||
|
const hue = Math.abs(hash) % 360;
|
||||||
|
return 'hsl(' + hue + ', 70%, 50%)';
|
||||||
|
}
|
||||||
|
|
||||||
|
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,198 @@
|
|||||||
|
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)}
|
||||||
|
whiteboardId={whiteboardId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</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 />;
|
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() {
|
export function RequireDevice() {
|
||||||
const { device, deviceLookupDone } = useAuth();
|
const { userKeyState } = useAuth();
|
||||||
if (!deviceLookupDone) return <FullScreenSpinner />;
|
if (userKeyState.status === 'loading') return <FullScreenSpinner />;
|
||||||
if (!device) return <Navigate to="/device" replace />;
|
if (userKeyState.status === 'needs-setup' || userKeyState.status === 'needs-unlock') {
|
||||||
|
return <Navigate to="/device" replace />;
|
||||||
|
}
|
||||||
return <Outlet />;
|
return <Outlet />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Inline SVG icons — no icon library dependency. Lucide-style stroke=1.75.
|
// 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 }) {
|
function Base({ children, ...props }: IconProps & { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
@@ -20,7 +21,7 @@ function Base({ children, ...props }: IconProps & { children: React.ReactNode })
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MailIcon(props: IconProps) {
|
function MailIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<rect x="3" y="5" width="18" height="14" rx="2" />
|
<rect x="3" y="5" width="18" height="14" rx="2" />
|
||||||
@@ -28,8 +29,9 @@ export function MailIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const MailIcon = memo(MailIconInner);
|
||||||
|
|
||||||
export function AtIcon(props: IconProps) {
|
function AtIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<circle cx="12" cy="12" r="4" />
|
<circle cx="12" cy="12" r="4" />
|
||||||
@@ -37,8 +39,9 @@ export function AtIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const AtIcon = memo(AtIconInner);
|
||||||
|
|
||||||
export function TicketIcon(props: IconProps) {
|
function TicketIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<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" />
|
<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>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const TicketIcon = memo(TicketIconInner);
|
||||||
|
|
||||||
export function ArrowRightIcon(props: IconProps) {
|
function ArrowRightIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M5 12h14" />
|
<path d="M5 12h14" />
|
||||||
@@ -55,8 +59,9 @@ export function ArrowRightIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const ArrowRightIcon = memo(ArrowRightIconInner);
|
||||||
|
|
||||||
export function CheckCircleIcon(props: IconProps) {
|
function CheckCircleIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<circle cx="12" cy="12" r="9" />
|
<circle cx="12" cy="12" r="9" />
|
||||||
@@ -64,8 +69,9 @@ export function CheckCircleIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const CheckCircleIcon = memo(CheckCircleIconInner);
|
||||||
|
|
||||||
export function AlertIcon(props: IconProps) {
|
function AlertIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M12 9v4" />
|
<path d="M12 9v4" />
|
||||||
@@ -74,8 +80,9 @@ export function AlertIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const AlertIcon = memo(AlertIconInner);
|
||||||
|
|
||||||
export function ShieldIcon(props: IconProps) {
|
function ShieldIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M12 3 4 6v6c0 5 3.5 8.5 8 9 4.5-.5 8-4 8-9V6l-8-3Z" />
|
<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>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const ShieldIcon = memo(ShieldIconInner);
|
||||||
|
|
||||||
export function LockIcon(props: IconProps) {
|
function LockIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<rect x="4" y="11" width="16" height="10" rx="2" />
|
<rect x="4" y="11" width="16" height="10" rx="2" />
|
||||||
@@ -92,8 +100,9 @@ export function LockIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const LockIcon = memo(LockIconInner);
|
||||||
|
|
||||||
export function WifiLowIcon(props: IconProps) {
|
function WifiLowIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M5 12.55a11 11 0 0 1 14 0" opacity="0.35" />
|
<path d="M5 12.55a11 11 0 0 1 14 0" opacity="0.35" />
|
||||||
@@ -102,8 +111,9 @@ export function WifiLowIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const WifiLowIcon = memo(WifiLowIconInner);
|
||||||
|
|
||||||
export function WifiOffIcon(props: IconProps) {
|
function WifiOffIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="m2 2 20 20" />
|
<path d="m2 2 20 20" />
|
||||||
@@ -116,8 +126,9 @@ export function WifiOffIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const WifiOffIcon = memo(WifiOffIconInner);
|
||||||
|
|
||||||
export function PinIcon(props: IconProps) {
|
function PinIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M12 17v5" />
|
<path d="M12 17v5" />
|
||||||
@@ -125,8 +136,9 @@ export function PinIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const PinIcon = memo(PinIconInner);
|
||||||
|
|
||||||
export function EyeOffIcon(props: IconProps) {
|
function EyeOffIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="m2 2 20 20" />
|
<path d="m2 2 20 20" />
|
||||||
@@ -136,18 +148,19 @@ export function EyeOffIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const EyeOffIcon = memo(EyeOffIconInner);
|
||||||
|
|
||||||
export function CaptionsIcon(props: IconProps) {
|
function EyeIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<rect x="3" y="6" width="18" height="12" rx="2" />
|
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" />
|
||||||
<path d="M7 13a2 2 0 1 1 0-2" />
|
<circle cx="12" cy="12" r="3" />
|
||||||
<path d="M14 13a2 2 0 1 1 0-2" />
|
|
||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const EyeIcon = memo(EyeIconInner);
|
||||||
|
|
||||||
export function PinOffIcon(props: IconProps) {
|
function PinOffIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="m2 2 20 20" />
|
<path d="m2 2 20 20" />
|
||||||
@@ -157,8 +170,9 @@ export function PinOffIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const PinOffIcon = memo(PinOffIconInner);
|
||||||
|
|
||||||
export function SparklesIcon(props: IconProps) {
|
function SparklesIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M12 3v4" />
|
<path d="M12 3v4" />
|
||||||
@@ -172,8 +186,9 @@ export function SparklesIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const SparklesIcon = memo(SparklesIconInner);
|
||||||
|
|
||||||
export function SpinnerIcon(props: IconProps) {
|
function SpinnerIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -208,16 +223,18 @@ export function SpinnerIcon(props: IconProps) {
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const SpinnerIcon = memo(SpinnerIconInner);
|
||||||
|
|
||||||
export function ChatBubbleIcon(props: IconProps) {
|
function ChatBubbleIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<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" />
|
<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>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const ChatBubbleIcon = memo(ChatBubbleIconInner);
|
||||||
|
|
||||||
export function UsersIcon(props: IconProps) {
|
function UsersIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
<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>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const UsersIcon = memo(UsersIconInner);
|
||||||
|
|
||||||
export function GearIcon(props: IconProps) {
|
function GearIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<circle cx="12" cy="12" r="3" />
|
<circle cx="12" cy="12" r="3" />
|
||||||
@@ -236,8 +254,9 @@ export function GearIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const GearIcon = memo(GearIconInner);
|
||||||
|
|
||||||
export function SearchIcon(props: IconProps) {
|
function SearchIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<circle cx="11" cy="11" r="7" />
|
<circle cx="11" cy="11" r="7" />
|
||||||
@@ -245,16 +264,18 @@ export function SearchIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const SearchIcon = memo(SearchIconInner);
|
||||||
|
|
||||||
export function PlusIcon(props: IconProps) {
|
function PlusIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M12 5v14M5 12h14" />
|
<path d="M12 5v14M5 12h14" />
|
||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const PlusIcon = memo(PlusIconInner);
|
||||||
|
|
||||||
export function SignOutIcon(props: IconProps) {
|
function SignOutIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
<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>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const SignOutIcon = memo(SignOutIconInner);
|
||||||
|
|
||||||
export function MenuIcon(props: IconProps) {
|
function MenuIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M3 6h18M3 12h18M3 18h18" />
|
<path d="M3 6h18M3 12h18M3 18h18" />
|
||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const MenuIcon = memo(MenuIconInner);
|
||||||
|
|
||||||
export function ChevronDownIcon(props: IconProps) {
|
function ChevronDownIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="m6 9 6 6 6-6" />
|
<path d="m6 9 6 6 6-6" />
|
||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const ChevronDownIcon = memo(ChevronDownIconInner);
|
||||||
|
|
||||||
export function PencilIcon(props: IconProps) {
|
function PencilIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
|
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
|
||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const PencilIcon = memo(PencilIconInner);
|
||||||
|
|
||||||
export function TrashIcon(props: IconProps) {
|
function TrashIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M3 6h18" />
|
<path d="M3 6h18" />
|
||||||
@@ -299,8 +324,9 @@ export function TrashIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const TrashIcon = memo(TrashIconInner);
|
||||||
|
|
||||||
export function SmileIcon(props: IconProps) {
|
function SmileIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<circle cx="12" cy="12" r="9" />
|
<circle cx="12" cy="12" r="9" />
|
||||||
@@ -310,8 +336,9 @@ export function SmileIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const SmileIcon = memo(SmileIconInner);
|
||||||
|
|
||||||
export function CopyIcon(props: IconProps) {
|
function CopyIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<rect x="9" y="9" width="13" height="13" rx="2" />
|
<rect x="9" y="9" width="13" height="13" rx="2" />
|
||||||
@@ -319,16 +346,18 @@ export function CopyIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const CopyIcon = memo(CopyIconInner);
|
||||||
|
|
||||||
export function PhoneIcon(props: IconProps) {
|
function PhoneIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<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" />
|
<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>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const PhoneIcon = memo(PhoneIconInner);
|
||||||
|
|
||||||
export function PhoneOffIcon(props: IconProps) {
|
function PhoneOffIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<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" />
|
<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>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const PhoneOffIcon = memo(PhoneOffIconInner);
|
||||||
|
|
||||||
export function MicIcon(props: IconProps) {
|
function MicIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<rect x="9" y="2" width="6" height="12" rx="3" />
|
<rect x="9" y="2" width="6" height="12" rx="3" />
|
||||||
@@ -348,8 +378,9 @@ export function MicIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const MicIcon = memo(MicIconInner);
|
||||||
|
|
||||||
export function MicOffIcon(props: IconProps) {
|
function MicOffIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M1 1l22 22" />
|
<path d="M1 1l22 22" />
|
||||||
@@ -362,8 +393,9 @@ export function MicOffIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const MicOffIcon = memo(MicOffIconInner);
|
||||||
|
|
||||||
export function InfoIcon(props: IconProps) {
|
function InfoIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<circle cx="12" cy="12" r="9" />
|
<circle cx="12" cy="12" r="9" />
|
||||||
@@ -372,16 +404,18 @@ export function InfoIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const InfoIcon = memo(InfoIconInner);
|
||||||
|
|
||||||
export function XIcon(props: IconProps) {
|
function XIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M18 6 6 18M6 6l12 12" />
|
<path d="M18 6 6 18M6 6l12 12" />
|
||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const XIcon = memo(XIconInner);
|
||||||
|
|
||||||
export function MonitorShareIcon(props: IconProps) {
|
function MonitorShareIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<rect x="3" y="4" width="18" height="12" rx="2" />
|
<rect x="3" y="4" width="18" height="12" rx="2" />
|
||||||
@@ -390,8 +424,9 @@ export function MonitorShareIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const MonitorShareIcon = memo(MonitorShareIconInner);
|
||||||
|
|
||||||
export function MonitorStopIcon(props: IconProps) {
|
function MonitorStopIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<rect x="3" y="4" width="18" height="12" rx="2" />
|
<rect x="3" y="4" width="18" height="12" rx="2" />
|
||||||
@@ -400,8 +435,9 @@ export function MonitorStopIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const MonitorStopIcon = memo(MonitorStopIconInner);
|
||||||
|
|
||||||
export function SunIcon(props: IconProps) {
|
function SunIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<circle cx="12" cy="12" r="4" />
|
<circle cx="12" cy="12" r="4" />
|
||||||
@@ -409,16 +445,18 @@ export function SunIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const SunIcon = memo(SunIconInner);
|
||||||
|
|
||||||
export function MoonIcon(props: IconProps) {
|
function MoonIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M21 12.79A9 9 0 1 1 11.21 3a7 7 0 0 0 9.79 9.79Z" />
|
<path d="M21 12.79A9 9 0 1 1 11.21 3a7 7 0 0 0 9.79 9.79Z" />
|
||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const MoonIcon = memo(MoonIconInner);
|
||||||
|
|
||||||
export function GridIcon(props: IconProps) {
|
function GridIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<rect x="3" y="3" width="7" height="7" rx="1.5" />
|
<rect x="3" y="3" width="7" height="7" rx="1.5" />
|
||||||
@@ -428,8 +466,9 @@ export function GridIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const GridIcon = memo(GridIconInner);
|
||||||
|
|
||||||
export function ImageIcon(props: IconProps) {
|
function ImageIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<rect x="3" y="5" width="18" height="14" rx="2" />
|
<rect x="3" y="5" width="18" height="14" rx="2" />
|
||||||
@@ -438,8 +477,9 @@ export function ImageIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const ImageIcon = memo(ImageIconInner);
|
||||||
|
|
||||||
export function FileIcon(props: IconProps) {
|
function FileIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M14 2H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7Z" />
|
<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>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const FileIcon = memo(FileIconInner);
|
||||||
|
|
||||||
export function PollIcon(props: IconProps) {
|
function PollIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M5 19V9" />
|
<path d="M5 19V9" />
|
||||||
@@ -458,8 +499,9 @@ export function PollIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const PollIcon = memo(PollIconInner);
|
||||||
|
|
||||||
export function FocusIcon(props: IconProps) {
|
function FocusIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||||
@@ -467,8 +509,9 @@ export function FocusIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const FocusIcon = memo(FocusIconInner);
|
||||||
|
|
||||||
export function MaximizeIcon(props: IconProps) {
|
function MaximizeIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M4 9V5a1 1 0 0 1 1-1h4" />
|
<path d="M4 9V5a1 1 0 0 1 1-1h4" />
|
||||||
@@ -478,8 +521,9 @@ export function MaximizeIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const MaximizeIcon = memo(MaximizeIconInner);
|
||||||
|
|
||||||
export function VideoIcon(props: IconProps) {
|
function VideoIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<rect x="2" y="6" width="15" height="12" rx="2" />
|
<rect x="2" y="6" width="15" height="12" rx="2" />
|
||||||
@@ -487,16 +531,18 @@ export function VideoIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const VideoIcon = memo(VideoIconInner);
|
||||||
|
|
||||||
export function CrownIcon(props: IconProps) {
|
function CrownIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<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" />
|
<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>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const CrownIcon = memo(CrownIconInner);
|
||||||
|
|
||||||
export function MusicIcon(props: IconProps) {
|
function MusicIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M9 18V5l12-2v13" />
|
<path d="M9 18V5l12-2v13" />
|
||||||
@@ -505,16 +551,18 @@ export function MusicIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const MusicIcon = memo(MusicIconInner);
|
||||||
|
|
||||||
export function SendIcon(props: IconProps) {
|
function SendIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="m3 11 18-8-8 18-2-8-8-2Z" />
|
<path d="m3 11 18-8-8 18-2-8-8-2Z" />
|
||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const SendIcon = memo(SendIconInner);
|
||||||
|
|
||||||
export function ArchiveIcon(props: IconProps) {
|
function ArchiveIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<rect x="3" y="4" width="18" height="5" rx="1" />
|
<rect x="3" y="4" width="18" height="5" rx="1" />
|
||||||
@@ -523,8 +571,9 @@ export function ArchiveIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const ArchiveIcon = memo(ArchiveIconInner);
|
||||||
|
|
||||||
export function BellIcon(props: IconProps) {
|
function BellIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M6 8a6 6 0 1 1 12 0c0 4 1.5 5 2 6H4c.5-1 2-2 2-6Z" />
|
<path d="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>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const BellIcon = memo(BellIconInner);
|
||||||
|
|
||||||
export function BellOffIcon(props: IconProps) {
|
function BellOffIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M9.5 18a2.5 2.5 0 0 0 5 0" />
|
<path d="M9.5 18a2.5 2.5 0 0 0 5 0" />
|
||||||
@@ -545,8 +595,9 @@ export function BellOffIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const BellOffIcon = memo(BellOffIconInner);
|
||||||
|
|
||||||
export function MoreVerticalIcon(props: IconProps) {
|
function MoreVerticalIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<circle cx="12" cy="5" r="1.5" />
|
<circle cx="12" cy="5" r="1.5" />
|
||||||
@@ -555,8 +606,9 @@ export function MoreVerticalIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const MoreVerticalIcon = memo(MoreVerticalIconInner);
|
||||||
|
|
||||||
export function HeadphonesIcon(props: IconProps) {
|
function HeadphonesIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M3 14v-2a9 9 0 0 1 18 0v2" />
|
<path d="M3 14v-2a9 9 0 0 1 18 0v2" />
|
||||||
@@ -565,8 +617,9 @@ export function HeadphonesIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const HeadphonesIcon = memo(HeadphonesIconInner);
|
||||||
|
|
||||||
export function HeadphonesOffIcon(props: IconProps) {
|
function HeadphonesOffIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M3 14v-2a9 9 0 0 1 11.3-8.7" />
|
<path d="M3 14v-2a9 9 0 0 1 11.3-8.7" />
|
||||||
@@ -577,8 +630,9 @@ export function HeadphonesOffIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const HeadphonesOffIcon = memo(HeadphonesOffIconInner);
|
||||||
|
|
||||||
export function ReplyIcon(props: IconProps) {
|
function ReplyIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<polyline points="9 17 4 12 9 7" />
|
<polyline points="9 17 4 12 9 7" />
|
||||||
@@ -586,8 +640,9 @@ export function ReplyIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const ReplyIcon = memo(ReplyIconInner);
|
||||||
|
|
||||||
export function ForwardIcon(props: IconProps) {
|
function ForwardIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<polyline points="15 17 20 12 15 7" />
|
<polyline points="15 17 20 12 15 7" />
|
||||||
@@ -595,16 +650,18 @@ export function ForwardIcon(props: IconProps) {
|
|||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const ForwardIcon = memo(ForwardIconInner);
|
||||||
|
|
||||||
export function ChevronUpIcon(props: IconProps) {
|
function ChevronUpIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<polyline points="18 15 12 9 6 15" />
|
<polyline points="18 15 12 9 6 15" />
|
||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const ChevronUpIcon = memo(ChevronUpIconInner);
|
||||||
|
|
||||||
export function AddUserIcon(props: IconProps) {
|
function AddUserIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
<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>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const AddUserIcon = memo(AddUserIconInner);
|
||||||
|
|
||||||
// Soft-N mark: rounded-square violet tile with a stylised "N" glyph. Used
|
// Soft-N mark: rounded-square violet tile with a stylised "N" glyph. Used
|
||||||
// wherever the app needs a standalone icon (sidebar rail, auth screen,
|
// wherever the app needs a standalone icon (sidebar rail, auth screen,
|
||||||
// favicon). Colour decisions sit inside the SVG so consumers just size the
|
// favicon). Colour decisions sit inside the SVG so consumers just size the
|
||||||
// element via `className`.
|
// element via `className`.
|
||||||
export function LogoMark(props: IconProps) {
|
function LogoMarkInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -638,10 +696,11 @@ export function LogoMark(props: IconProps) {
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export const LogoMark = memo(LogoMarkInner);
|
||||||
|
|
||||||
// Full lockup: soft-N mark + "Netralax" wordmark. `tone` decides text colour:
|
// Full lockup: soft-N mark + "Netralax" wordmark. `tone` decides text colour:
|
||||||
// "dark" = white text (use on dark background), "light" = near-black.
|
// "dark" = white text (use on dark background), "light" = near-black.
|
||||||
export function LogoLockup({
|
function LogoLockupInner({
|
||||||
tone = 'dark',
|
tone = 'dark',
|
||||||
...props
|
...props
|
||||||
}: IconProps & { tone?: 'dark' | 'light' }) {
|
}: IconProps & { tone?: 'dark' | 'light' }) {
|
||||||
@@ -676,3 +735,4 @@ export function LogoLockup({
|
|||||||
</svg>
|
</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 {
|
import {
|
||||||
type DeviceRecord,
|
fetchUserKeyBlob,
|
||||||
getOwnProfile,
|
getOwnProfile,
|
||||||
|
listOwnDevices,
|
||||||
|
registerDevice,
|
||||||
signOut as supabaseSignOut,
|
signOut as supabaseSignOut,
|
||||||
type Profile,
|
|
||||||
touchDeviceLastSeen,
|
touchDeviceLastSeen,
|
||||||
|
type Profile,
|
||||||
updateOwnProfile,
|
updateOwnProfile,
|
||||||
} from '@chat-app/shared/auth';
|
} from '@chat-app/shared/auth';
|
||||||
import { changeLocale, isSupportedLocale } from '@chat-app/shared/i18n';
|
import { changeLocale, isSupportedLocale } from '@chat-app/shared/i18n';
|
||||||
|
import type { DevicePlatform } from '@chat-app/shared/supabase';
|
||||||
import type { Session } from '@supabase/supabase-js';
|
import type { Session } from '@supabase/supabase-js';
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
@@ -20,24 +23,40 @@ import {
|
|||||||
} from 'react';
|
} from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { findExistingDevice } from '../lib/device';
|
import { clearDeviceRowId, getDeviceRowId, setDeviceRowId } from '../lib/deviceRowId';
|
||||||
import { PRESENCE_HEARTBEAT_MS } from '../lib/presence';
|
import { ensureInstallId } from '../lib/installId';
|
||||||
|
import { wipeLocalState } from '../lib/memoryWipe';
|
||||||
|
import { isWipeOnCloseEnabled } from '../lib/memoryWipeSettings';
|
||||||
import { setSecretStoreUser } from '../lib/secretStore';
|
import { setSecretStoreUser } from '../lib/secretStore';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
|
import { cachedUserKey, ensureLegacyMigrated } from '../lib/userIdentity';
|
||||||
import { registerWebPush } from '../lib/webPush';
|
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 {
|
interface AuthContextValue {
|
||||||
session: Session | null;
|
session: Session | null;
|
||||||
profile: Profile | null;
|
profile: Profile | null;
|
||||||
device: DeviceRecord | null;
|
userKeyState: UserKeyState;
|
||||||
// null while we're still resolving the very first auth state.
|
// null while we're still resolving the very first auth state.
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
// null until a device lookup has finished for the current session.
|
|
||||||
deviceLookupDone: boolean;
|
|
||||||
refreshProfile: () => Promise<void>;
|
refreshProfile: () => Promise<void>;
|
||||||
refreshDevice: () => Promise<void>;
|
refreshUserKeyState: () => Promise<void>;
|
||||||
setDevice: (device: DeviceRecord | null) => void;
|
|
||||||
signOut: () => Promise<void>;
|
signOut: () => Promise<void>;
|
||||||
|
revokedRemotely: boolean;
|
||||||
|
acknowledgeRevocation: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
@@ -47,8 +66,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const [session, setSession] = useState<Session | null>(null);
|
const [session, setSession] = useState<Session | null>(null);
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
const [profile, setProfile] = useState<Profile | null>(null);
|
const [profile, setProfile] = useState<Profile | null>(null);
|
||||||
const [device, setDevice] = useState<DeviceRecord | null>(null);
|
const [userKeyState, setUserKeyState] = useState<UserKeyState>({ status: 'loading' });
|
||||||
const [deviceLookupDone, setDeviceLookupDone] = useState(false);
|
const [revokedRemotely, setRevokedRemotely] = useState(false);
|
||||||
const autoOnlineUserRef = useRef<string | null>(null);
|
const autoOnlineUserRef = useRef<string | null>(null);
|
||||||
|
|
||||||
// Initial session + auth subscription. We verify the cached JWT against the
|
// Initial session + auth subscription. We verify the cached JWT against the
|
||||||
@@ -112,41 +131,155 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
}, [session, i18n.resolvedLanguage]);
|
}, [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) {
|
if (!session) {
|
||||||
setDevice(null);
|
setUserKeyState({ status: 'loading' });
|
||||||
setDeviceLookupDone(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setDeviceLookupDone(false);
|
setUserKeyState({ status: 'loading' });
|
||||||
const found = await findExistingDevice(session.user.id);
|
const cached = await cachedUserKey(session.user.id);
|
||||||
setDevice(found);
|
if (cached) {
|
||||||
setDeviceLookupDone(true);
|
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]);
|
}, [session]);
|
||||||
|
|
||||||
// Re-pull profile + device whenever session flips.
|
// Re-pull profile + user-key state whenever session flips.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!session) {
|
if (!session) {
|
||||||
setProfile(null);
|
setProfile(null);
|
||||||
setDevice(null);
|
setUserKeyState({ status: 'loading' });
|
||||||
setDeviceLookupDone(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void refreshProfile().catch((err: unknown) => {
|
void refreshProfile().catch((err: unknown) => {
|
||||||
console.error('refreshProfile failed', err);
|
console.error('refreshProfile failed', err);
|
||||||
});
|
});
|
||||||
void refreshDevice().catch((err: unknown) => {
|
void refreshUserKeyState().catch((err: unknown) => {
|
||||||
console.error('refreshDevice failed', err);
|
console.error('refreshUserKeyState failed', err);
|
||||||
setDeviceLookupDone(true);
|
// 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
|
// Best-effort web-push registration once we have a session. Keyed by an
|
||||||
// Tauri (uses native notifications) or when VITE_VAPID_PUBLIC_KEY is unset.
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (!device?.id) return;
|
if (!session) return;
|
||||||
void registerWebPush(device.id);
|
const installId = ensureInstallId();
|
||||||
}, [device?.id]);
|
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.
|
// Auto online/offline transition.
|
||||||
//
|
//
|
||||||
@@ -188,52 +321,78 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
};
|
};
|
||||||
}, [session, profile, refreshProfile]);
|
}, [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 signOut = useCallback(async () => {
|
||||||
|
const uid = session?.user.id ?? null;
|
||||||
await updateOwnProfile(supabase, { presenceState: 'offline' }).catch((err: unknown) => {
|
await updateOwnProfile(supabase, { presenceState: 'offline' }).catch((err: unknown) => {
|
||||||
console.warn('offline update before sign-out failed', err);
|
console.warn('offline update before sign-out failed', err);
|
||||||
});
|
});
|
||||||
await supabaseSignOut(supabase);
|
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>(
|
const value = useMemo<AuthContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
session,
|
session,
|
||||||
profile,
|
profile,
|
||||||
device,
|
userKeyState,
|
||||||
ready,
|
ready,
|
||||||
deviceLookupDone,
|
|
||||||
refreshProfile,
|
refreshProfile,
|
||||||
refreshDevice,
|
refreshUserKeyState,
|
||||||
setDevice,
|
|
||||||
signOut,
|
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>;
|
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 { sendEncryptedMessage } from '@chat-app/shared/chat';
|
||||||
import {
|
import {
|
||||||
type CallKind,
|
type CallKind,
|
||||||
@@ -40,7 +39,6 @@ import {
|
|||||||
playUndeafenBeep,
|
playUndeafenBeep,
|
||||||
playUnmuteBeep,
|
playUnmuteBeep,
|
||||||
} from '../lib/callSounds';
|
} from '../lib/callSounds';
|
||||||
import { useLiveCaptions } from '../lib/useLiveCaptions';
|
|
||||||
import { setCallWakeLock } from '../lib/wakeLock';
|
import { setCallWakeLock } from '../lib/wakeLock';
|
||||||
import { notify } from '../lib/osNotify';
|
import { notify } from '../lib/osNotify';
|
||||||
import {
|
import {
|
||||||
@@ -100,6 +98,7 @@ import {
|
|||||||
subscribeScreenShareVolumes,
|
subscribeScreenShareVolumes,
|
||||||
} from '../lib/screenShareVolumes';
|
} from '../lib/screenShareVolumes';
|
||||||
import { startSoundboardHotkeys } from '../lib/soundboardHotkeys';
|
import { startSoundboardHotkeys } from '../lib/soundboardHotkeys';
|
||||||
|
import { playSoundboardLocal } from '../lib/soundboardLocalPlay';
|
||||||
import { playEntry } from '../lib/soundboardPlayback';
|
import { playEntry } from '../lib/soundboardPlayback';
|
||||||
import {
|
import {
|
||||||
getPrefs as getSoundboardPrefs,
|
getPrefs as getSoundboardPrefs,
|
||||||
@@ -114,8 +113,9 @@ import {
|
|||||||
type ScreenSharePreset,
|
type ScreenSharePreset,
|
||||||
updateScreenShareSettings,
|
updateScreenShareSettings,
|
||||||
} from '../lib/screenShareSettings';
|
} from '../lib/screenShareSettings';
|
||||||
import { devLocalSecretStore } from '../lib/secretStore';
|
import { ensureInstallId } from '../lib/installId';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
|
import { cachedUserKey } from '../lib/userIdentity';
|
||||||
import { setWindowFullscreen } from '../lib/windowFullscreen';
|
import { setWindowFullscreen } from '../lib/windowFullscreen';
|
||||||
|
|
||||||
export type CallState =
|
export type CallState =
|
||||||
@@ -209,14 +209,6 @@ interface CallContextValue {
|
|||||||
* invite (fromUserId). Cleared on disconnect. Drives the crown badge,
|
* invite (fromUserId). Cleared on disconnect. Drives the crown badge,
|
||||||
* but only in group calls. Null while idle or in 1:1 contexts. */
|
* but only in group calls. Null while idle or in 1:1 contexts. */
|
||||||
callHostId: string | null;
|
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.
|
/** identity -> mute state. Broadcast from peer whenever mic-gain flips.
|
||||||
* Needed because we can't rely on LiveKit's native isMicrophoneEnabled —
|
* Needed because we can't rely on LiveKit's native isMicrophoneEnabled —
|
||||||
* the mic pipeline keeps the track published with sound flowing even
|
* the mic pipeline keeps the track published with sound flowing even
|
||||||
@@ -325,7 +317,7 @@ function newCallId(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CallProvider({ children }: { children: ReactNode }) {
|
export function CallProvider({ children }: { children: ReactNode }) {
|
||||||
const { session, device, profile } = useAuth();
|
const { session, profile } = useAuth();
|
||||||
const { conversations } = useConversationsContext();
|
const { conversations } = useConversationsContext();
|
||||||
const myId = session?.user.id;
|
const myId = session?.user.id;
|
||||||
|
|
||||||
@@ -347,9 +339,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
// useEffect) so peers don't hear themselves echoed back when the OS-level
|
// useEffect) so peers don't hear themselves echoed back when the OS-level
|
||||||
// process-tree exclusion isn't watertight.
|
// process-tree exclusion isn't watertight.
|
||||||
const [isCapturingSystemAudio, setIsCapturingSystemAudio] = useState(false);
|
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 [lastCallConversationId, setLastCallConversationId] = useState<string | null>(null);
|
||||||
const [callMode, setCallModeState] = useState<CallMode>('grid');
|
const [callMode, setCallModeState] = useState<CallMode>('grid');
|
||||||
const [focusedId, setFocusedIdState] = useState<string | null>(null);
|
const [focusedId, setFocusedIdState] = useState<string | null>(null);
|
||||||
@@ -446,13 +435,9 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
mediaKind: CallKind,
|
mediaKind: CallKind,
|
||||||
durationSec: number,
|
durationSec: number,
|
||||||
) => {
|
) => {
|
||||||
if (!session?.user.id || !device?.id) return;
|
if (!session?.user.id) return;
|
||||||
try {
|
try {
|
||||||
const priv = await loadDevicePrivateKey(
|
const priv = await cachedUserKey(session.user.id);
|
||||||
devLocalSecretStore,
|
|
||||||
session.user.id,
|
|
||||||
device.id,
|
|
||||||
);
|
|
||||||
if (!priv) return;
|
if (!priv) return;
|
||||||
const payload = JSON.stringify({
|
const payload = JSON.stringify({
|
||||||
v: 1,
|
v: 1,
|
||||||
@@ -466,14 +451,14 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
conversationId,
|
conversationId,
|
||||||
plaintext: payload,
|
plaintext: payload,
|
||||||
senderUserId: session.user.id,
|
senderUserId: session.user.id,
|
||||||
senderDeviceId: device.id,
|
senderDeviceId: ensureInstallId(),
|
||||||
senderPrivateKey: priv,
|
senderPrivateKey: priv,
|
||||||
});
|
});
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('emitCallEvent failed', err);
|
console.error('emitCallEvent failed', err);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[session?.user.id, device?.id],
|
[session?.user.id],
|
||||||
);
|
);
|
||||||
|
|
||||||
const clearRingTimer = useCallback(() => {
|
const clearRingTimer = useCallback(() => {
|
||||||
@@ -832,7 +817,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
setRemoteScreenShares([]);
|
setRemoteScreenShares([]);
|
||||||
setConnectionQualities({});
|
setConnectionQualities({});
|
||||||
setCallHostId(null);
|
setCallHostId(null);
|
||||||
setCaptions({});
|
|
||||||
setIsScreenSharing(false);
|
setIsScreenSharing(false);
|
||||||
setIsE2EEActive(false);
|
setIsE2EEActive(false);
|
||||||
}
|
}
|
||||||
@@ -974,8 +958,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
type?: string;
|
type?: string;
|
||||||
deafened?: boolean;
|
deafened?: boolean;
|
||||||
muted?: boolean;
|
muted?: boolean;
|
||||||
captionText?: string;
|
|
||||||
captionFinal?: boolean;
|
|
||||||
};
|
};
|
||||||
const id: string = participant.identity;
|
const id: string = participant.identity;
|
||||||
if (msg.type === 'presence') {
|
if (msg.type === 'presence') {
|
||||||
@@ -995,15 +977,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
return;
|
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 {
|
} catch {
|
||||||
/* ignore malformed */
|
/* ignore malformed */
|
||||||
}
|
}
|
||||||
@@ -1446,20 +1419,29 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
// the same system audio twice and Chromium's version would
|
// the same system audio twice and Chromium's version would
|
||||||
// include our own renderer playback.
|
// include our own renderer playback.
|
||||||
audio: preferNativeLoopback ? false : wantAudio,
|
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
|
...(ssParams.dims
|
||||||
? {
|
? {
|
||||||
resolution: {
|
resolution: {
|
||||||
width: ssParams.dims.width,
|
width: { ideal: ssParams.dims.width },
|
||||||
height: ssParams.dims.height,
|
height: { ideal: ssParams.dims.height },
|
||||||
frameRate: fps,
|
frameRate: fps,
|
||||||
},
|
} as unknown as { width: number; height: number; frameRate: number },
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
resolution: {
|
resolution: {
|
||||||
width: 3840,
|
|
||||||
height: 2160,
|
|
||||||
frameRate: fps,
|
frameRate: fps,
|
||||||
},
|
width: undefined,
|
||||||
|
height: undefined,
|
||||||
|
} as unknown as { width: number; height: number; frameRate: number },
|
||||||
}),
|
}),
|
||||||
...(displaySurface
|
...(displaySurface
|
||||||
? ({ displaySurface } as { displaySurface: DisplaySurfaceHint })
|
? ({ displaySurface } as { displaySurface: DisplaySurfaceHint })
|
||||||
@@ -1749,17 +1731,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 toggleCamera = useCallback(async () => {
|
||||||
const r = roomRef.current;
|
const r = roomRef.current;
|
||||||
if (!r) return;
|
if (!r) return;
|
||||||
@@ -2008,14 +1979,16 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const syncGlobalShortcuts = () => {
|
const syncGlobalShortcuts = () => {
|
||||||
if (!isTauriRuntime()) return;
|
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> = {
|
const desired: Record<HotkeyKind, string | null> = {
|
||||||
mute: settings.mute.enabled ? bindingToTauriShortcut(settings.mute) : null,
|
mute: enabledAndGlobal(settings.mute) ? bindingToTauriShortcut(settings.mute) : null,
|
||||||
deafen: settings.deafen.enabled ? bindingToTauriShortcut(settings.deafen) : null,
|
deafen: enabledAndGlobal(settings.deafen) ? bindingToTauriShortcut(settings.deafen) : null,
|
||||||
hangup: settings.hangup.enabled ? bindingToTauriShortcut(settings.hangup) : null,
|
hangup: enabledAndGlobal(settings.hangup) ? bindingToTauriShortcut(settings.hangup) : null,
|
||||||
screenShare: settings.screenShare.enabled
|
screenShare: enabledAndGlobal(settings.screenShare) ? bindingToTauriShortcut(settings.screenShare) : null,
|
||||||
? bindingToTauriShortcut(settings.screenShare)
|
video: enabledAndGlobal(settings.video) ? bindingToTauriShortcut(settings.video) : null,
|
||||||
: null,
|
|
||||||
video: settings.video.enabled ? bindingToTauriShortcut(settings.video) : null,
|
|
||||||
};
|
};
|
||||||
for (const kind of KINDS) {
|
for (const kind of KINDS) {
|
||||||
const want = desired[kind];
|
const want = desired[kind];
|
||||||
@@ -2285,15 +2258,36 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
pipelineRef.current?.setMonitorGain(prefs.monitorGain);
|
pipelineRef.current?.setMonitorGain(prefs.monitorGain);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Global soundboard hotkey registration — runs only while connected so the
|
// Global soundboard hotkey registration — always-on so the OS-level
|
||||||
// OS-level shortcuts don't fire when the user is outside of a call.
|
// shortcuts fire even outside a call (Stream-Deck-style local SFX). Inside
|
||||||
|
// a call we route through `playSoundboard` so peers hear; outside a call
|
||||||
|
// we fall back to `playSoundboardLocal` which plays through the system
|
||||||
|
// default output only.
|
||||||
|
//
|
||||||
|
// We deliberately do NOT depend on `state.kind` in the effect dep array:
|
||||||
|
// every call state transition (idle → connecting → connected → reconnecting
|
||||||
|
// → ...) would trigger a full unregister+re-register cycle through IPC, and
|
||||||
|
// during the 1–50 ms gap the hotkeys are silently dead. Instead we read the
|
||||||
|
// current call state through a ref that's always kept in sync.
|
||||||
|
const callStateKindRef = useRef(state.kind);
|
||||||
|
callStateKindRef.current = state.kind;
|
||||||
|
const playSoundboardRef = useRef(playSoundboard);
|
||||||
|
playSoundboardRef.current = playSoundboard;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.kind !== 'connected') return;
|
|
||||||
const teardown = startSoundboardHotkeys((id) => {
|
const teardown = startSoundboardHotkeys((id) => {
|
||||||
void playSoundboard(id);
|
if (callStateKindRef.current === 'connected') {
|
||||||
|
void playSoundboardRef.current(id);
|
||||||
|
} else {
|
||||||
|
void (async () => {
|
||||||
|
const entries = await listSoundboard();
|
||||||
|
const entry = entries.find((e) => e.id === id);
|
||||||
|
if (entry) await playSoundboardLocal(entry);
|
||||||
|
})();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return teardown;
|
return teardown;
|
||||||
}, [state.kind, playSoundboard]);
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const setAudioInputDevice = useCallback(async (deviceId: string | null) => {
|
const setAudioInputDevice = useCallback(async (deviceId: string | null) => {
|
||||||
updateAudioSettings({ inputDeviceId: deviceId });
|
updateAudioSettings({ inputDeviceId: deviceId });
|
||||||
@@ -2596,15 +2590,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
return () => window.removeEventListener('keydown', onKey);
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
}, [callMode, state.kind]);
|
}, [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>(
|
const value = useMemo<CallContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
state,
|
state,
|
||||||
@@ -2619,8 +2604,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
remoteMute,
|
remoteMute,
|
||||||
connectionQualities,
|
connectionQualities,
|
||||||
callHostId,
|
callHostId,
|
||||||
captions,
|
|
||||||
pushLocalCaption,
|
|
||||||
remoteScreenShares,
|
remoteScreenShares,
|
||||||
lastCallConversationId,
|
lastCallConversationId,
|
||||||
callMode,
|
callMode,
|
||||||
@@ -2676,8 +2659,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
remoteMute,
|
remoteMute,
|
||||||
connectionQualities,
|
connectionQualities,
|
||||||
callHostId,
|
callHostId,
|
||||||
captions,
|
|
||||||
pushLocalCaption,
|
|
||||||
remoteScreenShares,
|
remoteScreenShares,
|
||||||
lastCallConversationId,
|
lastCallConversationId,
|
||||||
callMode,
|
callMode,
|
||||||
|
|||||||
@@ -54,6 +54,15 @@ interface ConversationsContextValue {
|
|||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
markRead: (conversationId: string) => void;
|
markRead: (conversationId: string) => void;
|
||||||
setActiveConversation: (conversationId: string | null) => 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);
|
const ConversationsContext = createContext<ConversationsContextValue | null>(null);
|
||||||
@@ -164,6 +173,19 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
|
|||||||
[markRead],
|
[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(() => {
|
useEffect(() => {
|
||||||
if (!myId) {
|
if (!myId) {
|
||||||
setConversations([]);
|
setConversations([]);
|
||||||
@@ -225,7 +247,12 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
|
|||||||
(c) => c.id === row.conversation_id,
|
(c) => c.id === row.conversation_id,
|
||||||
);
|
);
|
||||||
const muted = isConversationMuted(convForMute?.mutedUntil ?? null);
|
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();
|
playNotificationTone();
|
||||||
const conv = conversationsRef.current.find(
|
const conv = conversationsRef.current.find(
|
||||||
(c) => c.id === row.conversation_id,
|
(c) => c.id === row.conversation_id,
|
||||||
@@ -303,6 +330,7 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
|
|||||||
refresh,
|
refresh,
|
||||||
markRead,
|
markRead,
|
||||||
setActiveConversation,
|
setActiveConversation,
|
||||||
|
patchConversation,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
conversations,
|
conversations,
|
||||||
@@ -313,6 +341,7 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
|
|||||||
refresh,
|
refresh,
|
||||||
markRead,
|
markRead,
|
||||||
setActiveConversation,
|
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);
|
||||||
|
}
|
||||||
@@ -43,19 +43,86 @@ async function resizeToSquare(file: File): Promise<Blob> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_ANIMATED_BYTES = 2 * 1024 * 1024; // 2 MB hard cap on animated uploads
|
||||||
|
const ANIMATED_MIMES = new Set(['image/gif', 'image/apng', 'image/webp', 'image/png']);
|
||||||
|
|
||||||
export async function uploadAvatar(userId: string, file: File): Promise<string> {
|
export async function uploadAvatar(userId: string, file: File): Promise<string> {
|
||||||
if (!file.type.startsWith('image/')) {
|
if (!file.type.startsWith('image/')) {
|
||||||
throw new Error('only image files are accepted');
|
throw new Error('only image files are accepted');
|
||||||
}
|
}
|
||||||
|
// Animated formats bypass the canvas re-encode (which would strip
|
||||||
|
// animation by sampling the first frame). We still validate dimensions
|
||||||
|
// and size so a 40-MB animated WebP can't slip through.
|
||||||
|
if (ANIMATED_MIMES.has(file.type) && (await isAnimated(file))) {
|
||||||
|
if (file.size > MAX_ANIMATED_BYTES) {
|
||||||
|
throw new Error('animated avatar too large (max 2 MB)');
|
||||||
|
}
|
||||||
|
const dims = await readDimensions(file);
|
||||||
|
if (dims.width > MAX_DIM || dims.height > MAX_DIM) {
|
||||||
|
throw new Error('animated avatar exceeds ' + MAX_DIM + 'px (got ' + dims.width + 'x' + dims.height + ')');
|
||||||
|
}
|
||||||
|
return uploadAvatarBlob(userId, file);
|
||||||
|
}
|
||||||
const blob = await resizeToSquare(file);
|
const blob = await resizeToSquare(file);
|
||||||
return uploadAvatarBlob(userId, blob);
|
return uploadAvatarBlob(userId, blob);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readDimensions(file: File): Promise<{ width: number; height: number }> {
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
try {
|
||||||
|
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||||
|
const i = new Image();
|
||||||
|
i.onload = () => resolve(i);
|
||||||
|
i.onerror = () => reject(new Error('image load failed'));
|
||||||
|
i.src = url;
|
||||||
|
});
|
||||||
|
return { width: img.naturalWidth, height: img.naturalHeight };
|
||||||
|
} finally {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isAnimated(file: File): Promise<boolean> {
|
||||||
|
// GIF: any GIF89a/GIF87a header is treated as potentially animated. The
|
||||||
|
// static-GIF case (one image-descriptor block) is rare enough that
|
||||||
|
// re-encoding wouldn't save much, so we accept the false-positives.
|
||||||
|
if (file.type === 'image/gif') return true;
|
||||||
|
// APNG: presence of an 'acTL' chunk inside the PNG stream. Scan the
|
||||||
|
// first 64 KB — APNGs put acTL near the front, before IDAT.
|
||||||
|
if (file.type === 'image/apng' || file.type === 'image/png') {
|
||||||
|
const head = await file.slice(0, 65536).arrayBuffer();
|
||||||
|
return containsBytes(head, [0x61, 0x63, 0x54, 0x4c]); // 'acTL'
|
||||||
|
}
|
||||||
|
// Animated WebP: 'ANIM' chunk in the RIFF container.
|
||||||
|
if (file.type === 'image/webp') {
|
||||||
|
const head = await file.slice(0, 65536).arrayBuffer();
|
||||||
|
return containsBytes(head, [0x41, 0x4e, 0x49, 0x4d]); // 'ANIM'
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function containsBytes(buf: ArrayBuffer, needle: number[]): boolean {
|
||||||
|
const view = new Uint8Array(buf);
|
||||||
|
const len = view.length;
|
||||||
|
const nlen = needle.length;
|
||||||
|
outer: for (let i = 0; i + nlen <= len; i++) {
|
||||||
|
for (let j = 0; j < nlen; j++) {
|
||||||
|
if (view[i + j] !== needle[j]) continue outer;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Upload an already-cropped Blob (e.g. from ImageCropDialog) without going
|
// Upload an already-cropped Blob (e.g. from ImageCropDialog) without going
|
||||||
// through the legacy center-crop. Caller is responsible for sizing — the
|
// through the legacy center-crop. Caller is responsible for sizing — the
|
||||||
// dialog already clamps to MAX_DIM via its outputWidth.
|
// dialog already clamps to MAX_DIM via its outputWidth.
|
||||||
export async function uploadAvatarBlob(userId: string, blob: Blob): Promise<string> {
|
export async function uploadAvatarBlob(userId: string, blob: Blob): Promise<string> {
|
||||||
const ext = blob.type === 'image/webp' ? 'webp' : 'jpg';
|
const ext =
|
||||||
|
blob.type === 'image/webp' ? 'webp' :
|
||||||
|
blob.type === 'image/gif' ? 'gif' :
|
||||||
|
blob.type === 'image/apng' || blob.type === 'image/png' ? 'png' :
|
||||||
|
'jpg';
|
||||||
// Random filename so old uploads don't get overwritten before we update
|
// Random filename so old uploads don't get overwritten before we update
|
||||||
// the profile row — Supabase Storage CDN caches by URL, so a fresh path
|
// the profile row — Supabase Storage CDN caches by URL, so a fresh path
|
||||||
// also forces clients to fetch the new image.
|
// also forces clients to fetch the new image.
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
// In-app changelog feed.
|
// In-app changelog feed.
|
||||||
//
|
//
|
||||||
// The release script (`scripts/release.mjs`) maintains a single
|
// The release script (`scripts/release.mjs`) maintains a single
|
||||||
// `changelog.json` file alongside `latest.json` on update.netralax.cloud.
|
// `changelog.json` file alongside `latest.json` on update.netralax.de.
|
||||||
// The list is newest-first, capped at 200 entries server-side, and rewritten
|
// The list is newest-first, capped at 200 entries server-side, and rewritten
|
||||||
// after every release.
|
// after every release.
|
||||||
|
//
|
||||||
|
// NOTE: already-installed clients still fetch this from update.netralax.cloud
|
||||||
|
// (baked into their bundle), so Caddy on the new VPS must keep serving the
|
||||||
|
// update.netralax.cloud vhost from the same directory during the transition.
|
||||||
|
|
||||||
const CHANGELOG_URL = 'https://update.netralax.cloud/windows/changelog.json';
|
const CHANGELOG_URL = 'https://update.netralax.de/windows/changelog.json';
|
||||||
|
|
||||||
export interface ChangelogEntry {
|
export interface ChangelogEntry {
|
||||||
version: string;
|
version: string;
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
__resetForTests,
|
||||||
|
clearDraft,
|
||||||
|
getDraftSync,
|
||||||
|
hasDraft,
|
||||||
|
hydrateDrafts,
|
||||||
|
setDraft,
|
||||||
|
} from './composerDraftStore';
|
||||||
|
|
||||||
|
const sqlExecuteMock = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const sqlSelectMock = vi.fn().mockResolvedValue([]);
|
||||||
|
const sqlLoadMock = vi.fn().mockResolvedValue('mock-handle');
|
||||||
|
|
||||||
|
vi.stubGlobal('window', {
|
||||||
|
electronAPI: {
|
||||||
|
platform: 'electron-chatapp-v1',
|
||||||
|
sqlLoad: sqlLoadMock,
|
||||||
|
sqlExecute: sqlExecuteMock,
|
||||||
|
sqlSelect: sqlSelectMock,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('composerDraftStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sqlExecuteMock.mockClear();
|
||||||
|
sqlSelectMock.mockClear();
|
||||||
|
sqlLoadMock.mockClear();
|
||||||
|
__resetForTests();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
__resetForTests();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for an unknown conversation', () => {
|
||||||
|
expect(getDraftSync('unknown')).toBeNull();
|
||||||
|
expect(hasDraft('unknown')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores and returns a draft synchronously after set', () => {
|
||||||
|
setDraft('a', { text: 'hi', replyToId: null });
|
||||||
|
const draft = getDraftSync('a');
|
||||||
|
expect(draft).not.toBeNull();
|
||||||
|
expect(draft?.text).toBe('hi');
|
||||||
|
expect(draft?.replyToId).toBeNull();
|
||||||
|
expect(hasDraft('a')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isolates drafts per conversation', () => {
|
||||||
|
setDraft('a', { text: 'one', replyToId: null });
|
||||||
|
setDraft('b', { text: 'two', replyToId: 'msg-9' });
|
||||||
|
expect(getDraftSync('a')?.text).toBe('one');
|
||||||
|
expect(getDraftSync('b')?.replyToId).toBe('msg-9');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clearDraft removes the draft from memory', () => {
|
||||||
|
setDraft('a', { text: 'one', replyToId: null });
|
||||||
|
clearDraft('a');
|
||||||
|
expect(getDraftSync('a')).toBeNull();
|
||||||
|
expect(hasDraft('a')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats an empty-string text + null reply as "no draft"', () => {
|
||||||
|
setDraft('a', { text: '', replyToId: null });
|
||||||
|
expect(getDraftSync('a')).toBeNull();
|
||||||
|
expect(hasDraft('a')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hydrateDrafts populates the in-memory map from SQLite rows', async () => {
|
||||||
|
sqlSelectMock.mockResolvedValueOnce([
|
||||||
|
{ conversation_id: 'a', text: 'persisted', reply_to_id: 'msg-1', updated_at: '2026-05-17T00:00:00Z' },
|
||||||
|
]);
|
||||||
|
await hydrateDrafts();
|
||||||
|
expect(getDraftSync('a')?.text).toBe('persisted');
|
||||||
|
expect(getDraftSync('a')?.replyToId).toBe('msg-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
// Composer-draft persistence. Two-tier semantics:
|
||||||
|
// * In-memory `Map<convId, Draft>` for instant synchronous reads on
|
||||||
|
// mount (mirrors the `messageMemoryCache` pattern from Phase 7).
|
||||||
|
// * SQLite (`composer_drafts` table, schema in `messageCache.ts`) for
|
||||||
|
// cross-restart persistence. Writes are debounced and fire-and-forget
|
||||||
|
// — losing the last 400ms of typing on a hard crash is acceptable;
|
||||||
|
// blocking the keystroke handler is not.
|
||||||
|
//
|
||||||
|
// Attachments are intentionally NOT serialized:
|
||||||
|
// * Files don't round-trip through SQLite cleanly (binary blobs blow
|
||||||
|
// up the cache size).
|
||||||
|
// * `replyToId` IS persisted; the consuming page looks up the actual
|
||||||
|
// message by id at render time.
|
||||||
|
|
||||||
|
import { isTauriRuntime } from './globalShortcut';
|
||||||
|
|
||||||
|
const DB_NAME = 'chatapp-cache';
|
||||||
|
const WRITE_DEBOUNCE_MS = 400;
|
||||||
|
|
||||||
|
interface Draft {
|
||||||
|
text: string;
|
||||||
|
replyToId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DraftRow {
|
||||||
|
conversation_id: string;
|
||||||
|
text: string;
|
||||||
|
reply_to_id: string | null;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const drafts = new Map<string, Draft>();
|
||||||
|
const pendingWrites = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
let handlePromise: Promise<string | null> | null = null;
|
||||||
|
|
||||||
|
async function getHandle(): Promise<string | null> {
|
||||||
|
if (handlePromise) return handlePromise;
|
||||||
|
if (!isTauriRuntime()) {
|
||||||
|
handlePromise = Promise.resolve(null);
|
||||||
|
return handlePromise;
|
||||||
|
}
|
||||||
|
handlePromise = (async () => {
|
||||||
|
try {
|
||||||
|
const handle = await window.electronAPI.sqlLoad({ name: DB_NAME });
|
||||||
|
// Self-contained DDL — the same statement also runs from
|
||||||
|
// `messageCache.ts`'s init path, but we don't want to depend on
|
||||||
|
// call order. SQLite's `CREATE TABLE IF NOT EXISTS` is idempotent
|
||||||
|
// so the double-creation is safe.
|
||||||
|
await window.electronAPI.sqlExecute({
|
||||||
|
handle,
|
||||||
|
query:
|
||||||
|
`CREATE TABLE IF NOT EXISTS composer_drafts (
|
||||||
|
conversation_id TEXT PRIMARY KEY,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
reply_to_id TEXT,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)`,
|
||||||
|
bindings: [],
|
||||||
|
});
|
||||||
|
return handle;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('composerDraftStore: sqlLoad failed', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return handlePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDraftSync(conversationId: string): Draft | null {
|
||||||
|
const stored = drafts.get(conversationId);
|
||||||
|
if (!stored) return null;
|
||||||
|
return { text: stored.text, replyToId: stored.replyToId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasDraft(conversationId: string): boolean {
|
||||||
|
return drafts.has(conversationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setDraft(conversationId: string, draft: Draft): void {
|
||||||
|
if (draft.text.length === 0 && draft.replyToId === null) {
|
||||||
|
if (drafts.has(conversationId)) {
|
||||||
|
drafts.delete(conversationId);
|
||||||
|
scheduleWrite(conversationId);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
drafts.set(conversationId, { text: draft.text, replyToId: draft.replyToId });
|
||||||
|
scheduleWrite(conversationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearDraft(conversationId: string): void {
|
||||||
|
if (!drafts.has(conversationId)) return;
|
||||||
|
drafts.delete(conversationId);
|
||||||
|
scheduleWrite(conversationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleWrite(conversationId: string): void {
|
||||||
|
const existing = pendingWrites.get(conversationId);
|
||||||
|
if (existing) clearTimeout(existing);
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
pendingWrites.delete(conversationId);
|
||||||
|
void flushOne(conversationId);
|
||||||
|
}, WRITE_DEBOUNCE_MS);
|
||||||
|
pendingWrites.set(conversationId, timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushOne(conversationId: string): Promise<void> {
|
||||||
|
const handle = await getHandle();
|
||||||
|
if (!handle) return;
|
||||||
|
const draft = drafts.get(conversationId);
|
||||||
|
try {
|
||||||
|
if (draft) {
|
||||||
|
await window.electronAPI.sqlExecute({
|
||||||
|
handle,
|
||||||
|
query:
|
||||||
|
`INSERT INTO composer_drafts (conversation_id, text, reply_to_id, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT(conversation_id) DO UPDATE SET
|
||||||
|
text = excluded.text,
|
||||||
|
reply_to_id = excluded.reply_to_id,
|
||||||
|
updated_at = excluded.updated_at`,
|
||||||
|
bindings: [conversationId, draft.text, draft.replyToId, new Date().toISOString()],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await window.electronAPI.sqlExecute({
|
||||||
|
handle,
|
||||||
|
query: 'DELETE FROM composer_drafts WHERE conversation_id = $1',
|
||||||
|
bindings: [conversationId],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('composerDraftStore: flush failed', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hydrateDrafts(): Promise<void> {
|
||||||
|
const handle = await getHandle();
|
||||||
|
if (!handle) return;
|
||||||
|
try {
|
||||||
|
const rows = (await window.electronAPI.sqlSelect({
|
||||||
|
handle,
|
||||||
|
query: 'SELECT conversation_id, text, reply_to_id, updated_at FROM composer_drafts',
|
||||||
|
bindings: [],
|
||||||
|
})) as unknown as DraftRow[];
|
||||||
|
for (const r of rows) {
|
||||||
|
if (!r.conversation_id || typeof r.text !== 'string') continue;
|
||||||
|
if (r.text.length === 0 && r.reply_to_id === null) continue;
|
||||||
|
drafts.set(r.conversation_id, { text: r.text, replyToId: r.reply_to_id });
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('composerDraftStore: hydrate failed', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function __resetForTests(): void {
|
||||||
|
for (const t of pendingWrites.values()) clearTimeout(t);
|
||||||
|
pendingWrites.clear();
|
||||||
|
drafts.clear();
|
||||||
|
handlePromise = null;
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@ import {
|
|||||||
type AttachmentHandle,
|
type AttachmentHandle,
|
||||||
type DecryptedMessage,
|
type DecryptedMessage,
|
||||||
type PollOption,
|
type PollOption,
|
||||||
|
type WhiteboardPayload,
|
||||||
|
type WatchTogetherPayload,
|
||||||
|
type GamePayload,
|
||||||
} from '@chat-app/shared/chat';
|
} from '@chat-app/shared/chat';
|
||||||
|
|
||||||
export type AttachmentBucket = 'media' | 'audio' | 'files';
|
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(
|
export function summarizePollVotes(
|
||||||
options: PollOption[],
|
options: PollOption[],
|
||||||
reactions: ReactionSummaryInput[],
|
reactions: ReactionSummaryInput[],
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
|
import { type OwnUserCtx, shareConvKeyToUser } from '@chat-app/shared/chat';
|
||||||
import { type OwnDeviceCtx, shareConvKeyToDevice } from '@chat-app/shared/chat';
|
|
||||||
import { pgHexToBytes } from '@chat-app/shared/supabase';
|
import { pgHexToBytes } from '@chat-app/shared/supabase';
|
||||||
|
|
||||||
import { devLocalSecretStore } from './secretStore';
|
|
||||||
import { supabase } from './supabase';
|
import { supabase } from './supabase';
|
||||||
|
|
||||||
// Watches the `devices` table for new entries AND, on mount, scans every
|
// Watches the `devices` table for new entries AND, on mount, scans every
|
||||||
// conversation we participate in for missing key bundles. Fills gaps by
|
// 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
|
// This fixes the "cannot decrypt" cliff for users that joined while no
|
||||||
// no other participant device was online to share the key with them.
|
// other participant was online to share the key with them.
|
||||||
|
|
||||||
export interface SyncCtx {
|
export interface SyncCtx {
|
||||||
myUserId: string;
|
myUserId: string;
|
||||||
@@ -57,8 +55,6 @@ export function startConversationKeySync(
|
|||||||
void ownUserId;
|
void ownUserId;
|
||||||
void ownDeviceId;
|
void ownDeviceId;
|
||||||
void backfilledKey;
|
void backfilledKey;
|
||||||
void loadDevicePrivateKey;
|
|
||||||
void devLocalSecretStore;
|
|
||||||
void supabase;
|
void supabase;
|
||||||
return () => {};
|
return () => {};
|
||||||
}
|
}
|
||||||
@@ -70,10 +66,10 @@ export function startConversationKeySync(
|
|||||||
// `wrapForOneDevice` and `syncOneConversationGaps` are exported below for
|
// `wrapForOneDevice` and `syncOneConversationGaps` are exported below for
|
||||||
// the device-approval module — once the user explicitly approves a new
|
// the device-approval module — once the user explicitly approves a new
|
||||||
// device the approval flow re-uses these helpers to wrap conv-keys for
|
// device the approval flow re-uses these helpers to wrap conv-keys for
|
||||||
// that specific deviceId.
|
// that specific user.
|
||||||
void (() => {
|
void (() => {
|
||||||
void listMyConversationIds;
|
void listMyConversationIds;
|
||||||
void listConversationDevices;
|
void listConversationMembers;
|
||||||
void listExistingKeyRecipients;
|
void listExistingKeyRecipients;
|
||||||
void getActiveKeyVersion;
|
void getActiveKeyVersion;
|
||||||
void syncAllExistingGaps;
|
void syncAllExistingGaps;
|
||||||
@@ -94,9 +90,9 @@ async function listMyConversationIds(myUserId: string): Promise<string[]> {
|
|||||||
return (data ?? []).map((r) => r.conversation_id as string);
|
return (data ?? []).map((r) => r.conversation_id as string);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listConversationDevices(
|
async function listConversationMembers(
|
||||||
conversationId: string,
|
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
|
const { data: members, error: mErr } = await supabase
|
||||||
.from('conversation_members')
|
.from('conversation_members')
|
||||||
.select('user_id')
|
.select('user_id')
|
||||||
@@ -109,15 +105,14 @@ async function listConversationDevices(
|
|||||||
const userIds = (members ?? []).map((m) => m.user_id as string);
|
const userIds = (members ?? []).map((m) => m.user_id as string);
|
||||||
if (userIds.length === 0) return [];
|
if (userIds.length === 0) return [];
|
||||||
|
|
||||||
const { data: devices, error: dErr } = await supabase
|
const { data: keys, error: kErr } = await rawFrom('user_keys')
|
||||||
.from('devices')
|
.select('user_id, public_key')
|
||||||
.select('id, user_id, public_key')
|
|
||||||
.in('user_id', userIds);
|
.in('user_id', userIds);
|
||||||
if (dErr) {
|
if (kErr) {
|
||||||
console.warn('keySync: devices lookup failed', dErr);
|
console.warn('keySync: user-keys lookup failed', kErr);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return (devices ?? []) as { id: string; user_id: string; public_key: string }[];
|
return (keys ?? []) as { user_id: string; public_key: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listExistingKeyRecipients(
|
async function listExistingKeyRecipients(
|
||||||
@@ -125,14 +120,14 @@ async function listExistingKeyRecipients(
|
|||||||
keyVersion: number,
|
keyVersion: number,
|
||||||
): Promise<Set<string>> {
|
): Promise<Set<string>> {
|
||||||
const { data, error } = await rawFrom('conversation_keys')
|
const { data, error } = await rawFrom('conversation_keys')
|
||||||
.select('recipient_device_id')
|
.select('recipient_user_id')
|
||||||
.eq('conversation_id', conversationId)
|
.eq('conversation_id', conversationId)
|
||||||
.eq('key_version', keyVersion);
|
.eq('key_version', keyVersion);
|
||||||
if (error) {
|
if (error) {
|
||||||
console.warn('keySync: existing keys lookup failed', error);
|
console.warn('keySync: existing keys lookup failed', error);
|
||||||
return new Set();
|
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> {
|
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> {
|
export async function syncOneConversationGaps(ctx: SyncCtx, convId: string): Promise<void> {
|
||||||
const version = await getActiveKeyVersion(convId);
|
const version = await getActiveKeyVersion(convId);
|
||||||
const devices = await listConversationDevices(convId);
|
const members = await listConversationMembers(convId);
|
||||||
if (devices.length === 0) return;
|
if (members.length === 0) return;
|
||||||
|
|
||||||
const recipients = await listExistingKeyRecipients(convId, version);
|
const recipients = await listExistingKeyRecipients(convId, version);
|
||||||
const ownCtx: OwnDeviceCtx = {
|
const ownCtx: OwnUserCtx = {
|
||||||
userId: ctx.myUserId,
|
userId: ctx.myUserId,
|
||||||
deviceId: ctx.myDeviceId,
|
|
||||||
privateKey: ctx.priv,
|
privateKey: ctx.priv,
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const dev of devices) {
|
for (const m of members) {
|
||||||
if (recipients.has(dev.id)) continue;
|
if (recipients.has(m.user_id)) continue;
|
||||||
// Skip our own device — we already have the bundle if we're capable of
|
// 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).
|
// 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 {
|
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) {
|
} catch (err: unknown) {
|
||||||
// Backfill is best-effort. Most common silent failures:
|
// Backfill is best-effort. Most common silent failures:
|
||||||
// - tryGetConvKey couldn't unwrap (another peer will fill the gap).
|
// - 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.
|
// accepted) DM member, or was removed from the conv.
|
||||||
// Both are recoverable / expected, swallow without spam.
|
// Both are recoverable / expected, swallow without spam.
|
||||||
if (isExpectedShareFailure(err)) continue;
|
if (isExpectedShareFailure(err)) continue;
|
||||||
console.warn('keySync: shareConvKeyToDevice gap-fill failed', {
|
console.warn('keySync: shareConvKeyToUser gap-fill failed', {
|
||||||
convId,
|
convId,
|
||||||
recipient: dev.id,
|
recipient: m.user_id,
|
||||||
err,
|
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(
|
export async function wrapForOneDevice(
|
||||||
ctx: SyncCtx,
|
ctx: SyncCtx,
|
||||||
newDeviceId: string,
|
newDeviceId: string,
|
||||||
newDeviceUserId: string,
|
newDeviceUserId: string,
|
||||||
newDevicePubHex: string,
|
newDevicePubHex: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
void newDeviceId; // kept for API compat; no longer used
|
||||||
|
|
||||||
const myConvs = new Set(await listMyConversationIds(ctx.myUserId));
|
const myConvs = new Set(await listMyConversationIds(ctx.myUserId));
|
||||||
const { data: peerMember, error: pErr } = await supabase
|
const { data: peerMember, error: pErr } = await supabase
|
||||||
.from('conversation_members')
|
.from('conversation_members')
|
||||||
@@ -230,16 +232,15 @@ export async function wrapForOneDevice(
|
|||||||
if (sharedConvs.length === 0) return;
|
if (sharedConvs.length === 0) return;
|
||||||
|
|
||||||
const newPub = pgHexToBytes(newDevicePubHex);
|
const newPub = pgHexToBytes(newDevicePubHex);
|
||||||
const ownCtx: OwnDeviceCtx = {
|
const ownCtx: OwnUserCtx = {
|
||||||
userId: ctx.myUserId,
|
userId: ctx.myUserId,
|
||||||
deviceId: ctx.myDeviceId,
|
|
||||||
privateKey: ctx.priv,
|
privateKey: ctx.priv,
|
||||||
};
|
};
|
||||||
for (const convId of sharedConvs) {
|
for (const convId of sharedConvs) {
|
||||||
try {
|
try {
|
||||||
await shareConvKeyToDevice(supabase, convId, newDeviceId, newPub, ownCtx);
|
await shareConvKeyToUser(supabase, convId, newDeviceUserId, newPub, ownCtx);
|
||||||
} catch (err: unknown) {
|
} 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),
|
s.crypto_box_open_easy(ciphertext, nonce, senderPublicKey, recipientPrivateKey),
|
||||||
secretbox: (plaintext, nonce, key) => s.crypto_secretbox_easy(plaintext, nonce, key),
|
secretbox: (plaintext, nonce, key) => s.crypto_secretbox_easy(plaintext, nonce, key),
|
||||||
secretboxOpen: (ciphertext, nonce, key) => s.crypto_secretbox_open_easy(ciphertext, 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[]> {
|
export async function compressImages(files: File[]): Promise<File[]> {
|
||||||
return Promise.all(files.map((f) => compressImage(f)));
|
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 */
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { isNearBottom, isNearTop, nextStickIntent, resolveInitialAnchor } from './scrollController';
|
||||||
|
|
||||||
|
const m = (scrollTop: number, scrollHeight: number, clientHeight: number) => ({
|
||||||
|
scrollTop,
|
||||||
|
scrollHeight,
|
||||||
|
clientHeight,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isNearBottom', () => {
|
||||||
|
it('true exactly at the bottom', () => {
|
||||||
|
expect(isNearBottom(m(900, 1000, 100), 64)).toBe(true);
|
||||||
|
});
|
||||||
|
it('true within threshold', () => {
|
||||||
|
expect(isNearBottom(m(860, 1000, 100), 64)).toBe(true);
|
||||||
|
});
|
||||||
|
it('false beyond threshold', () => {
|
||||||
|
expect(isNearBottom(m(800, 1000, 100), 64)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isNearTop', () => {
|
||||||
|
it('true at top', () => {
|
||||||
|
expect(isNearTop(m(0, 1000, 100), 64)).toBe(true);
|
||||||
|
});
|
||||||
|
it('false past threshold', () => {
|
||||||
|
expect(isNearTop(m(200, 1000, 100), 64)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveInitialAnchor', () => {
|
||||||
|
it('anchors to last row at end by default (no saved position)', () => {
|
||||||
|
expect(resolveInitialAnchor(null, 50)).toEqual({ index: 49, align: 'end' });
|
||||||
|
});
|
||||||
|
it('anchors to bottom when saved position stuck to bottom', () => {
|
||||||
|
expect(resolveInitialAnchor({ topmostIndex: 10, stickToBottom: true }, 50)).toEqual({
|
||||||
|
index: 49,
|
||||||
|
align: 'end',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('restores the saved row at the top when scrolled up', () => {
|
||||||
|
expect(resolveInitialAnchor({ topmostIndex: 12, stickToBottom: false }, 50)).toEqual({
|
||||||
|
index: 12,
|
||||||
|
align: 'start',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('clamps a stale saved index to the current row count', () => {
|
||||||
|
expect(resolveInitialAnchor({ topmostIndex: 999, stickToBottom: false }, 50)).toEqual({
|
||||||
|
index: 49,
|
||||||
|
align: 'start',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('handles an empty list', () => {
|
||||||
|
expect(resolveInitialAnchor(null, 0)).toEqual({ index: 0, align: 'end' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('nextStickIntent', () => {
|
||||||
|
it('turns ON when the bottom is reached', () => {
|
||||||
|
expect(nextStickIntent(false, { nearBottom: true, userMovedUp: false })).toBe(true);
|
||||||
|
});
|
||||||
|
it('turns OFF when the user genuinely moves up', () => {
|
||||||
|
expect(nextStickIntent(true, { nearBottom: false, userMovedUp: true })).toBe(false);
|
||||||
|
});
|
||||||
|
it('keeps the previous intent on a neutral scroll (measurement reflow)', () => {
|
||||||
|
expect(nextStickIntent(true, { nearBottom: false, userMovedUp: false })).toBe(true);
|
||||||
|
expect(nextStickIntent(false, { nearBottom: false, userMovedUp: false })).toBe(false);
|
||||||
|
});
|
||||||
|
it('reaching the bottom wins over a simultaneous move-up signal', () => {
|
||||||
|
expect(nextStickIntent(false, { nearBottom: true, userMovedUp: true })).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// Pure, DOM-free scroll-decision logic for MessageList. Unit-tested so the
|
||||||
|
// tricky math is verified without a browser (jsdom has no layout).
|
||||||
|
|
||||||
|
export interface ScrollMetrics {
|
||||||
|
scrollTop: number;
|
||||||
|
scrollHeight: number;
|
||||||
|
clientHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Distance from the bottom edge is within `threshold` px. */
|
||||||
|
export function isNearBottom(m: ScrollMetrics, threshold: number): boolean {
|
||||||
|
return m.scrollHeight - (m.scrollTop + m.clientHeight) <= threshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scroll offset is within `threshold` px of the top. */
|
||||||
|
export function isNearTop(m: ScrollMetrics, threshold: number): boolean {
|
||||||
|
return m.scrollTop <= threshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SavedPosition {
|
||||||
|
topmostIndex: number;
|
||||||
|
stickToBottom: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Anchor {
|
||||||
|
index: number;
|
||||||
|
align: 'start' | 'end';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a freshly-opened chat should start.
|
||||||
|
* - default / "left at bottom" → last row, aligned to the viewport bottom.
|
||||||
|
* - "left scrolled up" → the saved top-most row, aligned to the viewport top
|
||||||
|
* (clamped in case the cached row count shrank).
|
||||||
|
*/
|
||||||
|
export function resolveInitialAnchor(saved: SavedPosition | null, rowCount: number): Anchor {
|
||||||
|
if (rowCount <= 0) return { index: 0, align: 'end' };
|
||||||
|
if (saved && !saved.stickToBottom) {
|
||||||
|
const index = Math.max(0, Math.min(saved.topmostIndex, rowCount - 1));
|
||||||
|
return { index, align: 'start' };
|
||||||
|
}
|
||||||
|
return { index: rowCount - 1, align: 'end' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The next stick-to-bottom intent given the current intent and the latest
|
||||||
|
* scroll signal. Intent only flips on a *definitive* signal:
|
||||||
|
* - reaching the bottom turns it ON,
|
||||||
|
* - a genuine user move-up turns it OFF.
|
||||||
|
* A neutral scroll — e.g. a measurement reflow that grows the content while
|
||||||
|
* rows settle — leaves the intent unchanged. This is the core fix for the
|
||||||
|
* chat-switch bug: a reflow must never be mistaken for the user scrolling up
|
||||||
|
* and so must never silently unstick the list.
|
||||||
|
*/
|
||||||
|
export function nextStickIntent(
|
||||||
|
prev: boolean,
|
||||||
|
signal: { nearBottom: boolean; userMovedUp: boolean },
|
||||||
|
): boolean {
|
||||||
|
if (signal.nearBottom) return true;
|
||||||
|
if (signal.userMovedUp) return false;
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// Plays a soundboard entry to the local default audio output. Used when no
|
||||||
|
// call pipeline is active (the in-call path routes via the LiveKit
|
||||||
|
// publishing pipeline so peers hear; this path is local-only). Fetches the
|
||||||
|
// blob via getSoundBlob and creates a short-lived object URL for the audio
|
||||||
|
// element.
|
||||||
|
|
||||||
|
import { getSoundBlob, type SoundboardEntry } from './soundboardStorage';
|
||||||
|
|
||||||
|
const activeAudios = new Set<HTMLAudioElement>();
|
||||||
|
|
||||||
|
export async function playSoundboardLocal(entry: SoundboardEntry): Promise<void> {
|
||||||
|
const blob = await getSoundBlob(entry.id);
|
||||||
|
if (!blob) return;
|
||||||
|
const src = URL.createObjectURL(blob);
|
||||||
|
const el = new Audio(src);
|
||||||
|
// Use the per-entry gain as local volume. SoundboardEntry exposes `gain`
|
||||||
|
// (0..1) which mirrors the value used in the in-call pipeline.
|
||||||
|
el.volume = Math.max(0, Math.min(1, entry.gain));
|
||||||
|
activeAudios.add(el);
|
||||||
|
const cleanup = () => {
|
||||||
|
activeAudios.delete(el);
|
||||||
|
URL.revokeObjectURL(src);
|
||||||
|
};
|
||||||
|
el.addEventListener('ended', cleanup);
|
||||||
|
el.addEventListener('error', cleanup);
|
||||||
|
try {
|
||||||
|
await el.play();
|
||||||
|
} catch (err) {
|
||||||
|
cleanup();
|
||||||
|
console.warn('soundboardLocalPlay failed', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopSoundboardLocal(): void {
|
||||||
|
for (const el of activeAudios) {
|
||||||
|
try {
|
||||||
|
el.pause();
|
||||||
|
el.currentTime = 0;
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
activeAudios.clear();
|
||||||
|
}
|
||||||
@@ -369,3 +369,52 @@ export async function isHotkeyTaken(
|
|||||||
}
|
}
|
||||||
return false;
|
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,19 +1,22 @@
|
|||||||
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
|
|
||||||
import {
|
import {
|
||||||
type AttachmentHandle,
|
type AttachmentHandle,
|
||||||
|
clearConvKeyCache,
|
||||||
type DecryptedMessage,
|
type DecryptedMessage,
|
||||||
decryptMessages,
|
decryptMessages,
|
||||||
encryptAndUploadAttachment,
|
encryptAndUploadAttachment,
|
||||||
fetchConversationMessages,
|
fetchConversationMessages,
|
||||||
|
getOrCreateConvKey,
|
||||||
insertAttachmentRow,
|
insertAttachmentRow,
|
||||||
MAX_ATTACHMENT_BYTES,
|
MAX_ATTACHMENT_BYTES,
|
||||||
type MessageWithCipher,
|
type MessageWithCipher,
|
||||||
|
rotateConvKey,
|
||||||
sendEncryptedMessage,
|
sendEncryptedMessage,
|
||||||
} from '@chat-app/shared/chat';
|
} from '@chat-app/shared/chat';
|
||||||
import { bytesToPgHex, pgBytesToBytes } from '@chat-app/shared/supabase';
|
import { bytesToPgHex, pgBytesToBytes } from '@chat-app/shared/supabase';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { decryptBatch as decryptBatchWorker } from './decryptWorker';
|
import { decryptBatch as decryptBatchWorker } from './decryptWorker';
|
||||||
|
import { generateWebPThumb } from './imageCompress';
|
||||||
import {
|
import {
|
||||||
loadCachedMessages,
|
loadCachedMessages,
|
||||||
persistMessages,
|
persistMessages,
|
||||||
@@ -30,8 +33,13 @@ import {
|
|||||||
shouldGiveUp,
|
shouldGiveUp,
|
||||||
subscribeOutbox,
|
subscribeOutbox,
|
||||||
} from './messageOutbox';
|
} from './messageOutbox';
|
||||||
import { devLocalSecretStore } from './secretStore';
|
import {
|
||||||
|
getCachedMessages,
|
||||||
|
hasCachedMessages,
|
||||||
|
setCachedMessages,
|
||||||
|
} from './messageMemoryCache';
|
||||||
import { supabase } from './supabase';
|
import { supabase } from './supabase';
|
||||||
|
import { cachedUserKey } from './userIdentity';
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
messages: DecryptedMessage[];
|
messages: DecryptedMessage[];
|
||||||
@@ -68,13 +76,31 @@ function rowToMessage(row: Record<string, unknown>): MessageWithCipher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useConversationMessages({ conversationId, userId, deviceId }: Args): State & {
|
export function useConversationMessages({ conversationId, userId, deviceId }: Args): State & {
|
||||||
send: (text: string, images?: File[], replyToId?: string | null) => Promise<void>;
|
send: (
|
||||||
|
text: string,
|
||||||
|
images?: File[],
|
||||||
|
replyToId?: string | null,
|
||||||
|
opts?: { viewOnceFlags?: boolean[] },
|
||||||
|
) => Promise<void>;
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
pending: OutboxItem[];
|
pending: OutboxItem[];
|
||||||
retryPending: (id: string) => void;
|
retryPending: (id: string) => void;
|
||||||
cancelPending: (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[]>(() =>
|
const [pending, setPending] = useState<OutboxItem[]>(() =>
|
||||||
conversationId ? getOutbox(conversationId) : [],
|
conversationId ? getOutbox(conversationId) : [],
|
||||||
);
|
);
|
||||||
@@ -90,25 +116,148 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
});
|
});
|
||||||
}, [conversationId]);
|
}, [conversationId]);
|
||||||
|
|
||||||
// Load own private key once per (user, device).
|
// Load own user private key once per user.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
privateKeyRef.current = null;
|
privateKeyRef.current = null;
|
||||||
if (!userId || !deviceId) return;
|
if (!userId) return;
|
||||||
void loadDevicePrivateKey(devLocalSecretStore, userId, deviceId).then((pk) => {
|
void cachedUserKey(userId).then((pk) => {
|
||||||
privateKeyRef.current = pk;
|
privateKeyRef.current = pk;
|
||||||
});
|
});
|
||||||
}, [userId, deviceId]);
|
}, [userId]);
|
||||||
|
|
||||||
|
// Proactive rewrap sweep: when a conversation opens, ensure the active
|
||||||
|
// conv-key has a `recipient_user_id` bundle for every accepted member.
|
||||||
|
//
|
||||||
|
// If any peer is missing a bundle at the active version, the previous
|
||||||
|
// implementation called `shareConvKeyToUser` for each missing peer —
|
||||||
|
// that helper reads from the module-level conv-key cache first, and if
|
||||||
|
// the cache held a STALE locally-generated key (from a buggy bootstrap
|
||||||
|
// race in an earlier app version), the stale key got propagated to the
|
||||||
|
// peer's row. Both sides then encrypt with mutually un-mergeable keys
|
||||||
|
// and every message is "Nachricht nicht lesbar" forever (incident:
|
||||||
|
// conv aae12d84).
|
||||||
|
//
|
||||||
|
// The replacement: when any peer is missing, call `rotateConvKey` once.
|
||||||
|
// Rotation generates a fresh symmetric key locally, fetches each member's
|
||||||
|
// CURRENT pubkey, wraps the fresh key for everyone, and atomically bumps
|
||||||
|
// `active_key_version` via the `rotate_conv_key` RPC (FOR UPDATE lock
|
||||||
|
// serialises concurrent rotations). This bypasses the cache entirely:
|
||||||
|
// the new version's cache entry is the just-rotated key, and the stale
|
||||||
|
// entry at the old version is irrelevant because nobody reads it any more.
|
||||||
|
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;
|
||||||
|
|
||||||
|
// First, make sure we have a usable handle for the active version
|
||||||
|
// (this auto-rotates if we're locked out of our own bundle — the
|
||||||
|
// recovery path added in v0.21.1/v0.21.2).
|
||||||
|
const handle = await getOrCreateConvKey(supabase, conversationId, {
|
||||||
|
userId,
|
||||||
|
privateKey: priv,
|
||||||
|
});
|
||||||
|
if (cancelled) return;
|
||||||
|
if (handle.keyVersion > version) return; // already rotated by helper
|
||||||
|
|
||||||
|
// Check membership state on the server.
|
||||||
|
const { data: members, error: mErr } = await supabase
|
||||||
|
.from('conversation_members')
|
||||||
|
.select('user_id, accepted')
|
||||||
|
.eq('conversation_id', conversationId);
|
||||||
|
if (mErr || !members) return;
|
||||||
|
const peerIds = (members as Array<{ user_id: string; accepted: boolean }>)
|
||||||
|
.filter((m) => m.accepted && m.user_id !== userId)
|
||||||
|
.map((m) => m.user_id);
|
||||||
|
if (peerIds.length === 0) return;
|
||||||
|
|
||||||
|
// Count how many of the peers have a recipient_user_id bundle at
|
||||||
|
// the active version. If any are missing, rotate to V+1 — the
|
||||||
|
// rotation will wrap a fresh key for every accepted member with a
|
||||||
|
// user_keys row.
|
||||||
|
const { data: existingRows, error: rowsErr } = await (
|
||||||
|
supabase as unknown as {
|
||||||
|
from: (t: string) => {
|
||||||
|
select: (s: string) => {
|
||||||
|
eq: (c: string, v: string) => {
|
||||||
|
eq: (c: string, v: number) => {
|
||||||
|
in: (c: string, v: string[]) => Promise<{
|
||||||
|
data: Array<{ recipient_user_id: string }> | null;
|
||||||
|
error: unknown;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.from('conversation_keys')
|
||||||
|
.select('recipient_user_id')
|
||||||
|
.eq('conversation_id', conversationId)
|
||||||
|
.eq('key_version', version)
|
||||||
|
.in('recipient_user_id', peerIds);
|
||||||
|
if (rowsErr) return;
|
||||||
|
const wrappedPeerIds = new Set(
|
||||||
|
(existingRows ?? []).map((r) => r.recipient_user_id),
|
||||||
|
);
|
||||||
|
const missing = peerIds.filter((id) => !wrappedPeerIds.has(id));
|
||||||
|
if (missing.length === 0) return;
|
||||||
|
|
||||||
|
// At least one peer is missing a bundle — rotate. We deliberately do
|
||||||
|
// NOT use the cached conv-key here. The rotation generates a fresh
|
||||||
|
// key wrapped to every current member's CURRENT pubkey, so any
|
||||||
|
// staleness in the local cache for the OLD version is irrelevant
|
||||||
|
// going forward.
|
||||||
|
try {
|
||||||
|
await rotateConvKey(supabase, conversationId, {
|
||||||
|
userId,
|
||||||
|
privateKey: priv,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// Most likely cause: a concurrent peer also called rotate and
|
||||||
|
// won the race; their bumped active_key_version makes our
|
||||||
|
// `p_new_version <= cur_version` and the RPC raises. That's fine —
|
||||||
|
// the next chat-open / send will fetch the new active version and
|
||||||
|
// unwrap the bundle that peer wrapped for us.
|
||||||
|
console.warn('proactive rotate failed (likely concurrent rotation)', err);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('proactive rewrap sweep failed', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void run();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [conversationId, userId]);
|
||||||
|
|
||||||
const decryptBatch = useCallback(
|
const decryptBatch = useCallback(
|
||||||
async (messages: MessageWithCipher[]): Promise<DecryptedMessage[]> => {
|
async (messages: MessageWithCipher[]): Promise<DecryptedMessage[]> => {
|
||||||
const priv = privateKeyRef.current;
|
const priv = privateKeyRef.current;
|
||||||
if (!priv || !deviceId || messages.length === 0) {
|
if (!priv || !userId || messages.length === 0) {
|
||||||
return messages.map((m) => ({ ...m, plaintext: null }));
|
return messages.map((m) => ({ ...m, plaintext: null }));
|
||||||
}
|
}
|
||||||
return decryptMessages({
|
return decryptMessages({
|
||||||
client: supabase,
|
client: supabase,
|
||||||
messages,
|
messages,
|
||||||
ownDeviceId: deviceId,
|
ownUserId: userId,
|
||||||
ownPrivateKey: priv,
|
ownPrivateKey: priv,
|
||||||
// Offload the symmetric decrypt + utf-8 decode to a Web Worker so
|
// Offload the symmetric decrypt + utf-8 decode to a Web Worker so
|
||||||
// the main thread stays responsive during bulk operations (initial
|
// the main thread stays responsive during bulk operations (initial
|
||||||
@@ -116,7 +265,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
aeadBatchDelegate: decryptBatchWorker,
|
aeadBatchDelegate: decryptBatchWorker,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[deviceId],
|
[userId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
@@ -131,6 +280,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
const rows = await fetchConversationMessages(supabase, conversationId);
|
const rows = await fetchConversationMessages(supabase, conversationId);
|
||||||
const decrypted = await decryptBatch(rows);
|
const decrypted = await decryptBatch(rows);
|
||||||
setState({ messages: decrypted, loading: false, error: null });
|
setState({ messages: decrypted, loading: false, error: null });
|
||||||
|
setCachedMessages(conversationId, decrypted);
|
||||||
// Persist the fresh batch to the local cache so next conversation
|
// Persist the fresh batch to the local cache so next conversation
|
||||||
// switch / app start can hydrate instantly. Fire-and-forget — cache
|
// switch / app start can hydrate instantly. Fire-and-forget — cache
|
||||||
// write failure is never user-visible.
|
// write failure is never user-visible.
|
||||||
@@ -150,12 +300,17 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
// when the server response lands. On cache-miss this is a ~5ms no-op.
|
// when the server response lands. On cache-miss this is a ~5ms no-op.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!conversationId) return;
|
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;
|
let cancelled = false;
|
||||||
void loadCachedMessages(conversationId).then((cached) => {
|
void loadCachedMessages(conversationId).then((cached) => {
|
||||||
if (cancelled || cached.length === 0) return;
|
if (cancelled || cached.length === 0) return;
|
||||||
setState((prev) => {
|
setState((prev) => {
|
||||||
// Don't clobber a fresh server response that already landed.
|
// Don't clobber a fresh server response that already landed.
|
||||||
if (prev.messages.length > 0) return prev;
|
if (prev.messages.length > 0) return prev;
|
||||||
|
setCachedMessages(conversationId, cached);
|
||||||
return { messages: cached, loading: false, error: null };
|
return { messages: cached, loading: false, error: null };
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -240,7 +395,9 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
if (!decrypted) return;
|
if (!decrypted) return;
|
||||||
setState((prev) => {
|
setState((prev) => {
|
||||||
if (prev.messages.some((m) => m.id === decrypted!.id)) return 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],
|
[conversationId, deviceId, decryptBatch],
|
||||||
@@ -260,6 +417,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
editedAt: partial.editedAt,
|
editedAt: partial.editedAt,
|
||||||
deletedAt: partial.deletedAt,
|
deletedAt: partial.deletedAt,
|
||||||
};
|
};
|
||||||
|
if (conversationId) setCachedMessages(conversationId, next);
|
||||||
return { ...prev, messages: next };
|
return { ...prev, messages: next };
|
||||||
});
|
});
|
||||||
if (partial.editedAt && !partial.deletedAt) {
|
if (partial.editedAt && !partial.deletedAt) {
|
||||||
@@ -323,6 +481,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
if (idx === -1) return prev;
|
if (idx === -1) return prev;
|
||||||
const next = [...prev.messages];
|
const next = [...prev.messages];
|
||||||
next[idx] = decrypted!;
|
next[idx] = decrypted!;
|
||||||
|
if (conversationId) setCachedMessages(conversationId, next);
|
||||||
return { ...prev, messages: next };
|
return { ...prev, messages: next };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -330,25 +489,32 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
[conversationId, deviceId, decryptBatch],
|
[conversationId, deviceId, decryptBatch],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDelete = useCallback((row: Record<string, unknown>) => {
|
const handleDelete = useCallback(
|
||||||
const id = String(row.id);
|
(row: Record<string, unknown>) => {
|
||||||
setState((prev) => ({
|
const id = String(row.id);
|
||||||
...prev,
|
setState((prev) => {
|
||||||
messages: prev.messages.filter((m) => m.id !== id),
|
const next = prev.messages.filter((m) => m.id !== id);
|
||||||
}));
|
if (conversationId) setCachedMessages(conversationId, next);
|
||||||
void deleteCachedMessage(id);
|
return { ...prev, messages: next };
|
||||||
}, []);
|
});
|
||||||
|
void deleteCachedMessage(id);
|
||||||
|
},
|
||||||
|
[conversationId],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!conversationId || !userId || !deviceId) return;
|
if (!conversationId || !userId || !deviceId) return;
|
||||||
void refresh();
|
void refresh();
|
||||||
|
|
||||||
// Batch INSERT bursts so a paste / backfill doesn't fire N parallel
|
// Batch INSERT bursts so a paste / backfill doesn't fire N parallel
|
||||||
// refetches + decrypts. If more than BATCH_BURST_THRESHOLD ids arrive
|
// refetches + decrypts. The first event in a quiet period fires
|
||||||
// within BATCH_WINDOW_MS, collapse to a single refresh() which pulls
|
// `handleInsert` immediately so single incoming messages don't sit
|
||||||
// the last 100 in one query — cheaper and keeps order stable. For
|
// behind a debounce timer (previous behaviour: 250 ms blank between
|
||||||
// lone inserts the per-id path stays so latency is unchanged.
|
// notification-sound and message body). Subsequent events arriving
|
||||||
const BATCH_WINDOW_MS = 250;
|
// within BATCH_WINDOW_MS of the first are buffered; if the burst grows
|
||||||
|
// past BATCH_BURST_THRESHOLD the buffered tail collapses into one
|
||||||
|
// `refresh()` instead of N individual refetches.
|
||||||
|
const BATCH_WINDOW_MS = 80;
|
||||||
const BATCH_BURST_THRESHOLD = 3;
|
const BATCH_BURST_THRESHOLD = 3;
|
||||||
let burstBuffer: Array<Record<string, unknown>> = [];
|
let burstBuffer: Array<Record<string, unknown>> = [];
|
||||||
let burstTimer: number | null = null;
|
let burstTimer: number | null = null;
|
||||||
@@ -367,6 +533,15 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const queueInsert = (row: Record<string, unknown>) => {
|
const queueInsert = (row: Record<string, unknown>) => {
|
||||||
|
if (burstBuffer.length === 0 && burstTimer === null) {
|
||||||
|
// First event in a quiet period — fire immediately so the user sees
|
||||||
|
// the message right when they hear the notification sound. Arm a
|
||||||
|
// short window in case a burst follows; follow-ups go through the
|
||||||
|
// buffer and may collapse into a refresh.
|
||||||
|
void handleInsert(row);
|
||||||
|
burstTimer = window.setTimeout(flushBurst, BATCH_WINDOW_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
burstBuffer.push(row);
|
burstBuffer.push(row);
|
||||||
if (burstTimer === null) {
|
if (burstTimer === null) {
|
||||||
burstTimer = window.setTimeout(flushBurst, BATCH_WINDOW_MS);
|
burstTimer = window.setTimeout(flushBurst, BATCH_WINDOW_MS);
|
||||||
@@ -393,18 +568,46 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
// When a peer device wraps the conversation-key for us (e.g. we just
|
// Any conversation_keys change for this conv invalidates the cached
|
||||||
// registered a fresh device), re-decrypt the visible messages.
|
// conv-key for the affected version. The module-level cache in
|
||||||
|
// shared/chat/convKeys.ts otherwise holds the previously-unwrapped key
|
||||||
|
// forever within a session — which is exactly what propagated the
|
||||||
|
// stale local bootstrap key in conv aae12d84, recreating divergent
|
||||||
|
// bundles after a server-side cleanup. Clearing on any INSERT/UPDATE/
|
||||||
|
// DELETE for the conv forces the next `getOrCreateConvKey` /
|
||||||
|
// `tryGetConvKey` call to re-fetch the canonical bundle from the
|
||||||
|
// server. Cheap (a single Map.delete), defensive, and avoids stale-
|
||||||
|
// cache propagation across all of {peer rotation, device wrap, admin
|
||||||
|
// cleanup}.
|
||||||
|
//
|
||||||
|
// We also keep the historical "device wrap → refresh" trigger so a
|
||||||
|
// freshly-registered device of our own re-decrypts in place.
|
||||||
.on(
|
.on(
|
||||||
'postgres_changes',
|
'postgres_changes',
|
||||||
{
|
{
|
||||||
event: 'INSERT',
|
event: '*',
|
||||||
schema: 'public',
|
schema: 'public',
|
||||||
table: 'conversation_keys',
|
table: 'conversation_keys',
|
||||||
filter: 'conversation_id=eq.' + conversationId,
|
filter: 'conversation_id=eq.' + conversationId,
|
||||||
},
|
},
|
||||||
(payload: { new: { recipient_device_id?: string } }) => {
|
(payload: {
|
||||||
if (payload.new?.recipient_device_id === deviceId) {
|
eventType: 'INSERT' | 'UPDATE' | 'DELETE';
|
||||||
|
new: { recipient_device_id?: string; key_version?: number };
|
||||||
|
old: { recipient_device_id?: string; key_version?: number };
|
||||||
|
}) => {
|
||||||
|
const v =
|
||||||
|
payload.eventType === 'DELETE'
|
||||||
|
? payload.old?.key_version
|
||||||
|
: payload.new?.key_version;
|
||||||
|
if (typeof v === 'number') {
|
||||||
|
clearConvKeyCache(conversationId, v);
|
||||||
|
} else {
|
||||||
|
clearConvKeyCache(conversationId);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
payload.eventType === 'INSERT' &&
|
||||||
|
payload.new?.recipient_device_id === deviceId
|
||||||
|
) {
|
||||||
void refresh();
|
void refresh();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -454,20 +657,24 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
});
|
});
|
||||||
setState((prev) => {
|
setState((prev) => {
|
||||||
if (prev.messages.some((m) => m.id === msg.id)) return prev;
|
if (prev.messages.some((m) => m.id === msg.id)) return prev;
|
||||||
return {
|
const next = [
|
||||||
...prev,
|
...prev.messages,
|
||||||
messages: [
|
{ ...msg, plaintext: text } as DecryptedMessage,
|
||||||
...prev.messages,
|
];
|
||||||
{ ...msg, plaintext: text } as DecryptedMessage,
|
setCachedMessages(convId, next);
|
||||||
],
|
return { ...prev, messages: next };
|
||||||
};
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const send = useCallback(
|
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();
|
const trimmed = text.trim();
|
||||||
if ((!trimmed && images.length === 0) || !conversationId || !userId || !deviceId) return;
|
if ((!trimmed && images.length === 0) || !conversationId || !userId || !deviceId) return;
|
||||||
const priv = privateKeyRef.current;
|
const priv = privateKeyRef.current;
|
||||||
@@ -523,13 +730,23 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
|
|
||||||
// 1. Upload + encrypt each image. Collect handles + raw blob nonces
|
// 1. Upload + encrypt each image. Collect handles + raw blob nonces
|
||||||
// (so the public attachment row can reference the blob-level nonce).
|
// (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 handles: AttachmentHandle[] = [];
|
||||||
const blobNonceHexByHandleId = new Map<string, string>();
|
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) {
|
if (file.size > MAX_ATTACHMENT_BYTES) {
|
||||||
throw new Error('attachment exceeds max size (10 MB)');
|
throw new Error('attachment exceeds max size (10 MB)');
|
||||||
}
|
}
|
||||||
const dims = await readImageDimensions(file);
|
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({
|
const res = await encryptAndUploadAttachment({
|
||||||
client: supabase,
|
client: supabase,
|
||||||
conversationId,
|
conversationId,
|
||||||
@@ -538,7 +755,16 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
sizeBytes: file.size,
|
sizeBytes: file.size,
|
||||||
...(dims.width !== undefined ? { width: dims.width } : {}),
|
...(dims.width !== undefined ? { width: dims.width } : {}),
|
||||||
...(dims.height !== undefined ? { height: dims.height } : {}),
|
...(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);
|
handles.push(res.handle);
|
||||||
blobNonceHexByHandleId.set(res.handle.id, bytesToPgHex(res.nonce));
|
blobNonceHexByHandleId.set(res.handle.id, bytesToPgHex(res.nonce));
|
||||||
}
|
}
|
||||||
@@ -564,16 +790,15 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
: JSON.stringify({ v: 1, text: trimmed, attachments: handles });
|
: JSON.stringify({ v: 1, text: trimmed, attachments: handles });
|
||||||
setState((prev) => {
|
setState((prev) => {
|
||||||
if (prev.messages.some((m) => m.id === msg.id)) return prev;
|
if (prev.messages.some((m) => m.id === msg.id)) return prev;
|
||||||
return {
|
const next = [
|
||||||
...prev,
|
...prev.messages,
|
||||||
messages: [
|
{
|
||||||
...prev.messages,
|
...msg,
|
||||||
{
|
plaintext: attachmentsPayload,
|
||||||
...msg,
|
} as DecryptedMessage,
|
||||||
plaintext: attachmentsPayload,
|
];
|
||||||
} as DecryptedMessage,
|
setCachedMessages(conversationId, next);
|
||||||
],
|
return { ...prev, messages: next };
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 4. Insert public attachment metadata rows pointing at the new message.
|
// 4. Insert public attachment metadata rows pointing at the new message.
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user