Compare commits

..

5 Commits

Author SHA1 Message Date
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
21 changed files with 852 additions and 235 deletions
+1
View File
@@ -35,6 +35,7 @@
"react-easy-crop": "^5.5.7",
"react-i18next": "^15.1.1",
"react-router-dom": "^6.28.0",
"react-virtuoso": "^4.18.7",
"zustand": "^5.0.1"
},
"devDependencies": {
+2
View File
@@ -2,6 +2,7 @@ import { useEffect } from 'react';
import { Outlet } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { useIdleAutoLock } from '../hooks/useIdleAutoLock';
import { startConversationKeySync } from '../lib/conversationKeySync';
import { startDeviceApprovalListener } from '../lib/deviceApproval';
import { ensureInstallId } from '../lib/installId';
@@ -14,6 +15,7 @@ import { Sidebar } from './Sidebar';
export function AppShell() {
const { session } = useAuth();
useIdleAutoLock();
useMentionNotifications(session?.user.id);
useEffect(() => {
// Prompt once per authenticated shell mount. Module-level guard prevents
+110 -9
View File
@@ -1,4 +1,8 @@
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
import {
type AttachmentHandle,
downloadAndDecryptAttachment,
downloadAndDecryptAttachmentThumb,
} from '@chat-app/shared/chat';
import { useEffect, useState } from 'react';
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
@@ -56,6 +60,17 @@ export function AttachmentImage({ handle, mine = false }: Props) {
const [error, setError] = useState<string | null>(null);
const [lightboxOpen, setLightboxOpen] = useState(false);
// Whether the sender shipped a pre-built WebP thumb alongside this attachment
// (Phase 6B+). When true we render the bubble from just the thumb and only
// fetch the full blob when the user opens the lightbox or for view-once.
const hasServerThumb = Boolean(handle.thumbStoragePath && handle.thumbNonceB64);
// View-once needs the full blob ready instantly the moment the recipient
// taps (otherwise we'd show a spinner during the burn animation, then race
// the "mark viewed" RPC). Same for the legacy path with no server thumb —
// we have to download the whole thing just to render the client-side
// makeThumbnail fallback.
const needsEagerFull = handle.viewOnce === true || !hasServerThumb;
useEffect(() => {
let cancelled = false;
const created: string[] = [];
@@ -69,9 +84,39 @@ export function AttachmentImage({ handle, mine = false }: Props) {
return u;
};
// OPFS cache → decrypt → generate thumbnail for inline display.
// Lightbox swaps to the full blob when opened.
const thumbCacheId = handle.id + '-thumb';
void (async () => {
// Phase 6B fast path: if the sender shipped a server-side WebP thumb,
// grab it first so the bubble paints from ~20KB instead of waiting on
// the multi-MB full blob. The OPFS cache is keyed separately so the
// thumb survives independent of full-blob eviction.
if (hasServerThumb) {
try {
let thumbBlob: Blob | null = await getCachedAttachment(thumbCacheId);
if (!thumbBlob) {
thumbBlob = await downloadAndDecryptAttachmentThumb({
client: supabase,
handle,
});
if (thumbBlob) void putCachedAttachment(thumbCacheId, thumbBlob);
}
if (cancelled) return;
if (thumbBlob) {
setThumbUrl(take(thumbBlob));
}
} catch (err: unknown) {
// Thumb decrypt failure isn't fatal — fall through to the full
// blob path below so the user still sees the image.
console.warn('thumb load failed', err);
}
}
// Eagerly resolve the full blob when we need it for view-once or as
// the only render source (no server thumb). For the thumb-first path
// the full blob is deferred until the lightbox opens (see below).
if (!needsEagerFull) return;
const cached = await getCachedAttachment(handle.id);
let blob: Blob;
if (cached) {
@@ -90,10 +135,14 @@ export function AttachmentImage({ handle, mine = false }: Props) {
if (cancelled) return;
const full = take(blob);
setFullUrl(full);
const thumb = await makeThumbnail(blob);
if (cancelled) return;
if (thumb) {
setThumbUrl(take(thumb));
// Pre-Phase-6B fallback: no server thumb shipped, so re-derive a
// smaller preview on the client to keep memory pressure down.
if (!hasServerThumb) {
const thumb = await makeThumbnail(blob);
if (cancelled) return;
if (thumb) {
setThumbUrl(take(thumb));
}
}
})();
@@ -101,7 +150,51 @@ export function AttachmentImage({ handle, mine = false }: Props) {
cancelled = true;
for (const u of created) URL.revokeObjectURL(u);
};
}, [handle.id, handle.storagePath, handle.keyB64, handle.nonceB64]);
}, [
handle,
handle.id,
handle.storagePath,
handle.keyB64,
handle.nonceB64,
handle.thumbStoragePath,
handle.thumbNonceB64,
hasServerThumb,
needsEagerFull,
]);
// Lazy full-image fetch for click-to-expand (only kicks in when we
// skipped the eager full-blob download above). Resolves into the same
// `fullUrl` state that the Lightbox consumes; the thumb URL keeps
// backing the bubble until the lightbox actually mounts.
useEffect(() => {
if (!lightboxOpen) return;
if (fullUrl) return;
let cancelled = false;
const created: string[] = [];
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);
created.push(u);
setFullUrl(u);
})();
return () => {
cancelled = true;
for (const u of created) URL.revokeObjectURL(u);
};
}, [lightboxOpen, fullUrl, handle]);
const blobUrl = thumbUrl ?? fullUrl;
@@ -157,7 +250,15 @@ export function AttachmentImage({ handle, mine = false }: Props) {
className="block h-auto max-h-80 w-auto max-w-full object-contain"
/>
</button>
{lightboxOpen && fullUrl && <Lightbox url={fullUrl} onClose={() => setLightboxOpen(false)} />}
{lightboxOpen && (
<Lightbox
// Prefer the full blob the moment it's available; otherwise show
// the thumb so the user sees *something* during the lazy fetch
// (typical full-blob fetch is 100ms2s depending on size).
url={fullUrl ?? blobUrl}
onClose={() => setLightboxOpen(false)}
/>
)}
</>
);
}
@@ -1,5 +1,11 @@
import { useState } from 'react';
import {
type AutoLockMinutes,
getAutoLockMinutes,
notifyAutoLockChanged,
setAutoLockMinutes,
} from '../lib/autoLockSettings';
import { isWipeOnCloseEnabled, setWipeOnClose } from '../lib/memoryWipeSettings';
import {
changePin,
@@ -21,6 +27,7 @@ export function SecurityCenter({ userId }: Props) {
const [recovery, setRecovery] = useState<string | null>(null);
const [migration, setMigration] = useState<LegacyMigrationReport | null>(null);
const [wipeOnClose, setWipeOnCloseState] = useState<boolean>(() => isWipeOnCloseEnabled());
const [autoLockMinutes, setAutoLockMinutesState] = useState<AutoLockMinutes>(() => getAutoLockMinutes());
async function handleRetryMigration() {
setBusy(true); setMsg(null); setMigration(null);
@@ -149,6 +156,39 @@ export function SecurityCenter({ userId }: Props) {
</label>
</section>
<section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-fg-muted">
Auto-Lock nach Inaktivität
</h3>
<p className="mb-2 text-xs text-fg-muted">
Verlangt erneute PIN-Eingabe nach der gewählten Inaktivitätsdauer. Empfohlen für gemeinsam genutzte Rechner.
</p>
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium text-fg">Automatisch sperren</div>
<div className="text-xs text-fg-muted">
Verlangt erneute PIN-Eingabe nach X Minuten Inaktivität.
</div>
</div>
<select
value={autoLockMinutes}
onChange={(e) => {
const next = Number(e.target.value) as AutoLockMinutes;
setAutoLockMinutes(next);
notifyAutoLockChanged(next);
setAutoLockMinutesState(next);
}}
className="cursor-pointer rounded-md border border-line bg-surface-2 px-3 py-1.5 text-sm text-fg focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/40"
>
<option value={0}>Aus</option>
<option value={5}>5 min</option>
<option value={15}>15 min</option>
<option value={30}>30 min</option>
<option value={60}>60 min</option>
</select>
</div>
</section>
<section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-rose-400">Identität zurücksetzen</h3>
<p className="mb-2 text-xs text-fg-muted">Erstellt einen neuen Schlüssel. Alle alten Chats werden unlesbar.</p>
+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]);
}
+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[]> {
return Promise.all(files.map((f) => compressImage(f)));
}
// Bandwidth threshold for thumb generation. Below ~50KB the WebP overhead
// of a fresh re-encode can exceed the original; not worth a second upload.
const THUMB_SKIP_BELOW_BYTES = 50 * 1024;
const THUMB_MAX_DIM = 320;
const THUMB_QUALITY = 0.7;
// Animated formats lose motion when redrawn onto a Canvas, so we skip them
// and let the receiver render the full file. GIF is the dominant case; the
// rest stay too (apng/animated-webp).
const THUMB_ANIMATED_MIME = /^image\/(gif|apng)$/;
// Generates a small WebP preview thumb (max 320×320) from an image file.
// Used by the send path so each image attachment can ship a tiny inline
// preview alongside the encrypted full blob. Returns `null` when:
// - the input isn't an image,
// - the input is animated (GIF/APNG — would lose motion),
// - the input is already small enough that a thumb wouldn't save bandwidth,
// - OffscreenCanvas / createImageBitmap aren't available, or
// - decode/encode threw (corrupt input).
// The caller treats `null` as "skip thumb" and uploads only the full blob.
export async function generateWebPThumb(
file: File,
maxDim: number = THUMB_MAX_DIM,
): Promise<Blob | null> {
if (!file.type.startsWith('image/')) return null;
if (THUMB_ANIMATED_MIME.test(file.type)) return null;
if (file.size < THUMB_SKIP_BELOW_BYTES) return null;
if (typeof createImageBitmap !== 'function') return null;
if (typeof OffscreenCanvas !== 'function') return null;
try {
const bitmap = await createImageBitmap(file);
const ratio = Math.min(maxDim / bitmap.width, maxDim / bitmap.height, 1);
const w = Math.max(1, Math.round(bitmap.width * ratio));
const h = Math.max(1, Math.round(bitmap.height * ratio));
const canvas = new OffscreenCanvas(w, h);
const ctx = canvas.getContext('2d');
if (!ctx) {
bitmap.close();
return null;
}
ctx.drawImage(bitmap, 0, 0, w, h);
bitmap.close();
return await canvas.convertToBlob({ type: 'image/webp', quality: THUMB_QUALITY });
} catch (err: unknown) {
console.warn('generateWebPThumb failed', err);
return null;
}
}
+1
View File
@@ -16,6 +16,7 @@ const PRESERVE_LOCAL_STORAGE = new Set([
'chatapp.locale',
'chatapp.installId',
'chatapp.wipeOnClose.v1',
'chatapp.autoLockMinutes.v1',
'i18nextLng',
]);
@@ -16,6 +16,7 @@ import { bytesToPgHex, pgBytesToBytes } from '@chat-app/shared/supabase';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { decryptBatch as decryptBatchWorker } from './decryptWorker';
import { generateWebPThumb } from './imageCompress';
import {
loadCachedMessages,
persistMessages,
@@ -632,6 +633,12 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
throw new Error('attachment exceeds max size (10 MB)');
}
const dims = await readImageDimensions(file);
// Phase 6B: generate a small WebP preview thumb so the receiver's
// bubble loads fast (typical 320×240 WebP is 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({
client: supabase,
conversationId,
@@ -640,6 +647,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
sizeBytes: file.size,
...(dims.width !== undefined ? { width: dims.width } : {}),
...(dims.height !== undefined ? { height: dims.height } : {}),
...(thumbBlob ? { thumbBlob } : {}),
});
// Stamp the view-once flag on each handle the caller requested it
// for. The flag rides inside the encrypted payload (so peers can
+3 -2
View File
@@ -4,10 +4,11 @@ import {
} from '@chat-app/shared/auth';
import {
generateRecoveryCode, generateUserKeyPair, getCryptoBackend, normalizeRecoveryCode,
openUserKey, sealUserKey,
sealUserKey,
} from '@chat-app/shared/crypto';
import { migrateOwnLegacyBundles } from '@chat-app/shared/chat';
import { openUserKeyMaybeWorker } from './cryptoWorker';
import { devLocalSecretStore } from './secretStore';
import { supabase } from './supabase';
@@ -71,7 +72,7 @@ export async function loadOrUnlockUserKey(p: UnlockParams): Promise<UnlockOutcom
if (!sealed || !salt) throw new Error('no recovery blob configured');
let priv: Uint8Array;
try {
priv = await openUserKey({ sealed, pin: secret, salt, kdfParams: remote.kdfParams });
priv = await openUserKeyMaybeWorker({ sealed, pin: secret, salt, kdfParams: remote.kdfParams });
} catch (err) {
await recordPinAttempt(supabase, p.userId, false, p.isRecoveryCode === true).catch(() => {});
throw err;
+256 -144
View File
@@ -1,8 +1,9 @@
import { parseMessagePayload, pinMessage, unpinMessage } from '@chat-app/shared/chat';
import { extractErrorCode } from '@chat-app/shared/i18n';
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso';
import { ConversationHeader } from '../components/ConversationHeader';
import { EmojiPicker } from '../components/EmojiPicker';
@@ -77,7 +78,14 @@ import { usePeerPresence } from '../lib/usePeerPresence';
import { usePinnedMessages } from '../lib/usePinnedMessages';
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
@@ -89,10 +97,16 @@ const EMPTY_REACTIONS: AggregatedReaction[] = [];
// of ConversationPage when the route param (`id`) changes — switching
// chats unmounts/remounts the page in our router setup. Session-only
// (lost on reload, like Discord). The `stickToBottom` flag is preserved
// alongside the pixel offset so a chat the user left at the bottom keeps
// auto-following new messages when they return; a chat scrolled up
// returns to the exact spot the user was reading.
const scrollPositions = new Map<string, { scrollTop: number; stickToBottom: boolean }>();
// alongside the topmost-visible row index so a chat the user left at the
// bottom keeps auto-following new messages when they return; a chat
// scrolled up returns to roughly the same row the user was reading.
//
// We track the topmost-visible row index rather than a pixel `scrollTop`
// because `react-virtuoso` virtualizes the list — the underlying scroll
// element's pixel offset depends on dynamically-measured row heights and
// is not stable across re-mounts. Using a row index restores the user's
// reading position even if some rows above re-render at different heights.
const scrollPositions = new Map<string, { topmostIndex: number; stickToBottom: boolean }>();
export function ConversationPage() {
const { t } = useTranslation(['app', 'errors']);
@@ -263,8 +277,8 @@ export function ConversationPage() {
},
[send],
);
const scrollRef = useRef<HTMLDivElement>(null);
const loadMoreSentinelRef = useRef<HTMLDivElement>(null);
const virtuosoRef = useRef<VirtuosoHandle>(null);
const topmostIndexRef = useRef<number>(0);
const fileInputRef = useRef<HTMLInputElement>(null);
const composerRef = useRef<HTMLTextAreaElement>(null);
@@ -309,21 +323,17 @@ export function ConversationPage() {
previousMessageIdsRef.current = new Set(messages.map((message) => message.id));
}, [messages, myId, stickToBottom]);
useEffect(() => {
const el = loadMoreSentinelRef.current;
if (!el) return;
if (displayCount >= messages.length) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) {
setDisplayCount((n) => Math.min(messages.length, n * 2));
}
},
{ root: scrollRef.current, rootMargin: '200px 0px' },
);
observer.observe(el);
return () => observer.disconnect();
}, [displayCount, messages.length]);
// Replaces the previous IntersectionObserver-on-sentinel pattern: Virtuoso
// calls `startReached` when the user scrolls near the first row of the
// virtualized list. We bump `displayCount` the same way the old observer
// did. Wrapped in useCallback so Virtuoso doesn't tear down its scroll
// observer on every parent re-render.
const handleStartReached = useCallback(() => {
setDisplayCount((n) => {
if (n >= messages.length) return n;
return Math.min(messages.length, n * 2);
});
}, [messages.length]);
const messageById = useMemo(() => {
const m = new Map<string, DecryptedMessage>();
@@ -390,15 +400,87 @@ export function ConversationPage() {
return out;
}, [messages, buildQuoted]);
const jumpToMessage = useCallback((targetId: string) => {
const el = scrollRef.current?.querySelector<HTMLElement>(
'[data-message-id="' + CSS.escape(targetId) + '"]',
);
if (!el) return;
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
setHighlightedId(targetId);
window.setTimeout(() => setHighlightedId((cur) => (cur === targetId ? null : cur)), 1600);
}, []);
// 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]);
// 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);
window.setTimeout(
() => setHighlightedId((cur) => (cur === targetId ? null : cur)),
1600,
);
},
[messages, displayCount],
);
const handleReply = useCallback((m: DecryptedMessage) => {
setReplyTo(m);
@@ -549,85 +631,91 @@ export function ConversationPage() {
if (id && messages.length > 0) markRead(id);
}, [id, messages.length, markRead]);
// useLayoutEffect: run synchronously after DOM commit, before the
// browser paints. Using useEffect here let one frame of "scrollTop = 0
// (top of list)" paint between message-list mount and the auto-scroll,
// which is exactly the "flickers to a different position, then jumps"
// glitch users saw when re-entering a chat. Layout-effect fires while
// the message list is in the DOM but before paint, so the first frame
// already shows the correct scroll position.
useLayoutEffect(() => {
const el = scrollRef.current;
if (!el || !stickToBottom) return;
el.scrollTop = el.scrollHeight;
}, [messages.length, stickToBottom]);
// Scroll-to-bottom is handled by Virtuoso's `followOutput` prop, which
// fires whenever the rendered row count grows and auto-scrolls down only
// if the user was already at the bottom — exactly the Discord behavior
// we want for both outgoing sends and incoming realtime messages.
// The old useLayoutEffect that wrote `scrollTop = scrollHeight` is no
// longer needed: Virtuoso owns scroll positioning now.
// Restore saved scroll position once the conversation's messages have
// actually rendered. The earlier version fired on `[id]` alone and ran
// 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);
// 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.
const savedPositionRef = useRef<{ topmostIndex: number; stickToBottom: boolean } | null>(
null,
);
if (savedPositionRef.current === null && id) {
savedPositionRef.current = scrollPositions.get(id) ?? null;
}
// useLayoutEffect, same reason as above: writing scrollTop here happens
// before the first paint of the freshly-mounted chat, so the user
// doesn't see a frame at scrollTop=0 before the jump to the saved
// position. Combined with the messages.length gate this means the
// re-entry shows the message list AT the saved scroll location in one
// single paint — no "loaded then jumped" effect.
useLayoutEffect(() => {
const el = scrollRef.current;
if (!el || !id) return;
if (restoredForRef.current === id) return;
// Wait for the conversation's messages to populate; for a chat that
// truly has zero messages the bottom and the top are the same anyway.
if (messages.length === 0) return;
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]);
// Track whether the user is currently scrolled to the bottom. Virtuoso
// calls this whenever the bottom-state changes; we feed it into
// `stickToBottom` (used by the "jump to newest" pill and by the
// "new messages while away" counter logic). Also clears the unread-
// away counter when the user actually reaches the bottom.
const handleAtBottomStateChange = useCallback(
(atBottom: boolean) => {
setStickToBottom(atBottom);
if (atBottom) setNewMessagesWhileAway(0);
if (id) {
scrollPositions.set(id, {
topmostIndex: topmostIndexRef.current,
stickToBottom: atBottom,
});
}
},
[id],
);
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]);
// 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 el = scrollRef.current;
if (!el) return;
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
virtuosoRef.current?.scrollToIndex({
index: 'LAST',
align: 'end',
behavior: 'smooth',
});
setStickToBottom(true);
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) {
e?.preventDefault();
if ((!text.trim() && attachments.length === 0) || sending) return;
@@ -879,39 +967,69 @@ export function ConversationPage() {
{incomingHere && conversation && <IncomingCallPanel conversation={conversation} />}
{conversation && <InCallPanel conversation={conversation} />}
<div
ref={scrollRef}
onScroll={handleScroll}
className="discord-chat-surface flex-1 overflow-y-auto bg-surface-3 px-5 py-4"
>
<div className="discord-chat-surface flex min-h-0 flex-1 flex-col bg-surface-3">
{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" />
</div>
) : error ? (
<Banner>{error}</Banner>
<div className="px-5 py-4">
<Banner>{error}</Banner>
</div>
) : messages.length === 0 ? (
<EmptyState
icon={<SendIcon className="h-8 w-8" />}
title={t('app:chats.conv_empty_title', { defaultValue: 'Sag Hallo 👋' })}
description={t('app:chats.conv_empty_desc', {
defaultValue: 'Hier ist noch nichts. Schreibe die erste Nachricht.',
})}
/>
<div className="px-5 py-4">
<EmptyState
icon={<SendIcon className="h-8 w-8" />}
title={t('app:chats.conv_empty_title', { defaultValue: 'Sag Hallo 👋' })}
description={t('app:chats.conv_empty_desc', {
defaultValue: 'Hier ist noch nichts. Schreibe die erste Nachricht.',
})}
/>
</div>
) : (
<ul className="space-y-0.5">
{displayCount < messages.length && (
<li>
<div
ref={loadMoreSentinelRef}
className="flex items-center justify-center py-2 text-xs text-fg-muted"
>
Lade ältere Nachrichten
</div>
</li>
)}
{messages.slice(Math.max(0, messages.length - displayCount)).map((m, sliceIdx) => {
const idx = Math.max(0, messages.length - displayCount) + sliceIdx;
<Virtuoso
ref={virtuosoRef}
className="flex-1"
style={{ height: '100%' }}
data={virtuosoRows}
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
</div>
);
}
if (row.kind === 'pending') {
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 nextRaw = messages[idx + 1];
const prevIsCallEvent =
@@ -925,7 +1043,7 @@ export function ConversationPage() {
const senderProfile =
memberProfile ?? (m.senderId !== myId ? (conversation?.peer ?? null) : null);
return (
<li key={m.id}>
<div className="px-5">
{firstUnreadId === m.id && (
<div
aria-label="Neue Nachrichten"
@@ -972,19 +1090,10 @@ export function ConversationPage() {
isPinned={pinnedIds.has(m.id)}
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>
@@ -1348,9 +1457,12 @@ export function ConversationPage() {
open={pinnedPanelOpen}
pins={pins}
onClose={() => setPinnedPanelOpen(false)}
onJump={(_messageId) => {
// Future: scroll to message. For now just close the panel.
onJump={(messageId) => {
// 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);
jumpToMessage(messageId);
}}
onUnpin={(messageId) => void handleTogglePin(messageId)}
/>
+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);
}
})();
});
+92
View File
@@ -35,6 +35,14 @@ export interface AttachmentHandle {
/** UUID of the first non-sender who viewed. Populated alongside `viewedAt`
* by the mark-viewed RPC so the renderer can render attribution. */
viewedBy?: string | null;
/** Storage path of the encrypted WebP preview thumb (max 320×320). The
* thumb shares the per-attachment symmetric key with the full blob but
* uses its own nonce. Absent on pre-Phase-6B messages — the receiver
* falls through to downloading the full blob in that case. */
thumbStoragePath?: string;
/** Base-64 nonce that decrypts `<id>-thumb.bin`. Always present iff
* `thumbStoragePath` is set. */
thumbNonceB64?: string;
}
export type CallEventStatus = 'ended' | 'missed' | 'declined';
@@ -241,6 +249,13 @@ export interface EncryptedAttachmentResult {
// Encrypt + upload a blob. Does NOT insert the message_attachments row —
// the caller combines this with a message insert so everything commits
// atomically at the application layer.
//
// When `thumbBlob` is supplied (Phase 6B image-thumbnail path) a second
// ciphertext is uploaded to `<conversationId>/<id>-thumb.bin` encrypted
// with the SAME per-attachment key + a fresh nonce. The handle's
// `thumbStoragePath` / `thumbNonceB64` get populated so the receiver
// can prefer the thumb for inline preview. Thumb-upload failure is
// non-fatal — we log and fall through so the full image still posts.
export async function encryptAndUploadAttachment(params: {
client: AppSupabaseClient;
conversationId: string;
@@ -249,6 +264,7 @@ export async function encryptAndUploadAttachment(params: {
sizeBytes: number;
width?: number;
height?: number;
thumbBlob?: Blob | null;
}): Promise<EncryptedAttachmentResult> {
const backend = getCryptoBackend();
@@ -268,6 +284,35 @@ export async function encryptAndUploadAttachment(params: {
});
if (error) throw error;
let thumbStoragePath: string | undefined;
let thumbNonceB64: string | undefined;
if (params.thumbBlob) {
try {
const thumbBytes = new Uint8Array(await params.thumbBlob.arrayBuffer());
const thumbNonce = backend.randomBytes(backend.secretboxNonceLength);
const thumbCipher = backend.secretbox(thumbBytes, thumbNonce, key);
const thumbPath = params.conversationId + '/' + id + '-thumb.bin';
const { error: thumbErr } = await params.client.storage
.from(ATTACHMENT_BUCKET)
.upload(thumbPath, thumbCipher, {
contentType: 'application/octet-stream',
upsert: false,
});
if (thumbErr) {
// Non-fatal: log and continue with full-only handle. Receiver will
// fall back to fetching the full blob.
console.warn('thumb upload failed', thumbErr);
} else {
thumbStoragePath = thumbPath;
thumbNonceB64 = await toBase64(thumbNonce);
}
// Wipe nonce buffer.
for (let i = 0; i < thumbNonce.length; i++) thumbNonce[i] = 0;
} catch (err: unknown) {
console.warn('thumb encrypt/upload failed', err);
}
}
const handle: AttachmentHandle = {
id,
storagePath,
@@ -277,6 +322,8 @@ export async function encryptAndUploadAttachment(params: {
...(params.height !== undefined ? { height: params.height } : {}),
keyB64: await toBase64(key),
nonceB64: await toBase64(nonce),
...(thumbStoragePath !== undefined ? { thumbStoragePath } : {}),
...(thumbNonceB64 !== undefined ? { thumbNonceB64 } : {}),
};
return { handle, key, nonce };
@@ -309,6 +356,51 @@ export async function downloadAndDecryptAttachment(params: {
return new Blob([copy.buffer], { type: params.handle.mimeType });
}
// Download + decrypt the small WebP preview thumb that the sender uploaded
// alongside an image attachment (Phase 6B optimisation). Returns `null` if
// the handle has no thumb metadata (pre-Phase-6B message) or if the thumb
// blob is missing from storage — caller falls back to the full image.
//
// We deliberately swallow ANY download error (missing object, transient
// 5xx) so the receiver gracefully degrades to the full-blob path; only a
// successful decrypt-failure throws, since that signals a real corruption.
export async function downloadAndDecryptAttachmentThumb(params: {
client: AppSupabaseClient;
handle: AttachmentHandle;
}): Promise<Blob | null> {
if (!params.handle.thumbStoragePath || !params.handle.thumbNonceB64) {
return null;
}
const backend = getCryptoBackend();
let data: Blob | null = null;
try {
const res = await params.client.storage
.from(ATTACHMENT_BUCKET)
.download(params.handle.thumbStoragePath);
if (res.error) {
// Likely 404 — sender failed to upload thumb, or it's been GC'd.
// Receiver falls back to full image.
return null;
}
data = res.data;
} catch {
return null;
}
if (!data) return null;
const ciphertext = new Uint8Array(await data.arrayBuffer());
const key = await fromBase64(params.handle.keyB64);
const nonce = await fromBase64(params.handle.thumbNonceB64);
const plainBytes = backend.secretboxOpen(ciphertext, nonce, key);
for (let i = 0; i < key.length; i++) key[i] = 0;
for (let i = 0; i < nonce.length; i++) nonce[i] = 0;
const copy = new Uint8Array(plainBytes.byteLength);
copy.set(plainBytes);
// Thumbs are always image/webp regardless of original mime.
return new Blob([copy.buffer], { type: 'image/webp' });
}
// Insert the public metadata row for an attachment. The ciphertext itself has
// already been uploaded to storage under `handle.storagePath`.
export async function insertAttachmentRow(
+1 -11
View File
@@ -53,8 +53,6 @@
"archive": "Archivieren",
"unarchive": "Entarchivieren",
"archived_title": "Archiv",
"show_archived": "Archiv anzeigen",
"show_active": "Aktive anzeigen",
"archived_empty_title": "Nichts archiviert",
"archived_empty_subtitle": "Archivierte Unterhaltungen erscheinen hier.",
"mute": "Stummschalten",
@@ -84,8 +82,6 @@
"join": "Beitreten",
"in_call": "Im Anruf",
"waiting_for_peers": "Warte auf andere…",
"voice_connected": "Sprachchat verbunden",
"still_live": "Anruf läuft noch",
"share_screen": "Bildschirm teilen",
"stop_share_screen": "Screen-Share stoppen",
"is_sharing_screen": "{{name}} teilt den Bildschirm",
@@ -133,11 +129,9 @@
"action_unfriend": "Entfernen",
"action_accept": "Annehmen",
"action_decline": "Ablehnen",
"action_cancel": "Abbrechen",
"confirm_unfriend": "Diesen Freund entfernen?"
"action_cancel": "Abbrechen"
},
"admin": {
"nav": "Admin",
"title": "Admin-Panel",
"settings_title": "Globale Einstellungen",
"invites_enabled": "Neue Registrierungen erlauben",
@@ -188,15 +182,11 @@
"screen_share_quality": "Qualität",
"screen_share_hint": "WebRTC passt Bitrate + Auflösung dynamisch an die Netzwerkqualität an (SVC/VP9). Die Werte sind Obergrenzen. Änderungen greifen beim nächsten Call.",
"language": "Sprache",
"presence": "Status",
"show_read_receipts": "Lesebestätigungen anzeigen",
"show_read_receipts_hint": "Wenn aus, sehen andere nicht wann du ihre Nachrichten gelesen hast — und du siehst nicht wann sie deine gelesen haben.",
"allow_dms_strangers": "DMs von Fremden erlauben",
"allow_dms_strangers_hint": "Wenn aus, können dich nur Freunde direkt anschreiben.",
"this_device": "Dieses Gerät",
"danger_zone": "Gefahrenzone",
"sign_out": "Abmelden",
"section_ringtone": "Klingelton",
"ringtone_incoming": "Eingehender Anruf",
"ringtone_default_active": "Standard-Klingelton (Doppelton)",
"ringtone_custom_active": "{{name}} · {{size}} MB",
+1 -21
View File
@@ -50,28 +50,8 @@
"footer_studio": "Supabase Studio",
"legal_note": "Mit der Registrierung akzeptierst du, dass der Server nur Chiffretext sieht.",
"signed_in": {
"title": "Angemeldet",
"session_active": "Sitzung aktiv",
"user_id": "Benutzer-ID",
"email": "E-Mail",
"username": "Benutzername",
"display_name": "Anzeigename",
"admin": "Admin",
"yes": "Ja",
"no": "Nein",
"sign_out": "Abmelden",
"device_active": "Aktives Gerät",
"device_platform": "Plattform",
"device_registered_at": "Registriert"
},
"device": {
"title": "Dieses Gerät registrieren",
"subtitle": "Erzeugt ein X25519-Schlüsselpaar. Der private Schlüssel bleibt auf diesem Gerät.",
"name_label": "Gerätename",
"name_hint": "Erscheint in deiner Geräteliste. Wähle einen erkennbaren Namen.",
"name_placeholder": "Dennis Laptop",
"cta": "Gerät registrieren",
"cta_loading": "Schlüsselpaar wird erzeugt…",
"security_note_dev": "Dev-Build: Privater Schlüssel liegt unverschlüsselt im localStorage. Stronghold folgt vor dem Release."
"display_name": "Anzeigename"
}
}
@@ -1,16 +1,8 @@
{
"app_name": "ChatApp",
"loading": "Lädt…",
"finalising_session": "Sitzung wird abgeschlossen…",
"cancel": "Abbrechen",
"save": "Speichern",
"close": "Schließen",
"retry": "Wiederholen",
"online": "Online",
"offline": "Offline",
"idle": "Abwesend",
"dnd": "Nicht stören",
"invisible": "Unsichtbar",
"local_stack_online": "Lokaler Stack online",
"dev_build": "Dev-Build"
}
+1 -11
View File
@@ -53,8 +53,6 @@
"archive": "Archive",
"unarchive": "Unarchive",
"archived_title": "Archive",
"show_archived": "Show archive",
"show_active": "Show active",
"archived_empty_title": "Nothing archived",
"archived_empty_subtitle": "Archived conversations appear here.",
"mute": "Mute",
@@ -84,8 +82,6 @@
"join": "Join",
"in_call": "In call",
"waiting_for_peers": "Waiting for others…",
"voice_connected": "Voice connected",
"still_live": "Call still live",
"share_screen": "Share screen",
"stop_share_screen": "Stop sharing",
"is_sharing_screen": "{{name}} is sharing their screen",
@@ -133,11 +129,9 @@
"action_unfriend": "Unfriend",
"action_accept": "Accept",
"action_decline": "Decline",
"action_cancel": "Cancel",
"confirm_unfriend": "Remove this friend?"
"action_cancel": "Cancel"
},
"admin": {
"nav": "Admin",
"title": "Admin panel",
"settings_title": "Global settings",
"invites_enabled": "Allow new signups",
@@ -188,15 +182,11 @@
"screen_share_quality": "Quality",
"screen_share_hint": "WebRTC dynamically adjusts bitrate + resolution to match network conditions (SVC/VP9). Values are upper bounds. Changes apply on the next call.",
"language": "Language",
"presence": "Presence",
"show_read_receipts": "Show read receipts",
"show_read_receipts_hint": "When off, others can't see when you read their messages — and you won't see when they read yours.",
"allow_dms_strangers": "Allow DMs from strangers",
"allow_dms_strangers_hint": "When off, only friends can DM you.",
"this_device": "This device",
"danger_zone": "Danger zone",
"sign_out": "Sign out",
"section_ringtone": "Ringtone",
"ringtone_incoming": "Incoming call",
"ringtone_default_active": "Default ringtone (double beep)",
"ringtone_custom_active": "{{name}} · {{size}} MB",
+1 -21
View File
@@ -50,28 +50,8 @@
"footer_studio": "Supabase Studio",
"legal_note": "By signing up you accept that the server sees only ciphertext.",
"signed_in": {
"title": "Signed in",
"session_active": "Session active",
"user_id": "User ID",
"email": "Email",
"username": "Username",
"display_name": "Display name",
"admin": "Admin",
"yes": "yes",
"no": "no",
"sign_out": "Sign out",
"device_active": "Active device",
"device_platform": "Platform",
"device_registered_at": "Registered"
},
"device": {
"title": "Register this device",
"subtitle": "Generates an X25519 keypair. Private key stays on this device.",
"name_label": "Device name",
"name_hint": "Shown to you in your device list. Keep it recognisable.",
"name_placeholder": "Dennis Laptop",
"cta": "Register device",
"cta_loading": "Generating keypair…",
"security_note_dev": "Dev build: private key stored unencrypted in localStorage. Stronghold comes before release."
"display_name": "Display name"
}
}
@@ -1,16 +1,8 @@
{
"app_name": "ChatApp",
"loading": "Loading…",
"finalising_session": "Finalising session…",
"cancel": "Cancel",
"save": "Save",
"close": "Close",
"retry": "Retry",
"online": "Online",
"offline": "Offline",
"idle": "Idle",
"dnd": "Do not disturb",
"invisible": "Invisible",
"local_stack_online": "local stack online",
"dev_build": "dev build"
}
+14
View File
@@ -98,6 +98,9 @@ importers:
react-router-dom:
specifier: ^6.28.0
version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-virtuoso:
specifier: ^4.18.7
version: 4.18.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
zustand:
specifier: ^5.0.1
version: 5.0.12(@types/react@18.3.28)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1))
@@ -5420,6 +5423,12 @@ packages:
peerDependencies:
react: ^18.3.1
react-virtuoso@4.18.7:
resolution: {integrity: sha512-xNF5zDGEEIMB7cKwcen/pLig0YDf6OnfFrVgKFa7sHPf9fRem0CaLshyObbBcP88jzn0enavL39EgplgdyT21g==}
peerDependencies:
react: '>=16 || >=17 || >= 18 || >= 19'
react-dom: '>=16 || >=17 || >= 18 || >=19'
react@18.3.1:
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
engines: {node: '>=0.10.0'}
@@ -12820,6 +12829,11 @@ snapshots:
react-shallow-renderer: 16.15.0(react@18.3.1)
scheduler: 0.23.2
react-virtuoso@4.18.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react@18.3.1:
dependencies:
loose-envify: 1.4.0