Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -33,6 +33,10 @@ web-build/
|
||||
apps/desktop/out/
|
||||
apps/desktop/release/
|
||||
|
||||
# Bundle visualizer reports
|
||||
apps/desktop/stats.html
|
||||
apps/desktop/stats.json
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
|
||||
import path from 'node:path';
|
||||
import { visualizer } from 'rollup-plugin-visualizer';
|
||||
|
||||
const ANALYZE = process.env.ANALYZE === 'true';
|
||||
|
||||
const rendererAliases = {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
@@ -44,7 +47,30 @@ export default defineConfig({
|
||||
// bundle. Relative base produces `./assets/...` which works in both
|
||||
// dev (served from /) and packaged builds.
|
||||
base: './',
|
||||
plugins: [react()],
|
||||
// Visualizer plugins are gated behind ANALYZE=true so the production
|
||||
// build never pays the analysis cost. Re-enable with:
|
||||
// ANALYZE=true pnpm --filter @chat-app/desktop build
|
||||
// which writes apps/desktop/stats.html (treemap) + stats.json (raw).
|
||||
plugins: [
|
||||
react(),
|
||||
...(ANALYZE
|
||||
? [
|
||||
visualizer({
|
||||
filename: 'stats.html',
|
||||
template: 'treemap',
|
||||
gzipSize: true,
|
||||
brotliSize: true,
|
||||
open: false,
|
||||
}),
|
||||
visualizer({
|
||||
filename: 'stats.json',
|
||||
template: 'raw-data',
|
||||
gzipSize: true,
|
||||
brotliSize: true,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
resolve: {
|
||||
alias: rendererAliases,
|
||||
},
|
||||
|
||||
@@ -107,6 +107,11 @@ export const CHANNELS = {
|
||||
// OS fullscreen so the Windows taskbar / macOS menubar gets covered.
|
||||
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
|
||||
@@ -271,6 +276,12 @@ export interface UpdateProgress {
|
||||
total: number;
|
||||
}
|
||||
|
||||
// ---- Window content protection -------------------------------------------
|
||||
|
||||
export interface WindowSetContentProtectionArgs {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
// ---- Runtime marker ------------------------------------------------------
|
||||
|
||||
/** Value exposed on `window.electronAPI.platform`. Used by the renderer
|
||||
|
||||
@@ -26,6 +26,7 @@ import { register as registerShortcuts } from './modules/shortcuts';
|
||||
import { register as registerSql } from './modules/sql';
|
||||
import { register as registerTray } from './modules/tray';
|
||||
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 { attach as attachWindowState, loadState } from './window-state';
|
||||
|
||||
@@ -147,6 +148,17 @@ async function createWindow(): Promise<BrowserWindow> {
|
||||
attachWindowState(win, WINDOW_STATE_FILE);
|
||||
|
||||
if (!app.isPackaged) {
|
||||
// Auto-open DevTools in dev — the menu bar is stripped (Discord-style)
|
||||
// so F12 / Ctrl+Shift+I have no chord; opening detached gives a
|
||||
// separate inspector window for easy debugging.
|
||||
win.webContents.openDevTools({ mode: 'detach' });
|
||||
// Forward renderer console messages to the main-process stdout so
|
||||
// errors during local dev are visible in the terminal too (helps when
|
||||
// the inspector isn't focused).
|
||||
win.webContents.on('console-message', (_event, level, message, line, sourceId) => {
|
||||
const tag = level === 3 ? 'error' : level === 2 ? 'warn' : level === 1 ? 'log' : 'info';
|
||||
console.log('[renderer ' + tag + ']', message, '(' + sourceId + ':' + line + ')');
|
||||
});
|
||||
await win.loadURL(DEV_URL);
|
||||
} else {
|
||||
await win.loadFile(resolveRendererIndex());
|
||||
@@ -263,6 +275,7 @@ if (!gotLock) {
|
||||
registerTray(mainWindow);
|
||||
registerUpdater(mainWindow);
|
||||
registerWindowFullscreen(mainWindow);
|
||||
registerWindowContentProtection(mainWindow);
|
||||
registerAudioLoopback(mainWindow);
|
||||
});
|
||||
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
// (<1ms per op for the current workload).
|
||||
//
|
||||
// Binding param style: Tauri's plugin-sql used $1, $2... positionals with
|
||||
// bindings as an array. SQLite natively accepts $N so existing queries
|
||||
// keep working unmodified.
|
||||
// bindings as an array. SQLite parses `$NAME` as a NAMED parameter
|
||||
// (NAME = `1`, `2`, …), not as positional, so better-sqlite3 wants the
|
||||
// bindings as `{ '1': v1, '2': v2 }` not `[v1, v2]`. We accept the old
|
||||
// array-shape from callers and convert to the named-object on the way in.
|
||||
|
||||
import { app, ipcMain } from 'electron';
|
||||
import Database from 'better-sqlite3';
|
||||
@@ -40,6 +42,20 @@ function requireHandle(h: string): Handle {
|
||||
return entry;
|
||||
}
|
||||
|
||||
// Convert a positional bindings array `[v1, v2]` to the named-params object
|
||||
// `{ '1': v1, '2': v2 }` that better-sqlite3 needs when the SQL uses
|
||||
// `$1`/`$2` named placeholders. Returns the original array (spread later)
|
||||
// when it's empty.
|
||||
function bindParams(bindings: unknown[] | undefined): Record<string, unknown> | [] {
|
||||
const arr = bindings ?? [];
|
||||
if (arr.length === 0) return [];
|
||||
const obj: Record<string, unknown> = {};
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
obj[String(i + 1)] = arr[i];
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
export function register(): void {
|
||||
ipcMain.handle(CHANNELS.SQL_LOAD, async (_evt, args: SqlLoadArgs): Promise<string> => {
|
||||
const rawName = stripPrefix(args.name);
|
||||
@@ -59,7 +75,8 @@ export function register(): void {
|
||||
async (_evt, args: SqlExecuteArgs): Promise<SqlExecuteResult> => {
|
||||
const entry = requireHandle(args.handle);
|
||||
const stmt = entry.db.prepare(args.query);
|
||||
const info = stmt.run(...((args.bindings ?? []) as unknown[]));
|
||||
const params = bindParams(args.bindings);
|
||||
const info = Array.isArray(params) ? stmt.run() : stmt.run(params);
|
||||
return {
|
||||
rowsAffected: info.changes,
|
||||
lastInsertId:
|
||||
@@ -75,7 +92,11 @@ export function register(): void {
|
||||
async (_evt, args: SqlSelectArgs): Promise<SqlSelectResult> => {
|
||||
const entry = requireHandle(args.handle);
|
||||
const stmt = entry.db.prepare(args.query);
|
||||
const rows = stmt.all(...((args.bindings ?? []) as unknown[])) as Record<string, unknown>[];
|
||||
const params = bindParams(args.bindings);
|
||||
const rows = (Array.isArray(params) ? stmt.all() : stmt.all(params)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>[];
|
||||
return rows;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
+6
@@ -96,6 +96,12 @@ export interface ElectronAPI {
|
||||
|
||||
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;
|
||||
|
||||
@@ -160,6 +160,10 @@ const api = {
|
||||
setFullscreen: (enabled: boolean): Promise<void> =>
|
||||
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),
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@chat-app/desktop",
|
||||
"version": "0.19.1",
|
||||
"version": "0.21.1",
|
||||
"private": true,
|
||||
"description": "Electron desktop client (Windows / macOS / Linux)",
|
||||
"type": "module",
|
||||
@@ -35,6 +35,7 @@
|
||||
"react-easy-crop": "^5.5.7",
|
||||
"react-i18next": "^15.1.1",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"react-virtuoso": "^4.18.7",
|
||||
"zustand": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -51,6 +52,7 @@
|
||||
"electron-vite": "^2.3.0",
|
||||
"postcss": "^8.4.49",
|
||||
"rimraf": "^6.0.0",
|
||||
"rollup-plugin-visualizer": "^7.0.1",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"vite": "^5.4.11"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { HashRouter, Navigate, Outlet, Route, Routes } from 'react-router-dom';
|
||||
import { lazy, Suspense, useEffect } from 'react';
|
||||
import { HashRouter, Navigate, Outlet, Route, Routes, useParams } from 'react-router-dom';
|
||||
|
||||
import { AppShell } from './components/AppShell';
|
||||
import { CrashToast } from './components/CrashToast';
|
||||
@@ -13,6 +13,7 @@ import { CallProvider } from './context/CallContext';
|
||||
import { ConversationsProvider } from './context/ConversationsContext';
|
||||
import { FriendshipsProvider } from './context/FriendshipsContext';
|
||||
import { ThemeProvider } from './context/ThemeContext';
|
||||
import { hydrateDrafts } from './lib/composerDraftStore';
|
||||
import { AuthPage } from './pages/AuthPage';
|
||||
import { ChatsEmptyState, ChatsPage } from './pages/ChatsPage';
|
||||
import { ConversationPage } from './pages/ConversationPage';
|
||||
@@ -60,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() {
|
||||
useEffect(() => {
|
||||
void hydrateDrafts();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ErrorBoundary scope="root">
|
||||
<ThemeProvider>
|
||||
@@ -114,7 +131,7 @@ export function App() {
|
||||
path=":id"
|
||||
element={
|
||||
<ErrorBoundary scope="conversation">
|
||||
<ConversationPage />
|
||||
<ConversationRoute />
|
||||
</ErrorBoundary>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useIdleAutoLock } from '../hooks/useIdleAutoLock';
|
||||
import { startConversationKeySync } from '../lib/conversationKeySync';
|
||||
import { startDeviceApprovalListener } from '../lib/deviceApproval';
|
||||
import { ensureInstallId } from '../lib/installId';
|
||||
@@ -14,6 +15,7 @@ import { Sidebar } from './Sidebar';
|
||||
|
||||
export function AppShell() {
|
||||
const { session } = useAuth();
|
||||
useIdleAutoLock();
|
||||
useMentionNotifications(session?.user.id);
|
||||
useEffect(() => {
|
||||
// Prompt once per authenticated shell mount. Module-level guard prevents
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/s
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
|
||||
import { getVoiceSpeed, setVoiceSpeed, VOICE_SPEEDS, type VoiceSpeed } from '../lib/voiceSpeedSettings';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { AlertIcon, MicIcon, SpinnerIcon } from './icons';
|
||||
|
||||
@@ -23,6 +24,7 @@ export function AttachmentAudio({ handle }: Props) {
|
||||
const [duration, setDuration] = useState<number>(0);
|
||||
const [position, setPosition] = useState<number>(0);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [speed, setSpeed] = useState<VoiceSpeed>(() => getVoiceSpeed());
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -105,6 +107,12 @@ export function AttachmentAudio({ handle }: Props) {
|
||||
};
|
||||
}, [arrayBuf]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = audioRef.current;
|
||||
if (!el) return;
|
||||
el.playbackRate = speed;
|
||||
}, [speed, blobUrl]);
|
||||
|
||||
const fallbackPeaks = useMemo(
|
||||
() => (peaks ? null : Array.from({ length: BAR_COUNT }, () => 0.5)),
|
||||
[peaks],
|
||||
@@ -154,6 +162,29 @@ export function AttachmentAudio({ handle }: Props) {
|
||||
<PlayGlyph />
|
||||
)}
|
||||
</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
|
||||
role="slider"
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
type AttachmentHandle,
|
||||
downloadAndDecryptAttachment,
|
||||
downloadAndDecryptAttachmentThumb,
|
||||
} from '@chat-app/shared/chat';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
|
||||
import { supabase } from '../lib/supabase';
|
||||
@@ -56,6 +60,17 @@ export function AttachmentImage({ handle, mine = false }: Props) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
|
||||
// Whether the sender shipped a pre-built WebP thumb alongside this attachment
|
||||
// (Phase 6B+). When true we render the bubble from just the thumb and only
|
||||
// fetch the full blob when the user opens the lightbox or for view-once.
|
||||
const hasServerThumb = Boolean(handle.thumbStoragePath && handle.thumbNonceB64);
|
||||
// View-once needs the full blob ready instantly the moment the recipient
|
||||
// taps (otherwise we'd show a spinner during the burn animation, then race
|
||||
// the "mark viewed" RPC). Same for the legacy path with no server thumb —
|
||||
// we have to download the whole thing just to render the client-side
|
||||
// makeThumbnail fallback.
|
||||
const needsEagerFull = handle.viewOnce === true || !hasServerThumb;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const created: string[] = [];
|
||||
@@ -69,9 +84,39 @@ export function AttachmentImage({ handle, mine = false }: Props) {
|
||||
return u;
|
||||
};
|
||||
|
||||
// OPFS cache → decrypt → generate thumbnail for inline display.
|
||||
// Lightbox swaps to the full blob when opened.
|
||||
const thumbCacheId = handle.id + '-thumb';
|
||||
|
||||
void (async () => {
|
||||
// Phase 6B fast path: if the sender shipped a server-side WebP thumb,
|
||||
// grab it first so the bubble paints from ~20KB instead of waiting on
|
||||
// the multi-MB full blob. The OPFS cache is keyed separately so the
|
||||
// thumb survives independent of full-blob eviction.
|
||||
if (hasServerThumb) {
|
||||
try {
|
||||
let thumbBlob: Blob | null = await getCachedAttachment(thumbCacheId);
|
||||
if (!thumbBlob) {
|
||||
thumbBlob = await downloadAndDecryptAttachmentThumb({
|
||||
client: supabase,
|
||||
handle,
|
||||
});
|
||||
if (thumbBlob) void putCachedAttachment(thumbCacheId, thumbBlob);
|
||||
}
|
||||
if (cancelled) return;
|
||||
if (thumbBlob) {
|
||||
setThumbUrl(take(thumbBlob));
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
// Thumb decrypt failure isn't fatal — fall through to the full
|
||||
// blob path below so the user still sees the image.
|
||||
console.warn('thumb load failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Eagerly resolve the full blob when we need it for view-once or as
|
||||
// the only render source (no server thumb). For the thumb-first path
|
||||
// the full blob is deferred until the lightbox opens (see below).
|
||||
if (!needsEagerFull) return;
|
||||
|
||||
const cached = await getCachedAttachment(handle.id);
|
||||
let blob: Blob;
|
||||
if (cached) {
|
||||
@@ -90,10 +135,14 @@ export function AttachmentImage({ handle, mine = false }: Props) {
|
||||
if (cancelled) return;
|
||||
const full = take(blob);
|
||||
setFullUrl(full);
|
||||
const thumb = await makeThumbnail(blob);
|
||||
if (cancelled) return;
|
||||
if (thumb) {
|
||||
setThumbUrl(take(thumb));
|
||||
// Pre-Phase-6B fallback: no server thumb shipped, so re-derive a
|
||||
// smaller preview on the client to keep memory pressure down.
|
||||
if (!hasServerThumb) {
|
||||
const thumb = await makeThumbnail(blob);
|
||||
if (cancelled) return;
|
||||
if (thumb) {
|
||||
setThumbUrl(take(thumb));
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -101,7 +150,74 @@ export function AttachmentImage({ handle, mine = false }: Props) {
|
||||
cancelled = true;
|
||||
for (const u of created) URL.revokeObjectURL(u);
|
||||
};
|
||||
}, [handle.id, handle.storagePath, handle.keyB64, handle.nonceB64]);
|
||||
}, [
|
||||
handle,
|
||||
handle.id,
|
||||
handle.storagePath,
|
||||
handle.keyB64,
|
||||
handle.nonceB64,
|
||||
handle.thumbStoragePath,
|
||||
handle.thumbNonceB64,
|
||||
hasServerThumb,
|
||||
needsEagerFull,
|
||||
]);
|
||||
|
||||
// Lazy full-image fetch for click-to-expand (only kicks in when we
|
||||
// skipped the eager full-blob download above). Resolves into the same
|
||||
// `fullUrl` state that the Lightbox consumes; the thumb URL keeps
|
||||
// backing the bubble until the lightbox actually mounts.
|
||||
//
|
||||
// CRITICAL: do NOT revoke the just-created blob URL in this effect's
|
||||
// cleanup. Setting `fullUrl` re-triggers the effect (state change → re-
|
||||
// run → previous cleanup fires → URL revoked → Lightbox renders
|
||||
// referenced-but-revoked URL → "ERR_FILE_NOT_FOUND"). The dedicated
|
||||
// unmount-only effect below tracks the current URL via ref and revokes
|
||||
// it once when the component truly leaves the tree.
|
||||
//
|
||||
// Deps locked to `handle.id` (not `handle`) — handles are immutable per
|
||||
// attachment id, so object-identity churn from parent re-renders must
|
||||
// not re-trigger the fetch.
|
||||
useEffect(() => {
|
||||
if (!lightboxOpen) return;
|
||||
if (fullUrl) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const cached = await getCachedAttachment(handle.id);
|
||||
let blob: Blob;
|
||||
if (cached) {
|
||||
blob = cached;
|
||||
} else {
|
||||
try {
|
||||
blob = await downloadAndDecryptAttachment({ client: supabase, handle });
|
||||
void putCachedAttachment(handle.id, blob);
|
||||
} catch (err: unknown) {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : 'download failed');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (cancelled) return;
|
||||
const u = URL.createObjectURL(blob);
|
||||
setFullUrl(u);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [lightboxOpen, handle.id]);
|
||||
|
||||
// Track the currently-published fullUrl in a ref so the unmount-only
|
||||
// cleanup below can revoke whatever URL is live at teardown time
|
||||
// without subscribing to fullUrl changes (which would re-trigger and
|
||||
// revoke prematurely — see the comment above the fetch effect).
|
||||
const fullUrlRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
fullUrlRef.current = fullUrl;
|
||||
}, [fullUrl]);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (fullUrlRef.current) URL.revokeObjectURL(fullUrlRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const blobUrl = thumbUrl ?? fullUrl;
|
||||
|
||||
@@ -157,7 +273,15 @@ export function AttachmentImage({ handle, mine = false }: Props) {
|
||||
className="block h-auto max-h-80 w-auto max-w-full object-contain"
|
||||
/>
|
||||
</button>
|
||||
{lightboxOpen && fullUrl && <Lightbox url={fullUrl} onClose={() => setLightboxOpen(false)} />}
|
||||
{lightboxOpen && (
|
||||
<Lightbox
|
||||
// Prefer the full blob the moment it's available; otherwise show
|
||||
// the thumb so the user sees *something* during the lazy fetch
|
||||
// (typical full-blob fetch is 100ms–2s depending on size).
|
||||
url={fullUrl ?? blobUrl}
|
||||
onClose={() => setLightboxOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,11 @@ interface Props {
|
||||
// brand-tinted fallback; callers can override (e.g. to colour-by-id).
|
||||
fallbackClass?: string;
|
||||
alt?: string;
|
||||
// Browser loading hint. Use 'eager' for above-the-fold avatars (e.g. the
|
||||
// active conversation header, call tiles). Defaults to 'lazy' so off-screen
|
||||
// avatars (chat list rows, friends list, popovers) don't hammer Supabase
|
||||
// Storage on initial render.
|
||||
loading?: 'eager' | 'lazy';
|
||||
}
|
||||
|
||||
export function Avatar({
|
||||
@@ -19,6 +24,7 @@ export function Avatar({
|
||||
className = 'h-10 w-10',
|
||||
fallbackClass = 'bg-accent/20 text-accent',
|
||||
alt,
|
||||
loading = 'lazy',
|
||||
}: Props) {
|
||||
const effectiveUrl = useCachedAvatarUrl(url);
|
||||
if (effectiveUrl) {
|
||||
@@ -28,6 +34,7 @@ export function Avatar({
|
||||
alt={alt ?? displayName ?? ''}
|
||||
className={'shrink-0 rounded-full object-cover ' + className}
|
||||
draggable={false}
|
||||
loading={loading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||
import { useCall } from '../context/CallContext';
|
||||
|
||||
interface Props {
|
||||
conversation: ConversationSummary;
|
||||
}
|
||||
|
||||
const STALE_AFTER_MS = 5000;
|
||||
|
||||
/** Discord-style live-captions overlay. Pinned to the bottom-center of the
|
||||
* call surface; renders the most recent caption per participant, fading
|
||||
* entries out after `STALE_AFTER_MS` of silence. Self-captions are shown
|
||||
* too so the speaker can sanity-check what's being broadcast. */
|
||||
export function CallCaptionsOverlay({ conversation }: Props) {
|
||||
const { captions } = useCall();
|
||||
// Re-render every second so stale entries fade without needing the data
|
||||
// channel to fire — captions module just stores timestamps.
|
||||
const [, setNow] = useState(Date.now());
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const now = Date.now();
|
||||
const visible = Object.entries(captions)
|
||||
.filter(([, v]) => now - v.timestamp < STALE_AFTER_MS)
|
||||
.sort(([, a], [, b]) => a.timestamp - b.timestamp);
|
||||
|
||||
if (visible.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-24 z-30 flex flex-col items-center gap-1.5 px-6">
|
||||
{visible.map(([identity, c]) => {
|
||||
const member = conversation.members.find((m) => m.userId === identity);
|
||||
const name = member?.profile?.displayName ?? '?';
|
||||
const age = now - c.timestamp;
|
||||
const opacity = age > 3500 ? 1 - (age - 3500) / (STALE_AFTER_MS - 3500) : 1;
|
||||
return (
|
||||
<div
|
||||
key={identity}
|
||||
style={{ opacity: Math.max(0, opacity) }}
|
||||
className="max-w-[760px] rounded-lg bg-black/72 px-3.5 py-1.5 text-sm text-white shadow-md backdrop-blur-md transition-opacity"
|
||||
>
|
||||
<span className="mr-2 text-[11px] font-semibold uppercase tracking-wide text-white/55">
|
||||
{name}
|
||||
</span>
|
||||
<span className={c.final ? '' : 'italic text-white/85'}>{c.text}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
CaptionsIcon,
|
||||
HeadphonesIcon,
|
||||
HeadphonesOffIcon,
|
||||
MicIcon,
|
||||
@@ -32,10 +31,6 @@ interface Props {
|
||||
/** Toggle the in-call soundboard popover. Active = panel currently open. */
|
||||
onToggleSoundboard?: () => void;
|
||||
soundboardOpen?: boolean;
|
||||
/** Discord-style live-captions toggle. Optional — pages that don't support
|
||||
* SpeechRecognition (Firefox) skip the prop and the button is hidden. */
|
||||
onToggleCaptions?: () => void;
|
||||
captionsOn?: boolean;
|
||||
participantsOpen?: boolean;
|
||||
/** Compact variant used inside the docked call (36px buttons). */
|
||||
compact?: boolean;
|
||||
@@ -59,8 +54,6 @@ export function CallControls({
|
||||
onOpenParticipants,
|
||||
onToggleSoundboard,
|
||||
soundboardOpen = false,
|
||||
onToggleCaptions,
|
||||
captionsOn = false,
|
||||
participantsOpen = false,
|
||||
compact = false,
|
||||
glass = false,
|
||||
@@ -148,22 +141,6 @@ export function CallControls({
|
||||
<MusicIcon className="h-5 w-5" />
|
||||
</CallButton>
|
||||
)}
|
||||
{onToggleCaptions && (
|
||||
<CallButton
|
||||
label={
|
||||
captionsOn
|
||||
? t('app:call.captions_off', { defaultValue: 'Untertitel aus' })
|
||||
: t('app:call.captions_on', { defaultValue: 'Untertitel an' })
|
||||
}
|
||||
active={captionsOn}
|
||||
activeTone="accent"
|
||||
onClick={onToggleCaptions}
|
||||
glass={glass}
|
||||
className={btnSize}
|
||||
>
|
||||
<CaptionsIcon className="h-5 w-5" />
|
||||
</CallButton>
|
||||
)}
|
||||
{onOpenParticipants && (
|
||||
<CallButton
|
||||
label={t('app:call.participants', { defaultValue: 'Teilnehmer' })}
|
||||
|
||||
@@ -291,6 +291,7 @@ function AudioContent({
|
||||
src={avatarUrl}
|
||||
alt=""
|
||||
className="relative h-full w-full rounded-full object-cover"
|
||||
loading="eager"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
@@ -366,7 +367,7 @@ function VideoStub({
|
||||
}
|
||||
>
|
||||
{avatarUrl ? (
|
||||
<img src={avatarUrl} alt="" className="h-full w-full rounded-full object-cover" />
|
||||
<img src={avatarUrl} alt="" className="h-full w-full rounded-full object-cover" loading="eager" />
|
||||
) : (
|
||||
letter
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { MonitorShareIcon, PollIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
anchorRef: React.RefObject<HTMLButtonElement | null>;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onAttachFile: () => void;
|
||||
onCreatePoll: () => void;
|
||||
onCreateWhiteboard: () => void;
|
||||
onStartWatchTogether: () => void;
|
||||
onStartGame: () => void;
|
||||
canStartGame?: boolean;
|
||||
}
|
||||
|
||||
export function ComposerActionsMenu({
|
||||
anchorRef,
|
||||
open,
|
||||
onClose,
|
||||
onAttachFile,
|
||||
onCreatePoll,
|
||||
onCreateWhiteboard,
|
||||
onStartWatchTogether,
|
||||
onStartGame,
|
||||
canStartGame = true,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
const firstItemRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
// Auto-focus the first item when menu opens (a11y) + click-outside/Esc handlers
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
firstItemRef.current?.focus();
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
const target = e.target as Node | null;
|
||||
if (!target) return;
|
||||
if (menuRef.current?.contains(target)) return;
|
||||
if (anchorRef.current?.contains(target)) return;
|
||||
onClose();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('mousedown', onDocClick);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDocClick);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [open, onClose, anchorRef]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
// Each item: closes the menu, then runs the action.
|
||||
const items: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
Icon: React.ComponentType<React.ComponentPropsWithoutRef<'svg'>>;
|
||||
action: () => void;
|
||||
disabled?: boolean;
|
||||
disabledTitle?: string;
|
||||
section: 'top' | 'activities';
|
||||
}> = [
|
||||
{
|
||||
key: 'attach',
|
||||
label: t('app:composer.menu.attach', { defaultValue: 'Bild / Datei' }),
|
||||
Icon: PaperclipIcon,
|
||||
action: onAttachFile,
|
||||
section: 'top',
|
||||
},
|
||||
{
|
||||
key: 'poll',
|
||||
label: t('app:composer.menu.poll', { defaultValue: 'Umfrage' }),
|
||||
Icon: PollIcon,
|
||||
action: onCreatePoll,
|
||||
section: 'top',
|
||||
},
|
||||
{
|
||||
key: 'whiteboard',
|
||||
label: t('app:composer.menu.whiteboard', { defaultValue: 'Whiteboard' }),
|
||||
Icon: MonitorShareIcon,
|
||||
action: onCreateWhiteboard,
|
||||
section: 'activities',
|
||||
},
|
||||
{
|
||||
key: 'watch',
|
||||
label: t('app:composer.menu.watch', { defaultValue: 'Watch Together' }),
|
||||
Icon: PlayBoxIcon,
|
||||
action: onStartWatchTogether,
|
||||
section: 'activities',
|
||||
},
|
||||
{
|
||||
key: 'game',
|
||||
label: t('app:composer.menu.game', { defaultValue: 'Spiel starten' }),
|
||||
Icon: GameIcon,
|
||||
action: onStartGame,
|
||||
disabled: !canStartGame,
|
||||
disabledTitle: t('app:composer.menu.game_dm_only', {
|
||||
defaultValue: 'Nur in 1:1-Chats',
|
||||
}),
|
||||
section: 'activities',
|
||||
},
|
||||
];
|
||||
|
||||
const handleItemClick = (item: (typeof items)[number]) => {
|
||||
if (item.disabled) return;
|
||||
onClose();
|
||||
item.action();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const focusable = menuRef.current?.querySelectorAll<HTMLButtonElement>(
|
||||
'button[role="menuitem"]:not([disabled])',
|
||||
);
|
||||
if (!focusable || focusable.length === 0) return;
|
||||
const list = Array.from(focusable);
|
||||
const idx = list.findIndex((el) => el === document.activeElement);
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
list[(idx + 1) % list.length]?.focus();
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
list[(idx - 1 + list.length) % list.length]?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const topItems = items.filter((i) => i.section === 'top');
|
||||
const activityItems = items.filter((i) => i.section === 'activities');
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
role="menu"
|
||||
onKeyDown={handleKeyDown}
|
||||
// Positioned absolutely above the anchor; the wrapping parent (the
|
||||
// composer) must be `position: relative` for this to anchor correctly.
|
||||
className="absolute bottom-full left-0 z-30 mb-2 w-56 overflow-hidden rounded-xl border border-line bg-surface-2 shadow-xl"
|
||||
>
|
||||
{topItems.map((item, idx) => {
|
||||
const Icon = item.Icon;
|
||||
const isFirst = idx === 0;
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
ref={isFirst ? firstItemRef : undefined}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => handleItemClick(item)}
|
||||
disabled={item.disabled}
|
||||
title={item.disabled ? item.disabledTitle : undefined}
|
||||
className={
|
||||
'flex w-full cursor-pointer items-center gap-3 px-3 py-2.5 text-left text-sm font-medium text-fg transition focus:outline-none ' +
|
||||
(item.disabled
|
||||
? 'cursor-not-allowed opacity-50'
|
||||
: 'hover:bg-surface-3 focus-visible:bg-surface-3')
|
||||
}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0 text-fg-muted" />
|
||||
<span className="flex-1 truncate">{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div
|
||||
role="separator"
|
||||
className="border-t border-line/60"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="px-3 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{t('app:composer.menu.section_activities', { defaultValue: 'Aktivitäten' })}
|
||||
</div>
|
||||
{activityItems.map((item) => {
|
||||
const Icon = item.Icon;
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => handleItemClick(item)}
|
||||
disabled={item.disabled}
|
||||
title={item.disabled ? item.disabledTitle : undefined}
|
||||
className={
|
||||
'flex w-full cursor-pointer items-center gap-3 px-3 py-2.5 text-left text-sm font-medium text-fg transition focus:outline-none ' +
|
||||
(item.disabled
|
||||
? 'cursor-not-allowed opacity-50'
|
||||
: 'hover:bg-surface-3 focus-visible:bg-surface-3')
|
||||
}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0 text-fg-muted" />
|
||||
<span className="flex-1 truncate">{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Inline icons not in the central icons module ----------------------
|
||||
|
||||
function PaperclipIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
{...props}
|
||||
>
|
||||
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayBoxIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
{...props}
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="14" rx="2" />
|
||||
<path d="M10 9l5 3-5 3V9z" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GameIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
{...props}
|
||||
>
|
||||
<rect x="3" y="6" width="18" height="12" rx="3" />
|
||||
<path d="M8 12h4M10 10v4M16 11v.01M16 14v.01" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -129,9 +129,9 @@ function HeaderBar({
|
||||
className="relative shrink-0 rounded-full text-left transition enabled:cursor-pointer enabled:hover:ring-2 enabled:hover:ring-accent/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:cursor-default"
|
||||
>
|
||||
{isDm ? (
|
||||
<Avatar url={peerAvatar} displayName={title} className="h-10 w-10 text-base" />
|
||||
<Avatar url={peerAvatar} displayName={title} className="h-10 w-10 text-base" loading="eager" />
|
||||
) : peerAvatar ? (
|
||||
<Avatar url={peerAvatar} displayName={title} className="h-10 w-10 text-base" />
|
||||
<Avatar url={peerAvatar} displayName={title} className="h-10 w-10 text-base" loading="eager" />
|
||||
) : (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-accent/20 text-accent">
|
||||
<UsersIcon className="h-5 w-5" />
|
||||
@@ -197,7 +197,7 @@ function HeaderBar({
|
||||
|
||||
interface HeaderActionButtonProps {
|
||||
label: string;
|
||||
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
|
||||
icon: React.ComponentType<React.ComponentPropsWithoutRef<'svg'>>;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
tone?: 'default' | 'accent';
|
||||
|
||||
@@ -8,6 +8,7 @@ import { forwardRef, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useConversationsContext } from '../context/ConversationsContext';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { ArchiveIcon, AtIcon, BellIcon, BellOffIcon, MoreVerticalIcon } from './icons';
|
||||
|
||||
@@ -54,6 +55,7 @@ interface MenuPos {
|
||||
// under the trigger so it doesn't push off-screen on narrow windows.
|
||||
export function ConversationRowMenu({ conversationId, archived, mutedUntil, mentionsOnly }: Props) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { patchConversation } = useConversationsContext();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [submenuOpen, setSubmenuOpen] = useState<'mute' | null>(null);
|
||||
const [menuPos, setMenuPos] = useState<MenuPos | null>(null);
|
||||
@@ -122,44 +124,59 @@ export function ConversationRowMenu({ conversationId, archived, mutedUntil, ment
|
||||
const isMuted =
|
||||
mutedUntil !== null && new Date(mutedUntil).getTime() > Date.now();
|
||||
|
||||
// Optimistic updates: flip the local state before the server RPC so the
|
||||
// bell / archive icon / checkmark update on the same frame as the click.
|
||||
// Realtime echo via ConversationsContext will reconcile (no-op since the
|
||||
// optimistic patch already matches the server row). On error we restore
|
||||
// the previous value so the menu doesn't lie about persisted state.
|
||||
const handleArchive = useCallback(
|
||||
async (next: boolean) => {
|
||||
setOpen(false);
|
||||
const previous = archived;
|
||||
patchConversation(conversationId, { archived: next });
|
||||
try {
|
||||
await setConversationArchived(supabase, conversationId, next);
|
||||
} catch (err: unknown) {
|
||||
patchConversation(conversationId, { archived: previous });
|
||||
console.error('archive toggle failed', err);
|
||||
}
|
||||
},
|
||||
[conversationId],
|
||||
[conversationId, archived, patchConversation],
|
||||
);
|
||||
|
||||
const handleMute = useCallback(
|
||||
async (minutes: number | null) => {
|
||||
setOpen(false);
|
||||
setSubmenuOpen(null);
|
||||
const previous = mutedUntil;
|
||||
const nextIso = muteDurationToIso(minutes);
|
||||
patchConversation(conversationId, { mutedUntil: nextIso });
|
||||
try {
|
||||
await setConversationMutedUntil(supabase, conversationId, muteDurationToIso(minutes));
|
||||
await setConversationMutedUntil(supabase, conversationId, nextIso);
|
||||
} catch (err: unknown) {
|
||||
patchConversation(conversationId, { mutedUntil: previous });
|
||||
console.error('mute toggle failed', err);
|
||||
}
|
||||
},
|
||||
[conversationId],
|
||||
[conversationId, mutedUntil, patchConversation],
|
||||
);
|
||||
|
||||
const handleMentionsOnly = useCallback(
|
||||
async (next: boolean) => {
|
||||
setOpen(false);
|
||||
const previous = mentionsOnly;
|
||||
patchConversation(conversationId, { mentionsOnly: next });
|
||||
try {
|
||||
await setConversationMentionsOnly(supabase, {
|
||||
conversationId,
|
||||
mentionsOnly: next,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
patchConversation(conversationId, { mentionsOnly: previous });
|
||||
console.warn('mentions-only toggle failed', err);
|
||||
}
|
||||
},
|
||||
[conversationId],
|
||||
[conversationId, mentionsOnly, patchConversation],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -48,6 +48,14 @@ export function GameModal({ gameId, onClose }: Props) {
|
||||
|
||||
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,
|
||||
|
||||
@@ -41,20 +41,42 @@ export function ImageAnnotator({ file, onCancel, onSave }: Props) {
|
||||
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');
|
||||
onCancel();
|
||||
onCancelRef.current();
|
||||
};
|
||||
img.src = url;
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [file, onCancel]);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
}, [file]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!imageLoaded) return;
|
||||
|
||||
@@ -15,14 +15,7 @@ import {
|
||||
listSounds,
|
||||
subscribeSoundboardChanges,
|
||||
} from '../lib/soundboardStorage';
|
||||
import {
|
||||
getLiveCaptionsSettings,
|
||||
isLiveCaptionsSupported,
|
||||
subscribeLiveCaptionsSettings,
|
||||
updateLiveCaptionsSettings,
|
||||
} from '../lib/liveCaptions';
|
||||
import { useActiveSpeakers } from '../lib/useActiveSpeakers';
|
||||
import { CallCaptionsOverlay } from './CallCaptionsOverlay';
|
||||
import { CallControls } from './CallControls';
|
||||
import { CallParticipantTile, type TileConnectionQuality } from './CallParticipantTile';
|
||||
import { CallStatsOverlay } from './CallStatsOverlay';
|
||||
@@ -119,17 +112,6 @@ export function InCallPanel({ conversation }: Props) {
|
||||
const [sharePickerOpen, setSharePickerOpen] = useState(false);
|
||||
// Discord-style debug stats overlay (Ctrl+Shift+S toggles).
|
||||
const [statsOverlayOpen, setStatsOverlayOpen] = useState(false);
|
||||
// Live-captions toggle — mirror of getLiveCaptionsSettings().enabled so the
|
||||
// controls bar can show an "active" state without polling. Captions
|
||||
// broadcasting is wired in CallContext via useLiveCaptions; this only
|
||||
// tracks the toggle state for the button.
|
||||
const [captionsEnabled, setCaptionsEnabled] = useState<boolean>(
|
||||
() => getLiveCaptionsSettings().enabled,
|
||||
);
|
||||
useEffect(
|
||||
() => subscribeLiveCaptionsSettings((s) => setCaptionsEnabled(s.enabled)),
|
||||
[],
|
||||
);
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
// Match the modifier exactly to avoid clobbering other Ctrl+Shift combos.
|
||||
@@ -362,15 +344,6 @@ export function InCallPanel({ conversation }: Props) {
|
||||
soundboardOpen,
|
||||
}
|
||||
: {})}
|
||||
// Live-Captions only when SpeechRecognition is available in the
|
||||
// runtime — Firefox lacks it, would just show a dead button.
|
||||
{...(isLiveCaptionsSupported()
|
||||
? {
|
||||
onToggleCaptions: () =>
|
||||
updateLiveCaptionsSettings({ enabled: !captionsEnabled }),
|
||||
captionsOn: captionsEnabled,
|
||||
}
|
||||
: {})}
|
||||
onHangup={() => void hangup()}
|
||||
compact={callMode !== 'fullscreen'}
|
||||
glass={callMode === 'fullscreen'}
|
||||
@@ -499,7 +472,6 @@ export function InCallPanel({ conversation }: Props) {
|
||||
onClose={() => setStatsOverlayOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{captionsEnabled && <CallCaptionsOverlay conversation={conversation} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -644,8 +616,6 @@ export function InCallPanel({ conversation }: Props) {
|
||||
onClose={() => setStatsOverlayOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{captionsEnabled && <CallCaptionsOverlay conversation={conversation} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
softDeleteMessage,
|
||||
} from '@chat-app/shared/chat';
|
||||
import { extractErrorCode } from '@chat-app/shared/i18n';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -60,8 +60,17 @@ interface Props {
|
||||
senderAvatarUrl?: string | null | undefined;
|
||||
conversationId: string;
|
||||
reactions: AggregatedReaction[];
|
||||
onToggleReaction: (emoji: string) => Promise<void>;
|
||||
onVotePoll?: (emoji: string, optionEmojis: string[]) => Promise<void>;
|
||||
/**
|
||||
* Toggle a reaction on this message. Receives the message id so the parent
|
||||
* can pass a stable handler reference across every row (lets `React.memo`
|
||||
* actually skip re-renders triggered by composer keystrokes / typing pings).
|
||||
*/
|
||||
onToggleReaction: (messageId: string, emoji: string) => Promise<void>;
|
||||
/**
|
||||
* Cast/clear an exclusive poll vote. Receives the message id for the same
|
||||
* reason as `onToggleReaction`.
|
||||
*/
|
||||
onVotePoll?: (messageId: string, emoji: string, optionEmojis: string[]) => Promise<void>;
|
||||
showSeen?: boolean;
|
||||
/** Delivery state for own messages: 'sent' / 'delivered' / 'read'. */
|
||||
deliveryState?: 'sent' | 'delivered' | 'read';
|
||||
@@ -83,7 +92,7 @@ interface Props {
|
||||
onTogglePin?: (messageId: string) => void;
|
||||
}
|
||||
|
||||
export function MessageBubble({
|
||||
function MessageBubbleInner({
|
||||
message,
|
||||
mine,
|
||||
groupedWithPrev,
|
||||
@@ -257,12 +266,12 @@ export function MessageBubble({
|
||||
async (emoji: string) => {
|
||||
setPickerOpen(false);
|
||||
try {
|
||||
await onToggleReaction(emoji);
|
||||
await onToggleReaction(message.id, emoji);
|
||||
} catch (err: unknown) {
|
||||
console.error('toggleReaction failed', err);
|
||||
}
|
||||
},
|
||||
[onToggleReaction],
|
||||
[onToggleReaction, message.id],
|
||||
);
|
||||
|
||||
const copyableText =
|
||||
@@ -527,7 +536,9 @@ export function MessageBubble({
|
||||
reactions={reactions}
|
||||
mine={mine}
|
||||
onVote={(emoji) =>
|
||||
onVotePoll ? onVotePoll(emoji, pollOptionEmojis) : onToggleReaction(emoji)
|
||||
onVotePoll
|
||||
? onVotePoll(message.id, emoji, pollOptionEmojis)
|
||||
: onToggleReaction(message.id, emoji)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
@@ -593,7 +604,7 @@ export function MessageBubble({
|
||||
<button
|
||||
key={r.emoji + ':' + r.count}
|
||||
type="button"
|
||||
onClick={() => void onToggleReaction(r.emoji)}
|
||||
onClick={() => void onToggleReaction(message.id, r.emoji)}
|
||||
className={
|
||||
'inline-flex origin-bottom animate-reaction-pop cursor-pointer items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
|
||||
(r.mine
|
||||
@@ -743,6 +754,14 @@ export function MessageBubble({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoized export. Skips re-rendering when none of its props' shallow
|
||||
* references change — i.e. when the parent re-renders due to composer
|
||||
* keystrokes, typing-indicator updates, presence pings, etc. Relies on
|
||||
* the parent passing stable callback refs (see `ConversationPage`).
|
||||
*/
|
||||
export const MessageBubble = memo(MessageBubbleInner);
|
||||
|
||||
function PollCard({
|
||||
question,
|
||||
options,
|
||||
|
||||
@@ -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 { MonitorShareIcon } from './icons';
|
||||
import { ScreenShareAnnotations } from './ScreenShareAnnotations';
|
||||
|
||||
interface ScreenShareViewerProps {
|
||||
share: RemoteScreenShare;
|
||||
@@ -34,6 +35,7 @@ export function ScreenShareViewer({
|
||||
const { watchingShareUserIds, watchShare } = useCall();
|
||||
const watching = watchingShareUserIds.has(share.participantId);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [annotateEnabled, setAnnotateEnabled] = useState(false);
|
||||
const letter = displayName.trim().charAt(0).toUpperCase() || '?';
|
||||
|
||||
useEffect(() => {
|
||||
@@ -96,21 +98,28 @@ export function ScreenShareViewer({
|
||||
</div>
|
||||
|
||||
{watching ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
// Suppress the browser's built-in <video> context menu
|
||||
// ("Save Video As…", PiP, …) so the right-click event bubbles
|
||||
// to the wrapping tile div in InCallPanel — that's where the
|
||||
// app's volume / mute menu is wired up. Without this, the
|
||||
// native menu opens on top of ours in focus + fullscreen
|
||||
// modes (where the video covers the whole tile).
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
onDoubleClick={toggleFullscreen}
|
||||
className="block h-full w-full flex-1 cursor-zoom-in bg-black object-contain"
|
||||
/>
|
||||
<div className="relative h-full w-full flex-1">
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
// Suppress the browser's built-in <video> context menu
|
||||
// ("Save Video As…", PiP, …) so the right-click event bubbles
|
||||
// to the wrapping tile div in InCallPanel — that's where the
|
||||
// app's volume / mute menu is wired up. Without this, the
|
||||
// native menu opens on top of ours in focus + fullscreen
|
||||
// modes (where the video covers the whole tile).
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
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
|
||||
type="button"
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
type AutoLockMinutes,
|
||||
getAutoLockMinutes,
|
||||
notifyAutoLockChanged,
|
||||
setAutoLockMinutes,
|
||||
} from '../lib/autoLockSettings';
|
||||
import { isWipeOnCloseEnabled, setWipeOnClose } from '../lib/memoryWipeSettings';
|
||||
import {
|
||||
changePin,
|
||||
@@ -21,6 +27,7 @@ export function SecurityCenter({ userId }: Props) {
|
||||
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);
|
||||
@@ -149,6 +156,39 @@ export function SecurityCenter({ userId }: Props) {
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-fg-muted">
|
||||
Auto-Lock nach Inaktivität
|
||||
</h3>
|
||||
<p className="mb-2 text-xs text-fg-muted">
|
||||
Verlangt erneute PIN-Eingabe nach der gewählten Inaktivitätsdauer. Empfohlen für gemeinsam genutzte Rechner.
|
||||
</p>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-fg">Automatisch sperren</div>
|
||||
<div className="text-xs text-fg-muted">
|
||||
Verlangt erneute PIN-Eingabe nach X Minuten Inaktivität.
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
value={autoLockMinutes}
|
||||
onChange={(e) => {
|
||||
const next = Number(e.target.value) as AutoLockMinutes;
|
||||
setAutoLockMinutes(next);
|
||||
notifyAutoLockChanged(next);
|
||||
setAutoLockMinutesState(next);
|
||||
}}
|
||||
className="cursor-pointer rounded-md border border-line bg-surface-2 px-3 py-1.5 text-sm text-fg focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/40"
|
||||
>
|
||||
<option value={0}>Aus</option>
|
||||
<option value={5}>5 min</option>
|
||||
<option value={15}>15 min</option>
|
||||
<option value={30}>30 min</option>
|
||||
<option value={60}>60 min</option>
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-rose-400">Identität zurücksetzen</h3>
|
||||
<p className="mb-2 text-xs text-fg-muted">Erstellt einen neuen Schlüssel. Alle alten Chats werden unlesbar.</p>
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
interface NavItem {
|
||||
to: string;
|
||||
labelKey: string;
|
||||
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
|
||||
icon: React.ComponentType<React.ComponentPropsWithoutRef<'svg'>>;
|
||||
badge?: 'friends' | 'chats';
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ export function Sidebar() {
|
||||
interface RailNavLinkProps {
|
||||
to: string;
|
||||
label: string;
|
||||
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
|
||||
icon: React.ComponentType<React.ComponentPropsWithoutRef<'svg'>>;
|
||||
badge?: number;
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ function RailNavLink({ to, label, icon: Icon, badge = 0 }: RailNavLinkProps) {
|
||||
interface RailIconButtonProps {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element;
|
||||
icon: React.ComponentType<React.ComponentPropsWithoutRef<'svg'>>;
|
||||
tone?: 'default' | 'danger';
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { markAttachmentViewed } from '@chat-app/shared/chat';
|
||||
|
||||
@@ -16,16 +16,34 @@ interface Props {
|
||||
}
|
||||
|
||||
// Three states:
|
||||
// 1. viewedAt is null AND user is recipient → blurred lock card; tap opens
|
||||
// fullscreen lightbox AND fires the mark-viewed RPC.
|
||||
// 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.
|
||||
// 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 [fullscreen, setFullscreen] = useState(false);
|
||||
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">
|
||||
@@ -51,35 +69,78 @@ export function ViewOnceImage({ attachmentId, viewedAt, isSender, src }: Props)
|
||||
);
|
||||
}
|
||||
|
||||
// Recipient, not yet viewed.
|
||||
const handleOpen = async (): Promise<void> => {
|
||||
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;
|
||||
}
|
||||
setFullscreen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleOpen()}
|
||||
className="relative flex h-48 w-64 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-line bg-surface-3 text-fg-muted hover:border-accent/40"
|
||||
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">Einmal ansehen — antippen</span>
|
||||
<span className="text-xs font-medium">Gedrückt halten zum Ansehen</span>
|
||||
</button>
|
||||
{fullscreen && (
|
||||
{revealing && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 p-6"
|
||||
onClick={() => setFullscreen(false)}
|
||||
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 rounded-lg" />
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -2,6 +2,9 @@ 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;
|
||||
@@ -22,6 +25,8 @@ interface Props {
|
||||
onStroke: (payload: WhiteboardStrokePayload) => void;
|
||||
logicalWidth?: number;
|
||||
logicalHeight?: number;
|
||||
/** Enables live-cursor broadcast when set. */
|
||||
whiteboardId?: string | null;
|
||||
}
|
||||
|
||||
const DEFAULT_LOGICAL_W = 1280;
|
||||
@@ -35,12 +40,63 @@ export function WhiteboardCanvas({
|
||||
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;
|
||||
@@ -81,8 +137,9 @@ export function WhiteboardCanvas({
|
||||
};
|
||||
|
||||
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (!draftRef.current) return;
|
||||
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);
|
||||
};
|
||||
@@ -102,18 +159,53 @@ export function WhiteboardCanvas({
|
||||
};
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerLeave={handlePointerUp}
|
||||
className="block max-h-full max-w-full cursor-crosshair touch-none rounded-lg border border-line/40 bg-white shadow-2xl"
|
||||
<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>,
|
||||
|
||||
@@ -80,6 +80,7 @@ export function WhiteboardModal({ whiteboardId, onClose }: Props) {
|
||||
color={color}
|
||||
width={width}
|
||||
onStroke={(payload) => void insertStroke(payload)}
|
||||
whiteboardId={whiteboardId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Inline SVG icons — no icon library dependency. Lucide-style stroke=1.75.
|
||||
import { memo } from 'react';
|
||||
|
||||
type IconProps = React.SVGProps<SVGSVGElement>;
|
||||
type IconProps = React.ComponentPropsWithoutRef<'svg'>;
|
||||
|
||||
function Base({ children, ...props }: IconProps & { children: React.ReactNode }) {
|
||||
return (
|
||||
@@ -20,7 +21,7 @@ function Base({ children, ...props }: IconProps & { children: React.ReactNode })
|
||||
);
|
||||
}
|
||||
|
||||
export function MailIcon(props: IconProps) {
|
||||
function MailIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" />
|
||||
@@ -28,8 +29,9 @@ export function MailIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const MailIcon = memo(MailIconInner);
|
||||
|
||||
export function AtIcon(props: IconProps) {
|
||||
function AtIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
@@ -37,8 +39,9 @@ export function AtIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const AtIcon = memo(AtIconInner);
|
||||
|
||||
export function TicketIcon(props: IconProps) {
|
||||
function TicketIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M3 8a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v2a2 2 0 1 0 0 4v2a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-2a2 2 0 1 0 0-4V8Z" />
|
||||
@@ -46,8 +49,9 @@ export function TicketIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const TicketIcon = memo(TicketIconInner);
|
||||
|
||||
export function ArrowRightIcon(props: IconProps) {
|
||||
function ArrowRightIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M5 12h14" />
|
||||
@@ -55,8 +59,9 @@ export function ArrowRightIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const ArrowRightIcon = memo(ArrowRightIconInner);
|
||||
|
||||
export function CheckCircleIcon(props: IconProps) {
|
||||
function CheckCircleIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
@@ -64,8 +69,9 @@ export function CheckCircleIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const CheckCircleIcon = memo(CheckCircleIconInner);
|
||||
|
||||
export function AlertIcon(props: IconProps) {
|
||||
function AlertIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M12 9v4" />
|
||||
@@ -74,8 +80,9 @@ export function AlertIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const AlertIcon = memo(AlertIconInner);
|
||||
|
||||
export function ShieldIcon(props: IconProps) {
|
||||
function ShieldIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M12 3 4 6v6c0 5 3.5 8.5 8 9 4.5-.5 8-4 8-9V6l-8-3Z" />
|
||||
@@ -83,8 +90,9 @@ export function ShieldIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const ShieldIcon = memo(ShieldIconInner);
|
||||
|
||||
export function LockIcon(props: IconProps) {
|
||||
function LockIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<rect x="4" y="11" width="16" height="10" rx="2" />
|
||||
@@ -92,8 +100,9 @@ export function LockIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const LockIcon = memo(LockIconInner);
|
||||
|
||||
export function WifiLowIcon(props: IconProps) {
|
||||
function WifiLowIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M5 12.55a11 11 0 0 1 14 0" opacity="0.35" />
|
||||
@@ -102,8 +111,9 @@ export function WifiLowIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const WifiLowIcon = memo(WifiLowIconInner);
|
||||
|
||||
export function WifiOffIcon(props: IconProps) {
|
||||
function WifiOffIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="m2 2 20 20" />
|
||||
@@ -116,8 +126,9 @@ export function WifiOffIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const WifiOffIcon = memo(WifiOffIconInner);
|
||||
|
||||
export function PinIcon(props: IconProps) {
|
||||
function PinIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M12 17v5" />
|
||||
@@ -125,8 +136,9 @@ export function PinIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const PinIcon = memo(PinIconInner);
|
||||
|
||||
export function EyeOffIcon(props: IconProps) {
|
||||
function EyeOffIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="m2 2 20 20" />
|
||||
@@ -136,18 +148,19 @@ export function EyeOffIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const EyeOffIcon = memo(EyeOffIconInner);
|
||||
|
||||
export function CaptionsIcon(props: IconProps) {
|
||||
function EyeIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<rect x="3" y="6" width="18" height="12" rx="2" />
|
||||
<path d="M7 13a2 2 0 1 1 0-2" />
|
||||
<path d="M14 13a2 2 0 1 1 0-2" />
|
||||
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const EyeIcon = memo(EyeIconInner);
|
||||
|
||||
export function PinOffIcon(props: IconProps) {
|
||||
function PinOffIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="m2 2 20 20" />
|
||||
@@ -157,8 +170,9 @@ export function PinOffIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const PinOffIcon = memo(PinOffIconInner);
|
||||
|
||||
export function SparklesIcon(props: IconProps) {
|
||||
function SparklesIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M12 3v4" />
|
||||
@@ -172,8 +186,9 @@ export function SparklesIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const SparklesIcon = memo(SparklesIconInner);
|
||||
|
||||
export function SpinnerIcon(props: IconProps) {
|
||||
function SpinnerIconInner(props: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -208,16 +223,18 @@ export function SpinnerIcon(props: IconProps) {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
export const SpinnerIcon = memo(SpinnerIconInner);
|
||||
|
||||
export function ChatBubbleIcon(props: IconProps) {
|
||||
function ChatBubbleIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M21 15a2 2 0 0 1-2 2H8l-5 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const ChatBubbleIcon = memo(ChatBubbleIconInner);
|
||||
|
||||
export function UsersIcon(props: IconProps) {
|
||||
function UsersIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
@@ -227,8 +244,9 @@ export function UsersIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const UsersIcon = memo(UsersIconInner);
|
||||
|
||||
export function GearIcon(props: IconProps) {
|
||||
function GearIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
@@ -236,8 +254,9 @@ export function GearIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const GearIcon = memo(GearIconInner);
|
||||
|
||||
export function SearchIcon(props: IconProps) {
|
||||
function SearchIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
@@ -245,16 +264,18 @@ export function SearchIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const SearchIcon = memo(SearchIconInner);
|
||||
|
||||
export function PlusIcon(props: IconProps) {
|
||||
function PlusIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const PlusIcon = memo(PlusIconInner);
|
||||
|
||||
export function SignOutIcon(props: IconProps) {
|
||||
function SignOutIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
@@ -263,32 +284,36 @@ export function SignOutIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const SignOutIcon = memo(SignOutIconInner);
|
||||
|
||||
export function MenuIcon(props: IconProps) {
|
||||
function MenuIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M3 6h18M3 12h18M3 18h18" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const MenuIcon = memo(MenuIconInner);
|
||||
|
||||
export function ChevronDownIcon(props: IconProps) {
|
||||
function ChevronDownIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const ChevronDownIcon = memo(ChevronDownIconInner);
|
||||
|
||||
export function PencilIcon(props: IconProps) {
|
||||
function PencilIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const PencilIcon = memo(PencilIconInner);
|
||||
|
||||
export function TrashIcon(props: IconProps) {
|
||||
function TrashIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M3 6h18" />
|
||||
@@ -299,8 +324,9 @@ export function TrashIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const TrashIcon = memo(TrashIconInner);
|
||||
|
||||
export function SmileIcon(props: IconProps) {
|
||||
function SmileIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
@@ -310,8 +336,9 @@ export function SmileIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const SmileIcon = memo(SmileIconInner);
|
||||
|
||||
export function CopyIcon(props: IconProps) {
|
||||
function CopyIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" />
|
||||
@@ -319,16 +346,18 @@ export function CopyIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const CopyIcon = memo(CopyIconInner);
|
||||
|
||||
export function PhoneIcon(props: IconProps) {
|
||||
function PhoneIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6A19.79 19.79 0 0 1 2.12 4.18 2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92Z" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const PhoneIcon = memo(PhoneIconInner);
|
||||
|
||||
export function PhoneOffIcon(props: IconProps) {
|
||||
function PhoneOffIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-3.48-3.05" />
|
||||
@@ -337,8 +366,9 @@ export function PhoneOffIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const PhoneOffIcon = memo(PhoneOffIconInner);
|
||||
|
||||
export function MicIcon(props: IconProps) {
|
||||
function MicIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<rect x="9" y="2" width="6" height="12" rx="3" />
|
||||
@@ -348,8 +378,9 @@ export function MicIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const MicIcon = memo(MicIconInner);
|
||||
|
||||
export function MicOffIcon(props: IconProps) {
|
||||
function MicOffIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M1 1l22 22" />
|
||||
@@ -362,8 +393,9 @@ export function MicOffIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const MicOffIcon = memo(MicOffIconInner);
|
||||
|
||||
export function InfoIcon(props: IconProps) {
|
||||
function InfoIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
@@ -372,16 +404,18 @@ export function InfoIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const InfoIcon = memo(InfoIconInner);
|
||||
|
||||
export function XIcon(props: IconProps) {
|
||||
function XIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M18 6 6 18M6 6l12 12" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const XIcon = memo(XIconInner);
|
||||
|
||||
export function MonitorShareIcon(props: IconProps) {
|
||||
function MonitorShareIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<rect x="3" y="4" width="18" height="12" rx="2" />
|
||||
@@ -390,8 +424,9 @@ export function MonitorShareIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const MonitorShareIcon = memo(MonitorShareIconInner);
|
||||
|
||||
export function MonitorStopIcon(props: IconProps) {
|
||||
function MonitorStopIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<rect x="3" y="4" width="18" height="12" rx="2" />
|
||||
@@ -400,8 +435,9 @@ export function MonitorStopIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const MonitorStopIcon = memo(MonitorStopIconInner);
|
||||
|
||||
export function SunIcon(props: IconProps) {
|
||||
function SunIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
@@ -409,16 +445,18 @@ export function SunIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const SunIcon = memo(SunIconInner);
|
||||
|
||||
export function MoonIcon(props: IconProps) {
|
||||
function MoonIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3a7 7 0 0 0 9.79 9.79Z" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const MoonIcon = memo(MoonIconInner);
|
||||
|
||||
export function GridIcon(props: IconProps) {
|
||||
function GridIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<rect x="3" y="3" width="7" height="7" rx="1.5" />
|
||||
@@ -428,8 +466,9 @@ export function GridIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const GridIcon = memo(GridIconInner);
|
||||
|
||||
export function ImageIcon(props: IconProps) {
|
||||
function ImageIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" />
|
||||
@@ -438,8 +477,9 @@ export function ImageIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const ImageIcon = memo(ImageIconInner);
|
||||
|
||||
export function FileIcon(props: IconProps) {
|
||||
function FileIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M14 2H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7Z" />
|
||||
@@ -447,8 +487,9 @@ export function FileIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const FileIcon = memo(FileIconInner);
|
||||
|
||||
export function PollIcon(props: IconProps) {
|
||||
function PollIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M5 19V9" />
|
||||
@@ -458,8 +499,9 @@ export function PollIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const PollIcon = memo(PollIconInner);
|
||||
|
||||
export function FocusIcon(props: IconProps) {
|
||||
function FocusIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
@@ -467,8 +509,9 @@ export function FocusIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const FocusIcon = memo(FocusIconInner);
|
||||
|
||||
export function MaximizeIcon(props: IconProps) {
|
||||
function MaximizeIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M4 9V5a1 1 0 0 1 1-1h4" />
|
||||
@@ -478,8 +521,9 @@ export function MaximizeIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const MaximizeIcon = memo(MaximizeIconInner);
|
||||
|
||||
export function VideoIcon(props: IconProps) {
|
||||
function VideoIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<rect x="2" y="6" width="15" height="12" rx="2" />
|
||||
@@ -487,16 +531,18 @@ export function VideoIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const VideoIcon = memo(VideoIconInner);
|
||||
|
||||
export function CrownIcon(props: IconProps) {
|
||||
function CrownIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="m3 7 4 4 5-6 5 6 4-4v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const CrownIcon = memo(CrownIconInner);
|
||||
|
||||
export function MusicIcon(props: IconProps) {
|
||||
function MusicIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M9 18V5l12-2v13" />
|
||||
@@ -505,16 +551,18 @@ export function MusicIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const MusicIcon = memo(MusicIconInner);
|
||||
|
||||
export function SendIcon(props: IconProps) {
|
||||
function SendIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="m3 11 18-8-8 18-2-8-8-2Z" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const SendIcon = memo(SendIconInner);
|
||||
|
||||
export function ArchiveIcon(props: IconProps) {
|
||||
function ArchiveIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<rect x="3" y="4" width="18" height="5" rx="1" />
|
||||
@@ -523,8 +571,9 @@ export function ArchiveIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const ArchiveIcon = memo(ArchiveIconInner);
|
||||
|
||||
export function BellIcon(props: IconProps) {
|
||||
function BellIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M6 8a6 6 0 1 1 12 0c0 4 1.5 5 2 6H4c.5-1 2-2 2-6Z" />
|
||||
@@ -532,8 +581,9 @@ export function BellIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const BellIcon = memo(BellIconInner);
|
||||
|
||||
export function BellOffIcon(props: IconProps) {
|
||||
function BellOffIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M9.5 18a2.5 2.5 0 0 0 5 0" />
|
||||
@@ -545,8 +595,9 @@ export function BellOffIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const BellOffIcon = memo(BellOffIconInner);
|
||||
|
||||
export function MoreVerticalIcon(props: IconProps) {
|
||||
function MoreVerticalIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<circle cx="12" cy="5" r="1.5" />
|
||||
@@ -555,8 +606,9 @@ export function MoreVerticalIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const MoreVerticalIcon = memo(MoreVerticalIconInner);
|
||||
|
||||
export function HeadphonesIcon(props: IconProps) {
|
||||
function HeadphonesIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M3 14v-2a9 9 0 0 1 18 0v2" />
|
||||
@@ -565,8 +617,9 @@ export function HeadphonesIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const HeadphonesIcon = memo(HeadphonesIconInner);
|
||||
|
||||
export function HeadphonesOffIcon(props: IconProps) {
|
||||
function HeadphonesOffIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M3 14v-2a9 9 0 0 1 11.3-8.7" />
|
||||
@@ -577,8 +630,9 @@ export function HeadphonesOffIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const HeadphonesOffIcon = memo(HeadphonesOffIconInner);
|
||||
|
||||
export function ReplyIcon(props: IconProps) {
|
||||
function ReplyIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<polyline points="9 17 4 12 9 7" />
|
||||
@@ -586,8 +640,9 @@ export function ReplyIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const ReplyIcon = memo(ReplyIconInner);
|
||||
|
||||
export function ForwardIcon(props: IconProps) {
|
||||
function ForwardIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<polyline points="15 17 20 12 15 7" />
|
||||
@@ -595,16 +650,18 @@ export function ForwardIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const ForwardIcon = memo(ForwardIconInner);
|
||||
|
||||
export function ChevronUpIcon(props: IconProps) {
|
||||
function ChevronUpIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<polyline points="18 15 12 9 6 15" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const ChevronUpIcon = memo(ChevronUpIconInner);
|
||||
|
||||
export function AddUserIcon(props: IconProps) {
|
||||
function AddUserIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
@@ -613,12 +670,13 @@ export function AddUserIcon(props: IconProps) {
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const AddUserIcon = memo(AddUserIconInner);
|
||||
|
||||
// Soft-N mark: rounded-square violet tile with a stylised "N" glyph. Used
|
||||
// wherever the app needs a standalone icon (sidebar rail, auth screen,
|
||||
// favicon). Colour decisions sit inside the SVG so consumers just size the
|
||||
// element via `className`.
|
||||
export function LogoMark(props: IconProps) {
|
||||
function LogoMarkInner(props: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -638,10 +696,11 @@ export function LogoMark(props: IconProps) {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
export const LogoMark = memo(LogoMarkInner);
|
||||
|
||||
// Full lockup: soft-N mark + "Netralax" wordmark. `tone` decides text colour:
|
||||
// "dark" = white text (use on dark background), "light" = near-black.
|
||||
export function LogoLockup({
|
||||
function LogoLockupInner({
|
||||
tone = 'dark',
|
||||
...props
|
||||
}: IconProps & { tone?: 'dark' | 'light' }) {
|
||||
@@ -676,3 +735,4 @@ export function LogoLockup({
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
export const LogoLockup = memo(LogoLockupInner);
|
||||
|
||||
@@ -204,6 +204,19 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
void registerWebPush(installId);
|
||||
}, [session]);
|
||||
|
||||
// Pre-warm Supabase: fires the first round-trip in the background so the
|
||||
// first user-triggered query (e.g. loading conversations) doesn't pay
|
||||
// the cold-connection latency.
|
||||
//
|
||||
// Uses auth.getSession() instead of a `profiles` SELECT because the
|
||||
// SELECT race-fired before the supabase client committed its JWT to
|
||||
// request headers, causing a 400 from PostgREST on app boot. Auth
|
||||
// endpoints don't depend on RLS and tolerate the race.
|
||||
useEffect(() => {
|
||||
if (!session) return;
|
||||
void supabase.auth.getSession();
|
||||
}, [session]);
|
||||
|
||||
// Phase 3: ensure this install owns exactly one devices row. The row is
|
||||
// pure session-list telemetry — it does not carry any cryptographic
|
||||
// material since the per-user-key refactor. We re-use the row across
|
||||
|
||||
@@ -39,7 +39,6 @@ import {
|
||||
playUndeafenBeep,
|
||||
playUnmuteBeep,
|
||||
} from '../lib/callSounds';
|
||||
import { useLiveCaptions } from '../lib/useLiveCaptions';
|
||||
import { setCallWakeLock } from '../lib/wakeLock';
|
||||
import { notify } from '../lib/osNotify';
|
||||
import {
|
||||
@@ -99,6 +98,7 @@ import {
|
||||
subscribeScreenShareVolumes,
|
||||
} from '../lib/screenShareVolumes';
|
||||
import { startSoundboardHotkeys } from '../lib/soundboardHotkeys';
|
||||
import { playSoundboardLocal } from '../lib/soundboardLocalPlay';
|
||||
import { playEntry } from '../lib/soundboardPlayback';
|
||||
import {
|
||||
getPrefs as getSoundboardPrefs,
|
||||
@@ -209,14 +209,6 @@ interface CallContextValue {
|
||||
* invite (fromUserId). Cleared on disconnect. Drives the crown badge,
|
||||
* but only in group calls. Null while idle or in 1:1 contexts. */
|
||||
callHostId: string | null;
|
||||
/** identity -> latest live-caption fragment received via data channel.
|
||||
* Includes own captions for self-overlay. Receivers prune entries whose
|
||||
* timestamp is older than ~5s so stale lines fade out. */
|
||||
captions: Record<string, { text: string; final: boolean; timestamp: number }>;
|
||||
/** Surface a caption for the local user — the live-captions hook calls
|
||||
* this on every interim/final SpeechRecognition result so the overlay
|
||||
* shows our own line without going through the SFU round-trip. */
|
||||
pushLocalCaption: (text: string, final: boolean) => void;
|
||||
/** identity -> mute state. Broadcast from peer whenever mic-gain flips.
|
||||
* Needed because we can't rely on LiveKit's native isMicrophoneEnabled —
|
||||
* the mic pipeline keeps the track published with sound flowing even
|
||||
@@ -347,9 +339,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
// useEffect) so peers don't hear themselves echoed back when the OS-level
|
||||
// process-tree exclusion isn't watertight.
|
||||
const [isCapturingSystemAudio, setIsCapturingSystemAudio] = useState(false);
|
||||
const [captions, setCaptions] = useState<
|
||||
Record<string, { text: string; final: boolean; timestamp: number }>
|
||||
>({});
|
||||
const [lastCallConversationId, setLastCallConversationId] = useState<string | null>(null);
|
||||
const [callMode, setCallModeState] = useState<CallMode>('grid');
|
||||
const [focusedId, setFocusedIdState] = useState<string | null>(null);
|
||||
@@ -828,7 +817,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
setRemoteScreenShares([]);
|
||||
setConnectionQualities({});
|
||||
setCallHostId(null);
|
||||
setCaptions({});
|
||||
setIsScreenSharing(false);
|
||||
setIsE2EEActive(false);
|
||||
}
|
||||
@@ -970,8 +958,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
type?: string;
|
||||
deafened?: boolean;
|
||||
muted?: boolean;
|
||||
captionText?: string;
|
||||
captionFinal?: boolean;
|
||||
};
|
||||
const id: string = participant.identity;
|
||||
if (msg.type === 'presence') {
|
||||
@@ -991,15 +977,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'caption' && typeof msg.captionText === 'string') {
|
||||
const text2 = msg.captionText;
|
||||
const final = msg.captionFinal === true;
|
||||
setCaptions((prev) => ({
|
||||
...prev,
|
||||
[id]: { text: text2, final, timestamp: Date.now() },
|
||||
}));
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* ignore malformed */
|
||||
}
|
||||
@@ -1754,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 r = roomRef.current;
|
||||
if (!r) return;
|
||||
@@ -2292,15 +2258,36 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
pipelineRef.current?.setMonitorGain(prefs.monitorGain);
|
||||
}, []);
|
||||
|
||||
// Global soundboard hotkey registration — runs only while connected so the
|
||||
// OS-level shortcuts don't fire when the user is outside of a call.
|
||||
// Global soundboard hotkey registration — always-on so the OS-level
|
||||
// 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(() => {
|
||||
if (state.kind !== 'connected') return;
|
||||
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;
|
||||
}, [state.kind, playSoundboard]);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const setAudioInputDevice = useCallback(async (deviceId: string | null) => {
|
||||
updateAudioSettings({ inputDeviceId: deviceId });
|
||||
@@ -2603,15 +2590,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [callMode, state.kind]);
|
||||
|
||||
// Discord-style live-captions broadcaster — runs on the local mic while
|
||||
// we're connected, and ships interim/final transcripts on the LiveKit
|
||||
// DataChannel so peers can render them.
|
||||
useLiveCaptions({
|
||||
room,
|
||||
active: state.kind === 'connected' || state.kind === 'reconnecting',
|
||||
onLocalCaption: pushLocalCaption,
|
||||
});
|
||||
|
||||
const value = useMemo<CallContextValue>(
|
||||
() => ({
|
||||
state,
|
||||
@@ -2626,8 +2604,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
remoteMute,
|
||||
connectionQualities,
|
||||
callHostId,
|
||||
captions,
|
||||
pushLocalCaption,
|
||||
remoteScreenShares,
|
||||
lastCallConversationId,
|
||||
callMode,
|
||||
@@ -2683,8 +2659,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
remoteMute,
|
||||
connectionQualities,
|
||||
callHostId,
|
||||
captions,
|
||||
pushLocalCaption,
|
||||
remoteScreenShares,
|
||||
lastCallConversationId,
|
||||
callMode,
|
||||
|
||||
@@ -54,6 +54,15 @@ interface ConversationsContextValue {
|
||||
refresh: () => Promise<void>;
|
||||
markRead: (conversationId: string) => void;
|
||||
setActiveConversation: (conversationId: string | null) => void;
|
||||
// Optimistic patch for the caller's per-membership preferences (mute /
|
||||
// mentions-only / archive). Mutations to `conversation_members` echo back
|
||||
// via the realtime channel and `refresh()` reconciles canonically, but the
|
||||
// ~100-200ms roundtrip leaves the UI looking unresponsive. Callers patch
|
||||
// immediately, snapshot the previous state, and roll back on failure.
|
||||
patchConversation: (
|
||||
conversationId: string,
|
||||
patch: Partial<Pick<ConversationSummary, 'archived' | 'mutedUntil' | 'mentionsOnly'>>,
|
||||
) => void;
|
||||
}
|
||||
|
||||
const ConversationsContext = createContext<ConversationsContextValue | null>(null);
|
||||
@@ -164,6 +173,19 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
|
||||
[markRead],
|
||||
);
|
||||
|
||||
const patchConversation = useCallback<ConversationsContextValue['patchConversation']>(
|
||||
(convId, patch) => {
|
||||
setConversations((prev) => {
|
||||
const idx = prev.findIndex((c) => c.id === convId);
|
||||
if (idx === -1) return prev;
|
||||
const next = [...prev];
|
||||
next[idx] = { ...next[idx]!, ...patch };
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!myId) {
|
||||
setConversations([]);
|
||||
@@ -308,6 +330,7 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
|
||||
refresh,
|
||||
markRead,
|
||||
setActiveConversation,
|
||||
patchConversation,
|
||||
}),
|
||||
[
|
||||
conversations,
|
||||
@@ -318,6 +341,7 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
|
||||
refresh,
|
||||
markRead,
|
||||
setActiveConversation,
|
||||
patchConversation,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -64,10 +64,37 @@ export function useOwnDevices(): {
|
||||
|
||||
const revoke = useCallback(
|
||||
async (deviceId: string) => {
|
||||
await revokeDevice(supabase, deviceId);
|
||||
await refresh();
|
||||
// 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;
|
||||
}
|
||||
},
|
||||
[refresh],
|
||||
[],
|
||||
);
|
||||
|
||||
return { devices: state.devices, loading: state.loading, error: state.error, refresh, revoke };
|
||||
|
||||
@@ -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> {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
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);
|
||||
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
|
||||
// through the legacy center-crop. Caller is responsible for sizing — the
|
||||
// dialog already clamps to MAX_DIM via its outputWidth.
|
||||
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
|
||||
// the profile row — Supabase Storage CDN caches by URL, so a fresh path
|
||||
// also forces clients to fetch the new image.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -55,3 +55,51 @@ function renameToWebp(original: string): string {
|
||||
export async function compressImages(files: File[]): Promise<File[]> {
|
||||
return Promise.all(files.map((f) => compressImage(f)));
|
||||
}
|
||||
|
||||
// Bandwidth threshold for thumb generation. Below ~50KB the WebP overhead
|
||||
// of a fresh re-encode can exceed the original; not worth a second upload.
|
||||
const THUMB_SKIP_BELOW_BYTES = 50 * 1024;
|
||||
const THUMB_MAX_DIM = 320;
|
||||
const THUMB_QUALITY = 0.7;
|
||||
// Animated formats lose motion when redrawn onto a Canvas, so we skip them
|
||||
// and let the receiver render the full file. GIF is the dominant case; the
|
||||
// rest stay too (apng/animated-webp).
|
||||
const THUMB_ANIMATED_MIME = /^image\/(gif|apng)$/;
|
||||
|
||||
// Generates a small WebP preview thumb (max 320×320) from an image file.
|
||||
// Used by the send path so each image attachment can ship a tiny inline
|
||||
// preview alongside the encrypted full blob. Returns `null` when:
|
||||
// - the input isn't an image,
|
||||
// - the input is animated (GIF/APNG — would lose motion),
|
||||
// - the input is already small enough that a thumb wouldn't save bandwidth,
|
||||
// - OffscreenCanvas / createImageBitmap aren't available, or
|
||||
// - decode/encode threw (corrupt input).
|
||||
// The caller treats `null` as "skip thumb" and uploads only the full blob.
|
||||
export async function generateWebPThumb(
|
||||
file: File,
|
||||
maxDim: number = THUMB_MAX_DIM,
|
||||
): Promise<Blob | null> {
|
||||
if (!file.type.startsWith('image/')) return null;
|
||||
if (THUMB_ANIMATED_MIME.test(file.type)) return null;
|
||||
if (file.size < THUMB_SKIP_BELOW_BYTES) return null;
|
||||
if (typeof createImageBitmap !== 'function') return null;
|
||||
if (typeof OffscreenCanvas !== 'function') return null;
|
||||
try {
|
||||
const bitmap = await createImageBitmap(file);
|
||||
const ratio = Math.min(maxDim / bitmap.width, maxDim / bitmap.height, 1);
|
||||
const w = Math.max(1, Math.round(bitmap.width * ratio));
|
||||
const h = Math.max(1, Math.round(bitmap.height * ratio));
|
||||
const canvas = new OffscreenCanvas(w, h);
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
bitmap.close();
|
||||
return null;
|
||||
}
|
||||
ctx.drawImage(bitmap, 0, 0, w, h);
|
||||
bitmap.close();
|
||||
return await canvas.convertToBlob({ type: 'image/webp', quality: THUMB_QUALITY });
|
||||
} catch (err: unknown) {
|
||||
console.warn('generateWebPThumb failed', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
// Discord-style live captions. Uses the browser's SpeechRecognition API to
|
||||
// transcribe the LOCAL user's mic, then broadcasts each interim/final result
|
||||
// to peers via the LiveKit DataChannel. Receivers store and display them.
|
||||
//
|
||||
// Privacy note: speech recognition runs in the browser. On Chromium-based
|
||||
// runtimes (incl. Tauri's WebView2 on Windows) this calls into the browser's
|
||||
// own engine, which today reaches Google's cloud — same trade-off as Discord.
|
||||
// We ship a hard off switch and require an explicit user toggle.
|
||||
|
||||
const STORAGE_KEY = 'chatapp.liveCaptions.v1';
|
||||
|
||||
export interface LiveCaptionsSettings {
|
||||
enabled: boolean;
|
||||
/** BCP-47 language tag, e.g. "de-DE" or "en-US". Auto-detect uses navigator.language. */
|
||||
lang: string | null;
|
||||
}
|
||||
|
||||
const DEFAULTS: LiveCaptionsSettings = {
|
||||
enabled: false,
|
||||
lang: null,
|
||||
};
|
||||
|
||||
type Listener = (s: LiveCaptionsSettings) => void;
|
||||
const listeners = new Set<Listener>();
|
||||
let cached: LiveCaptionsSettings | null = null;
|
||||
|
||||
function read(): LiveCaptionsSettings {
|
||||
if (cached) return cached;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
cached = DEFAULTS;
|
||||
return cached;
|
||||
}
|
||||
const parsed = JSON.parse(raw) as Partial<LiveCaptionsSettings>;
|
||||
cached = {
|
||||
enabled: typeof parsed.enabled === 'boolean' ? parsed.enabled : DEFAULTS.enabled,
|
||||
lang:
|
||||
typeof parsed.lang === 'string' && parsed.lang.length > 0
|
||||
? parsed.lang
|
||||
: DEFAULTS.lang,
|
||||
};
|
||||
return cached;
|
||||
} catch {
|
||||
cached = DEFAULTS;
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
function write(s: LiveCaptionsSettings): void {
|
||||
cached = s;
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
|
||||
} catch {
|
||||
/* quota / private mode */
|
||||
}
|
||||
for (const l of listeners) l(s);
|
||||
}
|
||||
|
||||
export function getLiveCaptionsSettings(): LiveCaptionsSettings {
|
||||
return read();
|
||||
}
|
||||
|
||||
export function updateLiveCaptionsSettings(
|
||||
patch: Partial<LiveCaptionsSettings>,
|
||||
): LiveCaptionsSettings {
|
||||
const next = { ...read(), ...patch };
|
||||
write(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function subscribeLiveCaptionsSettings(listener: Listener): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
// Browser feature-detect. Chromium ships under both names; Firefox lacks it
|
||||
// outright. Returns the constructor or null.
|
||||
type SpeechRecognitionCtor = new () => SpeechRecognitionLike;
|
||||
interface SpeechRecognitionLike extends EventTarget {
|
||||
continuous: boolean;
|
||||
interimResults: boolean;
|
||||
lang: string;
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
abort: () => void;
|
||||
onresult: ((e: SpeechRecognitionEventLike) => void) | null;
|
||||
onerror: ((e: SpeechRecognitionErrorLike) => void) | null;
|
||||
onend: (() => void) | null;
|
||||
}
|
||||
interface SpeechRecognitionEventLike {
|
||||
resultIndex: number;
|
||||
results: ArrayLike<{
|
||||
isFinal: boolean;
|
||||
[index: number]: { transcript: string };
|
||||
length: number;
|
||||
}>;
|
||||
}
|
||||
interface SpeechRecognitionErrorLike {
|
||||
error: string;
|
||||
}
|
||||
|
||||
export function getSpeechRecognitionCtor(): SpeechRecognitionCtor | null {
|
||||
const w = window as unknown as {
|
||||
SpeechRecognition?: SpeechRecognitionCtor;
|
||||
webkitSpeechRecognition?: SpeechRecognitionCtor;
|
||||
};
|
||||
return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;
|
||||
}
|
||||
|
||||
export function isLiveCaptionsSupported(): boolean {
|
||||
return getSpeechRecognitionCtor() !== null;
|
||||
}
|
||||
|
||||
export type {
|
||||
SpeechRecognitionLike,
|
||||
SpeechRecognitionEventLike,
|
||||
SpeechRecognitionErrorLike,
|
||||
};
|
||||
@@ -16,6 +16,7 @@ const PRESERVE_LOCAL_STORAGE = new Set([
|
||||
'chatapp.locale',
|
||||
'chatapp.installId',
|
||||
'chatapp.wipeOnClose.v1',
|
||||
'chatapp.autoLockMinutes.v1',
|
||||
'i18nextLng',
|
||||
]);
|
||||
|
||||
|
||||
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,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();
|
||||
}
|
||||
@@ -5,17 +5,18 @@ import {
|
||||
decryptMessages,
|
||||
encryptAndUploadAttachment,
|
||||
fetchConversationMessages,
|
||||
getOrCreateConvKey,
|
||||
insertAttachmentRow,
|
||||
MAX_ATTACHMENT_BYTES,
|
||||
type MessageWithCipher,
|
||||
sendEncryptedMessage,
|
||||
shareConvKeyToUser,
|
||||
tryGetConvKey,
|
||||
} from '@chat-app/shared/chat';
|
||||
import { bytesToPgHex, pgBytesToBytes } from '@chat-app/shared/supabase';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { decryptBatch as decryptBatchWorker } from './decryptWorker';
|
||||
import { generateWebPThumb } from './imageCompress';
|
||||
import {
|
||||
loadCachedMessages,
|
||||
persistMessages,
|
||||
@@ -32,6 +33,11 @@ import {
|
||||
shouldGiveUp,
|
||||
subscribeOutbox,
|
||||
} from './messageOutbox';
|
||||
import {
|
||||
getCachedMessages,
|
||||
hasCachedMessages,
|
||||
setCachedMessages,
|
||||
} from './messageMemoryCache';
|
||||
import { supabase } from './supabase';
|
||||
import { cachedUserKey } from './userIdentity';
|
||||
|
||||
@@ -74,14 +80,27 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
text: string,
|
||||
images?: File[],
|
||||
replyToId?: string | null,
|
||||
opts?: { viewOnce?: boolean },
|
||||
opts?: { viewOnceFlags?: boolean[] },
|
||||
) => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
pending: OutboxItem[];
|
||||
retryPending: (id: string) => void;
|
||||
cancelPending: (id: string) => void;
|
||||
} {
|
||||
const [state, setState] = useState<State>({ messages: [], loading: true, error: null });
|
||||
// Initialize from the in-memory cache so a previously-viewed chat shows
|
||||
// content on the very first render after the parent remounts on `:id`
|
||||
// change. `loading` stays true ONLY for never-seen conversations (cache
|
||||
// miss) so the spinner doesn't flash on every chat switch.
|
||||
const [state, setState] = useState<State>(() => {
|
||||
if (conversationId && hasCachedMessages(conversationId)) {
|
||||
return {
|
||||
messages: getCachedMessages(conversationId),
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
return { messages: [], loading: true, error: null };
|
||||
});
|
||||
const [pending, setPending] = useState<OutboxItem[]>(() =>
|
||||
conversationId ? getOutbox(conversationId) : [],
|
||||
);
|
||||
@@ -135,8 +154,24 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
// db-types snapshot predates the active_key_version column; cast via unknown.
|
||||
const version = (convRow as unknown as { active_key_version: number }).active_key_version;
|
||||
|
||||
const handle = await tryGetConvKey(supabase, conversationId, userId, priv, version);
|
||||
if (!handle || cancelled) return;
|
||||
// Use `getOrCreateConvKey` rather than `tryGetConvKey` so that if we
|
||||
// can't unwrap our bundle at the active version (we lost the device
|
||||
// key, or the bundle was wiped by the 0.18.0 reset_user_key bug, or
|
||||
// we only ever had a legacy `recipient_device_id` row), the helper
|
||||
// auto-rotates the conv-key to version+1 and wraps fresh bundles
|
||||
// for every member with a `user_keys` row. This is the only path
|
||||
// that recovers stuck-legacy conversations on the receive side —
|
||||
// `tryGetConvKey` just returned null and left the chat permanently
|
||||
// un-decryptable for the locked-out party.
|
||||
const handle = await getOrCreateConvKey(supabase, conversationId, {
|
||||
userId,
|
||||
privateKey: priv,
|
||||
});
|
||||
if (cancelled) return;
|
||||
// If the helper rotated, all current members with a `user_keys`
|
||||
// public key were already wrapped by `rotateConvKey`. No further
|
||||
// sweep work is needed.
|
||||
if (handle.keyVersion > version) return;
|
||||
|
||||
const { data: members, error: mErr } = await supabase
|
||||
.from('conversation_members')
|
||||
@@ -228,6 +263,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
const rows = await fetchConversationMessages(supabase, conversationId);
|
||||
const decrypted = await decryptBatch(rows);
|
||||
setState({ messages: decrypted, loading: false, error: null });
|
||||
setCachedMessages(conversationId, decrypted);
|
||||
// Persist the fresh batch to the local cache so next conversation
|
||||
// switch / app start can hydrate instantly. Fire-and-forget — cache
|
||||
// write failure is never user-visible.
|
||||
@@ -247,12 +283,17 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
// when the server response lands. On cache-miss this is a ~5ms no-op.
|
||||
useEffect(() => {
|
||||
if (!conversationId) return;
|
||||
// Memory cache already populated state synchronously — skip the disk
|
||||
// round-trip entirely. The canonical data lands shortly via refresh();
|
||||
// the SQLite cache only matters for cold-start hydration.
|
||||
if (hasCachedMessages(conversationId)) return;
|
||||
let cancelled = false;
|
||||
void loadCachedMessages(conversationId).then((cached) => {
|
||||
if (cancelled || cached.length === 0) return;
|
||||
setState((prev) => {
|
||||
// Don't clobber a fresh server response that already landed.
|
||||
if (prev.messages.length > 0) return prev;
|
||||
setCachedMessages(conversationId, cached);
|
||||
return { messages: cached, loading: false, error: null };
|
||||
});
|
||||
});
|
||||
@@ -337,7 +378,9 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
if (!decrypted) return;
|
||||
setState((prev) => {
|
||||
if (prev.messages.some((m) => m.id === decrypted!.id)) return prev;
|
||||
return { ...prev, messages: [...prev.messages, decrypted!] };
|
||||
const next = [...prev.messages, decrypted!];
|
||||
if (conversationId) setCachedMessages(conversationId, next);
|
||||
return { ...prev, messages: next };
|
||||
});
|
||||
},
|
||||
[conversationId, deviceId, decryptBatch],
|
||||
@@ -357,6 +400,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
editedAt: partial.editedAt,
|
||||
deletedAt: partial.deletedAt,
|
||||
};
|
||||
if (conversationId) setCachedMessages(conversationId, next);
|
||||
return { ...prev, messages: next };
|
||||
});
|
||||
if (partial.editedAt && !partial.deletedAt) {
|
||||
@@ -420,6 +464,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
if (idx === -1) return prev;
|
||||
const next = [...prev.messages];
|
||||
next[idx] = decrypted!;
|
||||
if (conversationId) setCachedMessages(conversationId, next);
|
||||
return { ...prev, messages: next };
|
||||
});
|
||||
}
|
||||
@@ -427,14 +472,18 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
[conversationId, deviceId, decryptBatch],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback((row: Record<string, unknown>) => {
|
||||
const id = String(row.id);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
messages: prev.messages.filter((m) => m.id !== id),
|
||||
}));
|
||||
void deleteCachedMessage(id);
|
||||
}, []);
|
||||
const handleDelete = useCallback(
|
||||
(row: Record<string, unknown>) => {
|
||||
const id = String(row.id);
|
||||
setState((prev) => {
|
||||
const next = prev.messages.filter((m) => m.id !== id);
|
||||
if (conversationId) setCachedMessages(conversationId, next);
|
||||
return { ...prev, messages: next };
|
||||
});
|
||||
void deleteCachedMessage(id);
|
||||
},
|
||||
[conversationId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!conversationId || !userId || !deviceId) return;
|
||||
@@ -551,13 +600,12 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
});
|
||||
setState((prev) => {
|
||||
if (prev.messages.some((m) => m.id === msg.id)) return prev;
|
||||
return {
|
||||
...prev,
|
||||
messages: [
|
||||
...prev.messages,
|
||||
{ ...msg, plaintext: text } as DecryptedMessage,
|
||||
],
|
||||
};
|
||||
const next = [
|
||||
...prev.messages,
|
||||
{ ...msg, plaintext: text } as DecryptedMessage,
|
||||
];
|
||||
setCachedMessages(convId, next);
|
||||
return { ...prev, messages: next };
|
||||
});
|
||||
},
|
||||
[],
|
||||
@@ -568,7 +616,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
text: string,
|
||||
images: File[] = [],
|
||||
replyToId: string | null = null,
|
||||
opts: { viewOnce?: boolean } = {},
|
||||
opts: { viewOnceFlags?: boolean[] } = {},
|
||||
) => {
|
||||
const trimmed = text.trim();
|
||||
if ((!trimmed && images.length === 0) || !conversationId || !userId || !deviceId) return;
|
||||
@@ -625,13 +673,23 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
|
||||
// 1. Upload + encrypt each image. Collect handles + raw blob nonces
|
||||
// (so the public attachment row can reference the blob-level nonce).
|
||||
// P7.T4: view-once is now a per-attachment flag rather than a
|
||||
// composer-wide toggle. `opts.viewOnceFlags` is a parallel array;
|
||||
// missing entries (or whole-array absence) default to false.
|
||||
const handles: AttachmentHandle[] = [];
|
||||
const blobNonceHexByHandleId = new Map<string, string>();
|
||||
for (const file of images) {
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
const file = images[i]!;
|
||||
if (file.size > MAX_ATTACHMENT_BYTES) {
|
||||
throw new Error('attachment exceeds max size (10 MB)');
|
||||
}
|
||||
const dims = await readImageDimensions(file);
|
||||
// Phase 6B: generate a small WebP preview thumb so the receiver's
|
||||
// bubble loads fast (typical 320×240 WebP is 10–30KB vs the full
|
||||
// image's 1–10MB). `generateWebPThumb` short-circuits to null on
|
||||
// non-images, animated formats, and small files — and on failure;
|
||||
// the upload helper then just skips the second upload.
|
||||
const thumbBlob = await generateWebPThumb(file);
|
||||
const res = await encryptAndUploadAttachment({
|
||||
client: supabase,
|
||||
conversationId,
|
||||
@@ -640,13 +698,14 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
sizeBytes: file.size,
|
||||
...(dims.width !== undefined ? { width: dims.width } : {}),
|
||||
...(dims.height !== undefined ? { height: dims.height } : {}),
|
||||
...(thumbBlob ? { thumbBlob } : {}),
|
||||
});
|
||||
// Stamp the view-once flag on each handle the caller requested it
|
||||
// for. The flag rides inside the encrypted payload (so peers can
|
||||
// render the locked card without leaking who-sent-what to the
|
||||
// server) AND lands on the public message_attachments row via
|
||||
// insertAttachmentRow below (where the mark-viewed RPC enforces it).
|
||||
if (opts.viewOnce) {
|
||||
// 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);
|
||||
@@ -674,16 +733,15 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
: JSON.stringify({ v: 1, text: trimmed, attachments: handles });
|
||||
setState((prev) => {
|
||||
if (prev.messages.some((m) => m.id === msg.id)) return prev;
|
||||
return {
|
||||
...prev,
|
||||
messages: [
|
||||
...prev.messages,
|
||||
{
|
||||
...msg,
|
||||
plaintext: attachmentsPayload,
|
||||
} as DecryptedMessage,
|
||||
],
|
||||
};
|
||||
const next = [
|
||||
...prev.messages,
|
||||
{
|
||||
...msg,
|
||||
plaintext: attachmentsPayload,
|
||||
} as DecryptedMessage,
|
||||
];
|
||||
setCachedMessages(conversationId, next);
|
||||
return { ...prev, messages: next };
|
||||
});
|
||||
|
||||
// 4. Insert public attachment metadata rows pointing at the new message.
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
// Hook that runs SpeechRecognition on the local mic when live-captions are
|
||||
// enabled and a Room is connected. Each interim/final result is broadcast as
|
||||
// a `caption`-typed message via the LiveKit DataChannel so peers can render
|
||||
// it. Recognition stops cleanly when the call ends or the toggle flips off.
|
||||
|
||||
import type { Room } from 'livekit-client';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import {
|
||||
type LiveCaptionsSettings,
|
||||
getLiveCaptionsSettings,
|
||||
getSpeechRecognitionCtor,
|
||||
type SpeechRecognitionEventLike,
|
||||
type SpeechRecognitionLike,
|
||||
subscribeLiveCaptionsSettings,
|
||||
} from './liveCaptions';
|
||||
|
||||
interface Args {
|
||||
room: Room | null;
|
||||
/** True while we're connected and want captions to flow. */
|
||||
active: boolean;
|
||||
/** Callback fired locally for our own captions so the overlay can show
|
||||
* them without going through the SFU round-trip. */
|
||||
onLocalCaption: (text: string, final: boolean) => void;
|
||||
}
|
||||
|
||||
export function useLiveCaptions({ room, active, onLocalCaption }: Args): void {
|
||||
const recognitionRef = useRef<SpeechRecognitionLike | null>(null);
|
||||
const settingsRef = useRef<LiveCaptionsSettings>(getLiveCaptionsSettings());
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeLiveCaptionsSettings((s) => {
|
||||
settingsRef.current = s;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const Ctor = getSpeechRecognitionCtor();
|
||||
if (!Ctor) return; // unsupported runtime
|
||||
if (!active || !room) return;
|
||||
if (!getLiveCaptionsSettings().enabled) return;
|
||||
|
||||
const send = (text: string, final: boolean) => {
|
||||
onLocalCaption(text, final);
|
||||
try {
|
||||
const payload = new TextEncoder().encode(
|
||||
JSON.stringify({ type: 'caption', captionText: text, captionFinal: final }),
|
||||
);
|
||||
// Reliable channel — captions are infrequent enough to afford it,
|
||||
// and dropping interims looks worse than slight lag.
|
||||
void room.localParticipant.publishData(payload, { reliable: true });
|
||||
} catch {
|
||||
/* ignore — best-effort */
|
||||
}
|
||||
};
|
||||
|
||||
const start = () => {
|
||||
const r = new Ctor();
|
||||
r.continuous = true;
|
||||
r.interimResults = true;
|
||||
const lang = settingsRef.current.lang ?? navigator.language ?? 'de-DE';
|
||||
r.lang = lang;
|
||||
r.onresult = (e: SpeechRecognitionEventLike) => {
|
||||
// Pull whichever results arrived since last fire. Interim fires
|
||||
// many times per second; the final one is sticky and persists.
|
||||
for (let i = e.resultIndex; i < e.results.length; i++) {
|
||||
const result = e.results[i];
|
||||
if (!result || result.length === 0) continue;
|
||||
const alt = result[0];
|
||||
if (!alt) continue;
|
||||
const transcript = alt.transcript.trim();
|
||||
if (!transcript) continue;
|
||||
send(transcript, result.isFinal);
|
||||
}
|
||||
};
|
||||
r.onerror = () => {
|
||||
// Recoverable: stop + retry on next effect cycle. `not-allowed` and
|
||||
// `service-not-allowed` are permission-permanent — bail.
|
||||
try {
|
||||
r.stop();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
r.onend = () => {
|
||||
// SpeechRecognition tends to auto-stop after silence — if we still
|
||||
// want captions, restart it. Guard against tear-down race.
|
||||
if (recognitionRef.current === r && getLiveCaptionsSettings().enabled) {
|
||||
try {
|
||||
r.start();
|
||||
} catch {
|
||||
/* already running or browser refused */
|
||||
}
|
||||
}
|
||||
};
|
||||
try {
|
||||
r.start();
|
||||
recognitionRef.current = r;
|
||||
} catch {
|
||||
// Some browsers throw when start() is called too soon after a
|
||||
// previous abort — wait a tick and retry.
|
||||
window.setTimeout(() => {
|
||||
try {
|
||||
r.start();
|
||||
recognitionRef.current = r;
|
||||
} catch {
|
||||
/* give up */
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
};
|
||||
|
||||
start();
|
||||
|
||||
const unsub = subscribeLiveCaptionsSettings((s) => {
|
||||
const cur = recognitionRef.current;
|
||||
if (!s.enabled && cur) {
|
||||
recognitionRef.current = null;
|
||||
try {
|
||||
cur.abort();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
} else if (s.enabled && !cur) {
|
||||
start();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsub();
|
||||
const cur = recognitionRef.current;
|
||||
recognitionRef.current = null;
|
||||
if (cur) {
|
||||
try {
|
||||
cur.abort();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [active, room, onLocalCaption]);
|
||||
}
|
||||
@@ -1,12 +1,26 @@
|
||||
import { listPinnedMessages, type PinnedMessage } from '@chat-app/shared/chat';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { supabase } from './supabase';
|
||||
|
||||
export interface UsePinnedMessagesResult {
|
||||
pins: PinnedMessage[];
|
||||
// Optimistic insert. Caller flips the UI immediately; server insert +
|
||||
// realtime echo will reconcile (dedup'd by messageId). Returns the
|
||||
// previous snapshot so the caller can roll back on error.
|
||||
applyOptimisticPin: (messageId: string, pinnedBy: string) => PinnedMessage[];
|
||||
applyOptimisticUnpin: (messageId: string) => PinnedMessage[];
|
||||
// Hard restore for rollback after a failed server call.
|
||||
restorePins: (snapshot: PinnedMessage[]) => void;
|
||||
}
|
||||
|
||||
// Live list of pinned messages for one conversation. Subscribes to the
|
||||
// `pinned_messages` realtime channel for the conv so the header pill +
|
||||
// side-panel update without a refetch.
|
||||
export function usePinnedMessages(conversationId: string | undefined): PinnedMessage[] {
|
||||
// side-panel update without a refetch. The `applyOptimistic*` helpers let
|
||||
// callers flip local state synchronously on user action so the pin button
|
||||
// doesn't appear unresponsive while the ~100-200ms server roundtrip + the
|
||||
// realtime refetch round complete.
|
||||
export function usePinnedMessages(conversationId: string | undefined): UsePinnedMessagesResult {
|
||||
const [pins, setPins] = useState<PinnedMessage[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -44,5 +58,42 @@ export function usePinnedMessages(conversationId: string | undefined): PinnedMes
|
||||
};
|
||||
}, [conversationId]);
|
||||
|
||||
return pins;
|
||||
const applyOptimisticPin = useCallback<UsePinnedMessagesResult['applyOptimisticPin']>(
|
||||
(messageId, pinnedBy) => {
|
||||
if (!conversationId) return pins;
|
||||
let snapshot: PinnedMessage[] = pins;
|
||||
setPins((prev) => {
|
||||
snapshot = prev;
|
||||
if (prev.some((p) => p.messageId === messageId)) return prev;
|
||||
const optimistic: PinnedMessage = {
|
||||
conversationId,
|
||||
messageId,
|
||||
pinnedBy,
|
||||
pinnedAt: new Date().toISOString(),
|
||||
};
|
||||
// Newest first matches the listPinnedMessages order.
|
||||
return [optimistic, ...prev];
|
||||
});
|
||||
return snapshot;
|
||||
},
|
||||
[conversationId, pins],
|
||||
);
|
||||
|
||||
const applyOptimisticUnpin = useCallback<UsePinnedMessagesResult['applyOptimisticUnpin']>(
|
||||
(messageId) => {
|
||||
let snapshot: PinnedMessage[] = pins;
|
||||
setPins((prev) => {
|
||||
snapshot = prev;
|
||||
return prev.filter((p) => p.messageId !== messageId);
|
||||
});
|
||||
return snapshot;
|
||||
},
|
||||
[pins],
|
||||
);
|
||||
|
||||
const restorePins = useCallback<UsePinnedMessagesResult['restorePins']>((snapshot) => {
|
||||
setPins(snapshot);
|
||||
}, []);
|
||||
|
||||
return { pins, applyOptimisticPin, applyOptimisticUnpin, restorePins };
|
||||
}
|
||||
|
||||
@@ -4,10 +4,11 @@ import {
|
||||
} from '@chat-app/shared/auth';
|
||||
import {
|
||||
generateRecoveryCode, generateUserKeyPair, getCryptoBackend, normalizeRecoveryCode,
|
||||
openUserKey, sealUserKey,
|
||||
sealUserKey,
|
||||
} from '@chat-app/shared/crypto';
|
||||
import { migrateOwnLegacyBundles } from '@chat-app/shared/chat';
|
||||
|
||||
import { openUserKeyMaybeWorker } from './cryptoWorker';
|
||||
import { devLocalSecretStore } from './secretStore';
|
||||
import { supabase } from './supabase';
|
||||
|
||||
@@ -71,7 +72,7 @@ export async function loadOrUnlockUserKey(p: UnlockParams): Promise<UnlockOutcom
|
||||
if (!sealed || !salt) throw new Error('no recovery blob configured');
|
||||
let priv: Uint8Array;
|
||||
try {
|
||||
priv = await openUserKey({ sealed, pin: secret, salt, kdfParams: remote.kdfParams });
|
||||
priv = await openUserKeyMaybeWorker({ sealed, pin: secret, salt, kdfParams: remote.kdfParams });
|
||||
} catch (err) {
|
||||
await recordPinAttempt(supabase, p.userId, false, p.isRecoveryCode === true).catch(() => {});
|
||||
throw err;
|
||||
@@ -212,7 +213,7 @@ async function runLegacyMigration(
|
||||
}
|
||||
}
|
||||
|
||||
console.info(
|
||||
console.debug(
|
||||
'[crypto-migration] vault scan:',
|
||||
'serverDevices=' + report.serverDevices,
|
||||
'keysFromServerList=' + report.strongholdKeysFromServerDevices,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// Persists the preferred voice-message playback rate across sessions.
|
||||
// localStorage is fine here — non-sensitive, single source of truth per
|
||||
// device, no cross-device sync needed.
|
||||
|
||||
const KEY = 'chatapp:voice-speed';
|
||||
const ALLOWED = [1, 1.5, 2] as const;
|
||||
export type VoiceSpeed = (typeof ALLOWED)[number];
|
||||
|
||||
export function getVoiceSpeed(): VoiceSpeed {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(KEY);
|
||||
if (!raw) return 1;
|
||||
const parsed = Number(raw);
|
||||
if (ALLOWED.includes(parsed as VoiceSpeed)) return parsed as VoiceSpeed;
|
||||
} catch {
|
||||
/* localStorage unavailable */
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
export function setVoiceSpeed(speed: VoiceSpeed): void {
|
||||
try {
|
||||
window.localStorage.setItem(KEY, String(speed));
|
||||
} catch {
|
||||
/* localStorage unavailable — best effort */
|
||||
}
|
||||
}
|
||||
|
||||
export const VOICE_SPEEDS = ALLOWED;
|
||||
@@ -0,0 +1,81 @@
|
||||
// Live-cursor pubsub for the multi-user whiteboard. Uses Supabase's
|
||||
// `broadcast` channel rather than `presence` because we want fire-and-forget
|
||||
// position updates (no need to track join/leave) and presence has higher
|
||||
// minimum latency due to its diff-and-merge semantics.
|
||||
//
|
||||
// Throttled to ~30 fps so a continuous drag doesn't flood the channel.
|
||||
|
||||
import type { RealtimeChannel } from '@supabase/supabase-js';
|
||||
|
||||
import { supabase } from './supabase';
|
||||
|
||||
const THROTTLE_MS = 33; // ~30 fps
|
||||
|
||||
export interface CursorEvent {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
// logical canvas coordinates (matches WhiteboardCanvas internal space)
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface CursorSession {
|
||||
send: (x: number, y: number) => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export function openCursorSession(
|
||||
whiteboardId: string,
|
||||
self: { userId: string; displayName: string },
|
||||
onCursor: (ev: CursorEvent) => void,
|
||||
): CursorSession {
|
||||
const channel: RealtimeChannel = supabase.channel('wb-cursor:' + whiteboardId, {
|
||||
config: { broadcast: { self: false } },
|
||||
});
|
||||
channel.on('broadcast', { event: 'cursor' }, (payload) => {
|
||||
const ev = payload.payload as CursorEvent | undefined;
|
||||
if (!ev || ev.userId === self.userId) return;
|
||||
onCursor(ev);
|
||||
});
|
||||
channel.subscribe();
|
||||
|
||||
let lastSentAt = 0;
|
||||
let pending: { x: number; y: number } | null = null;
|
||||
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const flush = (): void => {
|
||||
flushTimer = null;
|
||||
if (!pending) return;
|
||||
const { x, y } = pending;
|
||||
pending = null;
|
||||
lastSentAt = Date.now();
|
||||
void channel.send({
|
||||
type: 'broadcast',
|
||||
event: 'cursor',
|
||||
payload: { userId: self.userId, displayName: self.displayName, x, y } satisfies CursorEvent,
|
||||
});
|
||||
};
|
||||
|
||||
const send = (x: number, y: number): void => {
|
||||
const now = Date.now();
|
||||
const since = now - lastSentAt;
|
||||
if (since >= THROTTLE_MS) {
|
||||
pending = { x, y };
|
||||
flush();
|
||||
} else {
|
||||
pending = { x, y };
|
||||
if (flushTimer === null) {
|
||||
flushTimer = setTimeout(flush, THROTTLE_MS - since);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const close = (): void => {
|
||||
if (flushTimer !== null) clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
pending = null;
|
||||
void supabase.removeChannel(channel);
|
||||
};
|
||||
|
||||
return { send, close };
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -85,9 +85,10 @@
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-duration: 0.001ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
transition-duration: 0.001ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// Web Worker — runs Argon2id pwhash + sealed user-key open off the main
|
||||
// thread. PIN-unlock used to freeze the UI for ~1-2 s on mid-hardware while
|
||||
// the moderate-preset KDF ran; pushing it here keeps the unlock screen
|
||||
// responsive.
|
||||
//
|
||||
// The worker bundles its own libsodium-wrappers-sumo instance and registers
|
||||
// it as the shared CryptoBackend inside this worker realm — there is no
|
||||
// shared state with the main thread, so we initialise once per worker and
|
||||
// re-use it across messages (the client wrapper currently spawns one-shot,
|
||||
// but the worker is safe to keep alive too).
|
||||
//
|
||||
// Message protocol (one-shot RPC):
|
||||
// request: { op: 'openUserKey', input: OpenUserKeyInput }
|
||||
// response: { ok: true, result: { privateKey: Uint8Array } }
|
||||
// | { ok: false, error: string }
|
||||
//
|
||||
// The private key bytes are transferred (zero-copy) back to the caller via
|
||||
// the structured-clone Transferable list; the worker's view of the buffer is
|
||||
// detached on transfer which also clears the only worker-side reference.
|
||||
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
import { setCryptoBackend, openUserKey } from '@chat-app/shared/crypto';
|
||||
import type { KdfParams } from '@chat-app/shared/crypto';
|
||||
import sodium from 'libsodium-wrappers-sumo';
|
||||
|
||||
import { createLibsodiumBackend } from '../lib/cryptoBackend';
|
||||
|
||||
export interface OpenUserKeyInput {
|
||||
sealed: Uint8Array;
|
||||
pin: string;
|
||||
salt: Uint8Array;
|
||||
kdfParams: KdfParams;
|
||||
}
|
||||
|
||||
export interface OpenUserKeyResult {
|
||||
privateKey: Uint8Array;
|
||||
}
|
||||
|
||||
type WorkerRequest = { op: 'openUserKey'; input: OpenUserKeyInput };
|
||||
type WorkerResponse =
|
||||
| { ok: true; result: OpenUserKeyResult }
|
||||
| { ok: false; error: string };
|
||||
|
||||
let backendReady: Promise<void> | null = null;
|
||||
|
||||
async function ensureBackend(): Promise<void> {
|
||||
if (!backendReady) {
|
||||
backendReady = (async () => {
|
||||
await sodium.ready;
|
||||
setCryptoBackend(await createLibsodiumBackend());
|
||||
})();
|
||||
}
|
||||
return backendReady;
|
||||
}
|
||||
|
||||
self.addEventListener('message', (ev: MessageEvent<WorkerRequest>) => {
|
||||
const msg = ev.data;
|
||||
void (async () => {
|
||||
try {
|
||||
if (!msg || msg.op !== 'openUserKey') {
|
||||
throw new Error('unknown op: ' + String((msg as { op?: unknown })?.op));
|
||||
}
|
||||
await ensureBackend();
|
||||
const privateKey = await openUserKey(msg.input);
|
||||
const response: WorkerResponse = { ok: true, result: { privateKey } };
|
||||
const transfers: Transferable[] = [];
|
||||
if (privateKey?.buffer instanceof ArrayBuffer) {
|
||||
transfers.push(privateKey.buffer);
|
||||
}
|
||||
(self as unknown as Worker).postMessage(response, transfers);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const response: WorkerResponse = { ok: false, error: message };
|
||||
(self as unknown as Worker).postMessage(response);
|
||||
}
|
||||
})();
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
|
||||
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
|
||||
# The following patterns were generated by expo-cli
|
||||
|
||||
expo-env.d.ts
|
||||
# @end expo-cli
|
||||
@@ -0,0 +1,260 @@
|
||||
# Phase 6 — Performance Pack
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax. This plan is grouped into 3 risk tiers — Group A is parallel-safe quick wins, Group B is medium-scope, Group C is audits.
|
||||
|
||||
**Goal:** A focused performance pass after the fifteen-features initiative shipped. Faster startup, smoother long chats, smaller bundle, less main-thread blocking on PIN-unlock, no UX regressions.
|
||||
|
||||
**Rollback anchor:** tag `pre-phase6-perf` → `888ed1b` (already pushed to origin).
|
||||
|
||||
**Strategy:** ship Group A first (5 tiny safe wins ≈ 5h), pause + smoke-test, then B (medium ≈ 2-3 days), then C (audits ≈ 1 day). No release between groups; one combined release at the very end.
|
||||
|
||||
---
|
||||
|
||||
## Group A — Safe quick wins (~5h, low risk)
|
||||
|
||||
### T1: Lazy-load four fat modals
|
||||
|
||||
**What:** Convert eager imports of `WhiteboardModal`, `WatchTogetherModal`, `ImageAnnotator`, `GameModal` to `React.lazy(() => import(...))` inside `ConversationPage.tsx`. Wrap each conditional render in `<Suspense fallback={null}>`.
|
||||
|
||||
**Why:** These modals total ~200-300 KB (canvas-confetti dep, IFrame player loader, ImageAnnotator's full op-stack, etc.) and render in <1 % of sessions. Initial bundle drops by that amount → faster cold load.
|
||||
|
||||
**Files:** `apps/desktop/src/pages/ConversationPage.tsx` only.
|
||||
|
||||
**Risk:** trivial. `Suspense` with `fallback={null}` means a few ms blank flicker the first time each modal opens (chunk download). Acceptable.
|
||||
|
||||
**Effort:** ~30 min.
|
||||
|
||||
---
|
||||
|
||||
### T2: `prefers-reduced-motion` global rule
|
||||
|
||||
**What:**
|
||||
- Global CSS rule in `apps/desktop/src/index.css` (or wherever global styles live): `@media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition: none !important; animation: none !important; } }`.
|
||||
- Gate the confetti burst in `GameModal.tsx` behind `window.matchMedia('(prefers-reduced-motion: reduce)').matches`.
|
||||
|
||||
**Why:** Accessibility + CPU savings for users who set the OS preference. Confetti is the most visible offender.
|
||||
|
||||
**Risk:** very low. Tailwind already respects motion-reduce variants in some classes; this is the global default.
|
||||
|
||||
**Effort:** ~30 min.
|
||||
|
||||
---
|
||||
|
||||
### T3: Memoize `MessageBubble` + audit callback stability
|
||||
|
||||
**What:**
|
||||
- Wrap `MessageBubble` export in `React.memo` with shallow equality (default).
|
||||
- Audit the message-list render site (ConversationPage or a MessagesList component) — every callback prop passed into the row (`onReply`, `onPin`, `onDelete`, …) must be `useCallback`-stable with no per-render closures. Replace anonymous `() => doX(message.id)` patterns with stable handlers that receive the id at call time.
|
||||
|
||||
**Why:** Typing in the composer currently re-runs the entire `messages.map(...)` and re-renders every bubble. With memoization + stable callbacks, only the new bubble appears; existing rows stay mounted. Big win on long chats.
|
||||
|
||||
**Risk:** medium-low. Possible bugs if a callback captures stale state (e.g. closure over `pinnedSet` that doesn't update). Mitigation: pass volatile state as props on the bubble and let `React.memo` handle the diff.
|
||||
|
||||
**Effort:** ~2h.
|
||||
|
||||
---
|
||||
|
||||
### T4: Memoize icon components (pragmatic "sprite-sheet" alternative)
|
||||
|
||||
**What:** Original idea was a real SVG sprite-sheet (single `<svg>` with `<symbol>` defs + `<use href="#name">`). Pragmatic alternative: wrap every icon component in `React.memo`. They're pure functions of `className`/`...props` so memoization is free, and 90 % of the perf win (avoiding React reconciliation on identical icon trees) comes from this without the sprite refactor risk.
|
||||
|
||||
**Files:** `apps/desktop/src/components/icons.tsx` (or `icons/` folder — whichever the codebase uses).
|
||||
|
||||
**Why:** Real sprite-sheet is invasive (refactor 60+ icon usages, change className/fill inheritance). Memoizing achieves the bulk of the win at <30 min effort. Real sprite-sheet stays available as a follow-up if bundle-analyzer (T11) shows icons are a top-3 bundle hog.
|
||||
|
||||
**Risk:** none — `React.memo` is purely a perf hint.
|
||||
|
||||
**Effort:** ~30 min.
|
||||
|
||||
---
|
||||
|
||||
### T5: Pre-warm Supabase + avatar loading hints
|
||||
|
||||
**What:**
|
||||
- In `AuthContext.tsx`, fire one trivial query early (e.g. `supabase.from('profiles').select('id').limit(1)`) so the connection is warm by the time the user does anything.
|
||||
- Audit `<img>` tags for avatars: add `loading="lazy"` to off-screen ones (chat list rows below the fold, deep history) and keep `loading="eager"` only for above-the-fold (current conv header, top of chat list).
|
||||
|
||||
**Why:** First real query after login currently pays cold-connection latency (~100-200 ms). Pre-warm hides it. Lazy avatars stop the browser from hammering Supabase Storage on initial render.
|
||||
|
||||
**Risk:** none.
|
||||
|
||||
**Effort:** ~1h.
|
||||
|
||||
---
|
||||
|
||||
### Group A final gate
|
||||
|
||||
- [ ] `pnpm --filter @chat-app/shared typecheck && pnpm --filter @chat-app/desktop typecheck`
|
||||
- [ ] `pnpm --filter @chat-app/shared test -- --run` (still 71/71)
|
||||
- [ ] User smoke-test: cold-start the app, send a few messages, type in composer, open one of the 4 modals — verify nothing broke + the visible improvements (faster initial render, smoother typing in long chats).
|
||||
- [ ] Tag `phase6a-done` for incremental rollback granularity if Group B introduces issues.
|
||||
|
||||
---
|
||||
|
||||
## Group B — Medium scope (~2-3 days, moderate risk)
|
||||
|
||||
### T6: Web-Worker for Argon2 + crypto_box_open (PIN-unlock path)
|
||||
|
||||
**What:**
|
||||
- Create `apps/desktop/src/lib/workers/crypto.worker.ts` that imports libsodium-wrappers and exposes a postMessage RPC: `{ op: 'unsealUserKey', sealedKey, pin, salt, kdfParams }` → `{ privateKey: Uint8Array }` (transferred).
|
||||
- Build with Vite's worker syntax: `new Worker(new URL('./workers/crypto.worker.ts', import.meta.url), { type: 'module' })`.
|
||||
- Refactor `apps/desktop/src/lib/userIdentity.ts`'s `unlockUserKey` (and any other hot Argon2 callers) to call the worker instead of the inline crypto backend.
|
||||
|
||||
**Why:** PIN-unlock currently runs Argon2id (~1-2 sec on mid hardware) on the main thread → UI freeze during login. Worker offloads it, login screen stays responsive.
|
||||
|
||||
**Risk:** medium. libsodium-wrappers needs to be initialized in both contexts. structured-clone transfers `Uint8Array` cleanly. The risk is that libsodium-wrappers might ship a bigger worker bundle than expected (we accept the trade-off because the main bundle gets smaller too).
|
||||
|
||||
**Effort:** ~1 day. Includes typing the postMessage RPC + ensuring the existing PIN-unlock flow keeps its error semantics (wrong PIN, etc.).
|
||||
|
||||
---
|
||||
|
||||
### T7: WebP thumbnails for image attachments
|
||||
|
||||
**What:**
|
||||
- When sending an image attachment: in addition to encrypting+uploading the full image (`<convId>/<attachmentId>.bin`), generate a 320×320 max-dim WebP thumb via `<canvas>.toBlob({ type: 'image/webp', quality: 0.7 })`, encrypt with the SAME per-attachment key, upload to `<convId>/<attachmentId>-thumb.bin`.
|
||||
- `MessageBubble` image render: try downloading the thumb first; fall back to full image on 404 (graceful for pre-Phase-6 attachments).
|
||||
- Click-to-expand: fetch the full image.
|
||||
|
||||
**Why:** A 5 MB image in the chat scroll loads 5 MB even off-screen. Thumb is ~10-30 KB. Scroll is silky, bandwidth drops 99 %.
|
||||
|
||||
**Files:** `packages/shared/src/chat/attachments.ts` (extend `encryptAndUploadAttachment` to optionally generate+upload thumb), `apps/desktop/src/components/MessageBubble.tsx` (try-thumb-first logic), maybe `Lightbox.tsx` (full image on click).
|
||||
|
||||
**Schema:** none — naming-convention based, 404-fallback preserves backward compat.
|
||||
|
||||
**Risk:** low-medium. Edge cases: very small images (thumb is bigger than full → skip thumb gen), animated GIFs (don't generate static-frame thumb, just use full).
|
||||
|
||||
**Effort:** ~½ day.
|
||||
|
||||
---
|
||||
|
||||
### T8: Virtual-scroll for message list
|
||||
|
||||
**What:**
|
||||
- `pnpm --filter @chat-app/desktop add react-virtuoso`
|
||||
- Replace the message-list `.map(...)` in (likely) `ConversationPage.tsx` / `MessagesList.tsx` with `<Virtuoso>`.
|
||||
- Configure: `data={messages}`, `itemContent={(_, msg) => <MessageBubble ... />}`, `followOutput="smooth"` for auto-scroll on new messages, `initialTopMostItemIndex={messages.length - 1}` to start at bottom.
|
||||
- If date-day headers exist: switch to `<GroupedVirtuoso>` with `groupCounts` + `groupContent`.
|
||||
|
||||
**Why:** Long conversations (1000+ messages) currently render all rows → scroll jank, layout thrashing. Virtuoso renders only visible rows + a small overscan buffer.
|
||||
|
||||
**Risk:** medium-high. Things that can go wrong:
|
||||
- Scroll-anchor preservation when Pinned-Messages panel opens.
|
||||
- Auto-scroll-to-bottom on send.
|
||||
- Smooth-scroll-to-message when clicking a pin or a reply.
|
||||
- Image-load reflow (Virtuoso handles this but needs proper height detection).
|
||||
|
||||
Mitigation: thorough manual smoke-test before commit. Keep the old render behind a feature flag for one release if jitters appear.
|
||||
|
||||
**Effort:** ~½ day to 1 day depending on edge cases.
|
||||
|
||||
---
|
||||
|
||||
### T9: PIN-Idle-Auto-Lock
|
||||
|
||||
**What:**
|
||||
- Settings → Sicherheit: new toggle "Auto-Lock nach Inaktivität" + dropdown (5 / 15 / 30 / 60 Minuten). Default OFF.
|
||||
- localStorage key `chatapp.autoLockMinutes` (or similar) — added to `PRESERVE_LOCAL_STORAGE` so memory-wipe doesn't disable the setting silently (same pattern as wipe-on-close toggle).
|
||||
- In `AuthContext` (or a new top-level hook): listen on `keydown` / `mousedown` / `pointermove`, reset a timer on each event. When the timer fires: `wipeLocalState(uid)` + navigate to `/device` (the PIN-unlock screen).
|
||||
|
||||
**Why:** Spec mentioned this as polish + a Security win — laptop left unattended, auto-locks after X min, attacker can't read messages without PIN.
|
||||
|
||||
**Risk:** low. The wipe-on-close infrastructure (P1.T12-T13) already handles all the local-state clearing — same call site.
|
||||
|
||||
**Effort:** ~½ day.
|
||||
|
||||
---
|
||||
|
||||
### T10: i18next tree-shake audit
|
||||
|
||||
**What:**
|
||||
- `pnpm --filter @chat-app/desktop add -D i18next-parser`
|
||||
- Configure it to scan `apps/desktop/src/**/*.{ts,tsx}` for `t('app:...')` calls + extract used keys.
|
||||
- Diff against `apps/desktop/locales/de/app.json` (or wherever the resource files live). List dead keys.
|
||||
- Prune them. Verify nothing visible regresses.
|
||||
|
||||
**Why:** Resource files accumulate keys from removed/redesigned features. Smaller resource bundle = faster app start (in-memory JSON parse).
|
||||
|
||||
**Risk:** low — `t()` always falls back to `defaultValue` if a key is missing, so even an accidental over-prune doesn't crash the UI; it just shows the German default.
|
||||
|
||||
**Effort:** ~2h (mostly looking at the diff + judgment calls).
|
||||
|
||||
---
|
||||
|
||||
### Group B final gate
|
||||
|
||||
- [ ] Both typechecks green
|
||||
- [ ] All shared tests green
|
||||
- [ ] User smoke-test: cold start (Argon2 worker), open a long chat (virtual scroll), send an image (thumb generation), idle 5+min (auto-lock if enabled), check console for noise.
|
||||
- [ ] Tag `phase6b-done`.
|
||||
|
||||
---
|
||||
|
||||
## Group C — Audits + judgment calls (~1 day)
|
||||
|
||||
### T11: Bundle-analyzer audit + targeted dep swaps
|
||||
|
||||
**What:**
|
||||
- `pnpm dlx vite-bundle-visualizer` against the desktop build → outputs HTML report.
|
||||
- Review the treemap. Common offenders to check:
|
||||
- Full lodash vs lodash-es (or no lodash at all if only a few utils)
|
||||
- Moment.js vs date-fns / native `Intl.DateTimeFormat`
|
||||
- Multiple realtime/socket clients
|
||||
- Icon libs pulling all icons
|
||||
- Dev-only deps accidentally in prod bundle
|
||||
- Apply targeted swaps (max ~3-5) based on the worst findings.
|
||||
|
||||
**Why:** Shrinks bundle further beyond T1's lazy-load. Each ~50 KB shaved is a real cold-start win.
|
||||
|
||||
**Files:** `apps/desktop/package.json`, the consumer files that import from swapped deps.
|
||||
|
||||
**Risk:** variable per swap. A Moment-to-date-fns swap touches many call sites. Cap at the 3 biggest offenders to keep risk bounded.
|
||||
|
||||
**Effort:** ~2h audit + variable fixes (estimate 2-3h additional).
|
||||
|
||||
---
|
||||
|
||||
### T12: Optimistic-UI audit + targeted gap fills
|
||||
|
||||
**What:**
|
||||
- Audit each user-write action across the app:
|
||||
- `send` (message) → likely already optimistic; verify
|
||||
- `editEncryptedMessage` → likely already optimistic
|
||||
- Pin / unpin
|
||||
- Add / remove reaction (doesn't exist yet — skip)
|
||||
- Vote on poll
|
||||
- Revoke device
|
||||
- Toggle mentions-only
|
||||
- Toggle mute
|
||||
- For each action that currently waits for the server roundtrip before updating local state: add optimistic-update with rollback on error.
|
||||
|
||||
**Why:** Perceived latency drops to ~0 ms for most clicks. Server roundtrip happens silently.
|
||||
|
||||
**Risk:** medium. Each optimistic-update is its own potential rollback bug. Mitigation: only touch actions where rollback is straightforward (e.g. a toggle's previous state is trivially recoverable). Skip if rollback is hairy.
|
||||
|
||||
**Effort:** ~1 day total (each action is ~30-60 min including verification).
|
||||
|
||||
---
|
||||
|
||||
### Group C final gate
|
||||
|
||||
- [ ] Both typechecks green, all shared tests green
|
||||
- [ ] Bundle size measured before/after (note in report)
|
||||
- [ ] User smoke-test of any actions that gained optimistic UI
|
||||
|
||||
---
|
||||
|
||||
## Deferred / skipped (with reasoning)
|
||||
|
||||
### Realtime-Channel-Pooling
|
||||
|
||||
**Skipped for now.** The current UI keeps only one conversation actively open at a time. Concurrent channels at steady state are typically 5-8 (auth-self, conversations-list, current conv messages, current conv typing, mentions, maybe whiteboard / game / watch). Pooling into a single multiplexed channel would require a manager singleton + per-call-site refactor (~15-20 sites), with a meaningful risk of subtle realtime bugs during the transition.
|
||||
|
||||
**Reconsider when:** sustained active channel count exceeds 15, or Supabase invoices a noticeable channel-quota line item. Then a focused 1-day refactor with thorough realtime smoke testing makes sense.
|
||||
|
||||
---
|
||||
|
||||
## Release strategy
|
||||
|
||||
- No `pnpm release` between Group A/B/C — single combined release after Group C (or earlier if Group B+C get deferred).
|
||||
- Suggested version when releasing: `0.20.0` (combines unreleased Phase 5 + 5C + Phase 6).
|
||||
- Rollback at any commit boundary via `git reset --hard pre-phase6-perf` (Group A) or `git reset --hard phase6a-done` / `phase6b-done` (per-group).
|
||||
@@ -0,0 +1,703 @@
|
||||
# Chat-Switch Flicker — Fix Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Eliminate the visible flicker / scroll-jump when switching between conversations so the transition matches Discord's "instant content swap" feel.
|
||||
|
||||
**Architecture:** Two complementary fixes that, together, kill seven independent state-bleed root causes:
|
||||
|
||||
1. **Force a fresh `ConversationPage` instance per `:id` route param** via a wrapper component that reads `useParams` and passes `key={id}`. Today the same React component instance handles every conversation because React Router does not remount the route element when only a param changes — that is what lets chat A's state (messages, scroll position, composer drafts, Virtuoso scroll cache) bleed into chat B's first render.
|
||||
2. **Add a module-scoped in-memory cache** (`Map<convId, DecryptedMessage[]>`) in `useConversationMessages` so the freshly-mounted hook starts with prior data synchronously on the first render. SQLite cache still hydrates async for never-seen chats. Net effect: instant content for any previously-visited chat, no spinner-flash.
|
||||
|
||||
**Tech Stack:** React 18, React Router v6 (v7_startTransition opt-in), react-virtuoso, better-sqlite3 (Electron main-process bridge via `window.electronAPI.sql*`), Vitest.
|
||||
|
||||
---
|
||||
|
||||
## Root-Cause Findings (Phase 1 evidence)
|
||||
|
||||
| # | Symptom | File:line | Why it happens |
|
||||
|---|---------|-----------|----------------|
|
||||
| RC1 | **Ghost messages of previous chat** for 50–300 ms | `apps/desktop/src/lib/useConversationMessages.ts:85, 220-243, 249-263` | `useState<State>({messages: [], loading: true})` only runs on first mount. On `conversationId` change, the state still holds chat A's messages; `refresh()` skips `loading: true` because `prev.messages.length > 0`; the cache effect also bails (`if (prev.messages.length > 0) return prev`). Chat A's data renders under chat B's id until the network round-trip finishes. |
|
||||
| RC2 | **Scroll jumps to wrong row** on switch | `apps/desktop/src/pages/ConversationPage.tsx:1023, 1034, 470-481` | Virtuoso instance is preserved across switches (parent isn't remounted). `initialTopMostItemIndex` is only honoured on Virtuoso's first mount, so chat A's last scroll position is what Virtuoso tries to keep when chat B's rows replace chat A's. |
|
||||
| RC3 | **Saved positions corrupted across switches** | `apps/desktop/src/pages/ConversationPage.tsx:705-716` | While chat A's rows are still rendered under chat B's id (RC1 window), `rangeChanged` fires for chat A's visible range and writes the index into `scrollPositions[chatBid]`. Next time you re-enter chat B, it restores chat A's row index. |
|
||||
| RC4 | **Stale composer / reply / search / unread state** for one render | `apps/desktop/src/pages/ConversationPage.tsx:307-320` | `useEffect([id])` resets `replyTo`, `displayCount`, `firstUnreadId`, etc. — but effects fire **after** the first render of the new id. The first paint of chat B briefly shows chat A's reply preview and `displayCount`. |
|
||||
| RC5 | **Phantom scroll-to-bottom** after switching | `apps/desktop/src/pages/ConversationPage.tsx:736-746` | `lastPendingCountRef` is never reset per-chat. Switching from a chat with 0 pending to a chat with N pending triggers `pending.length > lastPendingCountRef.current` → `scrollToIndex(LAST)` even though that pending state was always there. |
|
||||
| RC6 | **`newMessagesWhileAway` briefly off** | `apps/desktop/src/pages/ConversationPage.tsx:335-346` | `previousMessageIdsRef.current` contains chat A's ids on the first render of chat B → the diff classifies every chat B message as "new while away" until the reset effect lands. |
|
||||
| RC7 | **Cache load loses race with network refresh** | `apps/desktop/src/lib/useConversationMessages.ts:249-263` | Because state still holds chat A's messages, the cache-effect bails. Then refresh writes chat B's network result. Then the now-irrelevant `loadCachedMessages(chatB)` promise resolves and would no-op (length check still > 0 by then) — but the path is fragile and depends on timing. |
|
||||
|
||||
**All of RC1, RC3, RC4, RC5, RC6, RC7 are fixed in one stroke by Task 3 (`key={id}` remount).** RC2 is fixed because Virtuoso unmounts with its parent and re-applies `initialTopMostItemIndex` on the fresh mount. The remaining flicker after a remount — the brief spinner before async cache hydration completes — is eliminated by Task 2 (in-memory cache, synchronous on first render).
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- **Create**: `apps/desktop/src/lib/messageMemoryCache.ts` — small module-scoped Map plus `get` / `set` / `has` API. Lets us unit-test the cache logic without rendering the hook.
|
||||
- **Create**: `apps/desktop/src/lib/messageMemoryCache.test.ts` — vitest unit tests for the cache.
|
||||
- **Modify**: `apps/desktop/src/lib/useConversationMessages.ts` — initialize `useState` from `messageMemoryCache`, sync to it on every state change.
|
||||
- **Modify**: `apps/desktop/src/App.tsx` — add `ConversationRoute` wrapper that reads `useParams` and renders `<ConversationPage key={id} />`.
|
||||
- **Modify**: `apps/desktop/src/pages/ConversationPage.tsx` — delete the now-redundant `useEffect([id])` reset block and update the `scrollPositions` doc comment.
|
||||
|
||||
Each task below is self-contained and can be committed independently.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: In-memory message cache helper
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/desktop/src/lib/messageMemoryCache.ts`
|
||||
- Test: `apps/desktop/src/lib/messageMemoryCache.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `apps/desktop/src/lib/messageMemoryCache.test.ts`:
|
||||
|
||||
```ts
|
||||
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',
|
||||
ciphertext: new Uint8Array(),
|
||||
nonce: new Uint8Array(),
|
||||
keyVersion: 1,
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pnpm --filter @chat-app/desktop test -- messageMemoryCache`
|
||||
|
||||
Expected: FAIL — module `./messageMemoryCache` does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement the helper**
|
||||
|
||||
Create `apps/desktop/src/lib/messageMemoryCache.ts`:
|
||||
|
||||
```ts
|
||||
// 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();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `pnpm --filter @chat-app/desktop test -- messageMemoryCache`
|
||||
|
||||
Expected: PASS — all five test cases.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/desktop/src/lib/messageMemoryCache.ts apps/desktop/src/lib/messageMemoryCache.test.ts
|
||||
git commit -m "feat(chat-switch): in-memory message cache helper"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Wire the memory cache into `useConversationMessages`
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/desktop/src/lib/useConversationMessages.ts:85` (initial state)
|
||||
- Modify: `apps/desktop/src/lib/useConversationMessages.ts:226-243` (refresh) — sync to memory cache
|
||||
- Modify: `apps/desktop/src/lib/useConversationMessages.ts:249-263` (SQLite cache effect) — skip on memory-hit, sync after hydrate
|
||||
- Modify: `apps/desktop/src/lib/useConversationMessages.ts:339, 350, 419, 433, 553` (realtime + optimistic-send paths) — keep cache in sync with state
|
||||
|
||||
The hook keeps using `setState` everywhere — we just mirror writes into the memory cache and read from it on first render. No behavioural change for other code paths.
|
||||
|
||||
- [ ] **Step 1: Import the helper and initialize state from cache**
|
||||
|
||||
In `apps/desktop/src/lib/useConversationMessages.ts`, add the import near the other local-lib imports (around line 36):
|
||||
|
||||
```ts
|
||||
import {
|
||||
getCachedMessages,
|
||||
hasCachedMessages,
|
||||
setCachedMessages,
|
||||
} from './messageMemoryCache';
|
||||
```
|
||||
|
||||
Replace the initial `useState` at line 85:
|
||||
|
||||
```ts
|
||||
const [state, setState] = useState<State>({ messages: [], loading: true, error: null });
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```ts
|
||||
// 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 };
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Mirror successful refreshes into the memory cache**
|
||||
|
||||
In the `refresh` function (around line 229-235), replace:
|
||||
|
||||
```ts
|
||||
const rows = await fetchConversationMessages(supabase, conversationId);
|
||||
const decrypted = await decryptBatch(rows);
|
||||
setState({ messages: decrypted, loading: false, error: null });
|
||||
// Persist the fresh batch to the local cache so next conversation
|
||||
// switch / app start can hydrate instantly. Fire-and-forget — cache
|
||||
// write failure is never user-visible.
|
||||
void persistMessages(conversationId, decrypted);
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```ts
|
||||
const rows = await fetchConversationMessages(supabase, conversationId);
|
||||
const decrypted = await decryptBatch(rows);
|
||||
setState({ messages: decrypted, loading: false, error: null });
|
||||
setCachedMessages(conversationId, decrypted);
|
||||
// Persist the fresh batch to the local cache so next conversation
|
||||
// switch / app start can hydrate instantly. Fire-and-forget — cache
|
||||
// write failure is never user-visible.
|
||||
void persistMessages(conversationId, decrypted);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Skip SQLite hydration on memory-cache hit, mirror cold-miss into memory**
|
||||
|
||||
Replace the cache-hydration effect (around line 249-263):
|
||||
|
||||
```ts
|
||||
useEffect(() => {
|
||||
if (!conversationId) return;
|
||||
let cancelled = false;
|
||||
void loadCachedMessages(conversationId).then((cached) => {
|
||||
if (cancelled || cached.length === 0) return;
|
||||
setState((prev) => {
|
||||
// Don't clobber a fresh server response that already landed.
|
||||
if (prev.messages.length > 0) return prev;
|
||||
return { messages: cached, loading: false, error: null };
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [conversationId]);
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```ts
|
||||
useEffect(() => {
|
||||
if (!conversationId) return;
|
||||
// Memory cache already populated state synchronously — skip the disk
|
||||
// round-trip entirely. The canonical data lands shortly via refresh();
|
||||
// the SQLite cache only matters for cold-start hydration.
|
||||
if (hasCachedMessages(conversationId)) return;
|
||||
let cancelled = false;
|
||||
void loadCachedMessages(conversationId).then((cached) => {
|
||||
if (cancelled || cached.length === 0) return;
|
||||
setState((prev) => {
|
||||
// Don't clobber a fresh server response that already landed.
|
||||
if (prev.messages.length > 0) return prev;
|
||||
setCachedMessages(conversationId, cached);
|
||||
return { messages: cached, loading: false, error: null };
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [conversationId]);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Mirror realtime INSERT into the memory cache**
|
||||
|
||||
In `handleInsert` (around line 339), replace:
|
||||
|
||||
```ts
|
||||
setState((prev) => {
|
||||
if (prev.messages.some((m) => m.id === decrypted!.id)) return prev;
|
||||
return { ...prev, messages: [...prev.messages, decrypted!] };
|
||||
});
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```ts
|
||||
setState((prev) => {
|
||||
if (prev.messages.some((m) => m.id === decrypted!.id)) return prev;
|
||||
const next = [...prev.messages, decrypted!];
|
||||
if (conversationId) setCachedMessages(conversationId, next);
|
||||
return { ...prev, messages: next };
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Mirror realtime UPDATE (both partial and re-decrypt paths)**
|
||||
|
||||
In `handleUpdate` partial-update path (around line 350-362), replace:
|
||||
|
||||
```ts
|
||||
setState((prev) => {
|
||||
const idx = prev.messages.findIndex((m) => m.id === partial.id);
|
||||
if (idx === -1) return prev;
|
||||
const existing = prev.messages[idx];
|
||||
if (!existing) return prev;
|
||||
const next = [...prev.messages];
|
||||
next[idx] = {
|
||||
...existing,
|
||||
editedAt: partial.editedAt,
|
||||
deletedAt: partial.deletedAt,
|
||||
};
|
||||
return { ...prev, messages: next };
|
||||
});
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```ts
|
||||
setState((prev) => {
|
||||
const idx = prev.messages.findIndex((m) => m.id === partial.id);
|
||||
if (idx === -1) return prev;
|
||||
const existing = prev.messages[idx];
|
||||
if (!existing) return prev;
|
||||
const next = [...prev.messages];
|
||||
next[idx] = {
|
||||
...existing,
|
||||
editedAt: partial.editedAt,
|
||||
deletedAt: partial.deletedAt,
|
||||
};
|
||||
if (conversationId) setCachedMessages(conversationId, next);
|
||||
return { ...prev, messages: next };
|
||||
});
|
||||
```
|
||||
|
||||
In the same function's re-decrypt path (around line 419-425), replace:
|
||||
|
||||
```ts
|
||||
setState((prev) => {
|
||||
const idx = prev.messages.findIndex((m) => m.id === decrypted!.id);
|
||||
if (idx === -1) return prev;
|
||||
const next = [...prev.messages];
|
||||
next[idx] = decrypted!;
|
||||
return { ...prev, messages: next };
|
||||
});
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```ts
|
||||
setState((prev) => {
|
||||
const idx = prev.messages.findIndex((m) => m.id === decrypted!.id);
|
||||
if (idx === -1) return prev;
|
||||
const next = [...prev.messages];
|
||||
next[idx] = decrypted!;
|
||||
if (conversationId) setCachedMessages(conversationId, next);
|
||||
return { ...prev, messages: next };
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Mirror realtime DELETE**
|
||||
|
||||
Replace `handleDelete` (around line 431-438):
|
||||
|
||||
```ts
|
||||
const handleDelete = useCallback((row: Record<string, unknown>) => {
|
||||
const id = String(row.id);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
messages: prev.messages.filter((m) => m.id !== id),
|
||||
}));
|
||||
void deleteCachedMessage(id);
|
||||
}, []);
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```ts
|
||||
const handleDelete = useCallback(
|
||||
(row: Record<string, unknown>) => {
|
||||
const id = String(row.id);
|
||||
setState((prev) => {
|
||||
const next = prev.messages.filter((m) => m.id !== id);
|
||||
if (conversationId) setCachedMessages(conversationId, next);
|
||||
return { ...prev, messages: next };
|
||||
});
|
||||
void deleteCachedMessage(id);
|
||||
},
|
||||
[conversationId],
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Mirror optimistic send (sendText)**
|
||||
|
||||
In `sendText` (around line 553-562), replace:
|
||||
|
||||
```ts
|
||||
setState((prev) => {
|
||||
if (prev.messages.some((m) => m.id === msg.id)) return prev;
|
||||
return {
|
||||
...prev,
|
||||
messages: [
|
||||
...prev.messages,
|
||||
{ ...msg, plaintext: text } as DecryptedMessage,
|
||||
],
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```ts
|
||||
setState((prev) => {
|
||||
if (prev.messages.some((m) => m.id === msg.id)) return prev;
|
||||
const next = [
|
||||
...prev.messages,
|
||||
{ ...msg, plaintext: text } as DecryptedMessage,
|
||||
];
|
||||
setCachedMessages(convId, next);
|
||||
return { ...prev, messages: next };
|
||||
});
|
||||
```
|
||||
|
||||
(`convId` is already a parameter of `sendText` — no extra capture needed.)
|
||||
|
||||
- [ ] **Step 8: Run all desktop tests to verify nothing regressed**
|
||||
|
||||
Run: `pnpm --filter @chat-app/desktop test`
|
||||
|
||||
Expected: PASS — existing tests still green, the new `messageMemoryCache` tests still pass.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/desktop/src/lib/useConversationMessages.ts
|
||||
git commit -m "feat(chat-switch): hydrate useConversationMessages from in-memory cache"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Force fresh `ConversationPage` mount per `:id`
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/desktop/src/App.tsx:2` (import `useParams`)
|
||||
- Modify: `apps/desktop/src/App.tsx:55-61` area (add wrapper)
|
||||
- Modify: `apps/desktop/src/App.tsx:113-120` (the `:id` route)
|
||||
|
||||
- [ ] **Step 1: Add `useParams` to the router import**
|
||||
|
||||
Change line 2:
|
||||
|
||||
```ts
|
||||
import { HashRouter, Navigate, Outlet, Route, Routes } from 'react-router-dom';
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```ts
|
||||
import { HashRouter, Navigate, Outlet, Route, Routes, useParams } from 'react-router-dom';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the wrapper component**
|
||||
|
||||
Below the `RouteBoundary` function (around line 61), add:
|
||||
|
||||
```tsx
|
||||
// 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__'} />;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Use the wrapper in the route definition**
|
||||
|
||||
Replace lines 113-120:
|
||||
|
||||
```tsx
|
||||
<Route
|
||||
path=":id"
|
||||
element={
|
||||
<ErrorBoundary scope="conversation">
|
||||
<ConversationPage />
|
||||
</ErrorBoundary>
|
||||
}
|
||||
/>
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```tsx
|
||||
<Route
|
||||
path=":id"
|
||||
element={
|
||||
<ErrorBoundary scope="conversation">
|
||||
<ConversationRoute />
|
||||
</ErrorBoundary>
|
||||
}
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Typecheck**
|
||||
|
||||
Run: `pnpm --filter @chat-app/desktop typecheck`
|
||||
|
||||
Expected: PASS — no type errors.
|
||||
|
||||
- [ ] **Step 5: Manual smoke test in dev**
|
||||
|
||||
Run: `pnpm desktop:dev`
|
||||
|
||||
In the app:
|
||||
1. Open two conversations with cached messages.
|
||||
2. Toggle between them rapidly (5+ switches).
|
||||
3. Verify: no "ghost" of the previous chat's last message ever appears, and the spinner does **not** flash on switch.
|
||||
4. Scroll chat A up by ~10 messages, switch to B, switch back to A. The Virtuoso list lands at the same scroll row, not the bottom and not at row 0.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/desktop/src/App.tsx
|
||||
git commit -m "fix(chat-switch): remount ConversationPage per conversation id"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Drop redundant id-change reset effect & update doc comment
|
||||
|
||||
Because the parent is remounted per id (Task 3), every state in ConversationPage is already fresh on chat switch. The manual reset effect and related comments are now misleading dead weight.
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/desktop/src/pages/ConversationPage.tsx:97-110` — update `scrollPositions` doc comment
|
||||
- Modify: `apps/desktop/src/pages/ConversationPage.tsx:307-320` — delete the reset effect
|
||||
|
||||
- [ ] **Step 1: Update the `scrollPositions` doc comment**
|
||||
|
||||
Replace lines 97-110:
|
||||
|
||||
```ts
|
||||
// Per-conversation scroll memory. Module-scoped so it survives re-mounts
|
||||
// of ConversationPage when the route param (`id`) changes — switching
|
||||
// chats unmounts/remounts the page in our router setup. Session-only
|
||||
// (lost on reload, like Discord). The `stickToBottom` flag is preserved
|
||||
// alongside the topmost-visible row index so a chat the user left at the
|
||||
// bottom keeps auto-following new messages when they return; a chat
|
||||
// scrolled up returns to roughly the same row the user was reading.
|
||||
//
|
||||
// We track the topmost-visible row index rather than a pixel `scrollTop`
|
||||
// because `react-virtuoso` virtualizes the list — the underlying scroll
|
||||
// element's pixel offset depends on dynamically-measured row heights and
|
||||
// is not stable across re-mounts. Using a row index restores the user's
|
||||
// reading position even if some rows above re-render at different heights.
|
||||
const scrollPositions = new Map<string, { topmostIndex: number; stickToBottom: boolean }>();
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```ts
|
||||
// Per-conversation scroll memory. Module-scoped so it survives the
|
||||
// per-id remount of ConversationPage (see `ConversationRoute` in
|
||||
// App.tsx). Session-only (lost on reload, like Discord). The
|
||||
// `stickToBottom` flag is preserved alongside the topmost-visible row
|
||||
// index so a chat the user left at the bottom keeps auto-following new
|
||||
// messages when they return; a chat scrolled up returns to roughly the
|
||||
// same row the user was reading.
|
||||
//
|
||||
// We track the topmost-visible row index rather than a pixel `scrollTop`
|
||||
// because `react-virtuoso` virtualizes the list — the underlying scroll
|
||||
// element's pixel offset depends on dynamically-measured row heights and
|
||||
// is not stable across remounts. Using a row index restores the user's
|
||||
// reading position even if some rows above re-render at different heights.
|
||||
const scrollPositions = new Map<string, { topmostIndex: number; stickToBottom: boolean }>();
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Delete the manual reset effect**
|
||||
|
||||
Delete lines 307-320 entirely (the effect that resets `replyTo`, `forwardTarget`, `searchOpen`, `mediaDrawerOpen`, `pollDialogOpen`, `searchQuery`, `displayCount`, `firstUnreadId`, `firstUnreadJumpDismissed`, `newMessagesWhileAway`, `previousMessageIdsRef`, `firstUnreadComputedRef`):
|
||||
|
||||
```ts
|
||||
useEffect(() => {
|
||||
setReplyTo(null);
|
||||
setForwardTarget(null);
|
||||
setSearchOpen(false);
|
||||
setMediaDrawerOpen(false);
|
||||
setPollDialogOpen(false);
|
||||
setSearchQuery('');
|
||||
setDisplayCount(150);
|
||||
setFirstUnreadId(null);
|
||||
setFirstUnreadJumpDismissed(false);
|
||||
setNewMessagesWhileAway(0);
|
||||
previousMessageIdsRef.current = new Set();
|
||||
firstUnreadComputedRef.current = false;
|
||||
}, [id]);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Typecheck + tests**
|
||||
|
||||
Run in parallel:
|
||||
|
||||
```bash
|
||||
pnpm --filter @chat-app/desktop typecheck
|
||||
pnpm --filter @chat-app/desktop test
|
||||
```
|
||||
|
||||
Expected: both PASS.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/desktop/src/pages/ConversationPage.tsx
|
||||
git commit -m "refactor(chat-switch): drop redundant id-change reset effect"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Final QA in dev mode
|
||||
|
||||
Verification only — no code changes, no commit.
|
||||
|
||||
- [ ] **Step 1: Start dev**
|
||||
|
||||
Run: `pnpm desktop:dev`
|
||||
|
||||
- [ ] **Step 2: Confirm each fix landed**
|
||||
|
||||
Switch repeatedly between three chats (A, B, C). All of the following must hold:
|
||||
|
||||
| Behaviour | Pass criteria |
|
||||
|-----------|---------------|
|
||||
| Ghost messages | Never see chat A's messages under chat B's header. |
|
||||
| Spinner flash | First visit to a chat = spinner OK. Subsequent visits = no spinner. |
|
||||
| Scroll restore | Chat A scrolled up 10 rows → switch to B → back to A → lands at row 10 ±1. |
|
||||
| Composer state | Type "draft" in chat A composer → switch to B → composer is empty (drafts intentionally don't persist). |
|
||||
| Reply preview | Click reply on chat A → switch to B → no reply preview in B. |
|
||||
| Pending bubbles | Send while offline → bubble shows in correct chat → switch away and back → bubble still in same chat. |
|
||||
| Realtime updates | Send a message in chat A from another device → it lands in chat A list → switch to B → switch back to A → message is still there (verifies memory cache stays in sync with realtime inserts). |
|
||||
|
||||
- [ ] **Step 3: If any check fails**
|
||||
|
||||
Open `superpowers:systematic-debugging` and form a single new hypothesis per failing check. Do NOT layer fixes — return to Phase 1, gather evidence, then patch.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (post-write checklist)
|
||||
|
||||
**Spec coverage**: Each RC1–RC7 is addressed:
|
||||
- RC1 (state bleed) → Task 3 (key-based remount) + Task 2 (memory cache so the fresh mount is instant).
|
||||
- RC2 (Virtuoso scroll) → Task 3 (Virtuoso unmounts with parent, `initialTopMostItemIndex` re-applied on fresh mount).
|
||||
- RC3 (scrollPositions corruption) → Task 3 (no more cross-chat render under wrong id).
|
||||
- RC4 (stale local state for one render) → Task 3 + Task 4 (cleanup).
|
||||
- RC5 (phantom scroll-to-bottom) → Task 3 (fresh ref).
|
||||
- RC6 (newMessagesWhileAway misfire) → Task 3 (fresh ref).
|
||||
- RC7 (cache vs network race) → Task 2 (deterministic init from memory cache, SQLite hydration only on cold cache miss).
|
||||
|
||||
**Placeholders**: none — every step lists exact files, exact code, exact commands.
|
||||
|
||||
**Type consistency**: `ConversationRoute` is the only new component; `messageMemoryCache` exports (`getCachedMessages`, `hasCachedMessages`, `setCachedMessages`, `__resetForTests`) match the test imports exactly.
|
||||
|
||||
---
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Cross-fade animation between chats.** A small `view-transition` or opacity tween could further polish the swap, but the user reported jumpiness, not the absence of an animation. Tackle in a follow-up if it still feels too "snap-y" after this lands.
|
||||
- **Persisting composer drafts per chat.** Today every chat-switch loses the in-progress draft. The remount in Task 3 *preserves* that behaviour deliberately (no regression). A draft-persistence feature is a separate spec.
|
||||
- **Mobile (`apps/mobile`).** The mobile chat list uses a different virtualization stack; this plan only covers `apps/desktop`.
|
||||
@@ -0,0 +1,138 @@
|
||||
# Phase 7 — Composer Redesign (Hybrid)
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
|
||||
|
||||
**Goal:** Reduce composer toolbar from 9 cluttered icons to 5 hierarchically-organized buttons. Move "creative activities" (Whiteboard, Watch-Together, Mini-Games) into a `+` popover. Move View-Once from global composer toggle to per-attachment flag in the upload preview.
|
||||
|
||||
**Rollback anchor:** tag `pre-phase7-composer` (set in T1).
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Rollback anchor
|
||||
|
||||
- [ ] Run:
|
||||
```bash
|
||||
cd "D:\Programmieren\ChatApp-Electron\chat-app"
|
||||
git tag -a pre-phase7-composer -m "Rollback anchor before Phase 7 composer redesign"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `<ComposerActionsMenu>` popover component
|
||||
|
||||
**Files:** Create `apps/desktop/src/components/ComposerActionsMenu.tsx`
|
||||
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
interface Props {
|
||||
anchorRef: React.RefObject<HTMLButtonElement | null>;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onAttachFile: () => void;
|
||||
onCreatePoll: () => void;
|
||||
onCreateWhiteboard: () => void;
|
||||
onStartWatchTogether: () => void;
|
||||
onStartGame: () => void;
|
||||
canStartGame?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
Layout (floating panel anchored above `anchorRef`):
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ 📎 Bild / Datei │
|
||||
│ 📊 Umfrage │
|
||||
├─────────────────────────────┤
|
||||
│ AKTIVITÄTEN │
|
||||
│ ✏ Whiteboard │
|
||||
│ 📺 Watch Together │
|
||||
│ 🎮 Spiel starten │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
- Use existing icons from `apps/desktop/src/components/icons.tsx` (grep for `PaperclipIcon`/`PlusIcon`, `PollIcon`, `MonitorShareIcon`, `PlayBoxIcon`, `GameIcon`).
|
||||
- Click outside or `Esc` → `onClose`.
|
||||
- Disabled items: `opacity-50 cursor-not-allowed` + `title` hint (e.g. "Spiele nur in 1:1-Chats").
|
||||
- Each row ≥ 44px tall, `role="menu"`/`role="menuitem"`, arrow-up/down keyboard nav.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Refactor ConversationPage composer
|
||||
|
||||
**Files:** Modify `apps/desktop/src/pages/ConversationPage.tsx`
|
||||
|
||||
**Target layout:**
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────┐
|
||||
│ [+] [😊] [GIF] [🎤] Nachricht schreiben… [→] │
|
||||
└────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Changes:
|
||||
1. **Remove** inline buttons for: file-attach, poll, whiteboard, watch-together, game-picker.
|
||||
2. **Add** a `+` button at position 1 with a `useRef` anchor.
|
||||
3. **State:** `const [menuOpen, setMenuOpen] = useState(false);` + render `<ComposerActionsMenu>` with the existing handlers wired (`handleCreateWhiteboard`, `handleStartWatchTogether`, `handleStartGame`, `() => setPollDialogOpen(true)`, `() => fileInputRef.current?.click()`).
|
||||
4. **Remove** the standalone View-Once toggle button (moves to T4 per-attachment).
|
||||
5. **Keep inline:** Emoji picker, GIF picker, voice mic, send arrow.
|
||||
6. **Auto-close menu** after any item action.
|
||||
7. Pass `canStartGame={conversation?.members?.length === 2}` so the dropdown reflects the DM-only constraint.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: View-Once per-attachment in `AttachmentPreview`
|
||||
|
||||
**Files:**
|
||||
- Modify `apps/desktop/src/pages/ConversationPage.tsx` (`AttachmentPreview` component + the `attachments[]` state shape).
|
||||
- Modify `apps/desktop/src/hooks/useConversationMessages.ts` (`send()` signature + per-attachment handling).
|
||||
- Possibly extend the per-attachment encrypt/upload helper if it still treats `viewOnce` as a per-message flag.
|
||||
|
||||
**Behavior:**
|
||||
|
||||
Add a third hover-button on each image preview next to `✏` and `✕`: a `👁` icon that toggles `viewOnce` per attachment.
|
||||
- Active: icon switches (e.g. crossed-eye) + small `1×` badge in lower-right corner of the thumb.
|
||||
- Image-only (`file.type.startsWith('image/')`). Hidden on non-image previews.
|
||||
|
||||
**State refactor:**
|
||||
|
||||
Change `attachments: File[]` → `attachments: Array<{ file: File; viewOnce: boolean }>`. Every consumer site updated:
|
||||
- `setAttachments((prev) => [...prev, ...newOnes.map((f) => ({ file: f, viewOnce: false }))])`
|
||||
- `attachments.map((a, idx) => <AttachmentPreview file={a.file} ... onToggleViewOnce={() => setAttachments(prev => prev.map((x, i) => i === idx ? { ...x, viewOnce: !x.viewOnce } : x))} />)`
|
||||
- `setAttachments((prev) => prev.filter((_, i) => i !== idx))` — unchanged shape
|
||||
|
||||
**Send path:**
|
||||
|
||||
The `send()` currently accepts a `viewOnce` option that applies globally. Refactor so the per-attachment flag flows through:
|
||||
- Either change `send(payload, attachments, replyTo, { viewOnce })` → `send(payload, attachmentsWithFlags, replyTo)` where each entry carries its own `viewOnce`
|
||||
- OR pass a parallel `viewOnceFlags: boolean[]` array aligned with attachments
|
||||
|
||||
The encrypt/upload helper already supports per-attachment `view_once` (P2.T14 column `message_attachments.view_once`). The renderer just needs to pass the right flag per row.
|
||||
|
||||
**Grep first** to find the existing wiring: `Grep -rn "view_once\|viewOnce" apps/desktop/src/ packages/shared/src/chat/` — adapt to what's actually there.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Cleanup + Final gate
|
||||
|
||||
- [ ] `pnpm --filter @chat-app/desktop typecheck` — green
|
||||
- [ ] `pnpm --filter @chat-app/shared test -- --run` — green (71 tests)
|
||||
- [ ] `pnpm --filter @chat-app/desktop test -- --run` — green
|
||||
- [ ] `git status` — clean
|
||||
- [ ] Tag `phase7-done`
|
||||
- [ ] Report smoke-test points:
|
||||
1. Composer shows 5 inline buttons (was 9)
|
||||
2. Click `+` → popover opens; click anywhere outside or `Esc` closes it
|
||||
3. Attach image → preview shows ✏/👁/✕ on hover
|
||||
4. Toggle 👁 on attachment-1 only → recipient sees attachment-1 as view-once, attachment-2 normally
|
||||
5. Everything else unchanged (emoji, GIF, voice, send, edit-message, etc.)
|
||||
|
||||
---
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No emoji-as-icon (uses existing SVG icons).
|
||||
- No slash-commands (deferred to potential Phase 7B).
|
||||
- No reordering of inline buttons beyond the spec.
|
||||
- No per-attachment poll-attach (polls remain message-level).
|
||||
File diff suppressed because it is too large
Load Diff
@@ -35,6 +35,14 @@ export interface AttachmentHandle {
|
||||
/** UUID of the first non-sender who viewed. Populated alongside `viewedAt`
|
||||
* by the mark-viewed RPC so the renderer can render attribution. */
|
||||
viewedBy?: string | null;
|
||||
/** Storage path of the encrypted WebP preview thumb (max 320×320). The
|
||||
* thumb shares the per-attachment symmetric key with the full blob but
|
||||
* uses its own nonce. Absent on pre-Phase-6B messages — the receiver
|
||||
* falls through to downloading the full blob in that case. */
|
||||
thumbStoragePath?: string;
|
||||
/** Base-64 nonce that decrypts `<id>-thumb.bin`. Always present iff
|
||||
* `thumbStoragePath` is set. */
|
||||
thumbNonceB64?: string;
|
||||
}
|
||||
|
||||
export type CallEventStatus = 'ended' | 'missed' | 'declined';
|
||||
@@ -241,6 +249,13 @@ export interface EncryptedAttachmentResult {
|
||||
// Encrypt + upload a blob. Does NOT insert the message_attachments row —
|
||||
// the caller combines this with a message insert so everything commits
|
||||
// atomically at the application layer.
|
||||
//
|
||||
// When `thumbBlob` is supplied (Phase 6B image-thumbnail path) a second
|
||||
// ciphertext is uploaded to `<conversationId>/<id>-thumb.bin` encrypted
|
||||
// with the SAME per-attachment key + a fresh nonce. The handle's
|
||||
// `thumbStoragePath` / `thumbNonceB64` get populated so the receiver
|
||||
// can prefer the thumb for inline preview. Thumb-upload failure is
|
||||
// non-fatal — we log and fall through so the full image still posts.
|
||||
export async function encryptAndUploadAttachment(params: {
|
||||
client: AppSupabaseClient;
|
||||
conversationId: string;
|
||||
@@ -249,6 +264,7 @@ export async function encryptAndUploadAttachment(params: {
|
||||
sizeBytes: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
thumbBlob?: Blob | null;
|
||||
}): Promise<EncryptedAttachmentResult> {
|
||||
const backend = getCryptoBackend();
|
||||
|
||||
@@ -268,6 +284,35 @@ export async function encryptAndUploadAttachment(params: {
|
||||
});
|
||||
if (error) throw error;
|
||||
|
||||
let thumbStoragePath: string | undefined;
|
||||
let thumbNonceB64: string | undefined;
|
||||
if (params.thumbBlob) {
|
||||
try {
|
||||
const thumbBytes = new Uint8Array(await params.thumbBlob.arrayBuffer());
|
||||
const thumbNonce = backend.randomBytes(backend.secretboxNonceLength);
|
||||
const thumbCipher = backend.secretbox(thumbBytes, thumbNonce, key);
|
||||
const thumbPath = params.conversationId + '/' + id + '-thumb.bin';
|
||||
const { error: thumbErr } = await params.client.storage
|
||||
.from(ATTACHMENT_BUCKET)
|
||||
.upload(thumbPath, thumbCipher, {
|
||||
contentType: 'application/octet-stream',
|
||||
upsert: false,
|
||||
});
|
||||
if (thumbErr) {
|
||||
// Non-fatal: log and continue with full-only handle. Receiver will
|
||||
// fall back to fetching the full blob.
|
||||
console.warn('thumb upload failed', thumbErr);
|
||||
} else {
|
||||
thumbStoragePath = thumbPath;
|
||||
thumbNonceB64 = await toBase64(thumbNonce);
|
||||
}
|
||||
// Wipe nonce buffer.
|
||||
for (let i = 0; i < thumbNonce.length; i++) thumbNonce[i] = 0;
|
||||
} catch (err: unknown) {
|
||||
console.warn('thumb encrypt/upload failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
const handle: AttachmentHandle = {
|
||||
id,
|
||||
storagePath,
|
||||
@@ -277,6 +322,8 @@ export async function encryptAndUploadAttachment(params: {
|
||||
...(params.height !== undefined ? { height: params.height } : {}),
|
||||
keyB64: await toBase64(key),
|
||||
nonceB64: await toBase64(nonce),
|
||||
...(thumbStoragePath !== undefined ? { thumbStoragePath } : {}),
|
||||
...(thumbNonceB64 !== undefined ? { thumbNonceB64 } : {}),
|
||||
};
|
||||
|
||||
return { handle, key, nonce };
|
||||
@@ -309,6 +356,51 @@ export async function downloadAndDecryptAttachment(params: {
|
||||
return new Blob([copy.buffer], { type: params.handle.mimeType });
|
||||
}
|
||||
|
||||
// Download + decrypt the small WebP preview thumb that the sender uploaded
|
||||
// alongside an image attachment (Phase 6B optimisation). Returns `null` if
|
||||
// the handle has no thumb metadata (pre-Phase-6B message) or if the thumb
|
||||
// blob is missing from storage — caller falls back to the full image.
|
||||
//
|
||||
// We deliberately swallow ANY download error (missing object, transient
|
||||
// 5xx) so the receiver gracefully degrades to the full-blob path; only a
|
||||
// successful decrypt-failure throws, since that signals a real corruption.
|
||||
export async function downloadAndDecryptAttachmentThumb(params: {
|
||||
client: AppSupabaseClient;
|
||||
handle: AttachmentHandle;
|
||||
}): Promise<Blob | null> {
|
||||
if (!params.handle.thumbStoragePath || !params.handle.thumbNonceB64) {
|
||||
return null;
|
||||
}
|
||||
const backend = getCryptoBackend();
|
||||
|
||||
let data: Blob | null = null;
|
||||
try {
|
||||
const res = await params.client.storage
|
||||
.from(ATTACHMENT_BUCKET)
|
||||
.download(params.handle.thumbStoragePath);
|
||||
if (res.error) {
|
||||
// Likely 404 — sender failed to upload thumb, or it's been GC'd.
|
||||
// Receiver falls back to full image.
|
||||
return null;
|
||||
}
|
||||
data = res.data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!data) return null;
|
||||
|
||||
const ciphertext = new Uint8Array(await data.arrayBuffer());
|
||||
const key = await fromBase64(params.handle.keyB64);
|
||||
const nonce = await fromBase64(params.handle.thumbNonceB64);
|
||||
const plainBytes = backend.secretboxOpen(ciphertext, nonce, key);
|
||||
for (let i = 0; i < key.length; i++) key[i] = 0;
|
||||
for (let i = 0; i < nonce.length; i++) nonce[i] = 0;
|
||||
const copy = new Uint8Array(plainBytes.byteLength);
|
||||
copy.set(plainBytes);
|
||||
// Thumbs are always image/webp regardless of original mime.
|
||||
return new Blob([copy.buffer], { type: 'image/webp' });
|
||||
}
|
||||
|
||||
// Insert the public metadata row for an attachment. The ciphertext itself has
|
||||
// already been uploaded to storage under `handle.storagePath`.
|
||||
export async function insertAttachmentRow(
|
||||
|
||||
@@ -57,7 +57,7 @@ export async function migrateOwnLegacyBundles(params: MigrateParams): Promise<Mi
|
||||
attempted: 0, noStrongholdKey: 0, decryptFailed: 0, rpcFailed: 0,
|
||||
};
|
||||
if (params.ownLegacyDeviceIds.length === 0) {
|
||||
console.info('[crypto-migration] no legacy device-ids to consider — skipping');
|
||||
console.debug('[crypto-migration] no legacy device-ids to consider — skipping');
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ export async function migrateOwnLegacyBundles(params: MigrateParams): Promise<Mi
|
||||
.not('recipient_device_id', 'is', null);
|
||||
if (error) throw error;
|
||||
const rows = (rowsRaw ?? []) as LegacyRow[];
|
||||
console.info('[crypto-migration] legacy rows visible to me: ' + rows.length);
|
||||
console.debug('[crypto-migration] legacy rows visible to me: ' + rows.length);
|
||||
if (rows.length === 0) return result;
|
||||
|
||||
const senderDeviceIds = Array.from(new Set(rows.map((r) => r.sender_device_id).filter(Boolean)));
|
||||
@@ -136,13 +136,26 @@ export async function migrateOwnLegacyBundles(params: MigrateParams): Promise<Mi
|
||||
result.migratedConversations += 1;
|
||||
}
|
||||
|
||||
console.info(
|
||||
'[crypto-migration] result:',
|
||||
'attempted=' + result.attempted,
|
||||
'migrated=' + result.migratedConversations,
|
||||
'noKey=' + result.noStrongholdKey,
|
||||
'decryptFail=' + result.decryptFailed,
|
||||
'rpcFail=' + result.rpcFailed,
|
||||
);
|
||||
// If anything was actually migrated this run, leave it as console.info
|
||||
// so it's visible in default consoles. If we only re-failed on already-
|
||||
// unrecoverable rows (no local stronghold key), demote to debug — the
|
||||
// migration is idempotent but the noisy "noKey=N" line scared the user
|
||||
// who thought migration was already done.
|
||||
if (result.migratedConversations > 0 || result.decryptFailed > 0 || result.rpcFailed > 0) {
|
||||
console.info(
|
||||
'[crypto-migration] result:',
|
||||
'attempted=' + result.attempted,
|
||||
'migrated=' + result.migratedConversations,
|
||||
'noKey=' + result.noStrongholdKey,
|
||||
'decryptFail=' + result.decryptFailed,
|
||||
'rpcFail=' + result.rpcFailed,
|
||||
);
|
||||
} else {
|
||||
console.debug(
|
||||
'[crypto-migration] result (all unrecoverable, expected on stale clients):',
|
||||
'attempted=' + result.attempted,
|
||||
'noKey=' + result.noStrongholdKey,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -53,8 +53,6 @@
|
||||
"archive": "Archivieren",
|
||||
"unarchive": "Entarchivieren",
|
||||
"archived_title": "Archiv",
|
||||
"show_archived": "Archiv anzeigen",
|
||||
"show_active": "Aktive anzeigen",
|
||||
"archived_empty_title": "Nichts archiviert",
|
||||
"archived_empty_subtitle": "Archivierte Unterhaltungen erscheinen hier.",
|
||||
"mute": "Stummschalten",
|
||||
@@ -84,8 +82,6 @@
|
||||
"join": "Beitreten",
|
||||
"in_call": "Im Anruf",
|
||||
"waiting_for_peers": "Warte auf andere…",
|
||||
"voice_connected": "Sprachchat verbunden",
|
||||
"still_live": "Anruf läuft noch",
|
||||
"share_screen": "Bildschirm teilen",
|
||||
"stop_share_screen": "Screen-Share stoppen",
|
||||
"is_sharing_screen": "{{name}} teilt den Bildschirm",
|
||||
@@ -133,11 +129,9 @@
|
||||
"action_unfriend": "Entfernen",
|
||||
"action_accept": "Annehmen",
|
||||
"action_decline": "Ablehnen",
|
||||
"action_cancel": "Abbrechen",
|
||||
"confirm_unfriend": "Diesen Freund entfernen?"
|
||||
"action_cancel": "Abbrechen"
|
||||
},
|
||||
"admin": {
|
||||
"nav": "Admin",
|
||||
"title": "Admin-Panel",
|
||||
"settings_title": "Globale Einstellungen",
|
||||
"invites_enabled": "Neue Registrierungen erlauben",
|
||||
@@ -188,15 +182,11 @@
|
||||
"screen_share_quality": "Qualität",
|
||||
"screen_share_hint": "WebRTC passt Bitrate + Auflösung dynamisch an die Netzwerkqualität an (SVC/VP9). Die Werte sind Obergrenzen. Änderungen greifen beim nächsten Call.",
|
||||
"language": "Sprache",
|
||||
"presence": "Status",
|
||||
"show_read_receipts": "Lesebestätigungen anzeigen",
|
||||
"show_read_receipts_hint": "Wenn aus, sehen andere nicht wann du ihre Nachrichten gelesen hast — und du siehst nicht wann sie deine gelesen haben.",
|
||||
"allow_dms_strangers": "DMs von Fremden erlauben",
|
||||
"allow_dms_strangers_hint": "Wenn aus, können dich nur Freunde direkt anschreiben.",
|
||||
"this_device": "Dieses Gerät",
|
||||
"danger_zone": "Gefahrenzone",
|
||||
"sign_out": "Abmelden",
|
||||
"section_ringtone": "Klingelton",
|
||||
"ringtone_incoming": "Eingehender Anruf",
|
||||
"ringtone_default_active": "Standard-Klingelton (Doppelton)",
|
||||
"ringtone_custom_active": "{{name}} · {{size}} MB",
|
||||
|
||||
@@ -50,28 +50,8 @@
|
||||
"footer_studio": "Supabase Studio",
|
||||
"legal_note": "Mit der Registrierung akzeptierst du, dass der Server nur Chiffretext sieht.",
|
||||
"signed_in": {
|
||||
"title": "Angemeldet",
|
||||
"session_active": "Sitzung aktiv",
|
||||
"user_id": "Benutzer-ID",
|
||||
"email": "E-Mail",
|
||||
"username": "Benutzername",
|
||||
"display_name": "Anzeigename",
|
||||
"admin": "Admin",
|
||||
"yes": "Ja",
|
||||
"no": "Nein",
|
||||
"sign_out": "Abmelden",
|
||||
"device_active": "Aktives Gerät",
|
||||
"device_platform": "Plattform",
|
||||
"device_registered_at": "Registriert"
|
||||
},
|
||||
"device": {
|
||||
"title": "Dieses Gerät registrieren",
|
||||
"subtitle": "Erzeugt ein X25519-Schlüsselpaar. Der private Schlüssel bleibt auf diesem Gerät.",
|
||||
"name_label": "Gerätename",
|
||||
"name_hint": "Erscheint in deiner Geräteliste. Wähle einen erkennbaren Namen.",
|
||||
"name_placeholder": "Dennis Laptop",
|
||||
"cta": "Gerät registrieren",
|
||||
"cta_loading": "Schlüsselpaar wird erzeugt…",
|
||||
"security_note_dev": "Dev-Build: Privater Schlüssel liegt unverschlüsselt im localStorage. Stronghold folgt vor dem Release."
|
||||
"display_name": "Anzeigename"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
{
|
||||
"app_name": "ChatApp",
|
||||
"loading": "Lädt…",
|
||||
"finalising_session": "Sitzung wird abgeschlossen…",
|
||||
"cancel": "Abbrechen",
|
||||
"save": "Speichern",
|
||||
"close": "Schließen",
|
||||
"retry": "Wiederholen",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"idle": "Abwesend",
|
||||
"dnd": "Nicht stören",
|
||||
"invisible": "Unsichtbar",
|
||||
"local_stack_online": "Lokaler Stack online",
|
||||
"dev_build": "Dev-Build"
|
||||
}
|
||||
|
||||
@@ -53,8 +53,6 @@
|
||||
"archive": "Archive",
|
||||
"unarchive": "Unarchive",
|
||||
"archived_title": "Archive",
|
||||
"show_archived": "Show archive",
|
||||
"show_active": "Show active",
|
||||
"archived_empty_title": "Nothing archived",
|
||||
"archived_empty_subtitle": "Archived conversations appear here.",
|
||||
"mute": "Mute",
|
||||
@@ -84,8 +82,6 @@
|
||||
"join": "Join",
|
||||
"in_call": "In call",
|
||||
"waiting_for_peers": "Waiting for others…",
|
||||
"voice_connected": "Voice connected",
|
||||
"still_live": "Call still live",
|
||||
"share_screen": "Share screen",
|
||||
"stop_share_screen": "Stop sharing",
|
||||
"is_sharing_screen": "{{name}} is sharing their screen",
|
||||
@@ -133,11 +129,9 @@
|
||||
"action_unfriend": "Unfriend",
|
||||
"action_accept": "Accept",
|
||||
"action_decline": "Decline",
|
||||
"action_cancel": "Cancel",
|
||||
"confirm_unfriend": "Remove this friend?"
|
||||
"action_cancel": "Cancel"
|
||||
},
|
||||
"admin": {
|
||||
"nav": "Admin",
|
||||
"title": "Admin panel",
|
||||
"settings_title": "Global settings",
|
||||
"invites_enabled": "Allow new signups",
|
||||
@@ -188,15 +182,11 @@
|
||||
"screen_share_quality": "Quality",
|
||||
"screen_share_hint": "WebRTC dynamically adjusts bitrate + resolution to match network conditions (SVC/VP9). Values are upper bounds. Changes apply on the next call.",
|
||||
"language": "Language",
|
||||
"presence": "Presence",
|
||||
"show_read_receipts": "Show read receipts",
|
||||
"show_read_receipts_hint": "When off, others can't see when you read their messages — and you won't see when they read yours.",
|
||||
"allow_dms_strangers": "Allow DMs from strangers",
|
||||
"allow_dms_strangers_hint": "When off, only friends can DM you.",
|
||||
"this_device": "This device",
|
||||
"danger_zone": "Danger zone",
|
||||
"sign_out": "Sign out",
|
||||
"section_ringtone": "Ringtone",
|
||||
"ringtone_incoming": "Incoming call",
|
||||
"ringtone_default_active": "Default ringtone (double beep)",
|
||||
"ringtone_custom_active": "{{name}} · {{size}} MB",
|
||||
|
||||
@@ -50,28 +50,8 @@
|
||||
"footer_studio": "Supabase Studio",
|
||||
"legal_note": "By signing up you accept that the server sees only ciphertext.",
|
||||
"signed_in": {
|
||||
"title": "Signed in",
|
||||
"session_active": "Session active",
|
||||
"user_id": "User ID",
|
||||
"email": "Email",
|
||||
"username": "Username",
|
||||
"display_name": "Display name",
|
||||
"admin": "Admin",
|
||||
"yes": "yes",
|
||||
"no": "no",
|
||||
"sign_out": "Sign out",
|
||||
"device_active": "Active device",
|
||||
"device_platform": "Platform",
|
||||
"device_registered_at": "Registered"
|
||||
},
|
||||
"device": {
|
||||
"title": "Register this device",
|
||||
"subtitle": "Generates an X25519 keypair. Private key stays on this device.",
|
||||
"name_label": "Device name",
|
||||
"name_hint": "Shown to you in your device list. Keep it recognisable.",
|
||||
"name_placeholder": "Dennis Laptop",
|
||||
"cta": "Register device",
|
||||
"cta_loading": "Generating keypair…",
|
||||
"security_note_dev": "Dev build: private key stored unencrypted in localStorage. Stronghold comes before release."
|
||||
"display_name": "Display name"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
{
|
||||
"app_name": "ChatApp",
|
||||
"loading": "Loading…",
|
||||
"finalising_session": "Finalising session…",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"close": "Close",
|
||||
"retry": "Retry",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"idle": "Idle",
|
||||
"dnd": "Do not disturb",
|
||||
"invisible": "Invisible",
|
||||
"local_stack_online": "local stack online",
|
||||
"dev_build": "dev build"
|
||||
}
|
||||
|
||||
Generated
+198
@@ -98,6 +98,9 @@ importers:
|
||||
react-router-dom:
|
||||
specifier: ^6.28.0
|
||||
version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react-virtuoso:
|
||||
specifier: ^4.18.7
|
||||
version: 4.18.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
zustand:
|
||||
specifier: ^5.0.1
|
||||
version: 5.0.12(@types/react@18.3.28)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1))
|
||||
@@ -141,6 +144,9 @@ importers:
|
||||
rimraf:
|
||||
specifier: ^6.0.0
|
||||
version: 6.1.3
|
||||
rollup-plugin-visualizer:
|
||||
specifier: ^7.0.1
|
||||
version: 7.0.1(rollup@4.60.1)
|
||||
tailwindcss:
|
||||
specifier: ^3.4.15
|
||||
version: 3.4.19
|
||||
@@ -2612,6 +2618,10 @@ packages:
|
||||
builder-util@25.1.7:
|
||||
resolution: {integrity: sha512-7jPjzBwEGRbwNcep0gGNpLXG9P94VA3CPAZQCzxkFXiV2GMQKlziMbY//rXPI7WKfhsvGgFXjTcXdBEwgXw9ww==}
|
||||
|
||||
bundle-name@4.1.0:
|
||||
resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
bytes@3.1.2:
|
||||
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -2757,6 +2767,10 @@ packages:
|
||||
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
cliui@9.0.1:
|
||||
resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
clone-deep@4.0.1:
|
||||
resolution: {integrity: sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -2988,6 +3002,14 @@ packages:
|
||||
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
default-browser-id@5.0.1:
|
||||
resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
default-browser@5.5.0:
|
||||
resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
default-gateway@4.2.0:
|
||||
resolution: {integrity: sha512-h6sMrVB1VMWVrW13mSc6ia/DwYYw5MN6+exNu1OaJeFac5aSAvwM7lZ0NVfTABuSkQelr4h5oebg3KB1XPdjgA==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -3007,6 +3029,10 @@ packages:
|
||||
resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
define-lazy-prop@3.0.0:
|
||||
resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
define-properties@1.2.1:
|
||||
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -3128,6 +3154,9 @@ packages:
|
||||
engines: {node: '>= 12.20.55'}
|
||||
hasBin: true
|
||||
|
||||
emoji-regex@10.6.0:
|
||||
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
|
||||
|
||||
emoji-regex@8.0.0:
|
||||
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
||||
|
||||
@@ -3719,6 +3748,10 @@ packages:
|
||||
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
||||
engines: {node: 6.* || 8.* || >= 10.*}
|
||||
|
||||
get-east-asian-width@1.6.0:
|
||||
resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
get-intrinsic@1.3.0:
|
||||
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -4051,6 +4084,11 @@ packages:
|
||||
engines: {node: '>=8'}
|
||||
hasBin: true
|
||||
|
||||
is-docker@3.0.0:
|
||||
resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
hasBin: true
|
||||
|
||||
is-extglob@2.1.1:
|
||||
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -4071,6 +4109,15 @@ packages:
|
||||
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
is-in-ssh@1.0.0:
|
||||
resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
is-inside-container@1.0.0:
|
||||
resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==}
|
||||
engines: {node: '>=14.16'}
|
||||
hasBin: true
|
||||
|
||||
is-interactive@1.0.0:
|
||||
resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -4169,6 +4216,10 @@ packages:
|
||||
resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
is-wsl@3.1.1:
|
||||
resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
isarray@1.0.0:
|
||||
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
|
||||
|
||||
@@ -4962,6 +5013,10 @@ packages:
|
||||
resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
open@11.0.0:
|
||||
resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
open@7.4.2:
|
||||
resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -5187,6 +5242,10 @@ packages:
|
||||
resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
powershell-utils@0.1.0:
|
||||
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
prebuild-install@7.1.3:
|
||||
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -5420,6 +5479,12 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^18.3.1
|
||||
|
||||
react-virtuoso@4.18.7:
|
||||
resolution: {integrity: sha512-xNF5zDGEEIMB7cKwcen/pLig0YDf6OnfFrVgKFa7sHPf9fRem0CaLshyObbBcP88jzn0enavL39EgplgdyT21g==}
|
||||
peerDependencies:
|
||||
react: '>=16 || >=17 || >= 18 || >= 19'
|
||||
react-dom: '>=16 || >=17 || >= 18 || >=19'
|
||||
|
||||
react@18.3.1:
|
||||
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -5577,11 +5642,28 @@ packages:
|
||||
resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==}
|
||||
engines: {node: '>=8.0'}
|
||||
|
||||
rollup-plugin-visualizer@7.0.1:
|
||||
resolution: {integrity: sha512-UJUT4+1Ho4OcWmPYU3sYXgUqI8B8Ayfe06MX7y0qCJ1K8aGoKtR/NDd/2nZqM7ADkrzny+I99Ul7GgyoiVNAgg==}
|
||||
engines: {node: '>=22'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
rolldown: 1.x || ^1.0.0-beta || ^1.0.0-rc
|
||||
rollup: 2.x || 3.x || 4.x
|
||||
peerDependenciesMeta:
|
||||
rolldown:
|
||||
optional: true
|
||||
rollup:
|
||||
optional: true
|
||||
|
||||
rollup@4.60.1:
|
||||
resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==}
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
hasBin: true
|
||||
|
||||
run-applescript@7.1.0:
|
||||
resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
run-parallel@1.2.0:
|
||||
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
||||
|
||||
@@ -5817,6 +5899,10 @@ packages:
|
||||
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
source-map@0.7.6:
|
||||
resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
split-on-first@1.1.0:
|
||||
resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -5884,6 +5970,10 @@ packages:
|
||||
resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
string-width@7.2.0:
|
||||
resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
string.prototype.trim@1.2.10:
|
||||
resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -6470,6 +6560,10 @@ packages:
|
||||
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
wrap-ansi@9.0.2:
|
||||
resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
wrappy@1.0.2:
|
||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||
|
||||
@@ -6515,6 +6609,10 @@ packages:
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
wsl-utils@0.3.1:
|
||||
resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
xcode@3.0.1:
|
||||
resolution: {integrity: sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
@@ -6556,10 +6654,18 @@ packages:
|
||||
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
yargs-parser@22.0.0:
|
||||
resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
|
||||
|
||||
yargs@17.7.2:
|
||||
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
yargs@18.0.0:
|
||||
resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
|
||||
|
||||
yauzl@2.10.0:
|
||||
resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
|
||||
|
||||
@@ -9557,6 +9663,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
bundle-name@4.1.0:
|
||||
dependencies:
|
||||
run-applescript: 7.1.0
|
||||
|
||||
bytes@3.1.2: {}
|
||||
|
||||
cac@6.7.14: {}
|
||||
@@ -9741,6 +9851,12 @@ snapshots:
|
||||
strip-ansi: 6.0.1
|
||||
wrap-ansi: 7.0.0
|
||||
|
||||
cliui@9.0.1:
|
||||
dependencies:
|
||||
string-width: 7.2.0
|
||||
strip-ansi: 7.2.0
|
||||
wrap-ansi: 9.0.2
|
||||
|
||||
clone-deep@4.0.1:
|
||||
dependencies:
|
||||
is-plain-object: 2.0.4
|
||||
@@ -9960,6 +10076,13 @@ snapshots:
|
||||
|
||||
deepmerge@4.3.1: {}
|
||||
|
||||
default-browser-id@5.0.1: {}
|
||||
|
||||
default-browser@5.5.0:
|
||||
dependencies:
|
||||
bundle-name: 4.1.0
|
||||
default-browser-id: 5.0.1
|
||||
|
||||
default-gateway@4.2.0:
|
||||
dependencies:
|
||||
execa: 1.0.0
|
||||
@@ -9979,6 +10102,8 @@ snapshots:
|
||||
|
||||
define-lazy-prop@2.0.0: {}
|
||||
|
||||
define-lazy-prop@3.0.0: {}
|
||||
|
||||
define-properties@1.2.1:
|
||||
dependencies:
|
||||
define-data-property: 1.1.4
|
||||
@@ -10152,6 +10277,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
emoji-regex@10.6.0: {}
|
||||
|
||||
emoji-regex@8.0.0: {}
|
||||
|
||||
emoji-regex@9.2.2: {}
|
||||
@@ -10936,6 +11063,8 @@ snapshots:
|
||||
|
||||
get-caller-file@2.0.5: {}
|
||||
|
||||
get-east-asian-width@1.6.0: {}
|
||||
|
||||
get-intrinsic@1.3.0:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
@@ -11305,6 +11434,8 @@ snapshots:
|
||||
|
||||
is-docker@2.2.1: {}
|
||||
|
||||
is-docker@3.0.0: {}
|
||||
|
||||
is-extglob@2.1.1: {}
|
||||
|
||||
is-finalizationregistry@1.1.1:
|
||||
@@ -11325,6 +11456,12 @@ snapshots:
|
||||
dependencies:
|
||||
is-extglob: 2.1.1
|
||||
|
||||
is-in-ssh@1.0.0: {}
|
||||
|
||||
is-inside-container@1.0.0:
|
||||
dependencies:
|
||||
is-docker: 3.0.0
|
||||
|
||||
is-interactive@1.0.0: {}
|
||||
|
||||
is-lambda@1.0.1: {}
|
||||
@@ -11406,6 +11543,10 @@ snapshots:
|
||||
dependencies:
|
||||
is-docker: 2.2.1
|
||||
|
||||
is-wsl@3.1.1:
|
||||
dependencies:
|
||||
is-inside-container: 1.0.0
|
||||
|
||||
isarray@1.0.0: {}
|
||||
|
||||
isarray@2.0.5: {}
|
||||
@@ -12338,6 +12479,15 @@ snapshots:
|
||||
dependencies:
|
||||
mimic-fn: 2.1.0
|
||||
|
||||
open@11.0.0:
|
||||
dependencies:
|
||||
default-browser: 5.5.0
|
||||
define-lazy-prop: 3.0.0
|
||||
is-in-ssh: 1.0.0
|
||||
is-inside-container: 1.0.0
|
||||
powershell-utils: 0.1.0
|
||||
wsl-utils: 0.3.1
|
||||
|
||||
open@7.4.2:
|
||||
dependencies:
|
||||
is-docker: 2.2.1
|
||||
@@ -12539,6 +12689,8 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
powershell-utils@0.1.0: {}
|
||||
|
||||
prebuild-install@7.1.3:
|
||||
dependencies:
|
||||
detect-libc: 2.1.2
|
||||
@@ -12820,6 +12972,11 @@ snapshots:
|
||||
react-shallow-renderer: 16.15.0(react@18.3.1)
|
||||
scheduler: 0.23.2
|
||||
|
||||
react-virtuoso@4.18.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
react@18.3.1:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
@@ -13004,6 +13161,15 @@ snapshots:
|
||||
sprintf-js: 1.1.3
|
||||
optional: true
|
||||
|
||||
rollup-plugin-visualizer@7.0.1(rollup@4.60.1):
|
||||
dependencies:
|
||||
open: 11.0.0
|
||||
picomatch: 4.0.4
|
||||
source-map: 0.7.6
|
||||
yargs: 18.0.0
|
||||
optionalDependencies:
|
||||
rollup: 4.60.1
|
||||
|
||||
rollup@4.60.1:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
@@ -13035,6 +13201,8 @@ snapshots:
|
||||
'@rollup/rollup-win32-x64-msvc': 4.60.1
|
||||
fsevents: 2.3.3
|
||||
|
||||
run-applescript@7.1.0: {}
|
||||
|
||||
run-parallel@1.2.0:
|
||||
dependencies:
|
||||
queue-microtask: 1.2.3
|
||||
@@ -13294,6 +13462,8 @@ snapshots:
|
||||
|
||||
source-map@0.6.1: {}
|
||||
|
||||
source-map@0.7.6: {}
|
||||
|
||||
split-on-first@1.1.0: {}
|
||||
|
||||
sprintf-js@1.0.3: {}
|
||||
@@ -13350,6 +13520,12 @@ snapshots:
|
||||
emoji-regex: 9.2.2
|
||||
strip-ansi: 7.2.0
|
||||
|
||||
string-width@7.2.0:
|
||||
dependencies:
|
||||
emoji-regex: 10.6.0
|
||||
get-east-asian-width: 1.6.0
|
||||
strip-ansi: 7.2.0
|
||||
|
||||
string.prototype.trim@1.2.10:
|
||||
dependencies:
|
||||
call-bind: 1.0.9
|
||||
@@ -13993,6 +14169,12 @@ snapshots:
|
||||
string-width: 5.1.2
|
||||
strip-ansi: 7.2.0
|
||||
|
||||
wrap-ansi@9.0.2:
|
||||
dependencies:
|
||||
ansi-styles: 6.2.3
|
||||
string-width: 7.2.0
|
||||
strip-ansi: 7.2.0
|
||||
|
||||
wrappy@1.0.2: {}
|
||||
|
||||
write-file-atomic@2.4.3:
|
||||
@@ -14014,6 +14196,11 @@ snapshots:
|
||||
|
||||
ws@8.20.0: {}
|
||||
|
||||
wsl-utils@0.3.1:
|
||||
dependencies:
|
||||
is-wsl: 3.1.1
|
||||
powershell-utils: 0.1.0
|
||||
|
||||
xcode@3.0.1:
|
||||
dependencies:
|
||||
simple-plist: 1.3.1
|
||||
@@ -14042,6 +14229,8 @@ snapshots:
|
||||
|
||||
yargs-parser@21.1.1: {}
|
||||
|
||||
yargs-parser@22.0.0: {}
|
||||
|
||||
yargs@17.7.2:
|
||||
dependencies:
|
||||
cliui: 8.0.1
|
||||
@@ -14052,6 +14241,15 @@ snapshots:
|
||||
y18n: 5.0.8
|
||||
yargs-parser: 21.1.1
|
||||
|
||||
yargs@18.0.0:
|
||||
dependencies:
|
||||
cliui: 9.0.1
|
||||
escalade: 3.2.0
|
||||
get-caller-file: 2.0.5
|
||||
string-width: 7.2.0
|
||||
y18n: 5.0.8
|
||||
yargs-parser: 22.0.0
|
||||
|
||||
yauzl@2.10.0:
|
||||
dependencies:
|
||||
buffer-crc32: 0.2.13
|
||||
|
||||
Reference in New Issue
Block a user