Compare commits

...

25 Commits

Author SHA1 Message Date
byGalax d803773261 chore(desktop): release v0.20.0 2026-05-17 13:21:50 +02:00
byGalax 49855c5d3f fix(console-noise): pre-warm via auth.getSession + demote stuck crypto-migration logs to debug 2026-05-17 01:43:53 +02:00
byGalax c9a64bf898 feat(call): remove live-captions feature (privacy-inconsistent with E2E, unused) 2026-05-17 01:39:42 +02:00
byGalax 7f704e80f6 feat(P7.T4): per-attachment view-once toggle on AttachmentPreview 2026-05-17 01:25:08 +02:00
byGalax 940432d287 feat(P7.T3): composer toolbar — 5 inline buttons + popover for Bild/Poll/Aktivitäten 2026-05-17 01:18:32 +02:00
byGalax 28b6d64936 feat(P7.T2): ComposerActionsMenu popover (Bild/Datei + Umfrage + Aktivitäten group)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 01:14:34 +02:00
byGalax 92baa626d6 docs(P7): Phase 7 composer redesign plan (Hybrid: + menu + per-attachment view-once) 2026-05-17 01:11:04 +02:00
byGalax cd7ef8dccc fix(P6): AttachmentImage Lightbox blob URL — split unmount-only revoke, lock deps to handle.id 2026-05-17 01:09:09 +02:00
byGalax 58efc66ca7 fix(P4A): guard ImageAnnotator load callbacks with cancelled flag (React strict double-mount) 2026-05-17 01:03:37 +02:00
byGalax 1e139fb86e fix(P4A): ImageAnnotator effect-race — stabilize onCancel via ref so URL.revoke doesn't fire mid-decode 2026-05-17 01:01:27 +02:00
byGalax eeb713f03d fix(P6): convert positional bindings to named-params object for better-sqlite3 2026-05-17 00:59:39 +02:00
byGalax 837b5a326e fix(P6): hoist savedPositionRef above initialTopMostIndex (TDZ) + auto-open DevTools in dev 2026-05-17 00:56:34 +02:00
byGalax b1f37752d6 perf(P6C.T12): optimistic UI for mute / mentions-only / archive / pin / device-revoke
Audit of write-actions revealed that send (and edit via realtime UPDATE) are
already optimistic via local state insertion in `useConversationMessages`,
and friend nicknames are pure-local localStorage. Five user-write actions
were waiting on the ~100-200 ms server roundtrip + realtime echo:

* Toggle mute (`setConversationMutedUntil`)
* Toggle mentions-only (`setConversationMentionsOnly`)
* Toggle archive (`setConversationArchived`)
* Pin / unpin message (`pinMessage` / `unpinMessage`)
* Revoke device (`revokeDevice` RPC)

All five now flip local state synchronously and roll back on failure. The
existing realtime subscriptions reconcile canonically (no-op when the
optimistic patch already matches the server row), so this is purely a UX
latency improvement — no protocol or persistence changes.

Reactions (`toggleReaction` / `voteExclusive`) were intentionally skipped
this round: rollback semantics for the exclusive-vote path with multiple
sequential awaits are messy enough to warrant a dedicated pass.
2026-05-17 00:44:28 +02:00
byGalax 854c4b91a8 perf(P6C.T11): bundle audit + opt-in visualizer for future audits
Audited the renderer bundle with rollup-plugin-visualizer. Top offenders
(libsodium-sumo 292KB gz, livekit-client 177KB gz, @supabase 149KB gz)
all have justified usage and no viable swap. Mediapipe + track-processors
are already lazy-loaded into a separate chunk on first BackgroundBlur
activation. Bundle has zero duplicate packages, no moment/lodash/dayjs,
no syntax-highlight libs, no polyfills — already lean from T1+T6.

Added rollup-plugin-visualizer as a dev-dep, gated behind ANALYZE=true
so production builds pay no cost. Run with:
  ANALYZE=true pnpm --filter @chat-app/desktop build
to regenerate stats.html (treemap) + stats.json (raw) for future audits.
2026-05-17 00:35:41 +02:00
byGalax d3b708636f perf(P6B.T8): virtualize message list with react-virtuoso
Switches the ConversationPage chat list from a full O(N) render to
windowed rendering via react-virtuoso. On long histories only the
visible rows (plus a 400px overscan buffer) live in the DOM, ending the
scroll jank and layout thrashing that hit conversations with >500
messages.

Preserved behaviors:
- Newest message visible on open via initialTopMostItemIndex.
- Auto-scroll on send via a pending-count-based effect (the old
  setStickToBottom + useLayoutEffect pattern doesn't apply now that
  Virtuoso owns the scroll element).
- Realtime auto-follow only when scrolled to bottom (followOutput).
- 'New messages while away' counter + 'jump to newest' pill via
  atBottomStateChange.
- Pinned-message / reply / search jumps via virtuosoRef.scrollToIndex;
  expands displayCount on the fly if the target is outside the
  rendered slice. Flash highlight unchanged.
- Load-older infinite scroll via Virtuoso startReached (replaces the
  IntersectionObserver-on-sentinel pattern).
- Per-conversation position memory now keys on row index instead of
  pixel scrollTop (the latter isn't meaningful under virtualization).

Also wires the PinnedMessagesPanel onJump callback (previously a TODO
that just closed the panel) into jumpToMessage, since virtualization
made the smooth-scroll-from-pinned UX easy to deliver as a side
benefit.
2026-05-17 00:20:41 +02:00
byGalax c449943b52 perf(P6B.T7): WebP thumbnails for image attachments (320px max, thumb-first render) 2026-05-17 00:12:03 +02:00
byGalax db59e3f658 perf(P6B.T6): offload Argon2 + userKey unseal to Web Worker
PIN-unlock used to freeze the renderer for ~1-2 s on mid-hardware while
the moderate-preset Argon2id KDF + sealed-key secretbox open ran on the
main thread. Push that work into a Vite-bundled ESM Web Worker so the
unlock screen stays responsive.

The worker (apps/desktop/src/workers/crypto.worker.ts) bundles its own
libsodium-wrappers-sumo instance and registers a fresh CryptoBackend
inside the worker realm. Client wrapper (apps/desktop/src/lib/cryptoWorker.ts)
spawns a one-shot worker per unlock — workers are cheap, PIN-unlock is
once-per-session, and one-shot avoids the request-id bookkeeping that the
existing decrypt.worker needs for high-volume per-message decrypts.

Falls back to inline main-thread openUserKey when the Worker constructor
is unavailable (vitest's jsdom) or when worker spawn / round-trip fails
(strict CSP). All 14 desktop + 71 shared tests still pass — the existing
loadOrUnlockUserKey test exercises the inline-fallback branch.

Private key bytes are transferred (zero-copy) back to the main thread,
detaching the worker-side ArrayBuffer view on transfer.

Build emits crypto.worker-<hash>.js (~2.5 MB, mostly libsodium WASM glue
duplicated from the main bundle). Acceptable trade-off for the unblocked
UI; a future change could lazy-load libsodium on the main thread to drop
the duplication.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 00:05:00 +02:00
byGalax 6c6828006b feat(P6B.T9): PIN-Idle-Auto-Lock setting + idle watcher
Adds opt-in (default OFF) auto-lock: after X minutes of no user input
the app calls signOut() (full memory wipe + PIN re-entry on next open).
Settings dropdown (Aus / 5 / 15 / 30 / 60 min) lives in SecurityCenter
below the existing wipe-on-close toggle. The idle timer is mounted in
AppShell via useIdleAutoLock; activity events are throttled to 1 Hz to
avoid timer thrash on rapid mouse movement. The localStorage key is
added to PRESERVE_LOCAL_STORAGE so a wipe never silently disables the
feature.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 23:59:27 +02:00
byGalax 21376daf39 perf(P6B.T10): i18next tree-shake — prune dead locale keys
Audited all four i18next namespaces (common, auth, errors, app) against
static t() call-site grep across the entire desktop source tree.
Pruned 40 dead keys per locale, 80 total lines removed across 6 files.

Dead keys removed:
- app: call.{e2ee_active_hint,still_live,voice_connected},
       chats.{show_archived,show_active}, admin.nav,
       friends.confirm_unfriend, settings.{danger_zone,this_device,
       presence,section_ringtone}
- auth: signed_in.{title,session_active,user_id,admin,yes,no,sign_out,
        device_active,device_platform,device_registered_at},
        entire device.* section (old device-registration UI)
- common: loading, cancel, retry, online, offline, idle, dnd, invisible

Dynamic-key patterns were found and respected: t('errors:'+code)
keeps all errors keys; t('app:presence.'+val) keeps all presence keys;
t('app:annotator.tool.'+id) uses defaultValue so its locale entries
were not required.
2026-05-16 23:55:04 +02:00
byGalax 6d0e4fb1f0 perf(P6A.T5): pre-warm Supabase + avatar loading hints
Fire a no-await profiles query in AuthContext on session establish to absorb
cold-connection latency before the first user-triggered request. Add
loading='lazy' default to the central Avatar component so all off-screen
avatars (chat list, friends list, popovers, message senders) skip eager
Supabase Storage fetches; set loading='eager' on ConversationHeader (active
conv header) and CallParticipantTile inline imgs (both AudioContent and
VideoStub) which are always above-the-fold when visible.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 23:43:04 +02:00
byGalax 36ab7eca8a perf(P6A.T4): wrap all icon components in React.memo
All 57 exported SVG icon components in icons.tsx are now memoised via
React.memo, giving React permission to skip re-renders when props are
referentially equal.  Consumer icon-prop types updated from the legacy
SVGProps (includes string refs) to ComponentPropsWithoutRef<'svg'> so
the MemoExoticComponent return type satisfies TypeScript without casts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 23:39:47 +02:00
byGalax ddd696a790 perf(P6A.T3): memoize MessageBubble + stabilize parent callbacks 2026-05-16 23:30:50 +02:00
byGalax 53b3b5e1fc perf(P6A.T2): respect prefers-reduced-motion globally + gate confetti
- Update globals.css reduced-motion block: 0.01ms → 0.001ms durations
  and add scroll-behavior: auto to suppress all transitions/animations
  when OS reduced-motion preference is active.
- Gate canvas-confetti burst in GameModal behind matchMedia check so
  the particle effect is skipped entirely for users who opt out of motion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 23:25:01 +02:00
byGalax a4573b315d perf(P6A.T1): lazy-load ImageAnnotator/Whiteboard/WatchTogether/GameModal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 23:22:52 +02:00
byGalax c271e95100 docs(P6): Phase 6 performance pack plan (Groups A/B/C + deferred channel pooling) 2026-05-16 23:16:42 +02:00
49 changed files with 2364 additions and 912 deletions
+4
View File
@@ -33,6 +33,10 @@ web-build/
apps/desktop/out/ apps/desktop/out/
apps/desktop/release/ apps/desktop/release/
# Bundle visualizer reports
apps/desktop/stats.html
apps/desktop/stats.json
# Logs # Logs
*.log *.log
npm-debug.log* npm-debug.log*
+27 -1
View File
@@ -10,6 +10,9 @@
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'; import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
import path from 'node:path'; import path from 'node:path';
import { visualizer } from 'rollup-plugin-visualizer';
const ANALYZE = process.env.ANALYZE === 'true';
const rendererAliases = { const rendererAliases = {
'@': path.resolve(__dirname, './src'), '@': path.resolve(__dirname, './src'),
@@ -44,7 +47,30 @@ export default defineConfig({
// bundle. Relative base produces `./assets/...` which works in both // bundle. Relative base produces `./assets/...` which works in both
// dev (served from /) and packaged builds. // dev (served from /) and packaged builds.
base: './', base: './',
plugins: [react()], // Visualizer plugins are gated behind ANALYZE=true so the production
// build never pays the analysis cost. Re-enable with:
// ANALYZE=true pnpm --filter @chat-app/desktop build
// which writes apps/desktop/stats.html (treemap) + stats.json (raw).
plugins: [
react(),
...(ANALYZE
? [
visualizer({
filename: 'stats.html',
template: 'treemap',
gzipSize: true,
brotliSize: true,
open: false,
}),
visualizer({
filename: 'stats.json',
template: 'raw-data',
gzipSize: true,
brotliSize: true,
}),
]
: []),
],
resolve: { resolve: {
alias: rendererAliases, alias: rendererAliases,
}, },
+11
View File
@@ -147,6 +147,17 @@ async function createWindow(): Promise<BrowserWindow> {
attachWindowState(win, WINDOW_STATE_FILE); attachWindowState(win, WINDOW_STATE_FILE);
if (!app.isPackaged) { if (!app.isPackaged) {
// Auto-open DevTools in dev — the menu bar is stripped (Discord-style)
// so F12 / Ctrl+Shift+I have no chord; opening detached gives a
// separate inspector window for easy debugging.
win.webContents.openDevTools({ mode: 'detach' });
// Forward renderer console messages to the main-process stdout so
// errors during local dev are visible in the terminal too (helps when
// the inspector isn't focused).
win.webContents.on('console-message', (_event, level, message, line, sourceId) => {
const tag = level === 3 ? 'error' : level === 2 ? 'warn' : level === 1 ? 'log' : 'info';
console.log('[renderer ' + tag + ']', message, '(' + sourceId + ':' + line + ')');
});
await win.loadURL(DEV_URL); await win.loadURL(DEV_URL);
} else { } else {
await win.loadFile(resolveRendererIndex()); await win.loadFile(resolveRendererIndex());
+25 -4
View File
@@ -6,8 +6,10 @@
// (<1ms per op for the current workload). // (<1ms per op for the current workload).
// //
// Binding param style: Tauri's plugin-sql used $1, $2... positionals with // Binding param style: Tauri's plugin-sql used $1, $2... positionals with
// bindings as an array. SQLite natively accepts $N so existing queries // bindings as an array. SQLite parses `$NAME` as a NAMED parameter
// keep working unmodified. // (NAME = `1`, `2`, …), not as positional, so better-sqlite3 wants the
// bindings as `{ '1': v1, '2': v2 }` not `[v1, v2]`. We accept the old
// array-shape from callers and convert to the named-object on the way in.
import { app, ipcMain } from 'electron'; import { app, ipcMain } from 'electron';
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
@@ -40,6 +42,20 @@ function requireHandle(h: string): Handle {
return entry; return entry;
} }
// Convert a positional bindings array `[v1, v2]` to the named-params object
// `{ '1': v1, '2': v2 }` that better-sqlite3 needs when the SQL uses
// `$1`/`$2` named placeholders. Returns the original array (spread later)
// when it's empty.
function bindParams(bindings: unknown[] | undefined): Record<string, unknown> | [] {
const arr = bindings ?? [];
if (arr.length === 0) return [];
const obj: Record<string, unknown> = {};
for (let i = 0; i < arr.length; i++) {
obj[String(i + 1)] = arr[i];
}
return obj;
}
export function register(): void { export function register(): void {
ipcMain.handle(CHANNELS.SQL_LOAD, async (_evt, args: SqlLoadArgs): Promise<string> => { ipcMain.handle(CHANNELS.SQL_LOAD, async (_evt, args: SqlLoadArgs): Promise<string> => {
const rawName = stripPrefix(args.name); const rawName = stripPrefix(args.name);
@@ -59,7 +75,8 @@ export function register(): void {
async (_evt, args: SqlExecuteArgs): Promise<SqlExecuteResult> => { async (_evt, args: SqlExecuteArgs): Promise<SqlExecuteResult> => {
const entry = requireHandle(args.handle); const entry = requireHandle(args.handle);
const stmt = entry.db.prepare(args.query); const stmt = entry.db.prepare(args.query);
const info = stmt.run(...((args.bindings ?? []) as unknown[])); const params = bindParams(args.bindings);
const info = Array.isArray(params) ? stmt.run() : stmt.run(params);
return { return {
rowsAffected: info.changes, rowsAffected: info.changes,
lastInsertId: lastInsertId:
@@ -75,7 +92,11 @@ export function register(): void {
async (_evt, args: SqlSelectArgs): Promise<SqlSelectResult> => { async (_evt, args: SqlSelectArgs): Promise<SqlSelectResult> => {
const entry = requireHandle(args.handle); const entry = requireHandle(args.handle);
const stmt = entry.db.prepare(args.query); const stmt = entry.db.prepare(args.query);
const rows = stmt.all(...((args.bindings ?? []) as unknown[])) as Record<string, unknown>[]; const params = bindParams(args.bindings);
const rows = (Array.isArray(params) ? stmt.all() : stmt.all(params)) as Record<
string,
unknown
>[];
return rows; return rows;
}, },
); );
+3 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@chat-app/desktop", "name": "@chat-app/desktop",
"version": "0.19.1", "version": "0.20.0",
"private": true, "private": true,
"description": "Electron desktop client (Windows / macOS / Linux)", "description": "Electron desktop client (Windows / macOS / Linux)",
"type": "module", "type": "module",
@@ -35,6 +35,7 @@
"react-easy-crop": "^5.5.7", "react-easy-crop": "^5.5.7",
"react-i18next": "^15.1.1", "react-i18next": "^15.1.1",
"react-router-dom": "^6.28.0", "react-router-dom": "^6.28.0",
"react-virtuoso": "^4.18.7",
"zustand": "^5.0.1" "zustand": "^5.0.1"
}, },
"devDependencies": { "devDependencies": {
@@ -51,6 +52,7 @@
"electron-vite": "^2.3.0", "electron-vite": "^2.3.0",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"rimraf": "^6.0.0", "rimraf": "^6.0.0",
"rollup-plugin-visualizer": "^7.0.1",
"tailwindcss": "^3.4.15", "tailwindcss": "^3.4.15",
"vite": "^5.4.11" "vite": "^5.4.11"
}, },
+2
View File
@@ -2,6 +2,7 @@ import { useEffect } from 'react';
import { Outlet } from 'react-router-dom'; import { Outlet } from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useIdleAutoLock } from '../hooks/useIdleAutoLock';
import { startConversationKeySync } from '../lib/conversationKeySync'; import { startConversationKeySync } from '../lib/conversationKeySync';
import { startDeviceApprovalListener } from '../lib/deviceApproval'; import { startDeviceApprovalListener } from '../lib/deviceApproval';
import { ensureInstallId } from '../lib/installId'; import { ensureInstallId } from '../lib/installId';
@@ -14,6 +15,7 @@ import { Sidebar } from './Sidebar';
export function AppShell() { export function AppShell() {
const { session } = useAuth(); const { session } = useAuth();
useIdleAutoLock();
useMentionNotifications(session?.user.id); useMentionNotifications(session?.user.id);
useEffect(() => { useEffect(() => {
// Prompt once per authenticated shell mount. Module-level guard prevents // Prompt once per authenticated shell mount. Module-level guard prevents
+130 -6
View File
@@ -1,5 +1,9 @@
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat'; import {
import { useEffect, useState } from 'react'; type AttachmentHandle,
downloadAndDecryptAttachment,
downloadAndDecryptAttachmentThumb,
} from '@chat-app/shared/chat';
import { useEffect, useRef, useState } from 'react';
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache'; import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
@@ -56,6 +60,17 @@ export function AttachmentImage({ handle, mine = false }: Props) {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [lightboxOpen, setLightboxOpen] = useState(false); const [lightboxOpen, setLightboxOpen] = useState(false);
// Whether the sender shipped a pre-built WebP thumb alongside this attachment
// (Phase 6B+). When true we render the bubble from just the thumb and only
// fetch the full blob when the user opens the lightbox or for view-once.
const hasServerThumb = Boolean(handle.thumbStoragePath && handle.thumbNonceB64);
// View-once needs the full blob ready instantly the moment the recipient
// taps (otherwise we'd show a spinner during the burn animation, then race
// the "mark viewed" RPC). Same for the legacy path with no server thumb —
// we have to download the whole thing just to render the client-side
// makeThumbnail fallback.
const needsEagerFull = handle.viewOnce === true || !hasServerThumb;
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
const created: string[] = []; const created: string[] = [];
@@ -69,9 +84,39 @@ export function AttachmentImage({ handle, mine = false }: Props) {
return u; return u;
}; };
// OPFS cache → decrypt → generate thumbnail for inline display. const thumbCacheId = handle.id + '-thumb';
// Lightbox swaps to the full blob when opened.
void (async () => { void (async () => {
// Phase 6B fast path: if the sender shipped a server-side WebP thumb,
// grab it first so the bubble paints from ~20KB instead of waiting on
// the multi-MB full blob. The OPFS cache is keyed separately so the
// thumb survives independent of full-blob eviction.
if (hasServerThumb) {
try {
let thumbBlob: Blob | null = await getCachedAttachment(thumbCacheId);
if (!thumbBlob) {
thumbBlob = await downloadAndDecryptAttachmentThumb({
client: supabase,
handle,
});
if (thumbBlob) void putCachedAttachment(thumbCacheId, thumbBlob);
}
if (cancelled) return;
if (thumbBlob) {
setThumbUrl(take(thumbBlob));
}
} catch (err: unknown) {
// Thumb decrypt failure isn't fatal — fall through to the full
// blob path below so the user still sees the image.
console.warn('thumb load failed', err);
}
}
// Eagerly resolve the full blob when we need it for view-once or as
// the only render source (no server thumb). For the thumb-first path
// the full blob is deferred until the lightbox opens (see below).
if (!needsEagerFull) return;
const cached = await getCachedAttachment(handle.id); const cached = await getCachedAttachment(handle.id);
let blob: Blob; let blob: Blob;
if (cached) { if (cached) {
@@ -90,18 +135,89 @@ export function AttachmentImage({ handle, mine = false }: Props) {
if (cancelled) return; if (cancelled) return;
const full = take(blob); const full = take(blob);
setFullUrl(full); setFullUrl(full);
// 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); const thumb = await makeThumbnail(blob);
if (cancelled) return; if (cancelled) return;
if (thumb) { if (thumb) {
setThumbUrl(take(thumb)); setThumbUrl(take(thumb));
} }
}
})(); })();
return () => { return () => {
cancelled = true; cancelled = true;
for (const u of created) URL.revokeObjectURL(u); for (const u of created) URL.revokeObjectURL(u);
}; };
}, [handle.id, handle.storagePath, handle.keyB64, handle.nonceB64]); }, [
handle,
handle.id,
handle.storagePath,
handle.keyB64,
handle.nonceB64,
handle.thumbStoragePath,
handle.thumbNonceB64,
hasServerThumb,
needsEagerFull,
]);
// Lazy full-image fetch for click-to-expand (only kicks in when we
// skipped the eager full-blob download above). Resolves into the same
// `fullUrl` state that the Lightbox consumes; the thumb URL keeps
// backing the bubble until the lightbox actually mounts.
//
// CRITICAL: do NOT revoke the just-created blob URL in this effect's
// cleanup. Setting `fullUrl` re-triggers the effect (state change → re-
// run → previous cleanup fires → URL revoked → Lightbox renders
// referenced-but-revoked URL → "ERR_FILE_NOT_FOUND"). The dedicated
// unmount-only effect below tracks the current URL via ref and revokes
// it once when the component truly leaves the tree.
//
// Deps locked to `handle.id` (not `handle`) — handles are immutable per
// attachment id, so object-identity churn from parent re-renders must
// not re-trigger the fetch.
useEffect(() => {
if (!lightboxOpen) return;
if (fullUrl) return;
let cancelled = false;
void (async () => {
const cached = await getCachedAttachment(handle.id);
let blob: Blob;
if (cached) {
blob = cached;
} else {
try {
blob = await downloadAndDecryptAttachment({ client: supabase, handle });
void putCachedAttachment(handle.id, blob);
} catch (err: unknown) {
if (!cancelled) setError(err instanceof Error ? err.message : 'download failed');
return;
}
}
if (cancelled) return;
const u = URL.createObjectURL(blob);
setFullUrl(u);
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lightboxOpen, handle.id]);
// Track the currently-published fullUrl in a ref so the unmount-only
// cleanup below can revoke whatever URL is live at teardown time
// without subscribing to fullUrl changes (which would re-trigger and
// revoke prematurely — see the comment above the fetch effect).
const fullUrlRef = useRef<string | null>(null);
useEffect(() => {
fullUrlRef.current = fullUrl;
}, [fullUrl]);
useEffect(() => {
return () => {
if (fullUrlRef.current) URL.revokeObjectURL(fullUrlRef.current);
};
}, []);
const blobUrl = thumbUrl ?? fullUrl; const blobUrl = thumbUrl ?? fullUrl;
@@ -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" className="block h-auto max-h-80 w-auto max-w-full object-contain"
/> />
</button> </button>
{lightboxOpen && fullUrl && <Lightbox url={fullUrl} onClose={() => setLightboxOpen(false)} />} {lightboxOpen && (
<Lightbox
// Prefer the full blob the moment it's available; otherwise show
// the thumb so the user sees *something* during the lazy fetch
// (typical full-blob fetch is 100ms2s depending on size).
url={fullUrl ?? blobUrl}
onClose={() => setLightboxOpen(false)}
/>
)}
</> </>
); );
} }
+7
View File
@@ -11,6 +11,11 @@ interface Props {
// brand-tinted fallback; callers can override (e.g. to colour-by-id). // brand-tinted fallback; callers can override (e.g. to colour-by-id).
fallbackClass?: string; fallbackClass?: string;
alt?: string; alt?: string;
// Browser loading hint. Use 'eager' for above-the-fold avatars (e.g. the
// active conversation header, call tiles). Defaults to 'lazy' so off-screen
// avatars (chat list rows, friends list, popovers) don't hammer Supabase
// Storage on initial render.
loading?: 'eager' | 'lazy';
} }
export function Avatar({ export function Avatar({
@@ -19,6 +24,7 @@ export function Avatar({
className = 'h-10 w-10', className = 'h-10 w-10',
fallbackClass = 'bg-accent/20 text-accent', fallbackClass = 'bg-accent/20 text-accent',
alt, alt,
loading = 'lazy',
}: Props) { }: Props) {
const effectiveUrl = useCachedAvatarUrl(url); const effectiveUrl = useCachedAvatarUrl(url);
if (effectiveUrl) { if (effectiveUrl) {
@@ -28,6 +34,7 @@ export function Avatar({
alt={alt ?? displayName ?? ''} alt={alt ?? displayName ?? ''}
className={'shrink-0 rounded-full object-cover ' + className} className={'shrink-0 rounded-full object-cover ' + className}
draggable={false} draggable={false}
loading={loading}
/> />
); );
} }
@@ -1,55 +0,0 @@
import { useEffect, useState } from 'react';
import type { ConversationSummary } from '@chat-app/shared/chat';
import { useCall } from '../context/CallContext';
interface Props {
conversation: ConversationSummary;
}
const STALE_AFTER_MS = 5000;
/** Discord-style live-captions overlay. Pinned to the bottom-center of the
* call surface; renders the most recent caption per participant, fading
* entries out after `STALE_AFTER_MS` of silence. Self-captions are shown
* too so the speaker can sanity-check what's being broadcast. */
export function CallCaptionsOverlay({ conversation }: Props) {
const { captions } = useCall();
// Re-render every second so stale entries fade without needing the data
// channel to fire — captions module just stores timestamps.
const [, setNow] = useState(Date.now());
useEffect(() => {
const id = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(id);
}, []);
const now = Date.now();
const visible = Object.entries(captions)
.filter(([, v]) => now - v.timestamp < STALE_AFTER_MS)
.sort(([, a], [, b]) => a.timestamp - b.timestamp);
if (visible.length === 0) return null;
return (
<div className="pointer-events-none absolute inset-x-0 bottom-24 z-30 flex flex-col items-center gap-1.5 px-6">
{visible.map(([identity, c]) => {
const member = conversation.members.find((m) => m.userId === identity);
const name = member?.profile?.displayName ?? '?';
const age = now - c.timestamp;
const opacity = age > 3500 ? 1 - (age - 3500) / (STALE_AFTER_MS - 3500) : 1;
return (
<div
key={identity}
style={{ opacity: Math.max(0, opacity) }}
className="max-w-[760px] rounded-lg bg-black/72 px-3.5 py-1.5 text-sm text-white shadow-md backdrop-blur-md transition-opacity"
>
<span className="mr-2 text-[11px] font-semibold uppercase tracking-wide text-white/55">
{name}
</span>
<span className={c.final ? '' : 'italic text-white/85'}>{c.text}</span>
</div>
);
})}
</div>
);
}
@@ -1,7 +1,6 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
CaptionsIcon,
HeadphonesIcon, HeadphonesIcon,
HeadphonesOffIcon, HeadphonesOffIcon,
MicIcon, MicIcon,
@@ -32,10 +31,6 @@ interface Props {
/** Toggle the in-call soundboard popover. Active = panel currently open. */ /** Toggle the in-call soundboard popover. Active = panel currently open. */
onToggleSoundboard?: () => void; onToggleSoundboard?: () => void;
soundboardOpen?: boolean; soundboardOpen?: boolean;
/** Discord-style live-captions toggle. Optional — pages that don't support
* SpeechRecognition (Firefox) skip the prop and the button is hidden. */
onToggleCaptions?: () => void;
captionsOn?: boolean;
participantsOpen?: boolean; participantsOpen?: boolean;
/** Compact variant used inside the docked call (36px buttons). */ /** Compact variant used inside the docked call (36px buttons). */
compact?: boolean; compact?: boolean;
@@ -59,8 +54,6 @@ export function CallControls({
onOpenParticipants, onOpenParticipants,
onToggleSoundboard, onToggleSoundboard,
soundboardOpen = false, soundboardOpen = false,
onToggleCaptions,
captionsOn = false,
participantsOpen = false, participantsOpen = false,
compact = false, compact = false,
glass = false, glass = false,
@@ -148,22 +141,6 @@ export function CallControls({
<MusicIcon className="h-5 w-5" /> <MusicIcon className="h-5 w-5" />
</CallButton> </CallButton>
)} )}
{onToggleCaptions && (
<CallButton
label={
captionsOn
? t('app:call.captions_off', { defaultValue: 'Untertitel aus' })
: t('app:call.captions_on', { defaultValue: 'Untertitel an' })
}
active={captionsOn}
activeTone="accent"
onClick={onToggleCaptions}
glass={glass}
className={btnSize}
>
<CaptionsIcon className="h-5 w-5" />
</CallButton>
)}
{onOpenParticipants && ( {onOpenParticipants && (
<CallButton <CallButton
label={t('app:call.participants', { defaultValue: 'Teilnehmer' })} label={t('app:call.participants', { defaultValue: 'Teilnehmer' })}
@@ -291,6 +291,7 @@ function AudioContent({
src={avatarUrl} src={avatarUrl}
alt="" alt=""
className="relative h-full w-full rounded-full object-cover" className="relative h-full w-full rounded-full object-cover"
loading="eager"
/> />
) : ( ) : (
<span <span
@@ -366,7 +367,7 @@ function VideoStub({
} }
> >
{avatarUrl ? ( {avatarUrl ? (
<img src={avatarUrl} alt="" className="h-full w-full rounded-full object-cover" /> <img src={avatarUrl} alt="" className="h-full w-full rounded-full object-cover" loading="eager" />
) : ( ) : (
letter letter
)} )}
@@ -0,0 +1,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" className="relative shrink-0 rounded-full text-left transition enabled:cursor-pointer enabled:hover:ring-2 enabled:hover:ring-accent/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:cursor-default"
> >
{isDm ? ( {isDm ? (
<Avatar url={peerAvatar} displayName={title} className="h-10 w-10 text-base" /> <Avatar url={peerAvatar} displayName={title} className="h-10 w-10 text-base" loading="eager" />
) : peerAvatar ? ( ) : peerAvatar ? (
<Avatar url={peerAvatar} displayName={title} className="h-10 w-10 text-base" /> <Avatar url={peerAvatar} displayName={title} className="h-10 w-10 text-base" loading="eager" />
) : ( ) : (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-accent/20 text-accent"> <div className="flex h-10 w-10 items-center justify-center rounded-full bg-accent/20 text-accent">
<UsersIcon className="h-5 w-5" /> <UsersIcon className="h-5 w-5" />
@@ -197,7 +197,7 @@ function HeaderBar({
interface HeaderActionButtonProps { interface HeaderActionButtonProps {
label: string; label: string;
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element; icon: React.ComponentType<React.ComponentPropsWithoutRef<'svg'>>;
onClick?: () => void; onClick?: () => void;
disabled?: boolean; disabled?: boolean;
tone?: 'default' | 'accent'; tone?: 'default' | 'accent';
@@ -8,6 +8,7 @@ import { forwardRef, useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useConversationsContext } from '../context/ConversationsContext';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
import { ArchiveIcon, AtIcon, BellIcon, BellOffIcon, MoreVerticalIcon } from './icons'; 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. // under the trigger so it doesn't push off-screen on narrow windows.
export function ConversationRowMenu({ conversationId, archived, mutedUntil, mentionsOnly }: Props) { export function ConversationRowMenu({ conversationId, archived, mutedUntil, mentionsOnly }: Props) {
const { t } = useTranslation(['app']); const { t } = useTranslation(['app']);
const { patchConversation } = useConversationsContext();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [submenuOpen, setSubmenuOpen] = useState<'mute' | null>(null); const [submenuOpen, setSubmenuOpen] = useState<'mute' | null>(null);
const [menuPos, setMenuPos] = useState<MenuPos | null>(null); const [menuPos, setMenuPos] = useState<MenuPos | null>(null);
@@ -122,44 +124,59 @@ export function ConversationRowMenu({ conversationId, archived, mutedUntil, ment
const isMuted = const isMuted =
mutedUntil !== null && new Date(mutedUntil).getTime() > Date.now(); mutedUntil !== null && new Date(mutedUntil).getTime() > Date.now();
// Optimistic updates: flip the local state before the server RPC so the
// bell / archive icon / checkmark update on the same frame as the click.
// Realtime echo via ConversationsContext will reconcile (no-op since the
// optimistic patch already matches the server row). On error we restore
// the previous value so the menu doesn't lie about persisted state.
const handleArchive = useCallback( const handleArchive = useCallback(
async (next: boolean) => { async (next: boolean) => {
setOpen(false); setOpen(false);
const previous = archived;
patchConversation(conversationId, { archived: next });
try { try {
await setConversationArchived(supabase, conversationId, next); await setConversationArchived(supabase, conversationId, next);
} catch (err: unknown) { } catch (err: unknown) {
patchConversation(conversationId, { archived: previous });
console.error('archive toggle failed', err); console.error('archive toggle failed', err);
} }
}, },
[conversationId], [conversationId, archived, patchConversation],
); );
const handleMute = useCallback( const handleMute = useCallback(
async (minutes: number | null) => { async (minutes: number | null) => {
setOpen(false); setOpen(false);
setSubmenuOpen(null); setSubmenuOpen(null);
const previous = mutedUntil;
const nextIso = muteDurationToIso(minutes);
patchConversation(conversationId, { mutedUntil: nextIso });
try { try {
await setConversationMutedUntil(supabase, conversationId, muteDurationToIso(minutes)); await setConversationMutedUntil(supabase, conversationId, nextIso);
} catch (err: unknown) { } catch (err: unknown) {
patchConversation(conversationId, { mutedUntil: previous });
console.error('mute toggle failed', err); console.error('mute toggle failed', err);
} }
}, },
[conversationId], [conversationId, mutedUntil, patchConversation],
); );
const handleMentionsOnly = useCallback( const handleMentionsOnly = useCallback(
async (next: boolean) => { async (next: boolean) => {
setOpen(false); setOpen(false);
const previous = mentionsOnly;
patchConversation(conversationId, { mentionsOnly: next });
try { try {
await setConversationMentionsOnly(supabase, { await setConversationMentionsOnly(supabase, {
conversationId, conversationId,
mentionsOnly: next, mentionsOnly: next,
}); });
} catch (err: unknown) { } catch (err: unknown) {
patchConversation(conversationId, { mentionsOnly: previous });
console.warn('mentions-only toggle failed', err); console.warn('mentions-only toggle failed', err);
} }
}, },
[conversationId], [conversationId, mentionsOnly, patchConversation],
); );
return ( return (
@@ -48,6 +48,14 @@ export function GameModal({ gameId, onClose }: Props) {
useEffect(() => { useEffect(() => {
if (finished && winnerIdx !== null && winnerIdx === myPlayerIdx) { 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. // Two bursts from the lower corners for a celebratory feel.
void confetti({ void confetti({
particleCount: 80, particleCount: 80,
+25 -3
View File
@@ -41,20 +41,42 @@ export function ImageAnnotator({ file, onCancel, onSave }: Props) {
const draftRef = useRef<AnnotatorOp | null>(null); const draftRef = useRef<AnnotatorOp | null>(null);
const [draftTick, setDraftTick] = useState(0); 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(() => { 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 url = URL.createObjectURL(file);
const img = new Image(); const img = new Image();
img.onload = () => { img.onload = () => {
if (cancelled) return;
imageRef.current = img; imageRef.current = img;
setImageLoaded(true); setImageLoaded(true);
}; };
img.onerror = () => { img.onerror = () => {
if (cancelled) return;
console.error('ImageAnnotator: failed to decode source image'); console.error('ImageAnnotator: failed to decode source image');
onCancel(); onCancelRef.current();
}; };
img.src = url; img.src = url;
return () => URL.revokeObjectURL(url); return () => {
}, [file, onCancel]); cancelled = true;
URL.revokeObjectURL(url);
};
}, [file]);
useEffect(() => { useEffect(() => {
if (!imageLoaded) return; if (!imageLoaded) return;
@@ -15,14 +15,7 @@ import {
listSounds, listSounds,
subscribeSoundboardChanges, subscribeSoundboardChanges,
} from '../lib/soundboardStorage'; } from '../lib/soundboardStorage';
import {
getLiveCaptionsSettings,
isLiveCaptionsSupported,
subscribeLiveCaptionsSettings,
updateLiveCaptionsSettings,
} from '../lib/liveCaptions';
import { useActiveSpeakers } from '../lib/useActiveSpeakers'; import { useActiveSpeakers } from '../lib/useActiveSpeakers';
import { CallCaptionsOverlay } from './CallCaptionsOverlay';
import { CallControls } from './CallControls'; import { CallControls } from './CallControls';
import { CallParticipantTile, type TileConnectionQuality } from './CallParticipantTile'; import { CallParticipantTile, type TileConnectionQuality } from './CallParticipantTile';
import { CallStatsOverlay } from './CallStatsOverlay'; import { CallStatsOverlay } from './CallStatsOverlay';
@@ -119,17 +112,6 @@ export function InCallPanel({ conversation }: Props) {
const [sharePickerOpen, setSharePickerOpen] = useState(false); const [sharePickerOpen, setSharePickerOpen] = useState(false);
// Discord-style debug stats overlay (Ctrl+Shift+S toggles). // Discord-style debug stats overlay (Ctrl+Shift+S toggles).
const [statsOverlayOpen, setStatsOverlayOpen] = useState(false); const [statsOverlayOpen, setStatsOverlayOpen] = useState(false);
// Live-captions toggle — mirror of getLiveCaptionsSettings().enabled so the
// controls bar can show an "active" state without polling. Captions
// broadcasting is wired in CallContext via useLiveCaptions; this only
// tracks the toggle state for the button.
const [captionsEnabled, setCaptionsEnabled] = useState<boolean>(
() => getLiveCaptionsSettings().enabled,
);
useEffect(
() => subscribeLiveCaptionsSettings((s) => setCaptionsEnabled(s.enabled)),
[],
);
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
// Match the modifier exactly to avoid clobbering other Ctrl+Shift combos. // Match the modifier exactly to avoid clobbering other Ctrl+Shift combos.
@@ -362,15 +344,6 @@ export function InCallPanel({ conversation }: Props) {
soundboardOpen, soundboardOpen,
} }
: {})} : {})}
// Live-Captions only when SpeechRecognition is available in the
// runtime — Firefox lacks it, would just show a dead button.
{...(isLiveCaptionsSupported()
? {
onToggleCaptions: () =>
updateLiveCaptionsSettings({ enabled: !captionsEnabled }),
captionsOn: captionsEnabled,
}
: {})}
onHangup={() => void hangup()} onHangup={() => void hangup()}
compact={callMode !== 'fullscreen'} compact={callMode !== 'fullscreen'}
glass={callMode === 'fullscreen'} glass={callMode === 'fullscreen'}
@@ -499,7 +472,6 @@ export function InCallPanel({ conversation }: Props) {
onClose={() => setStatsOverlayOpen(false)} onClose={() => setStatsOverlayOpen(false)}
/> />
)} )}
{captionsEnabled && <CallCaptionsOverlay conversation={conversation} />}
</> </>
); );
} }
@@ -644,8 +616,6 @@ export function InCallPanel({ conversation }: Props) {
onClose={() => setStatsOverlayOpen(false)} onClose={() => setStatsOverlayOpen(false)}
/> />
)} )}
{captionsEnabled && <CallCaptionsOverlay conversation={conversation} />}
</section> </section>
); );
} }
+27 -8
View File
@@ -5,7 +5,7 @@ import {
softDeleteMessage, softDeleteMessage,
} from '@chat-app/shared/chat'; } from '@chat-app/shared/chat';
import { extractErrorCode } from '@chat-app/shared/i18n'; import { extractErrorCode } from '@chat-app/shared/i18n';
import { useCallback, useEffect, useRef, useState } from 'react'; import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -60,8 +60,17 @@ interface Props {
senderAvatarUrl?: string | null | undefined; senderAvatarUrl?: string | null | undefined;
conversationId: string; conversationId: string;
reactions: AggregatedReaction[]; reactions: AggregatedReaction[];
onToggleReaction: (emoji: string) => Promise<void>; /**
onVotePoll?: (emoji: string, optionEmojis: string[]) => Promise<void>; * Toggle a reaction on this message. Receives the message id so the parent
* can pass a stable handler reference across every row (lets `React.memo`
* actually skip re-renders triggered by composer keystrokes / typing pings).
*/
onToggleReaction: (messageId: string, emoji: string) => Promise<void>;
/**
* Cast/clear an exclusive poll vote. Receives the message id for the same
* reason as `onToggleReaction`.
*/
onVotePoll?: (messageId: string, emoji: string, optionEmojis: string[]) => Promise<void>;
showSeen?: boolean; showSeen?: boolean;
/** Delivery state for own messages: 'sent' / 'delivered' / 'read'. */ /** Delivery state for own messages: 'sent' / 'delivered' / 'read'. */
deliveryState?: 'sent' | 'delivered' | 'read'; deliveryState?: 'sent' | 'delivered' | 'read';
@@ -83,7 +92,7 @@ interface Props {
onTogglePin?: (messageId: string) => void; onTogglePin?: (messageId: string) => void;
} }
export function MessageBubble({ function MessageBubbleInner({
message, message,
mine, mine,
groupedWithPrev, groupedWithPrev,
@@ -257,12 +266,12 @@ export function MessageBubble({
async (emoji: string) => { async (emoji: string) => {
setPickerOpen(false); setPickerOpen(false);
try { try {
await onToggleReaction(emoji); await onToggleReaction(message.id, emoji);
} catch (err: unknown) { } catch (err: unknown) {
console.error('toggleReaction failed', err); console.error('toggleReaction failed', err);
} }
}, },
[onToggleReaction], [onToggleReaction, message.id],
); );
const copyableText = const copyableText =
@@ -527,7 +536,9 @@ export function MessageBubble({
reactions={reactions} reactions={reactions}
mine={mine} mine={mine}
onVote={(emoji) => onVote={(emoji) =>
onVotePoll ? onVotePoll(emoji, pollOptionEmojis) : onToggleReaction(emoji) onVotePoll
? onVotePoll(message.id, emoji, pollOptionEmojis)
: onToggleReaction(message.id, emoji)
} }
/> />
) : ( ) : (
@@ -593,7 +604,7 @@ export function MessageBubble({
<button <button
key={r.emoji + ':' + r.count} key={r.emoji + ':' + r.count}
type="button" type="button"
onClick={() => void onToggleReaction(r.emoji)} onClick={() => void onToggleReaction(message.id, r.emoji)}
className={ className={
'inline-flex origin-bottom animate-reaction-pop cursor-pointer items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' + 'inline-flex origin-bottom animate-reaction-pop cursor-pointer items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(r.mine (r.mine
@@ -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({ function PollCard({
question, question,
options, options,
@@ -1,5 +1,11 @@
import { useState } from 'react'; import { useState } from 'react';
import {
type AutoLockMinutes,
getAutoLockMinutes,
notifyAutoLockChanged,
setAutoLockMinutes,
} from '../lib/autoLockSettings';
import { isWipeOnCloseEnabled, setWipeOnClose } from '../lib/memoryWipeSettings'; import { isWipeOnCloseEnabled, setWipeOnClose } from '../lib/memoryWipeSettings';
import { import {
changePin, changePin,
@@ -21,6 +27,7 @@ export function SecurityCenter({ userId }: Props) {
const [recovery, setRecovery] = useState<string | null>(null); const [recovery, setRecovery] = useState<string | null>(null);
const [migration, setMigration] = useState<LegacyMigrationReport | null>(null); const [migration, setMigration] = useState<LegacyMigrationReport | null>(null);
const [wipeOnClose, setWipeOnCloseState] = useState<boolean>(() => isWipeOnCloseEnabled()); const [wipeOnClose, setWipeOnCloseState] = useState<boolean>(() => isWipeOnCloseEnabled());
const [autoLockMinutes, setAutoLockMinutesState] = useState<AutoLockMinutes>(() => getAutoLockMinutes());
async function handleRetryMigration() { async function handleRetryMigration() {
setBusy(true); setMsg(null); setMigration(null); setBusy(true); setMsg(null); setMigration(null);
@@ -149,6 +156,39 @@ export function SecurityCenter({ userId }: Props) {
</label> </label>
</section> </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> <section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-rose-400">Identität zurücksetzen</h3> <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> <p className="mb-2 text-xs text-fg-muted">Erstellt einen neuen Schlüssel. Alle alten Chats werden unlesbar.</p>
+3 -3
View File
@@ -17,7 +17,7 @@ import {
interface NavItem { interface NavItem {
to: string; to: string;
labelKey: string; labelKey: string;
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element; icon: React.ComponentType<React.ComponentPropsWithoutRef<'svg'>>;
badge?: 'friends' | 'chats'; badge?: 'friends' | 'chats';
} }
@@ -95,7 +95,7 @@ export function Sidebar() {
interface RailNavLinkProps { interface RailNavLinkProps {
to: string; to: string;
label: string; label: string;
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element; icon: React.ComponentType<React.ComponentPropsWithoutRef<'svg'>>;
badge?: number; badge?: number;
} }
@@ -135,7 +135,7 @@ function RailNavLink({ to, label, icon: Icon, badge = 0 }: RailNavLinkProps) {
interface RailIconButtonProps { interface RailIconButtonProps {
label: string; label: string;
onClick: () => void; onClick: () => void;
icon: (props: React.SVGProps<SVGSVGElement>) => React.JSX.Element; icon: React.ComponentType<React.ComponentPropsWithoutRef<'svg'>>;
tone?: 'default' | 'danger'; tone?: 'default' | 'danger';
} }
+124 -64
View File
@@ -1,6 +1,7 @@
// Inline SVG icons — no icon library dependency. Lucide-style stroke=1.75. // Inline SVG icons — no icon library dependency. Lucide-style stroke=1.75.
import { memo } from 'react';
type IconProps = React.SVGProps<SVGSVGElement>; type IconProps = React.ComponentPropsWithoutRef<'svg'>;
function Base({ children, ...props }: IconProps & { children: React.ReactNode }) { function Base({ children, ...props }: IconProps & { children: React.ReactNode }) {
return ( return (
@@ -20,7 +21,7 @@ function Base({ children, ...props }: IconProps & { children: React.ReactNode })
); );
} }
export function MailIcon(props: IconProps) { function MailIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<rect x="3" y="5" width="18" height="14" rx="2" /> <rect x="3" y="5" width="18" height="14" rx="2" />
@@ -28,8 +29,9 @@ export function MailIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const MailIcon = memo(MailIconInner);
export function AtIcon(props: IconProps) { function AtIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<circle cx="12" cy="12" r="4" /> <circle cx="12" cy="12" r="4" />
@@ -37,8 +39,9 @@ export function AtIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const AtIcon = memo(AtIconInner);
export function TicketIcon(props: IconProps) { function TicketIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M3 8a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v2a2 2 0 1 0 0 4v2a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-2a2 2 0 1 0 0-4V8Z" /> <path d="M3 8a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v2a2 2 0 1 0 0 4v2a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-2a2 2 0 1 0 0-4V8Z" />
@@ -46,8 +49,9 @@ export function TicketIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const TicketIcon = memo(TicketIconInner);
export function ArrowRightIcon(props: IconProps) { function ArrowRightIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M5 12h14" /> <path d="M5 12h14" />
@@ -55,8 +59,9 @@ export function ArrowRightIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const ArrowRightIcon = memo(ArrowRightIconInner);
export function CheckCircleIcon(props: IconProps) { function CheckCircleIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<circle cx="12" cy="12" r="9" /> <circle cx="12" cy="12" r="9" />
@@ -64,8 +69,9 @@ export function CheckCircleIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const CheckCircleIcon = memo(CheckCircleIconInner);
export function AlertIcon(props: IconProps) { function AlertIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M12 9v4" /> <path d="M12 9v4" />
@@ -74,8 +80,9 @@ export function AlertIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const AlertIcon = memo(AlertIconInner);
export function ShieldIcon(props: IconProps) { function ShieldIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M12 3 4 6v6c0 5 3.5 8.5 8 9 4.5-.5 8-4 8-9V6l-8-3Z" /> <path d="M12 3 4 6v6c0 5 3.5 8.5 8 9 4.5-.5 8-4 8-9V6l-8-3Z" />
@@ -83,8 +90,9 @@ export function ShieldIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const ShieldIcon = memo(ShieldIconInner);
export function LockIcon(props: IconProps) { function LockIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<rect x="4" y="11" width="16" height="10" rx="2" /> <rect x="4" y="11" width="16" height="10" rx="2" />
@@ -92,8 +100,9 @@ export function LockIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const LockIcon = memo(LockIconInner);
export function WifiLowIcon(props: IconProps) { function WifiLowIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M5 12.55a11 11 0 0 1 14 0" opacity="0.35" /> <path d="M5 12.55a11 11 0 0 1 14 0" opacity="0.35" />
@@ -102,8 +111,9 @@ export function WifiLowIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const WifiLowIcon = memo(WifiLowIconInner);
export function WifiOffIcon(props: IconProps) { function WifiOffIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="m2 2 20 20" /> <path d="m2 2 20 20" />
@@ -116,8 +126,9 @@ export function WifiOffIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const WifiOffIcon = memo(WifiOffIconInner);
export function PinIcon(props: IconProps) { function PinIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M12 17v5" /> <path d="M12 17v5" />
@@ -125,8 +136,9 @@ export function PinIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const PinIcon = memo(PinIconInner);
export function EyeOffIcon(props: IconProps) { function EyeOffIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="m2 2 20 20" /> <path d="m2 2 20 20" />
@@ -136,18 +148,19 @@ export function EyeOffIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const EyeOffIcon = memo(EyeOffIconInner);
export function CaptionsIcon(props: IconProps) { function EyeIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<rect x="3" y="6" width="18" height="12" rx="2" /> <path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" />
<path d="M7 13a2 2 0 1 1 0-2" /> <circle cx="12" cy="12" r="3" />
<path d="M14 13a2 2 0 1 1 0-2" />
</Base> </Base>
); );
} }
export const EyeIcon = memo(EyeIconInner);
export function PinOffIcon(props: IconProps) { function PinOffIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="m2 2 20 20" /> <path d="m2 2 20 20" />
@@ -157,8 +170,9 @@ export function PinOffIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const PinOffIcon = memo(PinOffIconInner);
export function SparklesIcon(props: IconProps) { function SparklesIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M12 3v4" /> <path d="M12 3v4" />
@@ -172,8 +186,9 @@ export function SparklesIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const SparklesIcon = memo(SparklesIconInner);
export function SpinnerIcon(props: IconProps) { function SpinnerIconInner(props: IconProps) {
return ( return (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@@ -208,16 +223,18 @@ export function SpinnerIcon(props: IconProps) {
</svg> </svg>
); );
} }
export const SpinnerIcon = memo(SpinnerIconInner);
export function ChatBubbleIcon(props: IconProps) { function ChatBubbleIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M21 15a2 2 0 0 1-2 2H8l-5 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z" /> <path d="M21 15a2 2 0 0 1-2 2H8l-5 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z" />
</Base> </Base>
); );
} }
export const ChatBubbleIcon = memo(ChatBubbleIconInner);
export function UsersIcon(props: IconProps) { function UsersIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /> <path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
@@ -227,8 +244,9 @@ export function UsersIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const UsersIcon = memo(UsersIconInner);
export function GearIcon(props: IconProps) { function GearIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<circle cx="12" cy="12" r="3" /> <circle cx="12" cy="12" r="3" />
@@ -236,8 +254,9 @@ export function GearIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const GearIcon = memo(GearIconInner);
export function SearchIcon(props: IconProps) { function SearchIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<circle cx="11" cy="11" r="7" /> <circle cx="11" cy="11" r="7" />
@@ -245,16 +264,18 @@ export function SearchIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const SearchIcon = memo(SearchIconInner);
export function PlusIcon(props: IconProps) { function PlusIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M12 5v14M5 12h14" /> <path d="M12 5v14M5 12h14" />
</Base> </Base>
); );
} }
export const PlusIcon = memo(PlusIconInner);
export function SignOutIcon(props: IconProps) { function SignOutIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /> <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
@@ -263,32 +284,36 @@ export function SignOutIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const SignOutIcon = memo(SignOutIconInner);
export function MenuIcon(props: IconProps) { function MenuIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M3 6h18M3 12h18M3 18h18" /> <path d="M3 6h18M3 12h18M3 18h18" />
</Base> </Base>
); );
} }
export const MenuIcon = memo(MenuIconInner);
export function ChevronDownIcon(props: IconProps) { function ChevronDownIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="m6 9 6 6 6-6" /> <path d="m6 9 6 6 6-6" />
</Base> </Base>
); );
} }
export const ChevronDownIcon = memo(ChevronDownIconInner);
export function PencilIcon(props: IconProps) { function PencilIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" /> <path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
</Base> </Base>
); );
} }
export const PencilIcon = memo(PencilIconInner);
export function TrashIcon(props: IconProps) { function TrashIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M3 6h18" /> <path d="M3 6h18" />
@@ -299,8 +324,9 @@ export function TrashIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const TrashIcon = memo(TrashIconInner);
export function SmileIcon(props: IconProps) { function SmileIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<circle cx="12" cy="12" r="9" /> <circle cx="12" cy="12" r="9" />
@@ -310,8 +336,9 @@ export function SmileIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const SmileIcon = memo(SmileIconInner);
export function CopyIcon(props: IconProps) { function CopyIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<rect x="9" y="9" width="13" height="13" rx="2" /> <rect x="9" y="9" width="13" height="13" rx="2" />
@@ -319,16 +346,18 @@ export function CopyIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const CopyIcon = memo(CopyIconInner);
export function PhoneIcon(props: IconProps) { function PhoneIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6A19.79 19.79 0 0 1 2.12 4.18 2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92Z" /> <path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6A19.79 19.79 0 0 1 2.12 4.18 2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92Z" />
</Base> </Base>
); );
} }
export const PhoneIcon = memo(PhoneIconInner);
export function PhoneOffIcon(props: IconProps) { function PhoneOffIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-3.48-3.05" /> <path d="M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-3.48-3.05" />
@@ -337,8 +366,9 @@ export function PhoneOffIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const PhoneOffIcon = memo(PhoneOffIconInner);
export function MicIcon(props: IconProps) { function MicIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<rect x="9" y="2" width="6" height="12" rx="3" /> <rect x="9" y="2" width="6" height="12" rx="3" />
@@ -348,8 +378,9 @@ export function MicIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const MicIcon = memo(MicIconInner);
export function MicOffIcon(props: IconProps) { function MicOffIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M1 1l22 22" /> <path d="M1 1l22 22" />
@@ -362,8 +393,9 @@ export function MicOffIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const MicOffIcon = memo(MicOffIconInner);
export function InfoIcon(props: IconProps) { function InfoIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<circle cx="12" cy="12" r="9" /> <circle cx="12" cy="12" r="9" />
@@ -372,16 +404,18 @@ export function InfoIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const InfoIcon = memo(InfoIconInner);
export function XIcon(props: IconProps) { function XIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M18 6 6 18M6 6l12 12" /> <path d="M18 6 6 18M6 6l12 12" />
</Base> </Base>
); );
} }
export const XIcon = memo(XIconInner);
export function MonitorShareIcon(props: IconProps) { function MonitorShareIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<rect x="3" y="4" width="18" height="12" rx="2" /> <rect x="3" y="4" width="18" height="12" rx="2" />
@@ -390,8 +424,9 @@ export function MonitorShareIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const MonitorShareIcon = memo(MonitorShareIconInner);
export function MonitorStopIcon(props: IconProps) { function MonitorStopIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<rect x="3" y="4" width="18" height="12" rx="2" /> <rect x="3" y="4" width="18" height="12" rx="2" />
@@ -400,8 +435,9 @@ export function MonitorStopIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const MonitorStopIcon = memo(MonitorStopIconInner);
export function SunIcon(props: IconProps) { function SunIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<circle cx="12" cy="12" r="4" /> <circle cx="12" cy="12" r="4" />
@@ -409,16 +445,18 @@ export function SunIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const SunIcon = memo(SunIconInner);
export function MoonIcon(props: IconProps) { function MoonIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M21 12.79A9 9 0 1 1 11.21 3a7 7 0 0 0 9.79 9.79Z" /> <path d="M21 12.79A9 9 0 1 1 11.21 3a7 7 0 0 0 9.79 9.79Z" />
</Base> </Base>
); );
} }
export const MoonIcon = memo(MoonIconInner);
export function GridIcon(props: IconProps) { function GridIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<rect x="3" y="3" width="7" height="7" rx="1.5" /> <rect x="3" y="3" width="7" height="7" rx="1.5" />
@@ -428,8 +466,9 @@ export function GridIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const GridIcon = memo(GridIconInner);
export function ImageIcon(props: IconProps) { function ImageIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<rect x="3" y="5" width="18" height="14" rx="2" /> <rect x="3" y="5" width="18" height="14" rx="2" />
@@ -438,8 +477,9 @@ export function ImageIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const ImageIcon = memo(ImageIconInner);
export function FileIcon(props: IconProps) { function FileIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M14 2H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7Z" /> <path d="M14 2H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7Z" />
@@ -447,8 +487,9 @@ export function FileIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const FileIcon = memo(FileIconInner);
export function PollIcon(props: IconProps) { function PollIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M5 19V9" /> <path d="M5 19V9" />
@@ -458,8 +499,9 @@ export function PollIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const PollIcon = memo(PollIconInner);
export function FocusIcon(props: IconProps) { function FocusIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<rect x="3" y="3" width="18" height="18" rx="2" /> <rect x="3" y="3" width="18" height="18" rx="2" />
@@ -467,8 +509,9 @@ export function FocusIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const FocusIcon = memo(FocusIconInner);
export function MaximizeIcon(props: IconProps) { function MaximizeIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M4 9V5a1 1 0 0 1 1-1h4" /> <path d="M4 9V5a1 1 0 0 1 1-1h4" />
@@ -478,8 +521,9 @@ export function MaximizeIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const MaximizeIcon = memo(MaximizeIconInner);
export function VideoIcon(props: IconProps) { function VideoIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<rect x="2" y="6" width="15" height="12" rx="2" /> <rect x="2" y="6" width="15" height="12" rx="2" />
@@ -487,16 +531,18 @@ export function VideoIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const VideoIcon = memo(VideoIconInner);
export function CrownIcon(props: IconProps) { function CrownIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="m3 7 4 4 5-6 5 6 4-4v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" /> <path d="m3 7 4 4 5-6 5 6 4-4v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" />
</Base> </Base>
); );
} }
export const CrownIcon = memo(CrownIconInner);
export function MusicIcon(props: IconProps) { function MusicIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M9 18V5l12-2v13" /> <path d="M9 18V5l12-2v13" />
@@ -505,16 +551,18 @@ export function MusicIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const MusicIcon = memo(MusicIconInner);
export function SendIcon(props: IconProps) { function SendIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="m3 11 18-8-8 18-2-8-8-2Z" /> <path d="m3 11 18-8-8 18-2-8-8-2Z" />
</Base> </Base>
); );
} }
export const SendIcon = memo(SendIconInner);
export function ArchiveIcon(props: IconProps) { function ArchiveIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<rect x="3" y="4" width="18" height="5" rx="1" /> <rect x="3" y="4" width="18" height="5" rx="1" />
@@ -523,8 +571,9 @@ export function ArchiveIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const ArchiveIcon = memo(ArchiveIconInner);
export function BellIcon(props: IconProps) { function BellIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M6 8a6 6 0 1 1 12 0c0 4 1.5 5 2 6H4c.5-1 2-2 2-6Z" /> <path d="M6 8a6 6 0 1 1 12 0c0 4 1.5 5 2 6H4c.5-1 2-2 2-6Z" />
@@ -532,8 +581,9 @@ export function BellIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const BellIcon = memo(BellIconInner);
export function BellOffIcon(props: IconProps) { function BellOffIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M9.5 18a2.5 2.5 0 0 0 5 0" /> <path d="M9.5 18a2.5 2.5 0 0 0 5 0" />
@@ -545,8 +595,9 @@ export function BellOffIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const BellOffIcon = memo(BellOffIconInner);
export function MoreVerticalIcon(props: IconProps) { function MoreVerticalIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<circle cx="12" cy="5" r="1.5" /> <circle cx="12" cy="5" r="1.5" />
@@ -555,8 +606,9 @@ export function MoreVerticalIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const MoreVerticalIcon = memo(MoreVerticalIconInner);
export function HeadphonesIcon(props: IconProps) { function HeadphonesIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M3 14v-2a9 9 0 0 1 18 0v2" /> <path d="M3 14v-2a9 9 0 0 1 18 0v2" />
@@ -565,8 +617,9 @@ export function HeadphonesIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const HeadphonesIcon = memo(HeadphonesIconInner);
export function HeadphonesOffIcon(props: IconProps) { function HeadphonesOffIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M3 14v-2a9 9 0 0 1 11.3-8.7" /> <path d="M3 14v-2a9 9 0 0 1 11.3-8.7" />
@@ -577,8 +630,9 @@ export function HeadphonesOffIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const HeadphonesOffIcon = memo(HeadphonesOffIconInner);
export function ReplyIcon(props: IconProps) { function ReplyIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<polyline points="9 17 4 12 9 7" /> <polyline points="9 17 4 12 9 7" />
@@ -586,8 +640,9 @@ export function ReplyIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const ReplyIcon = memo(ReplyIconInner);
export function ForwardIcon(props: IconProps) { function ForwardIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<polyline points="15 17 20 12 15 7" /> <polyline points="15 17 20 12 15 7" />
@@ -595,16 +650,18 @@ export function ForwardIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const ForwardIcon = memo(ForwardIconInner);
export function ChevronUpIcon(props: IconProps) { function ChevronUpIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<polyline points="18 15 12 9 6 15" /> <polyline points="18 15 12 9 6 15" />
</Base> </Base>
); );
} }
export const ChevronUpIcon = memo(ChevronUpIconInner);
export function AddUserIcon(props: IconProps) { function AddUserIconInner(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /> <path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
@@ -613,12 +670,13 @@ export function AddUserIcon(props: IconProps) {
</Base> </Base>
); );
} }
export const AddUserIcon = memo(AddUserIconInner);
// Soft-N mark: rounded-square violet tile with a stylised "N" glyph. Used // Soft-N mark: rounded-square violet tile with a stylised "N" glyph. Used
// wherever the app needs a standalone icon (sidebar rail, auth screen, // wherever the app needs a standalone icon (sidebar rail, auth screen,
// favicon). Colour decisions sit inside the SVG so consumers just size the // favicon). Colour decisions sit inside the SVG so consumers just size the
// element via `className`. // element via `className`.
export function LogoMark(props: IconProps) { function LogoMarkInner(props: IconProps) {
return ( return (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@@ -638,10 +696,11 @@ export function LogoMark(props: IconProps) {
</svg> </svg>
); );
} }
export const LogoMark = memo(LogoMarkInner);
// Full lockup: soft-N mark + "Netralax" wordmark. `tone` decides text colour: // Full lockup: soft-N mark + "Netralax" wordmark. `tone` decides text colour:
// "dark" = white text (use on dark background), "light" = near-black. // "dark" = white text (use on dark background), "light" = near-black.
export function LogoLockup({ function LogoLockupInner({
tone = 'dark', tone = 'dark',
...props ...props
}: IconProps & { tone?: 'dark' | 'light' }) { }: IconProps & { tone?: 'dark' | 'light' }) {
@@ -676,3 +735,4 @@ export function LogoLockup({
</svg> </svg>
); );
} }
export const LogoLockup = memo(LogoLockupInner);
+13
View File
@@ -204,6 +204,19 @@ export function AuthProvider({ children }: { children: ReactNode }) {
void registerWebPush(installId); void registerWebPush(installId);
}, [session]); }, [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 // Phase 3: ensure this install owns exactly one devices row. The row is
// pure session-list telemetry — it does not carry any cryptographic // pure session-list telemetry — it does not carry any cryptographic
// material since the per-user-key refactor. We re-use the row across // material since the per-user-key refactor. We re-use the row across
-48
View File
@@ -39,7 +39,6 @@ import {
playUndeafenBeep, playUndeafenBeep,
playUnmuteBeep, playUnmuteBeep,
} from '../lib/callSounds'; } from '../lib/callSounds';
import { useLiveCaptions } from '../lib/useLiveCaptions';
import { setCallWakeLock } from '../lib/wakeLock'; import { setCallWakeLock } from '../lib/wakeLock';
import { notify } from '../lib/osNotify'; import { notify } from '../lib/osNotify';
import { import {
@@ -209,14 +208,6 @@ interface CallContextValue {
* invite (fromUserId). Cleared on disconnect. Drives the crown badge, * invite (fromUserId). Cleared on disconnect. Drives the crown badge,
* but only in group calls. Null while idle or in 1:1 contexts. */ * but only in group calls. Null while idle or in 1:1 contexts. */
callHostId: string | null; callHostId: string | null;
/** identity -> latest live-caption fragment received via data channel.
* Includes own captions for self-overlay. Receivers prune entries whose
* timestamp is older than ~5s so stale lines fade out. */
captions: Record<string, { text: string; final: boolean; timestamp: number }>;
/** Surface a caption for the local user — the live-captions hook calls
* this on every interim/final SpeechRecognition result so the overlay
* shows our own line without going through the SFU round-trip. */
pushLocalCaption: (text: string, final: boolean) => void;
/** identity -> mute state. Broadcast from peer whenever mic-gain flips. /** identity -> mute state. Broadcast from peer whenever mic-gain flips.
* Needed because we can't rely on LiveKit's native isMicrophoneEnabled — * Needed because we can't rely on LiveKit's native isMicrophoneEnabled —
* the mic pipeline keeps the track published with sound flowing even * the mic pipeline keeps the track published with sound flowing even
@@ -347,9 +338,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
// useEffect) so peers don't hear themselves echoed back when the OS-level // useEffect) so peers don't hear themselves echoed back when the OS-level
// process-tree exclusion isn't watertight. // process-tree exclusion isn't watertight.
const [isCapturingSystemAudio, setIsCapturingSystemAudio] = useState(false); const [isCapturingSystemAudio, setIsCapturingSystemAudio] = useState(false);
const [captions, setCaptions] = useState<
Record<string, { text: string; final: boolean; timestamp: number }>
>({});
const [lastCallConversationId, setLastCallConversationId] = useState<string | null>(null); const [lastCallConversationId, setLastCallConversationId] = useState<string | null>(null);
const [callMode, setCallModeState] = useState<CallMode>('grid'); const [callMode, setCallModeState] = useState<CallMode>('grid');
const [focusedId, setFocusedIdState] = useState<string | null>(null); const [focusedId, setFocusedIdState] = useState<string | null>(null);
@@ -828,7 +816,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
setRemoteScreenShares([]); setRemoteScreenShares([]);
setConnectionQualities({}); setConnectionQualities({});
setCallHostId(null); setCallHostId(null);
setCaptions({});
setIsScreenSharing(false); setIsScreenSharing(false);
setIsE2EEActive(false); setIsE2EEActive(false);
} }
@@ -970,8 +957,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
type?: string; type?: string;
deafened?: boolean; deafened?: boolean;
muted?: boolean; muted?: boolean;
captionText?: string;
captionFinal?: boolean;
}; };
const id: string = participant.identity; const id: string = participant.identity;
if (msg.type === 'presence') { if (msg.type === 'presence') {
@@ -991,15 +976,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
} }
return; return;
} }
if (msg.type === 'caption' && typeof msg.captionText === 'string') {
const text2 = msg.captionText;
const final = msg.captionFinal === true;
setCaptions((prev) => ({
...prev,
[id]: { text: text2, final, timestamp: Date.now() },
}));
return;
}
} catch { } catch {
/* ignore malformed */ /* ignore malformed */
} }
@@ -1754,17 +1730,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
}); });
}, []); }, []);
const pushLocalCaption = useCallback(
(text: string, final: boolean) => {
if (!myId) return;
setCaptions((prev) => ({
...prev,
[myId]: { text, final, timestamp: Date.now() },
}));
},
[myId],
);
const toggleCamera = useCallback(async () => { const toggleCamera = useCallback(async () => {
const r = roomRef.current; const r = roomRef.current;
if (!r) return; if (!r) return;
@@ -2603,15 +2568,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
return () => window.removeEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey);
}, [callMode, state.kind]); }, [callMode, state.kind]);
// Discord-style live-captions broadcaster — runs on the local mic while
// we're connected, and ships interim/final transcripts on the LiveKit
// DataChannel so peers can render them.
useLiveCaptions({
room,
active: state.kind === 'connected' || state.kind === 'reconnecting',
onLocalCaption: pushLocalCaption,
});
const value = useMemo<CallContextValue>( const value = useMemo<CallContextValue>(
() => ({ () => ({
state, state,
@@ -2626,8 +2582,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
remoteMute, remoteMute,
connectionQualities, connectionQualities,
callHostId, callHostId,
captions,
pushLocalCaption,
remoteScreenShares, remoteScreenShares,
lastCallConversationId, lastCallConversationId,
callMode, callMode,
@@ -2683,8 +2637,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
remoteMute, remoteMute,
connectionQualities, connectionQualities,
callHostId, callHostId,
captions,
pushLocalCaption,
remoteScreenShares, remoteScreenShares,
lastCallConversationId, lastCallConversationId,
callMode, callMode,
@@ -54,6 +54,15 @@ interface ConversationsContextValue {
refresh: () => Promise<void>; refresh: () => Promise<void>;
markRead: (conversationId: string) => void; markRead: (conversationId: string) => void;
setActiveConversation: (conversationId: string | null) => void; setActiveConversation: (conversationId: string | null) => void;
// Optimistic patch for the caller's per-membership preferences (mute /
// mentions-only / archive). Mutations to `conversation_members` echo back
// via the realtime channel and `refresh()` reconciles canonically, but the
// ~100-200ms roundtrip leaves the UI looking unresponsive. Callers patch
// immediately, snapshot the previous state, and roll back on failure.
patchConversation: (
conversationId: string,
patch: Partial<Pick<ConversationSummary, 'archived' | 'mutedUntil' | 'mentionsOnly'>>,
) => void;
} }
const ConversationsContext = createContext<ConversationsContextValue | null>(null); const ConversationsContext = createContext<ConversationsContextValue | null>(null);
@@ -164,6 +173,19 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
[markRead], [markRead],
); );
const patchConversation = useCallback<ConversationsContextValue['patchConversation']>(
(convId, patch) => {
setConversations((prev) => {
const idx = prev.findIndex((c) => c.id === convId);
if (idx === -1) return prev;
const next = [...prev];
next[idx] = { ...next[idx]!, ...patch };
return next;
});
},
[],
);
useEffect(() => { useEffect(() => {
if (!myId) { if (!myId) {
setConversations([]); setConversations([]);
@@ -308,6 +330,7 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
refresh, refresh,
markRead, markRead,
setActiveConversation, setActiveConversation,
patchConversation,
}), }),
[ [
conversations, conversations,
@@ -318,6 +341,7 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
refresh, refresh,
markRead, markRead,
setActiveConversation, setActiveConversation,
patchConversation,
], ],
); );
+79
View File
@@ -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]);
}
+29 -2
View File
@@ -64,10 +64,37 @@ export function useOwnDevices(): {
const revoke = useCallback( const revoke = useCallback(
async (deviceId: string) => { async (deviceId: string) => {
// Optimistic: flip `revokedAt` on the row so the "Abgemeldet" badge
// appears on the same frame as the click. Capture a snapshot so we
// can restore exactly on RPC failure (the realtime subscription's
// own UPDATE echo would otherwise reconcile back to "not revoked"
// anyway). Skip if the row isn't in our list — nothing to undo.
let snapshot: DeviceRecord[] | null = null;
const stampedAt = new Date().toISOString();
setState((prev) => {
if (!prev.devices.some((d) => d.id === deviceId)) return prev;
snapshot = prev.devices;
return {
...prev,
devices: prev.devices.map((d) =>
d.id === deviceId ? { ...d, revokedAt: d.revokedAt ?? stampedAt } : d,
),
};
});
try {
await revokeDevice(supabase, deviceId); await revokeDevice(supabase, deviceId);
await refresh(); // 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 }; return { devices: state.devices, loading: state.loading, error: state.error, refresh, revoke };
+43
View File
@@ -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);
}
+73
View File
@@ -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);
}
}
+48
View File
@@ -55,3 +55,51 @@ function renameToWebp(original: string): string {
export async function compressImages(files: File[]): Promise<File[]> { export async function compressImages(files: File[]): Promise<File[]> {
return Promise.all(files.map((f) => compressImage(f))); return Promise.all(files.map((f) => compressImage(f)));
} }
// Bandwidth threshold for thumb generation. Below ~50KB the WebP overhead
// of a fresh re-encode can exceed the original; not worth a second upload.
const THUMB_SKIP_BELOW_BYTES = 50 * 1024;
const THUMB_MAX_DIM = 320;
const THUMB_QUALITY = 0.7;
// Animated formats lose motion when redrawn onto a Canvas, so we skip them
// and let the receiver render the full file. GIF is the dominant case; the
// rest stay too (apng/animated-webp).
const THUMB_ANIMATED_MIME = /^image\/(gif|apng)$/;
// Generates a small WebP preview thumb (max 320×320) from an image file.
// Used by the send path so each image attachment can ship a tiny inline
// preview alongside the encrypted full blob. Returns `null` when:
// - the input isn't an image,
// - the input is animated (GIF/APNG — would lose motion),
// - the input is already small enough that a thumb wouldn't save bandwidth,
// - OffscreenCanvas / createImageBitmap aren't available, or
// - decode/encode threw (corrupt input).
// The caller treats `null` as "skip thumb" and uploads only the full blob.
export async function generateWebPThumb(
file: File,
maxDim: number = THUMB_MAX_DIM,
): Promise<Blob | null> {
if (!file.type.startsWith('image/')) return null;
if (THUMB_ANIMATED_MIME.test(file.type)) return null;
if (file.size < THUMB_SKIP_BELOW_BYTES) return null;
if (typeof createImageBitmap !== 'function') return null;
if (typeof OffscreenCanvas !== 'function') return null;
try {
const bitmap = await createImageBitmap(file);
const ratio = Math.min(maxDim / bitmap.width, maxDim / bitmap.height, 1);
const w = Math.max(1, Math.round(bitmap.width * ratio));
const h = Math.max(1, Math.round(bitmap.height * ratio));
const canvas = new OffscreenCanvas(w, h);
const ctx = canvas.getContext('2d');
if (!ctx) {
bitmap.close();
return null;
}
ctx.drawImage(bitmap, 0, 0, w, h);
bitmap.close();
return await canvas.convertToBlob({ type: 'image/webp', quality: THUMB_QUALITY });
} catch (err: unknown) {
console.warn('generateWebPThumb failed', err);
return null;
}
}
-119
View File
@@ -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,
};
+1
View File
@@ -16,6 +16,7 @@ const PRESERVE_LOCAL_STORAGE = new Set([
'chatapp.locale', 'chatapp.locale',
'chatapp.installId', 'chatapp.installId',
'chatapp.wipeOnClose.v1', 'chatapp.wipeOnClose.v1',
'chatapp.autoLockMinutes.v1',
'i18nextLng', 'i18nextLng',
]); ]);
@@ -16,6 +16,7 @@ import { bytesToPgHex, pgBytesToBytes } from '@chat-app/shared/supabase';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { decryptBatch as decryptBatchWorker } from './decryptWorker'; import { decryptBatch as decryptBatchWorker } from './decryptWorker';
import { generateWebPThumb } from './imageCompress';
import { import {
loadCachedMessages, loadCachedMessages,
persistMessages, persistMessages,
@@ -74,7 +75,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
text: string, text: string,
images?: File[], images?: File[],
replyToId?: string | null, replyToId?: string | null,
opts?: { viewOnce?: boolean }, opts?: { viewOnceFlags?: boolean[] },
) => Promise<void>; ) => Promise<void>;
refresh: () => Promise<void>; refresh: () => Promise<void>;
pending: OutboxItem[]; pending: OutboxItem[];
@@ -568,7 +569,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
text: string, text: string,
images: File[] = [], images: File[] = [],
replyToId: string | null = null, replyToId: string | null = null,
opts: { viewOnce?: boolean } = {}, opts: { viewOnceFlags?: boolean[] } = {},
) => { ) => {
const trimmed = text.trim(); const trimmed = text.trim();
if ((!trimmed && images.length === 0) || !conversationId || !userId || !deviceId) return; if ((!trimmed && images.length === 0) || !conversationId || !userId || !deviceId) return;
@@ -625,13 +626,23 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
// 1. Upload + encrypt each image. Collect handles + raw blob nonces // 1. Upload + encrypt each image. Collect handles + raw blob nonces
// (so the public attachment row can reference the blob-level nonce). // (so the public attachment row can reference the blob-level nonce).
// P7.T4: view-once is now a per-attachment flag rather than a
// composer-wide toggle. `opts.viewOnceFlags` is a parallel array;
// missing entries (or whole-array absence) default to false.
const handles: AttachmentHandle[] = []; const handles: AttachmentHandle[] = [];
const blobNonceHexByHandleId = new Map<string, string>(); const blobNonceHexByHandleId = new Map<string, string>();
for (const file of images) { for (let i = 0; i < images.length; i++) {
const file = images[i]!;
if (file.size > MAX_ATTACHMENT_BYTES) { if (file.size > MAX_ATTACHMENT_BYTES) {
throw new Error('attachment exceeds max size (10 MB)'); throw new Error('attachment exceeds max size (10 MB)');
} }
const dims = await readImageDimensions(file); const dims = await readImageDimensions(file);
// Phase 6B: generate a small WebP preview thumb so the receiver's
// bubble loads fast (typical 320×240 WebP is 1030KB vs the full
// image's 110MB). `generateWebPThumb` short-circuits to null on
// non-images, animated formats, and small files — and on failure;
// the upload helper then just skips the second upload.
const thumbBlob = await generateWebPThumb(file);
const res = await encryptAndUploadAttachment({ const res = await encryptAndUploadAttachment({
client: supabase, client: supabase,
conversationId, conversationId,
@@ -640,13 +651,14 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
sizeBytes: file.size, sizeBytes: file.size,
...(dims.width !== undefined ? { width: dims.width } : {}), ...(dims.width !== undefined ? { width: dims.width } : {}),
...(dims.height !== undefined ? { height: dims.height } : {}), ...(dims.height !== undefined ? { height: dims.height } : {}),
...(thumbBlob ? { thumbBlob } : {}),
}); });
// Stamp the view-once flag on each handle the caller requested it // Stamp the view-once flag on each handle the caller flagged. The
// for. The flag rides inside the encrypted payload (so peers can // flag rides inside the encrypted payload (so peers can render the
// render the locked card without leaking who-sent-what to the // locked card without leaking who-sent-what to the server) AND
// server) AND lands on the public message_attachments row via // lands on the public message_attachments row via insertAttachmentRow
// insertAttachmentRow below (where the mark-viewed RPC enforces it). // below (where the mark-viewed RPC enforces it).
if (opts.viewOnce) { if (opts.viewOnceFlags?.[i]) {
res.handle.viewOnce = true; res.handle.viewOnce = true;
} }
handles.push(res.handle); handles.push(res.handle);
-142
View File
@@ -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]);
}
+55 -4
View File
@@ -1,12 +1,26 @@
import { listPinnedMessages, type PinnedMessage } from '@chat-app/shared/chat'; import { listPinnedMessages, type PinnedMessage } from '@chat-app/shared/chat';
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { supabase } from './supabase'; 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 // Live list of pinned messages for one conversation. Subscribes to the
// `pinned_messages` realtime channel for the conv so the header pill + // `pinned_messages` realtime channel for the conv so the header pill +
// side-panel update without a refetch. // side-panel update without a refetch. The `applyOptimistic*` helpers let
export function usePinnedMessages(conversationId: string | undefined): PinnedMessage[] { // 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[]>([]); const [pins, setPins] = useState<PinnedMessage[]>([]);
useEffect(() => { useEffect(() => {
@@ -44,5 +58,42 @@ export function usePinnedMessages(conversationId: string | undefined): PinnedMes
}; };
}, [conversationId]); }, [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 -3
View File
@@ -4,10 +4,11 @@ import {
} from '@chat-app/shared/auth'; } from '@chat-app/shared/auth';
import { import {
generateRecoveryCode, generateUserKeyPair, getCryptoBackend, normalizeRecoveryCode, generateRecoveryCode, generateUserKeyPair, getCryptoBackend, normalizeRecoveryCode,
openUserKey, sealUserKey, sealUserKey,
} from '@chat-app/shared/crypto'; } from '@chat-app/shared/crypto';
import { migrateOwnLegacyBundles } from '@chat-app/shared/chat'; import { migrateOwnLegacyBundles } from '@chat-app/shared/chat';
import { openUserKeyMaybeWorker } from './cryptoWorker';
import { devLocalSecretStore } from './secretStore'; import { devLocalSecretStore } from './secretStore';
import { supabase } from './supabase'; 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'); if (!sealed || !salt) throw new Error('no recovery blob configured');
let priv: Uint8Array; let priv: Uint8Array;
try { try {
priv = await openUserKey({ sealed, pin: secret, salt, kdfParams: remote.kdfParams }); priv = await openUserKeyMaybeWorker({ sealed, pin: secret, salt, kdfParams: remote.kdfParams });
} catch (err) { } catch (err) {
await recordPinAttempt(supabase, p.userId, false, p.isRecoveryCode === true).catch(() => {}); await recordPinAttempt(supabase, p.userId, false, p.isRecoveryCode === true).catch(() => {});
throw err; throw err;
@@ -212,7 +213,7 @@ async function runLegacyMigration(
} }
} }
console.info( console.debug(
'[crypto-migration] vault scan:', '[crypto-migration] vault scan:',
'serverDevices=' + report.serverDevices, 'serverDevices=' + report.serverDevices,
'keysFromServerList=' + report.strongholdKeysFromServerDevices, 'keysFromServerList=' + report.strongholdKeysFromServerDevices,
+429 -250
View File
@@ -1,9 +1,11 @@
import { parseMessagePayload, pinMessage, unpinMessage } from '@chat-app/shared/chat'; import { parseMessagePayload, pinMessage, unpinMessage } from '@chat-app/shared/chat';
import { extractErrorCode } from '@chat-app/shared/i18n'; import { extractErrorCode } from '@chat-app/shared/i18n';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso';
import { ComposerActionsMenu } from '../components/ComposerActionsMenu';
import { ConversationHeader } from '../components/ConversationHeader'; import { ConversationHeader } from '../components/ConversationHeader';
import { EmojiPicker } from '../components/EmojiPicker'; import { EmojiPicker } from '../components/EmojiPicker';
import { EmptyState } from '../components/EmptyState'; import { EmptyState } from '../components/EmptyState';
@@ -15,10 +17,10 @@ import {
ArrowRightIcon, ArrowRightIcon,
ChevronDownIcon, ChevronDownIcon,
ChevronUpIcon, ChevronUpIcon,
EyeIcon,
EyeOffIcon, EyeOffIcon,
PencilIcon, PencilIcon,
PlusIcon, PlusIcon,
PollIcon,
ReplyIcon, ReplyIcon,
SearchIcon, SearchIcon,
SendIcon, SendIcon,
@@ -26,13 +28,16 @@ import {
SpinnerIcon, SpinnerIcon,
XIcon, XIcon,
} from '../components/icons'; } from '../components/icons';
import { ImageAnnotator } from '../components/ImageAnnotator'; const ImageAnnotator = lazy(() =>
import('../components/ImageAnnotator').then((m) => ({ default: m.ImageAnnotator })),
);
import { InCallPanel } from '../components/InCallPanel'; import { InCallPanel } from '../components/InCallPanel';
import { IncomingCallPanel } from '../components/IncomingCallPanel'; import { IncomingCallPanel } from '../components/IncomingCallPanel';
import { CallPreviewPanel } from '../components/CallPreviewPanel'; import { CallPreviewPanel } from '../components/CallPreviewPanel';
import { MediaFilesDrawer } from '../components/MediaFilesDrawer'; import { MediaFilesDrawer } from '../components/MediaFilesDrawer';
import { MentionAutocomplete } from '../components/MentionAutocomplete'; import { MentionAutocomplete } from '../components/MentionAutocomplete';
import { MessageBubble, type QuotedRef } from '../components/MessageBubble'; import { MessageBubble, type QuotedRef } from '../components/MessageBubble';
import type { AggregatedReaction } from '../lib/useMessageReactions';
import { PinnedMessagesPanel } from '../components/PinnedMessagesPanel'; import { PinnedMessagesPanel } from '../components/PinnedMessagesPanel';
import { PollComposerDialog } from '../components/PollComposerDialog'; import { PollComposerDialog } from '../components/PollComposerDialog';
import { UserProfilePopover } from '../components/UserProfilePopover'; import { UserProfilePopover } from '../components/UserProfilePopover';
@@ -49,9 +54,15 @@ import {
createWatchTogetherPayload, createWatchTogetherPayload,
createGamePayload, createGamePayload,
} from '../lib/conversationFeatures'; } from '../lib/conversationFeatures';
import { WhiteboardModal } from '../components/WhiteboardModal'; const WhiteboardModal = lazy(() =>
import { WatchTogetherModal } from '../components/WatchTogetherModal'; import('../components/WhiteboardModal').then((m) => ({ default: m.WhiteboardModal })),
import { GameModal } from '../components/GameModal'; );
const WatchTogetherModal = lazy(() =>
import('../components/WatchTogetherModal').then((m) => ({ default: m.WatchTogetherModal })),
);
const GameModal = lazy(() =>
import('../components/GameModal').then((m) => ({ default: m.GameModal })),
);
import { createWhiteboard, createWatchSession, parseYouTubeUrl, createGame, type GameType } from '@chat-app/shared/chat'; import { createWhiteboard, createWatchSession, parseYouTubeUrl, createGame, type GameType } from '@chat-app/shared/chat';
import { compressImages } from '../lib/imageCompress'; import { compressImages } from '../lib/imageCompress';
import { ensureInstallId } from '../lib/installId'; import { ensureInstallId } from '../lib/installId';
@@ -68,16 +79,44 @@ import { usePeerPresence } from '../lib/usePeerPresence';
import { usePinnedMessages } from '../lib/usePinnedMessages'; import { usePinnedMessages } from '../lib/usePinnedMessages';
import { useTypingChannel } from '../lib/useTypingChannel'; import { useTypingChannel } from '../lib/useTypingChannel';
const STICK_THRESHOLD = 80; // Discriminated union for rows inside the virtualized message list. Keeping
// pending bubbles and the "load older" tile inside the same Virtuoso
// instance means scroll-to-bottom / followOutput stay coherent across both
// (we don't need a sibling scroll container for pending items).
type VirtuosoRow =
| { kind: 'loader'; key: string }
| { kind: 'message'; key: string; message: DecryptedMessage; idx: number }
| { kind: 'pending'; key: string; item: OutboxItem };
// Stable empty-reactions sentinel. We pass this when a message has no
// reactions instead of `[]` literal — a fresh array per render would defeat
// `React.memo` on `MessageBubble` since the `reactions` prop reference would
// change on every parent render.
const EMPTY_REACTIONS: AggregatedReaction[] = [];
// Per-conversation scroll memory. Module-scoped so it survives re-mounts // Per-conversation scroll memory. Module-scoped so it survives re-mounts
// of ConversationPage when the route param (`id`) changes — switching // of ConversationPage when the route param (`id`) changes — switching
// chats unmounts/remounts the page in our router setup. Session-only // chats unmounts/remounts the page in our router setup. Session-only
// (lost on reload, like Discord). The `stickToBottom` flag is preserved // (lost on reload, like Discord). The `stickToBottom` flag is preserved
// alongside the pixel offset so a chat the user left at the bottom keeps // alongside the topmost-visible row index so a chat the user left at the
// auto-following new messages when they return; a chat scrolled up // bottom keeps auto-following new messages when they return; a chat
// returns to the exact spot the user was reading. // scrolled up returns to roughly the same row the user was reading.
const scrollPositions = new Map<string, { scrollTop: number; stickToBottom: boolean }>(); //
// 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 }>();
/** Pending composer attachment: the raw File plus the per-attachment
* view-once flag the user can toggle from the thumb hover button (P7.T4).
* Lives only in composer state the flag is forwarded into
* `message_attachments.view_once` per row when the message is sent. */
interface PendingAttachment {
file: File;
viewOnce: boolean;
}
export function ConversationPage() { export function ConversationPage() {
const { t } = useTranslation(['app', 'errors']); const { t } = useTranslation(['app', 'errors']);
@@ -165,7 +204,11 @@ export function ConversationPage() {
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [sendError, setSendError] = useState<string | null>(null); const [sendError, setSendError] = useState<string | null>(null);
const [stickToBottom, setStickToBottom] = useState(true); const [stickToBottom, setStickToBottom] = useState(true);
const [attachments, setAttachments] = useState<File[]>([]); // Pending composer attachments — each carries its own view-once flag so
// the user can mark individual images "burn after viewing" via the hover
// toggle on the thumb (P7.T4). Non-image attachments keep viewOnce=false
// but the field stays on the object so the shape is uniform.
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
const [annotatingIndex, setAnnotatingIndex] = useState<number | null>(null); const [annotatingIndex, setAnnotatingIndex] = useState<number | null>(null);
const [infoPanelOpen, setInfoPanelOpen] = useState(false); const [infoPanelOpen, setInfoPanelOpen] = useState(false);
const [mediaDrawerOpen, setMediaDrawerOpen] = useState(false); const [mediaDrawerOpen, setMediaDrawerOpen] = useState(false);
@@ -208,28 +251,36 @@ export function ConversationPage() {
const [mentionState, setMentionState] = useState<{ query: string; start: number } | null>(null); const [mentionState, setMentionState] = useState<{ query: string; start: number } | null>(null);
const [emojiOpen, setEmojiOpen] = useState(false); const [emojiOpen, setEmojiOpen] = useState(false);
const [gifPickerOpen, setGifPickerOpen] = useState(false); const [gifPickerOpen, setGifPickerOpen] = useState(false);
// Sticky toggle: when on, the next image(s) sent are marked view-once. const [actionsMenuOpen, setActionsMenuOpen] = useState(false);
// Auto-clears on a successful send so the composer doesn't accidentally const actionsMenuAnchorRef = useRef<HTMLButtonElement>(null);
// burn the message-after-next. const { pins, applyOptimisticPin, applyOptimisticUnpin, restorePins } = usePinnedMessages(id);
const [viewOnceNext, setViewOnceNext] = useState(false);
const pins = usePinnedMessages(id);
const [pinnedPanelOpen, setPinnedPanelOpen] = useState(false); const [pinnedPanelOpen, setPinnedPanelOpen] = useState(false);
const pinnedIds = useMemo(() => new Set(pins.map((p) => p.messageId)), [pins]); const pinnedIds = useMemo(() => new Set(pins.map((p) => p.messageId)), [pins]);
// Optimistic pin/unpin: flip the local list synchronously so the pin badge
// / panel updates on the same frame as the click. Realtime echo via
// usePinnedMessages will refetch and reconcile (no-op since the optimistic
// row matches the server). On error we restore the snapshot so the badge
// doesn't lie about persisted state.
const handleTogglePin = useCallback( const handleTogglePin = useCallback(
async (messageId: string) => { async (messageId: string) => {
if (!id || !myId) return; if (!id || !myId) return;
const wasPinned = pinnedIds.has(messageId);
const snapshot = wasPinned
? applyOptimisticUnpin(messageId)
: applyOptimisticPin(messageId, myId);
try { try {
if (pinnedIds.has(messageId)) { if (wasPinned) {
await unpinMessage(supabase, id, messageId); await unpinMessage(supabase, id, messageId);
} else { } else {
await pinMessage(supabase, id, messageId, myId); await pinMessage(supabase, id, messageId, myId);
} }
} catch (err) { } catch (err) {
restorePins(snapshot);
console.warn('pin toggle failed', err); console.warn('pin toggle failed', err);
} }
}, },
[id, myId, pinnedIds], [id, myId, pinnedIds, applyOptimisticPin, applyOptimisticUnpin, restorePins],
); );
const handleGifPick = useCallback( const handleGifPick = useCallback(
@@ -248,8 +299,8 @@ export function ConversationPage() {
}, },
[send], [send],
); );
const scrollRef = useRef<HTMLDivElement>(null); const virtuosoRef = useRef<VirtuosoHandle>(null);
const loadMoreSentinelRef = useRef<HTMLDivElement>(null); const topmostIndexRef = useRef<number>(0);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const composerRef = useRef<HTMLTextAreaElement>(null); const composerRef = useRef<HTMLTextAreaElement>(null);
@@ -294,21 +345,17 @@ export function ConversationPage() {
previousMessageIdsRef.current = new Set(messages.map((message) => message.id)); previousMessageIdsRef.current = new Set(messages.map((message) => message.id));
}, [messages, myId, stickToBottom]); }, [messages, myId, stickToBottom]);
useEffect(() => { // Replaces the previous IntersectionObserver-on-sentinel pattern: Virtuoso
const el = loadMoreSentinelRef.current; // calls `startReached` when the user scrolls near the first row of the
if (!el) return; // virtualized list. We bump `displayCount` the same way the old observer
if (displayCount >= messages.length) return; // did. Wrapped in useCallback so Virtuoso doesn't tear down its scroll
const observer = new IntersectionObserver( // observer on every parent re-render.
(entries) => { const handleStartReached = useCallback(() => {
if (entries[0]?.isIntersecting) { setDisplayCount((n) => {
setDisplayCount((n) => Math.min(messages.length, n * 2)); if (n >= messages.length) return n;
} return Math.min(messages.length, n * 2);
}, });
{ root: scrollRef.current, rootMargin: '200px 0px' }, }, [messages.length]);
);
observer.observe(el);
return () => observer.disconnect();
}, [displayCount, messages.length]);
const messageById = useMemo(() => { const messageById = useMemo(() => {
const m = new Map<string, DecryptedMessage>(); const m = new Map<string, DecryptedMessage>();
@@ -361,15 +408,117 @@ export function ConversationPage() {
[messageById, senderNameFor, t], [messageById, senderNameFor, t],
); );
const jumpToMessage = useCallback((targetId: string) => { // Pre-compute quoted refs per message into a stable map. Calling
const el = scrollRef.current?.querySelector<HTMLElement>( // `buildQuoted(m.replyToId)` inline inside the `.map` returned a fresh
'[data-message-id="' + CSS.escape(targetId) + '"]', // object on every parent render, defeating `React.memo` on MessageBubble.
// With the map memoized on the same deps as `buildQuoted`, each bubble
// gets a stable `quoted` reference until the underlying data actually
// changes (new messages, sender renames, language switch).
const quotedByMessage = useMemo(() => {
const out = new Map<string, QuotedRef | null>();
for (const m of messages) {
out.set(m.id, buildQuoted(m.replyToId));
}
return out;
}, [messages, buildQuoted]);
// Build the discriminated-union row list Virtuoso renders. We use a
// single virtualized list rather than separate "messages" and "pending"
// sections so the unsent items stay at the bottom of the scroll viewport
// (and Virtuoso's `followOutput` still triggers correctly when a new
// outbox item is appended). Optional row 0 is the "load older" tile —
// matches the old IntersectionObserver-sentinel pattern.
const virtuosoRows = useMemo<VirtuosoRow[]>(() => {
const out: VirtuosoRow[] = [];
const hasLoader = displayCount < messages.length;
if (hasLoader) {
out.push({ kind: 'loader', key: '__loader__' });
}
const sliceStart = Math.max(0, messages.length - displayCount);
for (let i = sliceStart; i < messages.length; i++) {
const m = messages[i];
if (!m) continue;
out.push({ kind: 'message', key: m.id, message: m, idx: i });
}
for (const p of pending) {
out.push({ kind: 'pending', key: 'pending-' + p.id, item: p });
}
return out;
}, [messages, pending, displayCount]);
// Snapshot of the saved position for this conversation, captured once on
// mount. Used to derive the `initialTopMostItemIndex` we hand to the
// Virtuoso instance below — Virtuoso applies that index synchronously
// before its first paint, so re-entering a chat shows the saved row in
// one frame rather than a "starts at top, jumps" flicker.
//
// Declared HERE (above `initialTopMostIndex`) rather than further down
// because the useMemo that consumes it would otherwise hit a TDZ on
// first render — `const` refs aren't hoisted.
const savedPositionRef = useRef<{ topmostIndex: number; stickToBottom: boolean } | null>(
null,
); );
if (!el) return; if (savedPositionRef.current === null && id) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' }); savedPositionRef.current = scrollPositions.get(id) ?? null;
}
// Initial scroll position for the freshly-mounted Virtuoso instance.
// Default = bottom (newest message). If we have a saved position from a
// previous visit to this chat AND the user wasn't sticking to the
// bottom, restore the saved row index (clamped to the current row
// count in case the cache was trimmed).
const initialTopMostIndex = useMemo(() => {
const saved = savedPositionRef.current;
if (saved && !saved.stickToBottom) {
return Math.max(0, Math.min(saved.topmostIndex, virtuosoRows.length - 1));
}
return virtuosoRows.length - 1;
// virtuosoRows.length changes when the conversation loads — that's the
// intentional trigger so a freshly-loaded chat anchors to the bottom
// on first paint. We deliberately don't re-derive this on every row
// append; Virtuoso owns scroll position from that point on.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [virtuosoRows.length > 0]);
const jumpToMessage = useCallback(
(targetId: string) => {
const msgIdx = messages.findIndex((m) => m.id === targetId);
if (msgIdx < 0) return; // pinned message outside loaded cache — no-op
// Make sure the target is actually inside the rendered slice; if the
// user has only loaded the most-recent 150 rows but is jumping to an
// older message, expand the slice so the row exists in the virtual
// list before we ask Virtuoso to scroll to it.
const needsAtLeast = messages.length - msgIdx;
if (needsAtLeast > displayCount) {
setDisplayCount(needsAtLeast);
}
// Convert message-array index into row index for the virtuoso rows
// array (see `rows` further down). The slice starts at
// `messages.length - displayCount`, and row 0 is the optional
// "load older" header.
const targetDisplayCount = Math.max(displayCount, needsAtLeast);
const sliceStart = Math.max(0, messages.length - targetDisplayCount);
const hasLoader = targetDisplayCount < messages.length;
const rowIndex = (hasLoader ? 1 : 0) + (msgIdx - sliceStart);
// requestAnimationFrame: when we just bumped `displayCount`, Virtuoso
// needs a paint to register the new rows before scrollToIndex can
// resolve the target row. Without this, the scroll either no-ops or
// lands on a stale row.
requestAnimationFrame(() => {
virtuosoRef.current?.scrollToIndex({
index: rowIndex,
align: 'center',
behavior: 'smooth',
});
});
setHighlightedId(targetId); setHighlightedId(targetId);
window.setTimeout(() => setHighlightedId((cur) => (cur === targetId ? null : cur)), 1600); window.setTimeout(
}, []); () => setHighlightedId((cur) => (cur === targetId ? null : cur)),
1600,
);
},
[messages, displayCount],
);
const handleReply = useCallback((m: DecryptedMessage) => { const handleReply = useCallback((m: DecryptedMessage) => {
setReplyTo(m); setReplyTo(m);
@@ -380,6 +529,19 @@ export function ConversationPage() {
setForwardTarget(m); setForwardTarget(m);
}, []); }, []);
// Stable handler for MessageBubble's `onAvatarClick`. Previously this was
// an inline arrow in the `.map`, which gave every row a fresh callback ref
// and defeated `React.memo` on the bubble (every parent re-render — every
// keystroke in the composer — re-rendered all 200 bubbles).
const handleAvatarClick = useCallback((uid: string, ev: React.MouseEvent) => {
ev.stopPropagation();
setProfilePopover({
userId: uid,
x: ev.clientX,
y: ev.clientY,
});
}, []);
const searchActive = useMemo( const searchActive = useMemo(
() => () =>
searchQuery.trim().length > 0 || searchQuery.trim().length > 0 ||
@@ -507,98 +669,97 @@ export function ConversationPage() {
if (id && messages.length > 0) markRead(id); if (id && messages.length > 0) markRead(id);
}, [id, messages.length, markRead]); }, [id, messages.length, markRead]);
// useLayoutEffect: run synchronously after DOM commit, before the // Scroll-to-bottom is handled by Virtuoso's `followOutput` prop, which
// browser paints. Using useEffect here let one frame of "scrollTop = 0 // fires whenever the rendered row count grows and auto-scrolls down only
// (top of list)" paint between message-list mount and the auto-scroll, // if the user was already at the bottom — exactly the Discord behavior
// which is exactly the "flickers to a different position, then jumps" // we want for both outgoing sends and incoming realtime messages.
// glitch users saw when re-entering a chat. Layout-effect fires while // The old useLayoutEffect that wrote `scrollTop = scrollHeight` is no
// the message list is in the DOM but before paint, so the first frame // longer needed: Virtuoso owns scroll positioning now.
// already shows the correct scroll position.
useLayoutEffect(() => {
const el = scrollRef.current;
if (!el || !stickToBottom) return;
el.scrollTop = el.scrollHeight;
}, [messages.length, stickToBottom]);
// Restore saved scroll position once the conversation's messages have // (savedPositionRef declared earlier — see TDZ note above the
// actually rendered. The earlier version fired on `[id]` alone and ran // initialTopMostIndex useMemo.)
// before the message list populated — scrollHeight was still tiny, so
// `el.scrollTop = saved.scrollTop` got clamped to 0 by the browser
// and the user landed at the top instead of the saved position. By
// waiting for `messages.length > 0` we know the rendered scrollHeight
// is meaningful. `restoredForRef` ensures the restore runs at most
// once per chat switch (subsequent message arrivals don't re-trigger).
const restoredForRef = useRef<string | null>(null);
const isRestoringRef = useRef(false);
// useLayoutEffect, same reason as above: writing scrollTop here happens // Track whether the user is currently scrolled to the bottom. Virtuoso
// before the first paint of the freshly-mounted chat, so the user // calls this whenever the bottom-state changes; we feed it into
// doesn't see a frame at scrollTop=0 before the jump to the saved // `stickToBottom` (used by the "jump to newest" pill and by the
// position. Combined with the messages.length gate this means the // "new messages while away" counter logic). Also clears the unread-
// re-entry shows the message list AT the saved scroll location in one // away counter when the user actually reaches the bottom.
// single paint — no "loaded then jumped" effect. const handleAtBottomStateChange = useCallback(
useLayoutEffect(() => { (atBottom: boolean) => {
const el = scrollRef.current; setStickToBottom(atBottom);
if (!el || !id) return; if (atBottom) setNewMessagesWhileAway(0);
if (restoredForRef.current === id) return; if (id) {
// Wait for the conversation's messages to populate; for a chat that scrollPositions.set(id, {
// truly has zero messages the bottom and the top are the same anyway. topmostIndex: topmostIndexRef.current,
if (messages.length === 0) return; stickToBottom: atBottom,
restoredForRef.current = id;
const saved = scrollPositions.get(id);
// Suppress handleScroll's persistence during the programmatic scroll
// below — otherwise the browser's clamp/normalisation could write a
// different scrollTop back into the Map and lose the saved position.
isRestoringRef.current = true;
if (saved && !saved.stickToBottom) {
el.scrollTop = saved.scrollTop;
setStickToBottom(false);
} else {
setStickToBottom(true);
el.scrollTop = el.scrollHeight;
}
requestAnimationFrame(() => {
isRestoringRef.current = false;
}); });
}, [id, messages.length]);
const handleScroll = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
const nextStick = distanceFromBottom < STICK_THRESHOLD;
setStickToBottom(nextStick);
if (nextStick) setNewMessagesWhileAway(0);
// Persist position per chat so re-entering this conversation lands
// where the user left off (see scrollPositions module-level Map).
// Skipped during the in-flight restore so we don't immediately
// overwrite the saved position with a clamped value.
if (id && !isRestoringRef.current) {
scrollPositions.set(id, { scrollTop: el.scrollTop, stickToBottom: nextStick });
} }
}, [id]); },
[id],
);
// Persists the topmost-visible row index per conversation so re-entering
// the chat lands roughly where the user left off (see scrollPositions
// Map). Virtuoso fires `rangeChanged` whenever the visible range shifts;
// we only care about the start of the range here.
const handleRangeChanged = useCallback(
(range: { startIndex: number; endIndex: number }) => {
topmostIndexRef.current = range.startIndex;
if (id) {
const prev = scrollPositions.get(id);
scrollPositions.set(id, {
topmostIndex: range.startIndex,
stickToBottom: prev?.stickToBottom ?? true,
});
}
},
[id],
);
const jumpToBottom = useCallback(() => { const jumpToBottom = useCallback(() => {
const el = scrollRef.current; virtuosoRef.current?.scrollToIndex({
if (!el) return; index: 'LAST',
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }); align: 'end',
behavior: 'smooth',
});
setStickToBottom(true); setStickToBottom(true);
setNewMessagesWhileAway(0); setNewMessagesWhileAway(0);
}, []); }, []);
// Whenever an outgoing pending row appears, snap the viewport to the
// bottom so the user sees their freshly-sent message land. This replaces
// the old `setStickToBottom(true)` pattern that piggy-backed on a
// `useLayoutEffect` writing scrollTop — Virtuoso owns scroll positioning
// now, so we have to call it explicitly. Tracked via a ref so we only
// scroll when the count actually grew (not on every render where it
// happens to be > 0).
const lastPendingCountRef = useRef(0);
useEffect(() => {
if (pending.length > lastPendingCountRef.current) {
virtuosoRef.current?.scrollToIndex({
index: 'LAST',
align: 'end',
behavior: 'auto',
});
}
lastPendingCountRef.current = pending.length;
}, [pending.length]);
async function handleSend(e?: React.FormEvent) { async function handleSend(e?: React.FormEvent) {
e?.preventDefault(); e?.preventDefault();
if ((!text.trim() && attachments.length === 0) || sending) return; if ((!text.trim() && attachments.length === 0) || sending) return;
setSending(true); setSending(true);
setSendError(null); setSendError(null);
try { try {
await send(text, attachments, replyTo?.id ?? null, { viewOnce: viewOnceNext }); await send(
text,
attachments.map((a) => a.file),
replyTo?.id ?? null,
{ viewOnceFlags: attachments.map((a) => a.viewOnce) },
);
setText(''); setText('');
setAttachments([]); setAttachments([]);
setReplyTo(null); setReplyTo(null);
// Reset the sticky view-once flag so it only applies to the message
// the user explicitly armed it for — Snapchat / WhatsApp parity.
setViewOnceNext(false);
if (fileInputRef.current) fileInputRef.current.value = ''; if (fileInputRef.current) fileInputRef.current.value = '';
setStickToBottom(true); setStickToBottom(true);
notifyStopTyping(); notifyStopTyping();
@@ -712,13 +873,15 @@ export function ConversationPage() {
async function ingestFiles(files: File[]) { async function ingestFiles(files: File[]) {
const compressed = await compressImages(files); const compressed = await compressImages(files);
const next: File[] = []; const next: PendingAttachment[] = [];
for (const f of compressed) { for (const f of compressed) {
if (f.size > 10 * 1024 * 1024) { if (f.size > 10 * 1024 * 1024) {
setSendError('Datei zu groß (max 10 MB)'); setSendError('Datei zu groß (max 10 MB)');
continue; continue;
} }
next.push(f); // New attachments default to viewOnce=false; user opts in per-thumb
// via the eye-toggle button on the preview (P7.T4).
next.push({ file: f, viewOnce: false });
} }
setAttachments((prev) => [...prev, ...next].slice(0, 4)); setAttachments((prev) => [...prev, ...next].slice(0, 4));
} }
@@ -837,18 +1000,17 @@ export function ConversationPage() {
{incomingHere && conversation && <IncomingCallPanel conversation={conversation} />} {incomingHere && conversation && <IncomingCallPanel conversation={conversation} />}
{conversation && <InCallPanel conversation={conversation} />} {conversation && <InCallPanel conversation={conversation} />}
<div <div className="discord-chat-surface flex min-h-0 flex-1 flex-col bg-surface-3">
ref={scrollRef}
onScroll={handleScroll}
className="discord-chat-surface flex-1 overflow-y-auto bg-surface-3 px-5 py-4"
>
{loading ? ( {loading ? (
<div className="flex items-center gap-2 text-xs text-fg-muted"> <div className="flex items-center gap-2 px-5 py-4 text-xs text-fg-muted">
<SpinnerIcon className="h-3.5 w-3.5 text-accent" /> <SpinnerIcon className="h-3.5 w-3.5 text-accent" />
</div> </div>
) : error ? ( ) : error ? (
<div className="px-5 py-4">
<Banner>{error}</Banner> <Banner>{error}</Banner>
</div>
) : messages.length === 0 ? ( ) : messages.length === 0 ? (
<div className="px-5 py-4">
<EmptyState <EmptyState
icon={<SendIcon className="h-8 w-8" />} icon={<SendIcon className="h-8 w-8" />}
title={t('app:chats.conv_empty_title', { defaultValue: 'Sag Hallo 👋' })} title={t('app:chats.conv_empty_title', { defaultValue: 'Sag Hallo 👋' })}
@@ -856,20 +1018,51 @@ export function ConversationPage() {
defaultValue: 'Hier ist noch nichts. Schreibe die erste Nachricht.', defaultValue: 'Hier ist noch nichts. Schreibe die erste Nachricht.',
})} })}
/> />
</div>
) : ( ) : (
<ul className="space-y-0.5"> <Virtuoso
{displayCount < messages.length && ( ref={virtuosoRef}
<li> className="flex-1"
<div style={{ height: '100%' }}
ref={loadMoreSentinelRef} data={virtuosoRows}
className="flex items-center justify-center py-2 text-xs text-fg-muted" computeItemKey={(_idx, row) => row.key}
> // Initial position: either restored from per-conv memory, or
// pinned to the bottom for fresh entry. Virtuoso applies this
// synchronously before its first paint so the user doesn't see
// a "loaded at top, then jumped" flicker (matches the layout-
// effect behavior we used in the non-virtualized version).
initialTopMostItemIndex={initialTopMostIndex}
// followOutput auto-scrolls only when the user was already at
// the bottom; returning `false` from the callback when they're
// scrolled up preserves their reading position when realtime
// messages arrive (critical UX: do NOT jerk the user).
followOutput={(isAtBottom) => (isAtBottom ? 'smooth' : false)}
atBottomStateChange={handleAtBottomStateChange}
atBottomThreshold={80}
rangeChanged={handleRangeChanged}
startReached={handleStartReached}
// Render rows just outside the viewport so fast scrolling
// doesn't briefly flash empty space.
increaseViewportBy={400}
itemContent={(_index, row) => {
if (row.kind === 'loader') {
return (
<div className="flex items-center justify-center py-2 text-xs text-fg-muted">
Lade ältere Nachrichten Lade ältere Nachrichten
</div> </div>
</li> );
)} }
{messages.slice(Math.max(0, messages.length - displayCount)).map((m, sliceIdx) => { if (row.kind === 'pending') {
const idx = Math.max(0, messages.length - displayCount) + sliceIdx; return (
<PendingBubble
item={row.item}
onRetry={() => retryPending(row.item.id)}
onCancel={() => cancelPending(row.item.id)}
/>
);
}
const m = row.message;
const idx = row.idx;
const prevRaw = messages[idx - 1]; const prevRaw = messages[idx - 1];
const nextRaw = messages[idx + 1]; const nextRaw = messages[idx + 1];
const prevIsCallEvent = const prevIsCallEvent =
@@ -883,7 +1076,7 @@ export function ConversationPage() {
const senderProfile = const senderProfile =
memberProfile ?? (m.senderId !== myId ? (conversation?.peer ?? null) : null); memberProfile ?? (m.senderId !== myId ? (conversation?.peer ?? null) : null);
return ( return (
<li key={m.id}> <div className="px-5">
{firstUnreadId === m.id && ( {firstUnreadId === m.id && (
<div <div
aria-label="Neue Nachrichten" aria-label="Neue Nachrichten"
@@ -904,9 +1097,9 @@ export function ConversationPage() {
senderDisplayName={senderProfile?.displayName} senderDisplayName={senderProfile?.displayName}
senderAvatarUrl={senderProfile?.avatarUrl} senderAvatarUrl={senderProfile?.avatarUrl}
conversationId={id ?? ''} conversationId={id ?? ''}
reactions={reactionsByMessage.get(m.id) ?? []} reactions={reactionsByMessage.get(m.id) ?? EMPTY_REACTIONS}
onToggleReaction={(emoji) => toggleReaction(m.id, emoji)} onToggleReaction={toggleReaction}
onVotePoll={(emoji, optionEmojis) => votePoll(m.id, emoji, optionEmojis)} onVotePoll={votePoll}
showSeen={m.id === lastSeenMessageId} showSeen={m.id === lastSeenMessageId}
{...(m.senderId === myId {...(m.senderId === myId
? { ? {
@@ -921,35 +1114,19 @@ export function ConversationPage() {
}), }),
} }
: {})} : {})}
quoted={buildQuoted(m.replyToId)} quoted={quotedByMessage.get(m.id) ?? null}
onJumpToMessage={jumpToMessage} onJumpToMessage={jumpToMessage}
onReply={handleReply} onReply={handleReply}
onForward={handleForward} onForward={handleForward}
onAvatarClick={(uid, ev) => { onAvatarClick={handleAvatarClick}
ev.stopPropagation();
setProfilePopover({
userId: uid,
x: ev.clientX,
y: ev.clientY,
});
}}
highlighted={highlightedId === m.id} highlighted={highlightedId === m.id}
isPinned={pinnedIds.has(m.id)} isPinned={pinnedIds.has(m.id)}
onTogglePin={handleTogglePin} onTogglePin={handleTogglePin}
/> />
</li> </div>
); );
})} }}
{pending.map((p) => (
<li key={p.id}>
<PendingBubble
item={p}
onRetry={() => retryPending(p.id)}
onCancel={() => cancelPending(p.id)}
/> />
</li>
))}
</ul>
)} )}
</div> </div>
@@ -1037,13 +1214,22 @@ export function ConversationPage() {
{attachments.length > 0 && ( {attachments.length > 0 && (
<div className="mb-2 flex flex-wrap gap-2"> <div className="mb-2 flex flex-wrap gap-2">
{attachments.map((file, idx) => ( {attachments.map((a, idx) => (
<AttachmentPreview <AttachmentPreview
key={idx} key={idx}
file={file} file={a.file}
viewOnce={a.viewOnce}
onRemove={() => setAttachments((prev) => prev.filter((_, i) => i !== idx))} onRemove={() => setAttachments((prev) => prev.filter((_, i) => i !== idx))}
{...(file.type.startsWith('image/') {...(a.file.type.startsWith('image/')
? { onEdit: () => setAnnotatingIndex(idx) } ? {
onEdit: () => setAnnotatingIndex(idx),
onToggleViewOnce: () =>
setAttachments((prev) =>
prev.map((x, i) =>
i === idx ? { ...x, viewOnce: !x.viewOnce } : x,
),
),
}
: {})} : {})}
/> />
))} ))}
@@ -1082,55 +1268,32 @@ export function ConversationPage() {
className="hidden" className="hidden"
onChange={(e) => handleFilesChosen(e.target.files)} onChange={(e) => handleFilesChosen(e.target.files)}
/> />
{/* [+] popover trigger — opens ComposerActionsMenu (file/poll/whiteboard/watch/game) */}
<button <button
ref={actionsMenuAnchorRef}
type="button" type="button"
onClick={() => fileInputRef.current?.click()} onClick={() => setActionsMenuOpen((v) => !v)}
aria-label="Datei anhängen" aria-label={t('app:composer.more_actions', { defaultValue: 'Mehr Aktionen' })}
title="Datei anhängen" title={t('app:composer.more_actions', { defaultValue: 'Mehr Aktionen' })}
aria-expanded={actionsMenuOpen}
className="inline-flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 dark:hover:bg-[#313338]" className="inline-flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 dark:hover:bg-[#313338]"
> >
<PlusIcon className="h-4 w-4" /> <PlusIcon className="h-4 w-4" />
</button> </button>
<button <ComposerActionsMenu
type="button" anchorRef={actionsMenuAnchorRef}
onClick={() => { open={actionsMenuOpen}
onClose={() => setActionsMenuOpen(false)}
onAttachFile={() => fileInputRef.current?.click()}
onCreatePoll={() => {
setPollError(null); setPollError(null);
setPollDialogOpen(true); setPollDialogOpen(true);
}} }}
aria-label="Umfrage erstellen" onCreateWhiteboard={() => void handleCreateWhiteboard()}
title="Umfrage erstellen" onStartWatchTogether={() => setWatchDialogOpen(true)}
className="inline-flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 dark:hover:bg-[#313338]" onStartGame={() => setGameDialogOpen(true)}
> canStartGame={conversation?.members?.length === 2}
<PollIcon className="h-4 w-4" /> />
</button>
<button
type="button"
onClick={() => void handleCreateWhiteboard()}
disabled={creatingWhiteboard}
title={t('app:composer.whiteboard', { defaultValue: 'Whiteboard' })}
aria-label={t('app:composer.whiteboard', { defaultValue: 'Whiteboard' })}
className="inline-flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-[#313338]"
>
<WhiteboardIcon className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => setWatchDialogOpen(true)}
title={t('app:composer.watch_together', { defaultValue: 'Watch Together' })}
aria-label={t('app:composer.watch_together', { defaultValue: 'Watch Together' })}
className="inline-flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 dark:hover:bg-[#313338]"
>
<PlayBoxIcon className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => setGameDialogOpen(true)}
title={t('app:composer.game', { defaultValue: 'Spielen' })}
aria-label={t('app:composer.game', { defaultValue: 'Spielen' })}
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-md text-fg-muted transition hover:bg-surface-3 hover:text-fg"
>
<GameIcon className="h-4 w-4" />
</button>
<div className="relative"> <div className="relative">
<button <button
type="button" type="button"
@@ -1176,20 +1339,6 @@ export function ConversationPage() {
onPick={(gif) => void handleGifPick(gif)} onPick={(gif) => void handleGifPick(gif)}
/> />
</div> </div>
<button
type="button"
onClick={() => setViewOnceNext((v) => !v)}
aria-pressed={viewOnceNext}
title={viewOnceNext ? 'Nächstes Bild: einmal ansehen' : 'Nächstes Bild: normal'}
className={
'flex h-9 w-9 cursor-pointer items-center justify-center rounded-md transition ' +
(viewOnceNext
? 'bg-accent/20 text-accent'
: 'text-fg-muted hover:bg-surface-3 hover:text-fg')
}
>
<EyeOffIcon className="h-4 w-4" />
</button>
<VoiceRecorder <VoiceRecorder
disabled={sending} disabled={sending}
onComplete={async (file) => { onComplete={async (file) => {
@@ -1313,43 +1462,61 @@ export function ConversationPage() {
open={pinnedPanelOpen} open={pinnedPanelOpen}
pins={pins} pins={pins}
onClose={() => setPinnedPanelOpen(false)} onClose={() => setPinnedPanelOpen(false)}
onJump={(_messageId) => { onJump={(messageId) => {
// Future: scroll to message. For now just close the panel. // Close the panel first so the underlying message-list viewport
// is fully visible before the smooth-scroll runs (otherwise the
// panel would briefly cover the highlighted target row).
setPinnedPanelOpen(false); setPinnedPanelOpen(false);
jumpToMessage(messageId);
}} }}
onUnpin={(messageId) => void handleTogglePin(messageId)} onUnpin={(messageId) => void handleTogglePin(messageId)}
/> />
{annotatingIndex !== null && attachments[annotatingIndex] && ( {annotatingIndex !== null && attachments[annotatingIndex] && (
<Suspense fallback={null}>
<ImageAnnotator <ImageAnnotator
file={attachments[annotatingIndex]!} file={attachments[annotatingIndex]!.file}
onCancel={() => setAnnotatingIndex(null)} onCancel={() => setAnnotatingIndex(null)}
onSave={(next) => { onSave={(next) => {
setAttachments((prev) => prev.map((f, i) => (i === annotatingIndex ? next : f))); // Preserve the per-attachment viewOnce flag across annotation —
// the user's burn-after-viewing intent shouldn't reset just
// because they redrew the image.
setAttachments((prev) =>
prev.map((a, i) =>
i === annotatingIndex ? { file: next, viewOnce: a.viewOnce } : a,
),
);
setAnnotatingIndex(null); setAnnotatingIndex(null);
}} }}
/> />
</Suspense>
)} )}
{openWhiteboardId && ( {openWhiteboardId && (
<Suspense fallback={null}>
<WhiteboardModal <WhiteboardModal
whiteboardId={openWhiteboardId} whiteboardId={openWhiteboardId}
onClose={() => setOpenWhiteboardId(null)} onClose={() => setOpenWhiteboardId(null)}
/> />
</Suspense>
)} )}
{openWatchSessionId && ( {openWatchSessionId && (
<Suspense fallback={null}>
<WatchTogetherModal <WatchTogetherModal
sessionId={openWatchSessionId} sessionId={openWatchSessionId}
onClose={() => setOpenWatchSessionId(null)} onClose={() => setOpenWatchSessionId(null)}
/> />
</Suspense>
)} )}
{openGameId && ( {openGameId && (
<Suspense fallback={null}>
<GameModal <GameModal
gameId={openGameId} gameId={openGameId}
onClose={() => setOpenGameId(null)} onClose={() => setOpenGameId(null)}
/> />
</Suspense>
)} )}
{gameDialogOpen && ( {gameDialogOpen && (
@@ -1675,13 +1842,18 @@ function Banner({ children }: { children: React.ReactNode }) {
function AttachmentPreview({ function AttachmentPreview({
file, file,
viewOnce,
onRemove, onRemove,
onEdit, onEdit,
onToggleViewOnce,
}: { }: {
file: File; file: File;
viewOnce: boolean;
onRemove: () => void; onRemove: () => void;
onEdit?: () => void; onEdit?: () => void;
onToggleViewOnce?: () => void;
}) { }) {
const { t } = useTranslation(['app']);
const isImage = file.type.startsWith('image/'); const isImage = file.type.startsWith('image/');
const [url, setUrl] = useState<string | null>(null); const [url, setUrl] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
@@ -1714,6 +1886,42 @@ function AttachmentPreview({
<PencilIcon className="h-3 w-3" /> <PencilIcon className="h-3 w-3" />
</button> </button>
)} )}
{isImage && onToggleViewOnce && (
<button
type="button"
onClick={onToggleViewOnce}
aria-label={
viewOnce
? t('app:composer.view_once_off', { defaultValue: 'Einmal-Ansicht deaktivieren' })
: t('app:composer.view_once_on', { defaultValue: 'Einmal-Ansicht aktivieren' })
}
title={
viewOnce
? t('app:composer.view_once_on_hint', {
defaultValue: 'Empfänger sieht das Bild nur einmal',
})
: t('app:composer.view_once_off_hint', { defaultValue: 'Einmal-Ansicht ein/aus' })
}
className={
'absolute bottom-1 right-1 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full transition ' +
(viewOnce
? 'bg-accent text-accent-fg opacity-100'
: 'bg-black/70 text-white opacity-0 hover:bg-accent/80 group-hover:opacity-100')
}
>
{viewOnce ? <EyeIcon className="h-3 w-3" /> : <EyeOffIcon className="h-3 w-3" />}
</button>
)}
{/* When viewOnce is on, overlay a persistent "1×" badge so the user
has visual confirmation independent of the small toggle button. */}
{isImage && viewOnce && (
<div
aria-hidden="true"
className="pointer-events-none absolute bottom-1 right-7 rounded-md bg-accent/90 px-1 py-0.5 text-[9px] font-bold text-accent-fg"
>
1×
</div>
)}
<button <button
type="button" type="button"
onClick={onRemove} onClick={onRemove}
@@ -1726,32 +1934,3 @@ function AttachmentPreview({
); );
} }
function WhiteboardIcon(props: React.SVGProps<SVGSVGElement>) {
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="13" rx="2" />
<path d="M8 21h8M12 17v4" />
</svg>
);
}
function PlayBoxIcon(props: React.SVGProps<SVGSVGElement>) {
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.SVGProps<SVGSVGElement>) {
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>
);
}
+3 -2
View File
@@ -85,9 +85,10 @@
*, *,
*::before, *::before,
*::after { *::after {
animation-duration: 0.01ms !important; animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important; animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important; transition-duration: 0.001ms !important;
scroll-behavior: auto !important;
} }
} }
} }
+78
View File
@@ -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,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,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).
+92
View File
@@ -35,6 +35,14 @@ export interface AttachmentHandle {
/** UUID of the first non-sender who viewed. Populated alongside `viewedAt` /** UUID of the first non-sender who viewed. Populated alongside `viewedAt`
* by the mark-viewed RPC so the renderer can render attribution. */ * by the mark-viewed RPC so the renderer can render attribution. */
viewedBy?: string | null; 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'; export type CallEventStatus = 'ended' | 'missed' | 'declined';
@@ -241,6 +249,13 @@ export interface EncryptedAttachmentResult {
// Encrypt + upload a blob. Does NOT insert the message_attachments row — // Encrypt + upload a blob. Does NOT insert the message_attachments row —
// the caller combines this with a message insert so everything commits // the caller combines this with a message insert so everything commits
// atomically at the application layer. // 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: { export async function encryptAndUploadAttachment(params: {
client: AppSupabaseClient; client: AppSupabaseClient;
conversationId: string; conversationId: string;
@@ -249,6 +264,7 @@ export async function encryptAndUploadAttachment(params: {
sizeBytes: number; sizeBytes: number;
width?: number; width?: number;
height?: number; height?: number;
thumbBlob?: Blob | null;
}): Promise<EncryptedAttachmentResult> { }): Promise<EncryptedAttachmentResult> {
const backend = getCryptoBackend(); const backend = getCryptoBackend();
@@ -268,6 +284,35 @@ export async function encryptAndUploadAttachment(params: {
}); });
if (error) throw error; 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 = { const handle: AttachmentHandle = {
id, id,
storagePath, storagePath,
@@ -277,6 +322,8 @@ export async function encryptAndUploadAttachment(params: {
...(params.height !== undefined ? { height: params.height } : {}), ...(params.height !== undefined ? { height: params.height } : {}),
keyB64: await toBase64(key), keyB64: await toBase64(key),
nonceB64: await toBase64(nonce), nonceB64: await toBase64(nonce),
...(thumbStoragePath !== undefined ? { thumbStoragePath } : {}),
...(thumbNonceB64 !== undefined ? { thumbNonceB64 } : {}),
}; };
return { handle, key, nonce }; return { handle, key, nonce };
@@ -309,6 +356,51 @@ export async function downloadAndDecryptAttachment(params: {
return new Blob([copy.buffer], { type: params.handle.mimeType }); 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 // Insert the public metadata row for an attachment. The ciphertext itself has
// already been uploaded to storage under `handle.storagePath`. // already been uploaded to storage under `handle.storagePath`.
export async function insertAttachmentRow( export async function insertAttachmentRow(
+15 -2
View File
@@ -57,7 +57,7 @@ export async function migrateOwnLegacyBundles(params: MigrateParams): Promise<Mi
attempted: 0, noStrongholdKey: 0, decryptFailed: 0, rpcFailed: 0, attempted: 0, noStrongholdKey: 0, decryptFailed: 0, rpcFailed: 0,
}; };
if (params.ownLegacyDeviceIds.length === 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; return result;
} }
@@ -76,7 +76,7 @@ export async function migrateOwnLegacyBundles(params: MigrateParams): Promise<Mi
.not('recipient_device_id', 'is', null); .not('recipient_device_id', 'is', null);
if (error) throw error; if (error) throw error;
const rows = (rowsRaw ?? []) as LegacyRow[]; 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; if (rows.length === 0) return result;
const senderDeviceIds = Array.from(new Set(rows.map((r) => r.sender_device_id).filter(Boolean))); const senderDeviceIds = Array.from(new Set(rows.map((r) => r.sender_device_id).filter(Boolean)));
@@ -136,6 +136,12 @@ export async function migrateOwnLegacyBundles(params: MigrateParams): Promise<Mi
result.migratedConversations += 1; result.migratedConversations += 1;
} }
// 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( console.info(
'[crypto-migration] result:', '[crypto-migration] result:',
'attempted=' + result.attempted, 'attempted=' + result.attempted,
@@ -144,5 +150,12 @@ export async function migrateOwnLegacyBundles(params: MigrateParams): Promise<Mi
'decryptFail=' + result.decryptFailed, 'decryptFail=' + result.decryptFailed,
'rpcFail=' + result.rpcFailed, 'rpcFail=' + result.rpcFailed,
); );
} else {
console.debug(
'[crypto-migration] result (all unrecoverable, expected on stale clients):',
'attempted=' + result.attempted,
'noKey=' + result.noStrongholdKey,
);
}
return result; return result;
} }
+1 -11
View File
@@ -53,8 +53,6 @@
"archive": "Archivieren", "archive": "Archivieren",
"unarchive": "Entarchivieren", "unarchive": "Entarchivieren",
"archived_title": "Archiv", "archived_title": "Archiv",
"show_archived": "Archiv anzeigen",
"show_active": "Aktive anzeigen",
"archived_empty_title": "Nichts archiviert", "archived_empty_title": "Nichts archiviert",
"archived_empty_subtitle": "Archivierte Unterhaltungen erscheinen hier.", "archived_empty_subtitle": "Archivierte Unterhaltungen erscheinen hier.",
"mute": "Stummschalten", "mute": "Stummschalten",
@@ -84,8 +82,6 @@
"join": "Beitreten", "join": "Beitreten",
"in_call": "Im Anruf", "in_call": "Im Anruf",
"waiting_for_peers": "Warte auf andere…", "waiting_for_peers": "Warte auf andere…",
"voice_connected": "Sprachchat verbunden",
"still_live": "Anruf läuft noch",
"share_screen": "Bildschirm teilen", "share_screen": "Bildschirm teilen",
"stop_share_screen": "Screen-Share stoppen", "stop_share_screen": "Screen-Share stoppen",
"is_sharing_screen": "{{name}} teilt den Bildschirm", "is_sharing_screen": "{{name}} teilt den Bildschirm",
@@ -133,11 +129,9 @@
"action_unfriend": "Entfernen", "action_unfriend": "Entfernen",
"action_accept": "Annehmen", "action_accept": "Annehmen",
"action_decline": "Ablehnen", "action_decline": "Ablehnen",
"action_cancel": "Abbrechen", "action_cancel": "Abbrechen"
"confirm_unfriend": "Diesen Freund entfernen?"
}, },
"admin": { "admin": {
"nav": "Admin",
"title": "Admin-Panel", "title": "Admin-Panel",
"settings_title": "Globale Einstellungen", "settings_title": "Globale Einstellungen",
"invites_enabled": "Neue Registrierungen erlauben", "invites_enabled": "Neue Registrierungen erlauben",
@@ -188,15 +182,11 @@
"screen_share_quality": "Qualität", "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.", "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", "language": "Sprache",
"presence": "Status",
"show_read_receipts": "Lesebestätigungen anzeigen", "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.", "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": "DMs von Fremden erlauben",
"allow_dms_strangers_hint": "Wenn aus, können dich nur Freunde direkt anschreiben.", "allow_dms_strangers_hint": "Wenn aus, können dich nur Freunde direkt anschreiben.",
"this_device": "Dieses Gerät",
"danger_zone": "Gefahrenzone",
"sign_out": "Abmelden", "sign_out": "Abmelden",
"section_ringtone": "Klingelton",
"ringtone_incoming": "Eingehender Anruf", "ringtone_incoming": "Eingehender Anruf",
"ringtone_default_active": "Standard-Klingelton (Doppelton)", "ringtone_default_active": "Standard-Klingelton (Doppelton)",
"ringtone_custom_active": "{{name}} · {{size}} MB", "ringtone_custom_active": "{{name}} · {{size}} MB",
+1 -21
View File
@@ -50,28 +50,8 @@
"footer_studio": "Supabase Studio", "footer_studio": "Supabase Studio",
"legal_note": "Mit der Registrierung akzeptierst du, dass der Server nur Chiffretext sieht.", "legal_note": "Mit der Registrierung akzeptierst du, dass der Server nur Chiffretext sieht.",
"signed_in": { "signed_in": {
"title": "Angemeldet",
"session_active": "Sitzung aktiv",
"user_id": "Benutzer-ID",
"email": "E-Mail", "email": "E-Mail",
"username": "Benutzername", "username": "Benutzername",
"display_name": "Anzeigename", "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."
} }
} }
@@ -1,16 +1,8 @@
{ {
"app_name": "ChatApp", "app_name": "ChatApp",
"loading": "Lädt…",
"finalising_session": "Sitzung wird abgeschlossen…", "finalising_session": "Sitzung wird abgeschlossen…",
"cancel": "Abbrechen",
"save": "Speichern", "save": "Speichern",
"close": "Schließen", "close": "Schließen",
"retry": "Wiederholen",
"online": "Online",
"offline": "Offline",
"idle": "Abwesend",
"dnd": "Nicht stören",
"invisible": "Unsichtbar",
"local_stack_online": "Lokaler Stack online", "local_stack_online": "Lokaler Stack online",
"dev_build": "Dev-Build" "dev_build": "Dev-Build"
} }
+1 -11
View File
@@ -53,8 +53,6 @@
"archive": "Archive", "archive": "Archive",
"unarchive": "Unarchive", "unarchive": "Unarchive",
"archived_title": "Archive", "archived_title": "Archive",
"show_archived": "Show archive",
"show_active": "Show active",
"archived_empty_title": "Nothing archived", "archived_empty_title": "Nothing archived",
"archived_empty_subtitle": "Archived conversations appear here.", "archived_empty_subtitle": "Archived conversations appear here.",
"mute": "Mute", "mute": "Mute",
@@ -84,8 +82,6 @@
"join": "Join", "join": "Join",
"in_call": "In call", "in_call": "In call",
"waiting_for_peers": "Waiting for others…", "waiting_for_peers": "Waiting for others…",
"voice_connected": "Voice connected",
"still_live": "Call still live",
"share_screen": "Share screen", "share_screen": "Share screen",
"stop_share_screen": "Stop sharing", "stop_share_screen": "Stop sharing",
"is_sharing_screen": "{{name}} is sharing their screen", "is_sharing_screen": "{{name}} is sharing their screen",
@@ -133,11 +129,9 @@
"action_unfriend": "Unfriend", "action_unfriend": "Unfriend",
"action_accept": "Accept", "action_accept": "Accept",
"action_decline": "Decline", "action_decline": "Decline",
"action_cancel": "Cancel", "action_cancel": "Cancel"
"confirm_unfriend": "Remove this friend?"
}, },
"admin": { "admin": {
"nav": "Admin",
"title": "Admin panel", "title": "Admin panel",
"settings_title": "Global settings", "settings_title": "Global settings",
"invites_enabled": "Allow new signups", "invites_enabled": "Allow new signups",
@@ -188,15 +182,11 @@
"screen_share_quality": "Quality", "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.", "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", "language": "Language",
"presence": "Presence",
"show_read_receipts": "Show read receipts", "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.", "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": "Allow DMs from strangers",
"allow_dms_strangers_hint": "When off, only friends can DM you.", "allow_dms_strangers_hint": "When off, only friends can DM you.",
"this_device": "This device",
"danger_zone": "Danger zone",
"sign_out": "Sign out", "sign_out": "Sign out",
"section_ringtone": "Ringtone",
"ringtone_incoming": "Incoming call", "ringtone_incoming": "Incoming call",
"ringtone_default_active": "Default ringtone (double beep)", "ringtone_default_active": "Default ringtone (double beep)",
"ringtone_custom_active": "{{name}} · {{size}} MB", "ringtone_custom_active": "{{name}} · {{size}} MB",
+1 -21
View File
@@ -50,28 +50,8 @@
"footer_studio": "Supabase Studio", "footer_studio": "Supabase Studio",
"legal_note": "By signing up you accept that the server sees only ciphertext.", "legal_note": "By signing up you accept that the server sees only ciphertext.",
"signed_in": { "signed_in": {
"title": "Signed in",
"session_active": "Session active",
"user_id": "User ID",
"email": "Email", "email": "Email",
"username": "Username", "username": "Username",
"display_name": "Display name", "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."
} }
} }
@@ -1,16 +1,8 @@
{ {
"app_name": "ChatApp", "app_name": "ChatApp",
"loading": "Loading…",
"finalising_session": "Finalising session…", "finalising_session": "Finalising session…",
"cancel": "Cancel",
"save": "Save", "save": "Save",
"close": "Close", "close": "Close",
"retry": "Retry",
"online": "Online",
"offline": "Offline",
"idle": "Idle",
"dnd": "Do not disturb",
"invisible": "Invisible",
"local_stack_online": "local stack online", "local_stack_online": "local stack online",
"dev_build": "dev build" "dev_build": "dev build"
} }
+198
View File
@@ -98,6 +98,9 @@ importers:
react-router-dom: react-router-dom:
specifier: ^6.28.0 specifier: ^6.28.0
version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) 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: zustand:
specifier: ^5.0.1 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)) 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: rimraf:
specifier: ^6.0.0 specifier: ^6.0.0
version: 6.1.3 version: 6.1.3
rollup-plugin-visualizer:
specifier: ^7.0.1
version: 7.0.1(rollup@4.60.1)
tailwindcss: tailwindcss:
specifier: ^3.4.15 specifier: ^3.4.15
version: 3.4.19 version: 3.4.19
@@ -2612,6 +2618,10 @@ packages:
builder-util@25.1.7: builder-util@25.1.7:
resolution: {integrity: sha512-7jPjzBwEGRbwNcep0gGNpLXG9P94VA3CPAZQCzxkFXiV2GMQKlziMbY//rXPI7WKfhsvGgFXjTcXdBEwgXw9ww==} resolution: {integrity: sha512-7jPjzBwEGRbwNcep0gGNpLXG9P94VA3CPAZQCzxkFXiV2GMQKlziMbY//rXPI7WKfhsvGgFXjTcXdBEwgXw9ww==}
bundle-name@4.1.0:
resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
engines: {node: '>=18'}
bytes@3.1.2: bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -2757,6 +2767,10 @@ packages:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'} engines: {node: '>=12'}
cliui@9.0.1:
resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==}
engines: {node: '>=20'}
clone-deep@4.0.1: clone-deep@4.0.1:
resolution: {integrity: sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==} resolution: {integrity: sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -2988,6 +3002,14 @@ packages:
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
engines: {node: '>=0.10.0'} 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: default-gateway@4.2.0:
resolution: {integrity: sha512-h6sMrVB1VMWVrW13mSc6ia/DwYYw5MN6+exNu1OaJeFac5aSAvwM7lZ0NVfTABuSkQelr4h5oebg3KB1XPdjgA==} resolution: {integrity: sha512-h6sMrVB1VMWVrW13mSc6ia/DwYYw5MN6+exNu1OaJeFac5aSAvwM7lZ0NVfTABuSkQelr4h5oebg3KB1XPdjgA==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -3007,6 +3029,10 @@ packages:
resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==}
engines: {node: '>=8'} engines: {node: '>=8'}
define-lazy-prop@3.0.0:
resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==}
engines: {node: '>=12'}
define-properties@1.2.1: define-properties@1.2.1:
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -3128,6 +3154,9 @@ packages:
engines: {node: '>= 12.20.55'} engines: {node: '>= 12.20.55'}
hasBin: true hasBin: true
emoji-regex@10.6.0:
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
emoji-regex@8.0.0: emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
@@ -3719,6 +3748,10 @@ packages:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*} 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: get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -4051,6 +4084,11 @@ packages:
engines: {node: '>=8'} engines: {node: '>=8'}
hasBin: true 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: is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -4071,6 +4109,15 @@ packages:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'} 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: is-interactive@1.0.0:
resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -4169,6 +4216,10 @@ packages:
resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==}
engines: {node: '>=8'} engines: {node: '>=8'}
is-wsl@3.1.1:
resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==}
engines: {node: '>=16'}
isarray@1.0.0: isarray@1.0.0:
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
@@ -4962,6 +5013,10 @@ packages:
resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
engines: {node: '>=6'} engines: {node: '>=6'}
open@11.0.0:
resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==}
engines: {node: '>=20'}
open@7.4.2: open@7.4.2:
resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==} resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -5187,6 +5242,10 @@ packages:
resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==}
engines: {node: ^10 || ^12 || >=14} 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: prebuild-install@7.1.3:
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -5420,6 +5479,12 @@ packages:
peerDependencies: peerDependencies:
react: ^18.3.1 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: react@18.3.1:
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -5577,11 +5642,28 @@ packages:
resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==}
engines: {node: '>=8.0'} 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: rollup@4.60.1:
resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==} resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'} engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true hasBin: true
run-applescript@7.1.0:
resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==}
engines: {node: '>=18'}
run-parallel@1.2.0: run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
@@ -5817,6 +5899,10 @@ packages:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'} 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: split-on-first@1.1.0:
resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==} resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -5884,6 +5970,10 @@ packages:
resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}
engines: {node: '>=12'} 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: string.prototype.trim@1.2.10:
resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -6470,6 +6560,10 @@ packages:
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
engines: {node: '>=12'} engines: {node: '>=12'}
wrap-ansi@9.0.2:
resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==}
engines: {node: '>=18'}
wrappy@1.0.2: wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
@@ -6515,6 +6609,10 @@ packages:
utf-8-validate: utf-8-validate:
optional: true optional: true
wsl-utils@0.3.1:
resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==}
engines: {node: '>=20'}
xcode@3.0.1: xcode@3.0.1:
resolution: {integrity: sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==} resolution: {integrity: sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==}
engines: {node: '>=10.0.0'} engines: {node: '>=10.0.0'}
@@ -6556,10 +6654,18 @@ packages:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'} 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: yargs@17.7.2:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'} engines: {node: '>=12'}
yargs@18.0.0:
resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
yauzl@2.10.0: yauzl@2.10.0:
resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
@@ -9557,6 +9663,10 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
bundle-name@4.1.0:
dependencies:
run-applescript: 7.1.0
bytes@3.1.2: {} bytes@3.1.2: {}
cac@6.7.14: {} cac@6.7.14: {}
@@ -9741,6 +9851,12 @@ snapshots:
strip-ansi: 6.0.1 strip-ansi: 6.0.1
wrap-ansi: 7.0.0 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: clone-deep@4.0.1:
dependencies: dependencies:
is-plain-object: 2.0.4 is-plain-object: 2.0.4
@@ -9960,6 +10076,13 @@ snapshots:
deepmerge@4.3.1: {} 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: default-gateway@4.2.0:
dependencies: dependencies:
execa: 1.0.0 execa: 1.0.0
@@ -9979,6 +10102,8 @@ snapshots:
define-lazy-prop@2.0.0: {} define-lazy-prop@2.0.0: {}
define-lazy-prop@3.0.0: {}
define-properties@1.2.1: define-properties@1.2.1:
dependencies: dependencies:
define-data-property: 1.1.4 define-data-property: 1.1.4
@@ -10152,6 +10277,8 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
emoji-regex@10.6.0: {}
emoji-regex@8.0.0: {} emoji-regex@8.0.0: {}
emoji-regex@9.2.2: {} emoji-regex@9.2.2: {}
@@ -10936,6 +11063,8 @@ snapshots:
get-caller-file@2.0.5: {} get-caller-file@2.0.5: {}
get-east-asian-width@1.6.0: {}
get-intrinsic@1.3.0: get-intrinsic@1.3.0:
dependencies: dependencies:
call-bind-apply-helpers: 1.0.2 call-bind-apply-helpers: 1.0.2
@@ -11305,6 +11434,8 @@ snapshots:
is-docker@2.2.1: {} is-docker@2.2.1: {}
is-docker@3.0.0: {}
is-extglob@2.1.1: {} is-extglob@2.1.1: {}
is-finalizationregistry@1.1.1: is-finalizationregistry@1.1.1:
@@ -11325,6 +11456,12 @@ snapshots:
dependencies: dependencies:
is-extglob: 2.1.1 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-interactive@1.0.0: {}
is-lambda@1.0.1: {} is-lambda@1.0.1: {}
@@ -11406,6 +11543,10 @@ snapshots:
dependencies: dependencies:
is-docker: 2.2.1 is-docker: 2.2.1
is-wsl@3.1.1:
dependencies:
is-inside-container: 1.0.0
isarray@1.0.0: {} isarray@1.0.0: {}
isarray@2.0.5: {} isarray@2.0.5: {}
@@ -12338,6 +12479,15 @@ snapshots:
dependencies: dependencies:
mimic-fn: 2.1.0 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: open@7.4.2:
dependencies: dependencies:
is-docker: 2.2.1 is-docker: 2.2.1
@@ -12539,6 +12689,8 @@ snapshots:
picocolors: 1.1.1 picocolors: 1.1.1
source-map-js: 1.2.1 source-map-js: 1.2.1
powershell-utils@0.1.0: {}
prebuild-install@7.1.3: prebuild-install@7.1.3:
dependencies: dependencies:
detect-libc: 2.1.2 detect-libc: 2.1.2
@@ -12820,6 +12972,11 @@ snapshots:
react-shallow-renderer: 16.15.0(react@18.3.1) react-shallow-renderer: 16.15.0(react@18.3.1)
scheduler: 0.23.2 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: react@18.3.1:
dependencies: dependencies:
loose-envify: 1.4.0 loose-envify: 1.4.0
@@ -13004,6 +13161,15 @@ snapshots:
sprintf-js: 1.1.3 sprintf-js: 1.1.3
optional: true 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: rollup@4.60.1:
dependencies: dependencies:
'@types/estree': 1.0.8 '@types/estree': 1.0.8
@@ -13035,6 +13201,8 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.60.1 '@rollup/rollup-win32-x64-msvc': 4.60.1
fsevents: 2.3.3 fsevents: 2.3.3
run-applescript@7.1.0: {}
run-parallel@1.2.0: run-parallel@1.2.0:
dependencies: dependencies:
queue-microtask: 1.2.3 queue-microtask: 1.2.3
@@ -13294,6 +13462,8 @@ snapshots:
source-map@0.6.1: {} source-map@0.6.1: {}
source-map@0.7.6: {}
split-on-first@1.1.0: {} split-on-first@1.1.0: {}
sprintf-js@1.0.3: {} sprintf-js@1.0.3: {}
@@ -13350,6 +13520,12 @@ snapshots:
emoji-regex: 9.2.2 emoji-regex: 9.2.2
strip-ansi: 7.2.0 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: string.prototype.trim@1.2.10:
dependencies: dependencies:
call-bind: 1.0.9 call-bind: 1.0.9
@@ -13993,6 +14169,12 @@ snapshots:
string-width: 5.1.2 string-width: 5.1.2
strip-ansi: 7.2.0 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: {} wrappy@1.0.2: {}
write-file-atomic@2.4.3: write-file-atomic@2.4.3:
@@ -14014,6 +14196,11 @@ snapshots:
ws@8.20.0: {} ws@8.20.0: {}
wsl-utils@0.3.1:
dependencies:
is-wsl: 3.1.1
powershell-utils: 0.1.0
xcode@3.0.1: xcode@3.0.1:
dependencies: dependencies:
simple-plist: 1.3.1 simple-plist: 1.3.1
@@ -14042,6 +14229,8 @@ snapshots:
yargs-parser@21.1.1: {} yargs-parser@21.1.1: {}
yargs-parser@22.0.0: {}
yargs@17.7.2: yargs@17.7.2:
dependencies: dependencies:
cliui: 8.0.1 cliui: 8.0.1
@@ -14052,6 +14241,15 @@ snapshots:
y18n: 5.0.8 y18n: 5.0.8
yargs-parser: 21.1.1 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: yauzl@2.10.0:
dependencies: dependencies:
buffer-crc32: 0.2.13 buffer-crc32: 0.2.13