Compare commits
37 Commits
phase6a-done
...
v0.21.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 787437c3f1 | |||
| be5647281d | |||
| fc8fc275cb | |||
| e19a71e892 | |||
| 11869be443 | |||
| 3fa8b6dbc1 | |||
| f9f8e3fdb8 | |||
| 4ed3f04300 | |||
| a7ffcbff83 | |||
| 82600915f1 | |||
| 93098a74ca | |||
| c8f0e8efd5 | |||
| fd9b8a88d6 | |||
| ab2f7130fe | |||
| b9a3dde1aa | |||
| 65d2446804 | |||
| faa12a4ebb | |||
| 37dd1b4f23 | |||
| d803773261 | |||
| 49855c5d3f | |||
| c9a64bf898 | |||
| 7f704e80f6 | |||
| 940432d287 | |||
| 28b6d64936 | |||
| 92baa626d6 | |||
| cd7ef8dccc | |||
| 58efc66ca7 | |||
| 1e139fb86e | |||
| eeb713f03d | |||
| 837b5a326e | |||
| b1f37752d6 | |||
| 854c4b91a8 | |||
| d3b708636f | |||
| c449943b52 | |||
| db59e3f658 | |||
| 6c6828006b | |||
| 21376daf39 |
@@ -33,6 +33,10 @@ web-build/
|
||||
apps/desktop/out/
|
||||
apps/desktop/release/
|
||||
|
||||
# Bundle visualizer reports
|
||||
apps/desktop/stats.html
|
||||
apps/desktop/stats.json
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
|
||||
import path from 'node:path';
|
||||
import { visualizer } from 'rollup-plugin-visualizer';
|
||||
|
||||
const ANALYZE = process.env.ANALYZE === 'true';
|
||||
|
||||
const rendererAliases = {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
@@ -44,7 +47,30 @@ export default defineConfig({
|
||||
// bundle. Relative base produces `./assets/...` which works in both
|
||||
// dev (served from /) and packaged builds.
|
||||
base: './',
|
||||
plugins: [react()],
|
||||
// Visualizer plugins are gated behind ANALYZE=true so the production
|
||||
// build never pays the analysis cost. Re-enable with:
|
||||
// ANALYZE=true pnpm --filter @chat-app/desktop build
|
||||
// which writes apps/desktop/stats.html (treemap) + stats.json (raw).
|
||||
plugins: [
|
||||
react(),
|
||||
...(ANALYZE
|
||||
? [
|
||||
visualizer({
|
||||
filename: 'stats.html',
|
||||
template: 'treemap',
|
||||
gzipSize: true,
|
||||
brotliSize: true,
|
||||
open: false,
|
||||
}),
|
||||
visualizer({
|
||||
filename: 'stats.json',
|
||||
template: 'raw-data',
|
||||
gzipSize: true,
|
||||
brotliSize: true,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
resolve: {
|
||||
alias: rendererAliases,
|
||||
},
|
||||
|
||||
@@ -107,6 +107,11 @@ export const CHANNELS = {
|
||||
// OS fullscreen so the Windows taskbar / macOS menubar gets covered.
|
||||
WINDOW_SET_FULLSCREEN: 'window:set-fullscreen',
|
||||
|
||||
// Content-protection toggle. Enables/disables OS-level screenshot/screen-
|
||||
// recording block (WDA_MONITOR on Windows, NSWindowSharingNone on macOS)
|
||||
// while a view-once image is being revealed. No-op on Linux X11.
|
||||
WINDOW_SET_CONTENT_PROTECTION: 'window:set-content-protection',
|
||||
|
||||
// Wipe-on-close — main process pushes this to the renderer right before
|
||||
// exiting if the user has enabled the Settings → Sicherheit toggle. The
|
||||
// renderer clears its sensitive caches (memoryWipe.ts) and acks via
|
||||
@@ -271,6 +276,12 @@ export interface UpdateProgress {
|
||||
total: number;
|
||||
}
|
||||
|
||||
// ---- Window content protection -------------------------------------------
|
||||
|
||||
export interface WindowSetContentProtectionArgs {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
// ---- Runtime marker ------------------------------------------------------
|
||||
|
||||
/** Value exposed on `window.electronAPI.platform`. Used by the renderer
|
||||
|
||||
@@ -26,6 +26,7 @@ import { register as registerShortcuts } from './modules/shortcuts';
|
||||
import { register as registerSql } from './modules/sql';
|
||||
import { register as registerTray } from './modules/tray';
|
||||
import { register as registerUpdater } from './modules/updater';
|
||||
import { register as registerWindowContentProtection } from './modules/window-content-protection';
|
||||
import { register as registerWindowFullscreen } from './modules/window-fullscreen';
|
||||
import { attach as attachWindowState, loadState } from './window-state';
|
||||
|
||||
@@ -147,6 +148,17 @@ async function createWindow(): Promise<BrowserWindow> {
|
||||
attachWindowState(win, WINDOW_STATE_FILE);
|
||||
|
||||
if (!app.isPackaged) {
|
||||
// Auto-open DevTools in dev — the menu bar is stripped (Discord-style)
|
||||
// so F12 / Ctrl+Shift+I have no chord; opening detached gives a
|
||||
// separate inspector window for easy debugging.
|
||||
win.webContents.openDevTools({ mode: 'detach' });
|
||||
// Forward renderer console messages to the main-process stdout so
|
||||
// errors during local dev are visible in the terminal too (helps when
|
||||
// the inspector isn't focused).
|
||||
win.webContents.on('console-message', (_event, level, message, line, sourceId) => {
|
||||
const tag = level === 3 ? 'error' : level === 2 ? 'warn' : level === 1 ? 'log' : 'info';
|
||||
console.log('[renderer ' + tag + ']', message, '(' + sourceId + ':' + line + ')');
|
||||
});
|
||||
await win.loadURL(DEV_URL);
|
||||
} else {
|
||||
await win.loadFile(resolveRendererIndex());
|
||||
@@ -263,6 +275,7 @@ if (!gotLock) {
|
||||
registerTray(mainWindow);
|
||||
registerUpdater(mainWindow);
|
||||
registerWindowFullscreen(mainWindow);
|
||||
registerWindowContentProtection(mainWindow);
|
||||
registerAudioLoopback(mainWindow);
|
||||
});
|
||||
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
// (<1ms per op for the current workload).
|
||||
//
|
||||
// Binding param style: Tauri's plugin-sql used $1, $2... positionals with
|
||||
// bindings as an array. SQLite natively accepts $N so existing queries
|
||||
// keep working unmodified.
|
||||
// bindings as an array. SQLite parses `$NAME` as a NAMED parameter
|
||||
// (NAME = `1`, `2`, …), not as positional, so better-sqlite3 wants the
|
||||
// bindings as `{ '1': v1, '2': v2 }` not `[v1, v2]`. We accept the old
|
||||
// array-shape from callers and convert to the named-object on the way in.
|
||||
|
||||
import { app, ipcMain } from 'electron';
|
||||
import Database from 'better-sqlite3';
|
||||
@@ -40,6 +42,20 @@ function requireHandle(h: string): Handle {
|
||||
return entry;
|
||||
}
|
||||
|
||||
// Convert a positional bindings array `[v1, v2]` to the named-params object
|
||||
// `{ '1': v1, '2': v2 }` that better-sqlite3 needs when the SQL uses
|
||||
// `$1`/`$2` named placeholders. Returns the original array (spread later)
|
||||
// when it's empty.
|
||||
function bindParams(bindings: unknown[] | undefined): Record<string, unknown> | [] {
|
||||
const arr = bindings ?? [];
|
||||
if (arr.length === 0) return [];
|
||||
const obj: Record<string, unknown> = {};
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
obj[String(i + 1)] = arr[i];
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
export function register(): void {
|
||||
ipcMain.handle(CHANNELS.SQL_LOAD, async (_evt, args: SqlLoadArgs): Promise<string> => {
|
||||
const rawName = stripPrefix(args.name);
|
||||
@@ -59,7 +75,8 @@ export function register(): void {
|
||||
async (_evt, args: SqlExecuteArgs): Promise<SqlExecuteResult> => {
|
||||
const entry = requireHandle(args.handle);
|
||||
const stmt = entry.db.prepare(args.query);
|
||||
const info = stmt.run(...((args.bindings ?? []) as unknown[]));
|
||||
const params = bindParams(args.bindings);
|
||||
const info = Array.isArray(params) ? stmt.run() : stmt.run(params);
|
||||
return {
|
||||
rowsAffected: info.changes,
|
||||
lastInsertId:
|
||||
@@ -75,7 +92,11 @@ export function register(): void {
|
||||
async (_evt, args: SqlSelectArgs): Promise<SqlSelectResult> => {
|
||||
const entry = requireHandle(args.handle);
|
||||
const stmt = entry.db.prepare(args.query);
|
||||
const rows = stmt.all(...((args.bindings ?? []) as unknown[])) as Record<string, unknown>[];
|
||||
const params = bindParams(args.bindings);
|
||||
const rows = (Array.isArray(params) ? stmt.all() : stmt.all(params)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>[];
|
||||
return rows;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// Window content-protection adapter. Enables / disables OS-level
|
||||
// screenshot and screen-recording blocking on the host BrowserWindow.
|
||||
//
|
||||
// Windows: WDA_MONITOR (SetWindowDisplayAffinity) — the window surface
|
||||
// appears black in any screen capture tool (OBS, Snipping Tool,
|
||||
// Win+PrtScr, etc.) while protection is enabled.
|
||||
// macOS: NSWindowSharingNone — equivalent coverage for QuickTime,
|
||||
// Cmd+Shift+3/4, and external recorders.
|
||||
// Linux: No-op. Electron exposes the API on all platforms but the
|
||||
// X11/Wayland compositors don't honour it in Electron 33.
|
||||
//
|
||||
// Called by the renderer during view-once image reveals so the image
|
||||
// cannot be captured by an OS-level screenshot while it is on screen.
|
||||
|
||||
import { BrowserWindow, ipcMain } from 'electron';
|
||||
|
||||
import { CHANNELS, type WindowSetContentProtectionArgs } from '../ipc-types';
|
||||
|
||||
export function register(mainWindow: BrowserWindow): void {
|
||||
ipcMain.handle(
|
||||
CHANNELS.WINDOW_SET_CONTENT_PROTECTION,
|
||||
(_evt, args: WindowSetContentProtectionArgs) => {
|
||||
// Electron's setContentProtection covers Windows (WDA_MONITOR) and
|
||||
// macOS (NSWindowSharingNone) in one call. No-op on Linux X11.
|
||||
// Wrapped in try/catch because the window can already be destroyed
|
||||
// by the time this fires during a teardown.
|
||||
try {
|
||||
const win = BrowserWindow.fromWebContents(_evt.sender) ?? mainWindow;
|
||||
if (!win || win.isDestroyed()) return;
|
||||
win.setContentProtection(args.enabled);
|
||||
} catch (err) {
|
||||
console.warn('setContentProtection failed', err);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
+6
@@ -96,6 +96,12 @@ export interface ElectronAPI {
|
||||
|
||||
setFullscreen: (enabled: boolean) => Promise<void>;
|
||||
|
||||
/** Block OS-level screen capture (Win+PrtScr, OBS, etc.) while a
|
||||
* view-once image is being revealed. Covers Windows (WDA_MONITOR) and
|
||||
* macOS (NSWindowSharingNone). No-op on Linux X11. Optional: always
|
||||
* feature-check because the web build has no preload bridge. */
|
||||
setContentProtection?: (enabled: boolean) => Promise<void>;
|
||||
|
||||
/** Subscribe to the main-process pre-quit notification. Used by the
|
||||
* "Cache beim Schließen leeren" Settings toggle. */
|
||||
onWipeBeforeQuit: (cb: () => Promise<void>) => () => void;
|
||||
|
||||
@@ -160,6 +160,10 @@ const api = {
|
||||
setFullscreen: (enabled: boolean): Promise<void> =>
|
||||
ipcRenderer.invoke(CHANNELS.WINDOW_SET_FULLSCREEN, enabled),
|
||||
|
||||
// Window content protection ----------------------------------------------
|
||||
setContentProtection: (enabled: boolean): Promise<void> =>
|
||||
ipcRenderer.invoke(CHANNELS.WINDOW_SET_CONTENT_PROTECTION, { enabled }),
|
||||
|
||||
// OS hostname ------------------------------------------------------------
|
||||
getHostname: (): Promise<string | null> => ipcRenderer.invoke(CHANNELS.APP_HOSTNAME),
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@chat-app/desktop",
|
||||
"version": "0.19.1",
|
||||
"version": "0.21.0",
|
||||
"private": true,
|
||||
"description": "Electron desktop client (Windows / macOS / Linux)",
|
||||
"type": "module",
|
||||
@@ -35,6 +35,7 @@
|
||||
"react-easy-crop": "^5.5.7",
|
||||
"react-i18next": "^15.1.1",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"react-virtuoso": "^4.18.7",
|
||||
"zustand": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -51,6 +52,7 @@
|
||||
"electron-vite": "^2.3.0",
|
||||
"postcss": "^8.4.49",
|
||||
"rimraf": "^6.0.0",
|
||||
"rollup-plugin-visualizer": "^7.0.1",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"vite": "^5.4.11"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { HashRouter, Navigate, Outlet, Route, Routes } from 'react-router-dom';
|
||||
import { lazy, Suspense, useEffect } from 'react';
|
||||
import { HashRouter, Navigate, Outlet, Route, Routes, useParams } from 'react-router-dom';
|
||||
|
||||
import { AppShell } from './components/AppShell';
|
||||
import { CrashToast } from './components/CrashToast';
|
||||
@@ -13,6 +13,7 @@ import { CallProvider } from './context/CallContext';
|
||||
import { ConversationsProvider } from './context/ConversationsContext';
|
||||
import { FriendshipsProvider } from './context/FriendshipsContext';
|
||||
import { ThemeProvider } from './context/ThemeContext';
|
||||
import { hydrateDrafts } from './lib/composerDraftStore';
|
||||
import { AuthPage } from './pages/AuthPage';
|
||||
import { ChatsEmptyState, ChatsPage } from './pages/ChatsPage';
|
||||
import { ConversationPage } from './pages/ConversationPage';
|
||||
@@ -60,7 +61,23 @@ function RouteBoundary({ scope }: { scope: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Forces a fresh `ConversationPage` instance per `:id` so React unmounts
|
||||
// the previous conversation entirely on switch. Without this, the same
|
||||
// component instance handles every conversation, which leaks state
|
||||
// between chats (messages, scroll position, composer drafts) for one
|
||||
// render frame and gives the "flicker" we're trying to remove.
|
||||
// `useConversationMessages` re-hydrates from `messageMemoryCache` on the
|
||||
// fresh mount so previously-visited chats still render instantly.
|
||||
function ConversationRoute() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
return <ConversationPage key={id ?? '__no_id__'} />;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
useEffect(() => {
|
||||
void hydrateDrafts();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ErrorBoundary scope="root">
|
||||
<ThemeProvider>
|
||||
@@ -114,7 +131,7 @@ export function App() {
|
||||
path=":id"
|
||||
element={
|
||||
<ErrorBoundary scope="conversation">
|
||||
<ConversationPage />
|
||||
<ConversationRoute />
|
||||
</ErrorBoundary>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useIdleAutoLock } from '../hooks/useIdleAutoLock';
|
||||
import { startConversationKeySync } from '../lib/conversationKeySync';
|
||||
import { startDeviceApprovalListener } from '../lib/deviceApproval';
|
||||
import { ensureInstallId } from '../lib/installId';
|
||||
@@ -14,6 +15,7 @@ import { Sidebar } from './Sidebar';
|
||||
|
||||
export function AppShell() {
|
||||
const { session } = useAuth();
|
||||
useIdleAutoLock();
|
||||
useMentionNotifications(session?.user.id);
|
||||
useEffect(() => {
|
||||
// Prompt once per authenticated shell mount. Module-level guard prevents
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/s
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
|
||||
import { getVoiceSpeed, setVoiceSpeed, VOICE_SPEEDS, type VoiceSpeed } from '../lib/voiceSpeedSettings';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { AlertIcon, MicIcon, SpinnerIcon } from './icons';
|
||||
|
||||
@@ -23,6 +24,7 @@ export function AttachmentAudio({ handle }: Props) {
|
||||
const [duration, setDuration] = useState<number>(0);
|
||||
const [position, setPosition] = useState<number>(0);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [speed, setSpeed] = useState<VoiceSpeed>(() => getVoiceSpeed());
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -105,6 +107,12 @@ export function AttachmentAudio({ handle }: Props) {
|
||||
};
|
||||
}, [arrayBuf]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = audioRef.current;
|
||||
if (!el) return;
|
||||
el.playbackRate = speed;
|
||||
}, [speed, blobUrl]);
|
||||
|
||||
const fallbackPeaks = useMemo(
|
||||
() => (peaks ? null : Array.from({ length: BAR_COUNT }, () => 0.5)),
|
||||
[peaks],
|
||||
@@ -154,6 +162,29 @@ export function AttachmentAudio({ handle }: Props) {
|
||||
<PlayGlyph />
|
||||
)}
|
||||
</button>
|
||||
<div className="flex shrink-0 items-center gap-0.5 rounded-md bg-surface-3 p-0.5 text-[10px] font-semibold text-fg-muted">
|
||||
{VOICE_SPEEDS.map((s) => {
|
||||
const active = s === speed;
|
||||
return (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSpeed(s);
|
||||
setVoiceSpeed(s);
|
||||
}}
|
||||
className={
|
||||
'flex h-6 w-7 cursor-pointer items-center justify-center rounded transition ' +
|
||||
(active ? 'bg-accent text-accent-fg' : 'hover:bg-surface hover:text-fg')
|
||||
}
|
||||
aria-pressed={active}
|
||||
title={'Wiedergabegeschwindigkeit ' + s + '×'}
|
||||
>
|
||||
{s}×
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div
|
||||
role="slider"
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
type AttachmentHandle,
|
||||
downloadAndDecryptAttachment,
|
||||
downloadAndDecryptAttachmentThumb,
|
||||
} from '@chat-app/shared/chat';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
|
||||
import { supabase } from '../lib/supabase';
|
||||
@@ -56,6 +60,17 @@ export function AttachmentImage({ handle, mine = false }: Props) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
|
||||
// Whether the sender shipped a pre-built WebP thumb alongside this attachment
|
||||
// (Phase 6B+). When true we render the bubble from just the thumb and only
|
||||
// fetch the full blob when the user opens the lightbox or for view-once.
|
||||
const hasServerThumb = Boolean(handle.thumbStoragePath && handle.thumbNonceB64);
|
||||
// View-once needs the full blob ready instantly the moment the recipient
|
||||
// taps (otherwise we'd show a spinner during the burn animation, then race
|
||||
// the "mark viewed" RPC). Same for the legacy path with no server thumb —
|
||||
// we have to download the whole thing just to render the client-side
|
||||
// makeThumbnail fallback.
|
||||
const needsEagerFull = handle.viewOnce === true || !hasServerThumb;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const created: string[] = [];
|
||||
@@ -69,9 +84,39 @@ export function AttachmentImage({ handle, mine = false }: Props) {
|
||||
return u;
|
||||
};
|
||||
|
||||
// OPFS cache → decrypt → generate thumbnail for inline display.
|
||||
// Lightbox swaps to the full blob when opened.
|
||||
const thumbCacheId = handle.id + '-thumb';
|
||||
|
||||
void (async () => {
|
||||
// Phase 6B fast path: if the sender shipped a server-side WebP thumb,
|
||||
// grab it first so the bubble paints from ~20KB instead of waiting on
|
||||
// the multi-MB full blob. The OPFS cache is keyed separately so the
|
||||
// thumb survives independent of full-blob eviction.
|
||||
if (hasServerThumb) {
|
||||
try {
|
||||
let thumbBlob: Blob | null = await getCachedAttachment(thumbCacheId);
|
||||
if (!thumbBlob) {
|
||||
thumbBlob = await downloadAndDecryptAttachmentThumb({
|
||||
client: supabase,
|
||||
handle,
|
||||
});
|
||||
if (thumbBlob) void putCachedAttachment(thumbCacheId, thumbBlob);
|
||||
}
|
||||
if (cancelled) return;
|
||||
if (thumbBlob) {
|
||||
setThumbUrl(take(thumbBlob));
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
// Thumb decrypt failure isn't fatal — fall through to the full
|
||||
// blob path below so the user still sees the image.
|
||||
console.warn('thumb load failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Eagerly resolve the full blob when we need it for view-once or as
|
||||
// the only render source (no server thumb). For the thumb-first path
|
||||
// the full blob is deferred until the lightbox opens (see below).
|
||||
if (!needsEagerFull) return;
|
||||
|
||||
const cached = await getCachedAttachment(handle.id);
|
||||
let blob: Blob;
|
||||
if (cached) {
|
||||
@@ -90,10 +135,14 @@ export function AttachmentImage({ handle, mine = false }: Props) {
|
||||
if (cancelled) return;
|
||||
const full = take(blob);
|
||||
setFullUrl(full);
|
||||
const thumb = await makeThumbnail(blob);
|
||||
if (cancelled) return;
|
||||
if (thumb) {
|
||||
setThumbUrl(take(thumb));
|
||||
// Pre-Phase-6B fallback: no server thumb shipped, so re-derive a
|
||||
// smaller preview on the client to keep memory pressure down.
|
||||
if (!hasServerThumb) {
|
||||
const thumb = await makeThumbnail(blob);
|
||||
if (cancelled) return;
|
||||
if (thumb) {
|
||||
setThumbUrl(take(thumb));
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -101,7 +150,74 @@ export function AttachmentImage({ handle, mine = false }: Props) {
|
||||
cancelled = true;
|
||||
for (const u of created) URL.revokeObjectURL(u);
|
||||
};
|
||||
}, [handle.id, handle.storagePath, handle.keyB64, handle.nonceB64]);
|
||||
}, [
|
||||
handle,
|
||||
handle.id,
|
||||
handle.storagePath,
|
||||
handle.keyB64,
|
||||
handle.nonceB64,
|
||||
handle.thumbStoragePath,
|
||||
handle.thumbNonceB64,
|
||||
hasServerThumb,
|
||||
needsEagerFull,
|
||||
]);
|
||||
|
||||
// Lazy full-image fetch for click-to-expand (only kicks in when we
|
||||
// skipped the eager full-blob download above). Resolves into the same
|
||||
// `fullUrl` state that the Lightbox consumes; the thumb URL keeps
|
||||
// backing the bubble until the lightbox actually mounts.
|
||||
//
|
||||
// CRITICAL: do NOT revoke the just-created blob URL in this effect's
|
||||
// cleanup. Setting `fullUrl` re-triggers the effect (state change → re-
|
||||
// run → previous cleanup fires → URL revoked → Lightbox renders
|
||||
// referenced-but-revoked URL → "ERR_FILE_NOT_FOUND"). The dedicated
|
||||
// unmount-only effect below tracks the current URL via ref and revokes
|
||||
// it once when the component truly leaves the tree.
|
||||
//
|
||||
// Deps locked to `handle.id` (not `handle`) — handles are immutable per
|
||||
// attachment id, so object-identity churn from parent re-renders must
|
||||
// not re-trigger the fetch.
|
||||
useEffect(() => {
|
||||
if (!lightboxOpen) return;
|
||||
if (fullUrl) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const cached = await getCachedAttachment(handle.id);
|
||||
let blob: Blob;
|
||||
if (cached) {
|
||||
blob = cached;
|
||||
} else {
|
||||
try {
|
||||
blob = await downloadAndDecryptAttachment({ client: supabase, handle });
|
||||
void putCachedAttachment(handle.id, blob);
|
||||
} catch (err: unknown) {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : 'download failed');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (cancelled) return;
|
||||
const u = URL.createObjectURL(blob);
|
||||
setFullUrl(u);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [lightboxOpen, handle.id]);
|
||||
|
||||
// Track the currently-published fullUrl in a ref so the unmount-only
|
||||
// cleanup below can revoke whatever URL is live at teardown time
|
||||
// without subscribing to fullUrl changes (which would re-trigger and
|
||||
// revoke prematurely — see the comment above the fetch effect).
|
||||
const fullUrlRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
fullUrlRef.current = fullUrl;
|
||||
}, [fullUrl]);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (fullUrlRef.current) URL.revokeObjectURL(fullUrlRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const blobUrl = thumbUrl ?? fullUrl;
|
||||
|
||||
@@ -157,7 +273,15 @@ export function AttachmentImage({ handle, mine = false }: Props) {
|
||||
className="block h-auto max-h-80 w-auto max-w-full object-contain"
|
||||
/>
|
||||
</button>
|
||||
{lightboxOpen && fullUrl && <Lightbox url={fullUrl} onClose={() => setLightboxOpen(false)} />}
|
||||
{lightboxOpen && (
|
||||
<Lightbox
|
||||
// Prefer the full blob the moment it's available; otherwise show
|
||||
// the thumb so the user sees *something* during the lazy fetch
|
||||
// (typical full-blob fetch is 100ms–2s depending on size).
|
||||
url={fullUrl ?? blobUrl}
|
||||
onClose={() => setLightboxOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||
import { useCall } from '../context/CallContext';
|
||||
|
||||
interface Props {
|
||||
conversation: ConversationSummary;
|
||||
}
|
||||
|
||||
const STALE_AFTER_MS = 5000;
|
||||
|
||||
/** Discord-style live-captions overlay. Pinned to the bottom-center of the
|
||||
* call surface; renders the most recent caption per participant, fading
|
||||
* entries out after `STALE_AFTER_MS` of silence. Self-captions are shown
|
||||
* too so the speaker can sanity-check what's being broadcast. */
|
||||
export function CallCaptionsOverlay({ conversation }: Props) {
|
||||
const { captions } = useCall();
|
||||
// Re-render every second so stale entries fade without needing the data
|
||||
// channel to fire — captions module just stores timestamps.
|
||||
const [, setNow] = useState(Date.now());
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const now = Date.now();
|
||||
const visible = Object.entries(captions)
|
||||
.filter(([, v]) => now - v.timestamp < STALE_AFTER_MS)
|
||||
.sort(([, a], [, b]) => a.timestamp - b.timestamp);
|
||||
|
||||
if (visible.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-24 z-30 flex flex-col items-center gap-1.5 px-6">
|
||||
{visible.map(([identity, c]) => {
|
||||
const member = conversation.members.find((m) => m.userId === identity);
|
||||
const name = member?.profile?.displayName ?? '?';
|
||||
const age = now - c.timestamp;
|
||||
const opacity = age > 3500 ? 1 - (age - 3500) / (STALE_AFTER_MS - 3500) : 1;
|
||||
return (
|
||||
<div
|
||||
key={identity}
|
||||
style={{ opacity: Math.max(0, opacity) }}
|
||||
className="max-w-[760px] rounded-lg bg-black/72 px-3.5 py-1.5 text-sm text-white shadow-md backdrop-blur-md transition-opacity"
|
||||
>
|
||||
<span className="mr-2 text-[11px] font-semibold uppercase tracking-wide text-white/55">
|
||||
{name}
|
||||
</span>
|
||||
<span className={c.final ? '' : 'italic text-white/85'}>{c.text}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
CaptionsIcon,
|
||||
HeadphonesIcon,
|
||||
HeadphonesOffIcon,
|
||||
MicIcon,
|
||||
@@ -32,10 +31,6 @@ interface Props {
|
||||
/** Toggle the in-call soundboard popover. Active = panel currently open. */
|
||||
onToggleSoundboard?: () => void;
|
||||
soundboardOpen?: boolean;
|
||||
/** Discord-style live-captions toggle. Optional — pages that don't support
|
||||
* SpeechRecognition (Firefox) skip the prop and the button is hidden. */
|
||||
onToggleCaptions?: () => void;
|
||||
captionsOn?: boolean;
|
||||
participantsOpen?: boolean;
|
||||
/** Compact variant used inside the docked call (36px buttons). */
|
||||
compact?: boolean;
|
||||
@@ -59,8 +54,6 @@ export function CallControls({
|
||||
onOpenParticipants,
|
||||
onToggleSoundboard,
|
||||
soundboardOpen = false,
|
||||
onToggleCaptions,
|
||||
captionsOn = false,
|
||||
participantsOpen = false,
|
||||
compact = false,
|
||||
glass = false,
|
||||
@@ -148,22 +141,6 @@ export function CallControls({
|
||||
<MusicIcon className="h-5 w-5" />
|
||||
</CallButton>
|
||||
)}
|
||||
{onToggleCaptions && (
|
||||
<CallButton
|
||||
label={
|
||||
captionsOn
|
||||
? t('app:call.captions_off', { defaultValue: 'Untertitel aus' })
|
||||
: t('app:call.captions_on', { defaultValue: 'Untertitel an' })
|
||||
}
|
||||
active={captionsOn}
|
||||
activeTone="accent"
|
||||
onClick={onToggleCaptions}
|
||||
glass={glass}
|
||||
className={btnSize}
|
||||
>
|
||||
<CaptionsIcon className="h-5 w-5" />
|
||||
</CallButton>
|
||||
)}
|
||||
{onOpenParticipants && (
|
||||
<CallButton
|
||||
label={t('app:call.participants', { defaultValue: 'Teilnehmer' })}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { forwardRef, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useConversationsContext } from '../context/ConversationsContext';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { ArchiveIcon, AtIcon, BellIcon, BellOffIcon, MoreVerticalIcon } from './icons';
|
||||
|
||||
@@ -54,6 +55,7 @@ interface MenuPos {
|
||||
// under the trigger so it doesn't push off-screen on narrow windows.
|
||||
export function ConversationRowMenu({ conversationId, archived, mutedUntil, mentionsOnly }: Props) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const { patchConversation } = useConversationsContext();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [submenuOpen, setSubmenuOpen] = useState<'mute' | null>(null);
|
||||
const [menuPos, setMenuPos] = useState<MenuPos | null>(null);
|
||||
@@ -122,44 +124,59 @@ export function ConversationRowMenu({ conversationId, archived, mutedUntil, ment
|
||||
const isMuted =
|
||||
mutedUntil !== null && new Date(mutedUntil).getTime() > Date.now();
|
||||
|
||||
// Optimistic updates: flip the local state before the server RPC so the
|
||||
// bell / archive icon / checkmark update on the same frame as the click.
|
||||
// Realtime echo via ConversationsContext will reconcile (no-op since the
|
||||
// optimistic patch already matches the server row). On error we restore
|
||||
// the previous value so the menu doesn't lie about persisted state.
|
||||
const handleArchive = useCallback(
|
||||
async (next: boolean) => {
|
||||
setOpen(false);
|
||||
const previous = archived;
|
||||
patchConversation(conversationId, { archived: next });
|
||||
try {
|
||||
await setConversationArchived(supabase, conversationId, next);
|
||||
} catch (err: unknown) {
|
||||
patchConversation(conversationId, { archived: previous });
|
||||
console.error('archive toggle failed', err);
|
||||
}
|
||||
},
|
||||
[conversationId],
|
||||
[conversationId, archived, patchConversation],
|
||||
);
|
||||
|
||||
const handleMute = useCallback(
|
||||
async (minutes: number | null) => {
|
||||
setOpen(false);
|
||||
setSubmenuOpen(null);
|
||||
const previous = mutedUntil;
|
||||
const nextIso = muteDurationToIso(minutes);
|
||||
patchConversation(conversationId, { mutedUntil: nextIso });
|
||||
try {
|
||||
await setConversationMutedUntil(supabase, conversationId, muteDurationToIso(minutes));
|
||||
await setConversationMutedUntil(supabase, conversationId, nextIso);
|
||||
} catch (err: unknown) {
|
||||
patchConversation(conversationId, { mutedUntil: previous });
|
||||
console.error('mute toggle failed', err);
|
||||
}
|
||||
},
|
||||
[conversationId],
|
||||
[conversationId, mutedUntil, patchConversation],
|
||||
);
|
||||
|
||||
const handleMentionsOnly = useCallback(
|
||||
async (next: boolean) => {
|
||||
setOpen(false);
|
||||
const previous = mentionsOnly;
|
||||
patchConversation(conversationId, { mentionsOnly: next });
|
||||
try {
|
||||
await setConversationMentionsOnly(supabase, {
|
||||
conversationId,
|
||||
mentionsOnly: next,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
patchConversation(conversationId, { mentionsOnly: previous });
|
||||
console.warn('mentions-only toggle failed', err);
|
||||
}
|
||||
},
|
||||
[conversationId],
|
||||
[conversationId, mentionsOnly, patchConversation],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -41,20 +41,42 @@ export function ImageAnnotator({ file, onCancel, onSave }: Props) {
|
||||
const draftRef = useRef<AnnotatorOp | null>(null);
|
||||
const [draftTick, setDraftTick] = useState(0);
|
||||
|
||||
// Hold a stable ref to onCancel so the image-load effect doesn't depend
|
||||
// on its identity. Without this, parents that pass an inline `() => …`
|
||||
// re-render the modal on every keystroke / state change, the effect re-
|
||||
// runs, the previous URL.createObjectURL gets revoked WHILE the new img
|
||||
// is still decoding → img.onerror fires ("file not found") → onCancel →
|
||||
// modal flashes open + closes instantly.
|
||||
const onCancelRef = useRef(onCancel);
|
||||
useEffect(() => { onCancelRef.current = onCancel; }, [onCancel]);
|
||||
|
||||
useEffect(() => {
|
||||
// React 18 strict mode in dev double-mounts effects to test idempotency.
|
||||
// The first run creates a blob URL, sets img.src, returns a cleanup
|
||||
// that revokes — and the cleanup fires BEFORE the (still-in-flight)
|
||||
// image fetch completes. The browser then emits ERR_FILE_NOT_FOUND for
|
||||
// the revoked URL → img.onerror → modal closes instantly. The
|
||||
// `cancelled` flag guards every callback so a torn-down run can't
|
||||
// close the modal that the second mount just opened.
|
||||
let cancelled = false;
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
if (cancelled) return;
|
||||
imageRef.current = img;
|
||||
setImageLoaded(true);
|
||||
};
|
||||
img.onerror = () => {
|
||||
if (cancelled) return;
|
||||
console.error('ImageAnnotator: failed to decode source image');
|
||||
onCancel();
|
||||
onCancelRef.current();
|
||||
};
|
||||
img.src = url;
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [file, onCancel]);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
}, [file]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!imageLoaded) return;
|
||||
|
||||
@@ -15,14 +15,7 @@ import {
|
||||
listSounds,
|
||||
subscribeSoundboardChanges,
|
||||
} from '../lib/soundboardStorage';
|
||||
import {
|
||||
getLiveCaptionsSettings,
|
||||
isLiveCaptionsSupported,
|
||||
subscribeLiveCaptionsSettings,
|
||||
updateLiveCaptionsSettings,
|
||||
} from '../lib/liveCaptions';
|
||||
import { useActiveSpeakers } from '../lib/useActiveSpeakers';
|
||||
import { CallCaptionsOverlay } from './CallCaptionsOverlay';
|
||||
import { CallControls } from './CallControls';
|
||||
import { CallParticipantTile, type TileConnectionQuality } from './CallParticipantTile';
|
||||
import { CallStatsOverlay } from './CallStatsOverlay';
|
||||
@@ -119,17 +112,6 @@ export function InCallPanel({ conversation }: Props) {
|
||||
const [sharePickerOpen, setSharePickerOpen] = useState(false);
|
||||
// Discord-style debug stats overlay (Ctrl+Shift+S toggles).
|
||||
const [statsOverlayOpen, setStatsOverlayOpen] = useState(false);
|
||||
// Live-captions toggle — mirror of getLiveCaptionsSettings().enabled so the
|
||||
// controls bar can show an "active" state without polling. Captions
|
||||
// broadcasting is wired in CallContext via useLiveCaptions; this only
|
||||
// tracks the toggle state for the button.
|
||||
const [captionsEnabled, setCaptionsEnabled] = useState<boolean>(
|
||||
() => getLiveCaptionsSettings().enabled,
|
||||
);
|
||||
useEffect(
|
||||
() => subscribeLiveCaptionsSettings((s) => setCaptionsEnabled(s.enabled)),
|
||||
[],
|
||||
);
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
// Match the modifier exactly to avoid clobbering other Ctrl+Shift combos.
|
||||
@@ -362,15 +344,6 @@ export function InCallPanel({ conversation }: Props) {
|
||||
soundboardOpen,
|
||||
}
|
||||
: {})}
|
||||
// Live-Captions only when SpeechRecognition is available in the
|
||||
// runtime — Firefox lacks it, would just show a dead button.
|
||||
{...(isLiveCaptionsSupported()
|
||||
? {
|
||||
onToggleCaptions: () =>
|
||||
updateLiveCaptionsSettings({ enabled: !captionsEnabled }),
|
||||
captionsOn: captionsEnabled,
|
||||
}
|
||||
: {})}
|
||||
onHangup={() => void hangup()}
|
||||
compact={callMode !== 'fullscreen'}
|
||||
glass={callMode === 'fullscreen'}
|
||||
@@ -499,7 +472,6 @@ export function InCallPanel({ conversation }: Props) {
|
||||
onClose={() => setStatsOverlayOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{captionsEnabled && <CallCaptionsOverlay conversation={conversation} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -644,8 +616,6 @@ export function InCallPanel({ conversation }: Props) {
|
||||
onClose={() => setStatsOverlayOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{captionsEnabled && <CallCaptionsOverlay conversation={conversation} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { PencilIcon, XIcon } from './icons';
|
||||
|
||||
interface Stroke {
|
||||
id: string;
|
||||
userId: string;
|
||||
color: string;
|
||||
// normalized 0..1 coordinates so any viewer's canvas size renders consistently
|
||||
points: Array<[number, number]>;
|
||||
bornAt: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** Stable per-share key. Use the share's participantId. */
|
||||
shareKey: string;
|
||||
/** Render annotations transparently (off when the toolbar is closed). */
|
||||
enabled: boolean;
|
||||
onToggleEnabled: (next: boolean) => void;
|
||||
}
|
||||
|
||||
const FADE_MS = 8000;
|
||||
const COLORS = ['#ef4444', '#3b82f6', '#22c55e', '#facc15', '#a855f7', '#000000'] as const;
|
||||
|
||||
export function ScreenShareAnnotations({ shareKey, enabled, onToggleEnabled }: Props) {
|
||||
const { session } = useAuth();
|
||||
const userId = session?.user.id ?? 'anon';
|
||||
const [color, setColor] = useState<string>(COLORS[0]);
|
||||
const [strokes, setStrokes] = useState<Stroke[]>([]);
|
||||
const draftRef = useRef<Stroke | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
|
||||
const broadcastRef = useRef<((s: Stroke) => void) | null>(null);
|
||||
|
||||
// Subscribe to remote strokes.
|
||||
useEffect(() => {
|
||||
const channel = supabase.channel('screen-annotation:' + shareKey, {
|
||||
config: { broadcast: { self: false } },
|
||||
});
|
||||
channel.on('broadcast', { event: 'stroke' }, (payload) => {
|
||||
const s = payload.payload as Stroke | undefined;
|
||||
if (!s || s.userId === userId) return;
|
||||
setStrokes((prev) => [...prev, { ...s, bornAt: Date.now() }]);
|
||||
});
|
||||
channel.subscribe();
|
||||
broadcastRef.current = (s: Stroke) => {
|
||||
void channel.send({
|
||||
type: 'broadcast',
|
||||
event: 'stroke',
|
||||
payload: s,
|
||||
});
|
||||
};
|
||||
return () => {
|
||||
broadcastRef.current = null;
|
||||
void supabase.removeChannel(channel);
|
||||
};
|
||||
}, [shareKey, userId]);
|
||||
|
||||
// Garbage-collect faded strokes after FADE_MS + a small grace window.
|
||||
useEffect(() => {
|
||||
if (strokes.length === 0) return;
|
||||
const id = setInterval(() => {
|
||||
const cutoff = Date.now() - FADE_MS - 500;
|
||||
setStrokes((prev) => {
|
||||
const next = prev.filter((s) => s.bornAt > cutoff);
|
||||
return next.length === prev.length ? prev : next;
|
||||
});
|
||||
}, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [strokes.length]);
|
||||
|
||||
// Paint the canvas on every render tick.
|
||||
useEffect(() => {
|
||||
const cv = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!cv || !container) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
if (cv.width !== rect.width || cv.height !== rect.height) {
|
||||
cv.width = Math.max(1, Math.floor(rect.width));
|
||||
cv.height = Math.max(1, Math.floor(rect.height));
|
||||
}
|
||||
const ctx = cv.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.clearRect(0, 0, cv.width, cv.height);
|
||||
const now = Date.now();
|
||||
const drawStroke = (s: Stroke) => {
|
||||
const age = now - s.bornAt;
|
||||
const alpha = Math.max(0, 1 - age / FADE_MS);
|
||||
if (alpha <= 0) return;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.strokeStyle = s.color;
|
||||
ctx.lineWidth = 3;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < s.points.length; i++) {
|
||||
const [nx, ny] = s.points[i]!;
|
||||
const x = nx * cv.width;
|
||||
const y = ny * cv.height;
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
};
|
||||
for (const s of strokes) drawStroke(s);
|
||||
if (draftRef.current) drawStroke(draftRef.current);
|
||||
});
|
||||
|
||||
// Animation frame loop so faded strokes visually decay between paints.
|
||||
useEffect(() => {
|
||||
if (strokes.length === 0 && !draftRef.current) return;
|
||||
let raf = 0;
|
||||
const tick = () => {
|
||||
// Nudge state to force a re-paint. Slightly hacky but cheaper than a
|
||||
// dedicated refresh state.
|
||||
setStrokes((prev) => prev.slice());
|
||||
raf = window.requestAnimationFrame(tick);
|
||||
};
|
||||
raf = window.requestAnimationFrame(tick);
|
||||
return () => window.cancelAnimationFrame(raf);
|
||||
}, [strokes.length]);
|
||||
|
||||
const normalized = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return [0, 0] as [number, number];
|
||||
const rect = container.getBoundingClientRect();
|
||||
const nx = (e.clientX - rect.left) / rect.width;
|
||||
const ny = (e.clientY - rect.top) / rect.height;
|
||||
return [Math.min(1, Math.max(0, nx)), Math.min(1, Math.max(0, ny))] as [number, number];
|
||||
}, []);
|
||||
|
||||
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!enabled) return;
|
||||
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
||||
draftRef.current = {
|
||||
id: Math.random().toString(36).slice(2),
|
||||
userId,
|
||||
color,
|
||||
points: [normalized(e)],
|
||||
bornAt: Date.now(),
|
||||
};
|
||||
};
|
||||
|
||||
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!draftRef.current) return;
|
||||
draftRef.current.points.push(normalized(e));
|
||||
setStrokes((prev) => prev.slice()); // cheap re-render trigger
|
||||
};
|
||||
|
||||
const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const target = e.currentTarget as HTMLDivElement;
|
||||
if (target.hasPointerCapture(e.pointerId)) target.releasePointerCapture(e.pointerId);
|
||||
const draft = draftRef.current;
|
||||
draftRef.current = null;
|
||||
if (!draft || draft.points.length < 2) {
|
||||
setStrokes((prev) => prev.slice());
|
||||
return;
|
||||
}
|
||||
setStrokes((prev) => [...prev, draft]);
|
||||
broadcastRef.current?.(draft);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={containerRef}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerLeave={onPointerUp}
|
||||
className={
|
||||
'absolute inset-0 z-10 ' +
|
||||
(enabled ? 'cursor-crosshair touch-none' : 'pointer-events-none')
|
||||
}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="pointer-events-none absolute inset-0 h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute right-3 top-12 z-20 flex flex-col items-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleEnabled(!enabled)}
|
||||
aria-pressed={enabled}
|
||||
title={enabled ? 'Annotation aus' : 'Annotation an'}
|
||||
className={
|
||||
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-full text-white shadow-lg transition ' +
|
||||
(enabled ? 'bg-accent' : 'bg-black/60 hover:bg-black/80')
|
||||
}
|
||||
>
|
||||
{enabled ? <XIcon className="h-3.5 w-3.5" /> : <PencilIcon className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
{enabled && (
|
||||
<div className="flex items-center gap-1 rounded-full bg-black/60 p-1 shadow-lg">
|
||||
{COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setColor(c)}
|
||||
aria-label={c}
|
||||
aria-pressed={c === color}
|
||||
className={
|
||||
'h-5 w-5 cursor-pointer rounded-full border-2 transition ' +
|
||||
(c === color ? 'border-white scale-110' : 'border-white/30 hover:scale-105')
|
||||
}
|
||||
style={{ backgroundColor: c }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { type RemoteScreenShare, useCall } from '../context/CallContext';
|
||||
import { MonitorShareIcon } from './icons';
|
||||
import { ScreenShareAnnotations } from './ScreenShareAnnotations';
|
||||
|
||||
interface ScreenShareViewerProps {
|
||||
share: RemoteScreenShare;
|
||||
@@ -34,6 +35,7 @@ export function ScreenShareViewer({
|
||||
const { watchingShareUserIds, watchShare } = useCall();
|
||||
const watching = watchingShareUserIds.has(share.participantId);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [annotateEnabled, setAnnotateEnabled] = useState(false);
|
||||
const letter = displayName.trim().charAt(0).toUpperCase() || '?';
|
||||
|
||||
useEffect(() => {
|
||||
@@ -96,21 +98,28 @@ export function ScreenShareViewer({
|
||||
</div>
|
||||
|
||||
{watching ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
// Suppress the browser's built-in <video> context menu
|
||||
// ("Save Video As…", PiP, …) so the right-click event bubbles
|
||||
// to the wrapping tile div in InCallPanel — that's where the
|
||||
// app's volume / mute menu is wired up. Without this, the
|
||||
// native menu opens on top of ours in focus + fullscreen
|
||||
// modes (where the video covers the whole tile).
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
onDoubleClick={toggleFullscreen}
|
||||
className="block h-full w-full flex-1 cursor-zoom-in bg-black object-contain"
|
||||
/>
|
||||
<div className="relative h-full w-full flex-1">
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
// Suppress the browser's built-in <video> context menu
|
||||
// ("Save Video As…", PiP, …) so the right-click event bubbles
|
||||
// to the wrapping tile div in InCallPanel — that's where the
|
||||
// app's volume / mute menu is wired up. Without this, the
|
||||
// native menu opens on top of ours in focus + fullscreen
|
||||
// modes (where the video covers the whole tile).
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
onDoubleClick={toggleFullscreen}
|
||||
className="block h-full w-full cursor-zoom-in bg-black object-contain"
|
||||
/>
|
||||
<ScreenShareAnnotations
|
||||
shareKey={share.participantId}
|
||||
enabled={annotateEnabled}
|
||||
onToggleEnabled={setAnnotateEnabled}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
type AutoLockMinutes,
|
||||
getAutoLockMinutes,
|
||||
notifyAutoLockChanged,
|
||||
setAutoLockMinutes,
|
||||
} from '../lib/autoLockSettings';
|
||||
import { isWipeOnCloseEnabled, setWipeOnClose } from '../lib/memoryWipeSettings';
|
||||
import {
|
||||
changePin,
|
||||
@@ -21,6 +27,7 @@ export function SecurityCenter({ userId }: Props) {
|
||||
const [recovery, setRecovery] = useState<string | null>(null);
|
||||
const [migration, setMigration] = useState<LegacyMigrationReport | null>(null);
|
||||
const [wipeOnClose, setWipeOnCloseState] = useState<boolean>(() => isWipeOnCloseEnabled());
|
||||
const [autoLockMinutes, setAutoLockMinutesState] = useState<AutoLockMinutes>(() => getAutoLockMinutes());
|
||||
|
||||
async function handleRetryMigration() {
|
||||
setBusy(true); setMsg(null); setMigration(null);
|
||||
@@ -149,6 +156,39 @@ export function SecurityCenter({ userId }: Props) {
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-fg-muted">
|
||||
Auto-Lock nach Inaktivität
|
||||
</h3>
|
||||
<p className="mb-2 text-xs text-fg-muted">
|
||||
Verlangt erneute PIN-Eingabe nach der gewählten Inaktivitätsdauer. Empfohlen für gemeinsam genutzte Rechner.
|
||||
</p>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-fg">Automatisch sperren</div>
|
||||
<div className="text-xs text-fg-muted">
|
||||
Verlangt erneute PIN-Eingabe nach X Minuten Inaktivität.
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
value={autoLockMinutes}
|
||||
onChange={(e) => {
|
||||
const next = Number(e.target.value) as AutoLockMinutes;
|
||||
setAutoLockMinutes(next);
|
||||
notifyAutoLockChanged(next);
|
||||
setAutoLockMinutesState(next);
|
||||
}}
|
||||
className="cursor-pointer rounded-md border border-line bg-surface-2 px-3 py-1.5 text-sm text-fg focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/40"
|
||||
>
|
||||
<option value={0}>Aus</option>
|
||||
<option value={5}>5 min</option>
|
||||
<option value={15}>15 min</option>
|
||||
<option value={30}>30 min</option>
|
||||
<option value={60}>60 min</option>
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-rose-400">Identität zurücksetzen</h3>
|
||||
<p className="mb-2 text-xs text-fg-muted">Erstellt einen neuen Schlüssel. Alle alten Chats werden unlesbar.</p>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { markAttachmentViewed } from '@chat-app/shared/chat';
|
||||
|
||||
@@ -16,16 +16,34 @@ interface Props {
|
||||
}
|
||||
|
||||
// Three states:
|
||||
// 1. viewedAt is null AND user is recipient → blurred lock card; tap opens
|
||||
// fullscreen lightbox AND fires the mark-viewed RPC.
|
||||
// 1. viewedAt is null AND user is recipient → blurred lock card. Press-and-
|
||||
// hold reveals the image fullscreen; release closes it AND fires the
|
||||
// mark-viewed RPC.
|
||||
// 2. viewedAt is set → tombstone "Angesehen am …".
|
||||
// 3. user is sender → normal image, tombstone update appears once recipient burns it.
|
||||
// 3. user is sender → normal image, tombstone update appears once recipient
|
||||
// burns it.
|
||||
//
|
||||
// While revealed, the renderer window enables content-protection
|
||||
// (`win.setContentProtection(true)`) so OS-level screen capture (OBS, Win/Cmd
|
||||
// snipping tools, screen recorders) sees a black/empty surface. Re-enabled
|
||||
// on release / unmount.
|
||||
export function ViewOnceImage({ attachmentId, viewedAt, isSender, src }: Props) {
|
||||
const [revealedAt, setRevealedAt] = useState<string | null>(viewedAt);
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const [revealing, setRevealing] = useState(false);
|
||||
const burnedRef = useRef(false);
|
||||
const holdingRef = useRef(false);
|
||||
|
||||
const burned = revealedAt !== null;
|
||||
|
||||
// Tear down screen-capture protection if the component unmounts mid-reveal.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (revealing || holdingRef.current) {
|
||||
void window.electronAPI?.setContentProtection?.(false).catch(() => {});
|
||||
}
|
||||
};
|
||||
}, [revealing]);
|
||||
|
||||
if (burned && !isSender) {
|
||||
return (
|
||||
<div className="flex h-32 w-48 items-center justify-center rounded-lg border border-dashed border-line bg-surface-3 text-xs text-fg-muted">
|
||||
@@ -51,35 +69,78 @@ export function ViewOnceImage({ attachmentId, viewedAt, isSender, src }: Props)
|
||||
);
|
||||
}
|
||||
|
||||
// Recipient, not yet viewed.
|
||||
const handleOpen = async (): Promise<void> => {
|
||||
const startReveal = async (): Promise<void> => {
|
||||
if (burnedRef.current) return;
|
||||
burnedRef.current = true;
|
||||
holdingRef.current = true;
|
||||
try {
|
||||
await window.electronAPI?.setContentProtection?.(true);
|
||||
} catch (err) {
|
||||
console.warn('setContentProtection enable failed', err);
|
||||
}
|
||||
// The user may have released during the await. If so, skip showing the
|
||||
// dialog and run the close-path directly so we don't leave the renderer
|
||||
// in protected mode with no visible UI.
|
||||
if (!holdingRef.current) {
|
||||
// User released during the IPC await — endReveal already fired and is
|
||||
// responsible for teardown (setContentProtection(false) + mark-viewed).
|
||||
// Skipping teardown here avoids a duplicate markAttachmentViewed RPC.
|
||||
return;
|
||||
}
|
||||
setRevealing(true);
|
||||
};
|
||||
|
||||
const endReveal = async (): Promise<void> => {
|
||||
if (!holdingRef.current && !revealing) return;
|
||||
holdingRef.current = false;
|
||||
if (revealing) setRevealing(false);
|
||||
await teardownReveal();
|
||||
};
|
||||
|
||||
const teardownReveal = async (): Promise<void> => {
|
||||
try {
|
||||
await window.electronAPI?.setContentProtection?.(false);
|
||||
} catch (err) {
|
||||
console.warn('setContentProtection disable failed', err);
|
||||
}
|
||||
try {
|
||||
const res = await markAttachmentViewed(supabase, attachmentId);
|
||||
if (res.viewedAt) setRevealedAt(res.viewedAt);
|
||||
} catch (err) {
|
||||
console.warn('mark-viewed failed', err);
|
||||
burnedRef.current = false;
|
||||
}
|
||||
setFullscreen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleOpen()}
|
||||
className="relative flex h-48 w-64 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-line bg-surface-3 text-fg-muted hover:border-accent/40"
|
||||
onPointerDown={() => void startReveal()}
|
||||
onPointerUp={() => void endReveal()}
|
||||
onPointerLeave={() => void endReveal()}
|
||||
onPointerCancel={() => void endReveal()}
|
||||
className="relative flex h-48 w-64 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-line bg-surface-3 text-fg-muted hover:border-accent/40 select-none"
|
||||
>
|
||||
<LockIcon className="h-6 w-6 text-accent" />
|
||||
<span className="text-xs font-medium">Einmal ansehen — antippen</span>
|
||||
<span className="text-xs font-medium">Gedrückt halten zum Ansehen</span>
|
||||
</button>
|
||||
{fullscreen && (
|
||||
{revealing && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 p-6"
|
||||
onClick={() => setFullscreen(false)}
|
||||
aria-label="Einmal-ansehen Bild"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/95 p-6"
|
||||
>
|
||||
<img src={src} alt="" className="max-h-full max-w-full rounded-lg" />
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
className="max-h-full max-w-full select-none rounded-lg"
|
||||
draggable={false}
|
||||
/>
|
||||
<span className="absolute bottom-6 left-1/2 -translate-x-1/2 rounded-full bg-white/10 px-3 py-1 text-xs font-semibold text-white">
|
||||
Loslassen zum Schließen — Aufnahme blockiert
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -2,6 +2,9 @@ import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { WhiteboardStroke } from '@chat-app/shared/chat';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { openCursorSession, type CursorEvent, type CursorSession } from '../lib/whiteboardCursors';
|
||||
|
||||
export type WhiteboardTool = 'pen' | 'eraser';
|
||||
export type WhiteboardColor = '#000000' | '#ef4444' | '#3b82f6' | '#22c55e' | '#facc15' | '#a855f7';
|
||||
export type WhiteboardWidth = 2 | 4 | 8;
|
||||
@@ -22,6 +25,8 @@ interface Props {
|
||||
onStroke: (payload: WhiteboardStrokePayload) => void;
|
||||
logicalWidth?: number;
|
||||
logicalHeight?: number;
|
||||
/** Enables live-cursor broadcast when set. */
|
||||
whiteboardId?: string | null;
|
||||
}
|
||||
|
||||
const DEFAULT_LOGICAL_W = 1280;
|
||||
@@ -35,12 +40,63 @@ export function WhiteboardCanvas({
|
||||
onStroke,
|
||||
logicalWidth = DEFAULT_LOGICAL_W,
|
||||
logicalHeight = DEFAULT_LOGICAL_H,
|
||||
whiteboardId,
|
||||
}: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const draftRef = useRef<WhiteboardStrokePayload | null>(null);
|
||||
const strokeStartRef = useRef<number>(0);
|
||||
const [, forceTick] = useState(0);
|
||||
|
||||
const { session, profile } = useAuth();
|
||||
const [remoteCursors, setRemoteCursors] = useState<Map<string, CursorEvent & { lastSeen: number }>>(
|
||||
() => new Map(),
|
||||
);
|
||||
const cursorSessionRef = useRef<CursorSession | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!whiteboardId) return;
|
||||
const me = session?.user;
|
||||
if (!me) return;
|
||||
const displayName = profile?.displayName ?? me.email ?? me.id.slice(0, 8);
|
||||
const s = openCursorSession(
|
||||
whiteboardId,
|
||||
{ userId: me.id, displayName },
|
||||
(ev) => {
|
||||
setRemoteCursors((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(ev.userId, { ...ev, lastSeen: Date.now() });
|
||||
return next;
|
||||
});
|
||||
},
|
||||
);
|
||||
cursorSessionRef.current = s;
|
||||
return () => {
|
||||
s.close();
|
||||
cursorSessionRef.current = null;
|
||||
};
|
||||
}, [whiteboardId, session?.user, profile?.displayName]);
|
||||
|
||||
// Stale-cursor sweep: drop cursors that haven't been heard from in 2s. Cheap
|
||||
// poll because the Map is tiny (at most one entry per active collaborator).
|
||||
useEffect(() => {
|
||||
if (remoteCursors.size === 0) return;
|
||||
const id = setInterval(() => {
|
||||
const now = Date.now();
|
||||
setRemoteCursors((prev) => {
|
||||
let changed = false;
|
||||
const next = new Map(prev);
|
||||
for (const [k, v] of next) {
|
||||
if (now - v.lastSeen > 2000) {
|
||||
next.delete(k);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [remoteCursors.size]);
|
||||
|
||||
useEffect(() => {
|
||||
const cv = canvasRef.current;
|
||||
if (!cv) return;
|
||||
@@ -81,8 +137,9 @@ export function WhiteboardCanvas({
|
||||
};
|
||||
|
||||
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (!draftRef.current) return;
|
||||
const [x, y] = canvasPoint(e);
|
||||
cursorSessionRef.current?.send(x, y);
|
||||
if (!draftRef.current) return;
|
||||
draftRef.current.points.push([x, y, Date.now() - strokeStartRef.current]);
|
||||
forceTick((n) => n + 1);
|
||||
};
|
||||
@@ -102,18 +159,53 @@ export function WhiteboardCanvas({
|
||||
};
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerLeave={handlePointerUp}
|
||||
className="block max-h-full max-w-full cursor-crosshair touch-none rounded-lg border border-line/40 bg-white shadow-2xl"
|
||||
<div
|
||||
className="relative"
|
||||
style={{ aspectRatio: logicalWidth + ' / ' + logicalHeight, width: '100%' }}
|
||||
/>
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerLeave={handlePointerUp}
|
||||
className="block max-h-full max-w-full cursor-crosshair touch-none rounded-lg border border-line/40 bg-white shadow-2xl"
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
/>
|
||||
{Array.from(remoteCursors.values()).map((c) => {
|
||||
const pctX = (c.x / logicalWidth) * 100;
|
||||
const pctY = (c.y / logicalHeight) * 100;
|
||||
return (
|
||||
<div
|
||||
key={c.userId}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute"
|
||||
style={{ left: pctX + '%', top: pctY + '%', transform: 'translate(-2px, -2px)' }}
|
||||
>
|
||||
<span
|
||||
className="block h-2 w-2 rounded-full border-2 border-white shadow"
|
||||
style={{ backgroundColor: colorForUserId(c.userId) }}
|
||||
/>
|
||||
<span className="ml-2 inline-block translate-y-[-2px] rounded-full bg-black/60 px-1.5 py-0.5 text-[9px] font-semibold text-white">
|
||||
{c.displayName}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function colorForUserId(userId: string): string {
|
||||
// Deterministic hue from the user id so each collaborator gets a stable
|
||||
// colour across sessions. Saturation/lightness fixed to keep the cursor
|
||||
// legible against the white canvas.
|
||||
let hash = 0;
|
||||
for (let i = 0; i < userId.length; i++) hash = (hash * 31 + userId.charCodeAt(i)) | 0;
|
||||
const hue = Math.abs(hash) % 360;
|
||||
return 'hsl(' + hue + ', 70%, 50%)';
|
||||
}
|
||||
|
||||
function renderStroke(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
s: Partial<WhiteboardStrokePayload>,
|
||||
|
||||
@@ -80,6 +80,7 @@ export function WhiteboardModal({ whiteboardId, onClose }: Props) {
|
||||
color={color}
|
||||
width={width}
|
||||
onStroke={(payload) => void insertStroke(payload)}
|
||||
whiteboardId={whiteboardId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -150,16 +150,15 @@ function EyeOffIconInner(props: IconProps) {
|
||||
}
|
||||
export const EyeOffIcon = memo(EyeOffIconInner);
|
||||
|
||||
function CaptionsIconInner(props: IconProps) {
|
||||
function EyeIconInner(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<rect x="3" y="6" width="18" height="12" rx="2" />
|
||||
<path d="M7 13a2 2 0 1 1 0-2" />
|
||||
<path d="M14 13a2 2 0 1 1 0-2" />
|
||||
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
export const CaptionsIcon = memo(CaptionsIconInner);
|
||||
export const EyeIcon = memo(EyeIconInner);
|
||||
|
||||
function PinOffIconInner(props: IconProps) {
|
||||
return (
|
||||
|
||||
@@ -207,9 +207,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
// 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.from('profiles').select('id').limit(1).then(() => undefined);
|
||||
void supabase.auth.getSession();
|
||||
}, [session]);
|
||||
|
||||
// Phase 3: ensure this install owns exactly one devices row. The row is
|
||||
|
||||
@@ -39,7 +39,6 @@ import {
|
||||
playUndeafenBeep,
|
||||
playUnmuteBeep,
|
||||
} from '../lib/callSounds';
|
||||
import { useLiveCaptions } from '../lib/useLiveCaptions';
|
||||
import { setCallWakeLock } from '../lib/wakeLock';
|
||||
import { notify } from '../lib/osNotify';
|
||||
import {
|
||||
@@ -99,6 +98,7 @@ import {
|
||||
subscribeScreenShareVolumes,
|
||||
} from '../lib/screenShareVolumes';
|
||||
import { startSoundboardHotkeys } from '../lib/soundboardHotkeys';
|
||||
import { playSoundboardLocal } from '../lib/soundboardLocalPlay';
|
||||
import { playEntry } from '../lib/soundboardPlayback';
|
||||
import {
|
||||
getPrefs as getSoundboardPrefs,
|
||||
@@ -209,14 +209,6 @@ interface CallContextValue {
|
||||
* invite (fromUserId). Cleared on disconnect. Drives the crown badge,
|
||||
* but only in group calls. Null while idle or in 1:1 contexts. */
|
||||
callHostId: string | null;
|
||||
/** identity -> latest live-caption fragment received via data channel.
|
||||
* Includes own captions for self-overlay. Receivers prune entries whose
|
||||
* timestamp is older than ~5s so stale lines fade out. */
|
||||
captions: Record<string, { text: string; final: boolean; timestamp: number }>;
|
||||
/** Surface a caption for the local user — the live-captions hook calls
|
||||
* this on every interim/final SpeechRecognition result so the overlay
|
||||
* shows our own line without going through the SFU round-trip. */
|
||||
pushLocalCaption: (text: string, final: boolean) => void;
|
||||
/** identity -> mute state. Broadcast from peer whenever mic-gain flips.
|
||||
* Needed because we can't rely on LiveKit's native isMicrophoneEnabled —
|
||||
* the mic pipeline keeps the track published with sound flowing even
|
||||
@@ -347,9 +339,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
// useEffect) so peers don't hear themselves echoed back when the OS-level
|
||||
// process-tree exclusion isn't watertight.
|
||||
const [isCapturingSystemAudio, setIsCapturingSystemAudio] = useState(false);
|
||||
const [captions, setCaptions] = useState<
|
||||
Record<string, { text: string; final: boolean; timestamp: number }>
|
||||
>({});
|
||||
const [lastCallConversationId, setLastCallConversationId] = useState<string | null>(null);
|
||||
const [callMode, setCallModeState] = useState<CallMode>('grid');
|
||||
const [focusedId, setFocusedIdState] = useState<string | null>(null);
|
||||
@@ -828,7 +817,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
setRemoteScreenShares([]);
|
||||
setConnectionQualities({});
|
||||
setCallHostId(null);
|
||||
setCaptions({});
|
||||
setIsScreenSharing(false);
|
||||
setIsE2EEActive(false);
|
||||
}
|
||||
@@ -970,8 +958,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
type?: string;
|
||||
deafened?: boolean;
|
||||
muted?: boolean;
|
||||
captionText?: string;
|
||||
captionFinal?: boolean;
|
||||
};
|
||||
const id: string = participant.identity;
|
||||
if (msg.type === 'presence') {
|
||||
@@ -991,15 +977,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'caption' && typeof msg.captionText === 'string') {
|
||||
const text2 = msg.captionText;
|
||||
const final = msg.captionFinal === true;
|
||||
setCaptions((prev) => ({
|
||||
...prev,
|
||||
[id]: { text: text2, final, timestamp: Date.now() },
|
||||
}));
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* ignore malformed */
|
||||
}
|
||||
@@ -1754,17 +1731,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const pushLocalCaption = useCallback(
|
||||
(text: string, final: boolean) => {
|
||||
if (!myId) return;
|
||||
setCaptions((prev) => ({
|
||||
...prev,
|
||||
[myId]: { text, final, timestamp: Date.now() },
|
||||
}));
|
||||
},
|
||||
[myId],
|
||||
);
|
||||
|
||||
const toggleCamera = useCallback(async () => {
|
||||
const r = roomRef.current;
|
||||
if (!r) return;
|
||||
@@ -2292,15 +2258,36 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
pipelineRef.current?.setMonitorGain(prefs.monitorGain);
|
||||
}, []);
|
||||
|
||||
// Global soundboard hotkey registration — runs only while connected so the
|
||||
// OS-level shortcuts don't fire when the user is outside of a call.
|
||||
// Global soundboard hotkey registration — always-on so the OS-level
|
||||
// shortcuts fire even outside a call (Stream-Deck-style local SFX). Inside
|
||||
// a call we route through `playSoundboard` so peers hear; outside a call
|
||||
// we fall back to `playSoundboardLocal` which plays through the system
|
||||
// default output only.
|
||||
//
|
||||
// We deliberately do NOT depend on `state.kind` in the effect dep array:
|
||||
// every call state transition (idle → connecting → connected → reconnecting
|
||||
// → ...) would trigger a full unregister+re-register cycle through IPC, and
|
||||
// during the 1–50 ms gap the hotkeys are silently dead. Instead we read the
|
||||
// current call state through a ref that's always kept in sync.
|
||||
const callStateKindRef = useRef(state.kind);
|
||||
callStateKindRef.current = state.kind;
|
||||
const playSoundboardRef = useRef(playSoundboard);
|
||||
playSoundboardRef.current = playSoundboard;
|
||||
|
||||
useEffect(() => {
|
||||
if (state.kind !== 'connected') return;
|
||||
const teardown = startSoundboardHotkeys((id) => {
|
||||
void playSoundboard(id);
|
||||
if (callStateKindRef.current === 'connected') {
|
||||
void playSoundboardRef.current(id);
|
||||
} else {
|
||||
void (async () => {
|
||||
const entries = await listSoundboard();
|
||||
const entry = entries.find((e) => e.id === id);
|
||||
if (entry) await playSoundboardLocal(entry);
|
||||
})();
|
||||
}
|
||||
});
|
||||
return teardown;
|
||||
}, [state.kind, playSoundboard]);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const setAudioInputDevice = useCallback(async (deviceId: string | null) => {
|
||||
updateAudioSettings({ inputDeviceId: deviceId });
|
||||
@@ -2603,15 +2590,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [callMode, state.kind]);
|
||||
|
||||
// Discord-style live-captions broadcaster — runs on the local mic while
|
||||
// we're connected, and ships interim/final transcripts on the LiveKit
|
||||
// DataChannel so peers can render them.
|
||||
useLiveCaptions({
|
||||
room,
|
||||
active: state.kind === 'connected' || state.kind === 'reconnecting',
|
||||
onLocalCaption: pushLocalCaption,
|
||||
});
|
||||
|
||||
const value = useMemo<CallContextValue>(
|
||||
() => ({
|
||||
state,
|
||||
@@ -2626,8 +2604,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
remoteMute,
|
||||
connectionQualities,
|
||||
callHostId,
|
||||
captions,
|
||||
pushLocalCaption,
|
||||
remoteScreenShares,
|
||||
lastCallConversationId,
|
||||
callMode,
|
||||
@@ -2683,8 +2659,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
remoteMute,
|
||||
connectionQualities,
|
||||
callHostId,
|
||||
captions,
|
||||
pushLocalCaption,
|
||||
remoteScreenShares,
|
||||
lastCallConversationId,
|
||||
callMode,
|
||||
|
||||
@@ -54,6 +54,15 @@ interface ConversationsContextValue {
|
||||
refresh: () => Promise<void>;
|
||||
markRead: (conversationId: string) => void;
|
||||
setActiveConversation: (conversationId: string | null) => void;
|
||||
// Optimistic patch for the caller's per-membership preferences (mute /
|
||||
// mentions-only / archive). Mutations to `conversation_members` echo back
|
||||
// via the realtime channel and `refresh()` reconciles canonically, but the
|
||||
// ~100-200ms roundtrip leaves the UI looking unresponsive. Callers patch
|
||||
// immediately, snapshot the previous state, and roll back on failure.
|
||||
patchConversation: (
|
||||
conversationId: string,
|
||||
patch: Partial<Pick<ConversationSummary, 'archived' | 'mutedUntil' | 'mentionsOnly'>>,
|
||||
) => void;
|
||||
}
|
||||
|
||||
const ConversationsContext = createContext<ConversationsContextValue | null>(null);
|
||||
@@ -164,6 +173,19 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
|
||||
[markRead],
|
||||
);
|
||||
|
||||
const patchConversation = useCallback<ConversationsContextValue['patchConversation']>(
|
||||
(convId, patch) => {
|
||||
setConversations((prev) => {
|
||||
const idx = prev.findIndex((c) => c.id === convId);
|
||||
if (idx === -1) return prev;
|
||||
const next = [...prev];
|
||||
next[idx] = { ...next[idx]!, ...patch };
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!myId) {
|
||||
setConversations([]);
|
||||
@@ -308,6 +330,7 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
|
||||
refresh,
|
||||
markRead,
|
||||
setActiveConversation,
|
||||
patchConversation,
|
||||
}),
|
||||
[
|
||||
conversations,
|
||||
@@ -318,6 +341,7 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
|
||||
refresh,
|
||||
markRead,
|
||||
setActiveConversation,
|
||||
patchConversation,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import {
|
||||
getAutoLockMinutes,
|
||||
subscribeAutoLockSetting,
|
||||
type AutoLockMinutes,
|
||||
} from '../lib/autoLockSettings';
|
||||
|
||||
const ACTIVITY_EVENTS: Array<keyof WindowEventMap> = [
|
||||
'keydown',
|
||||
'mousedown',
|
||||
'pointermove',
|
||||
'touchstart',
|
||||
'wheel',
|
||||
];
|
||||
|
||||
// Throttle activity-event resets to once per second to avoid thrashing the
|
||||
// timer on rapid mouse movement.
|
||||
const RESET_THROTTLE_MS = 1000;
|
||||
|
||||
export function useIdleAutoLock(): void {
|
||||
const { session, signOut } = useAuth();
|
||||
const minutesRef = useRef<AutoLockMinutes>(getAutoLockMinutes());
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const lastResetAtRef = useRef<number>(0);
|
||||
|
||||
// Keep minutesRef live to the setting.
|
||||
useEffect(() => {
|
||||
const unsub = subscribeAutoLockSetting((v) => {
|
||||
minutesRef.current = v;
|
||||
scheduleNext();
|
||||
});
|
||||
return unsub;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Helper: schedule the lock based on the current setting.
|
||||
function scheduleNext(): void {
|
||||
if (timerRef.current !== null) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
const min = minutesRef.current;
|
||||
if (min === 0) return; // disabled
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
timerRef.current = null;
|
||||
// Fire the lock. signOut wipes local state and navigates to /device
|
||||
// (PIN re-entry screen).
|
||||
void signOut().catch((err) => console.warn('auto-lock signOut failed', err));
|
||||
}, min * 60 * 1000);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!session) return;
|
||||
scheduleNext();
|
||||
|
||||
const onActivity = () => {
|
||||
const now = Date.now();
|
||||
if (now - lastResetAtRef.current < RESET_THROTTLE_MS) return;
|
||||
lastResetAtRef.current = now;
|
||||
scheduleNext();
|
||||
};
|
||||
|
||||
for (const ev of ACTIVITY_EVENTS) {
|
||||
window.addEventListener(ev, onActivity, { passive: true });
|
||||
}
|
||||
return () => {
|
||||
for (const ev of ACTIVITY_EVENTS) {
|
||||
window.removeEventListener(ev, onActivity);
|
||||
}
|
||||
if (timerRef.current !== null) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [session]);
|
||||
}
|
||||
@@ -64,10 +64,37 @@ export function useOwnDevices(): {
|
||||
|
||||
const revoke = useCallback(
|
||||
async (deviceId: string) => {
|
||||
await revokeDevice(supabase, deviceId);
|
||||
await refresh();
|
||||
// Optimistic: flip `revokedAt` on the row so the "Abgemeldet" badge
|
||||
// appears on the same frame as the click. Capture a snapshot so we
|
||||
// can restore exactly on RPC failure (the realtime subscription's
|
||||
// own UPDATE echo would otherwise reconcile back to "not revoked"
|
||||
// anyway). Skip if the row isn't in our list — nothing to undo.
|
||||
let snapshot: DeviceRecord[] | null = null;
|
||||
const stampedAt = new Date().toISOString();
|
||||
setState((prev) => {
|
||||
if (!prev.devices.some((d) => d.id === deviceId)) return prev;
|
||||
snapshot = prev.devices;
|
||||
return {
|
||||
...prev,
|
||||
devices: prev.devices.map((d) =>
|
||||
d.id === deviceId ? { ...d, revokedAt: d.revokedAt ?? stampedAt } : d,
|
||||
),
|
||||
};
|
||||
});
|
||||
try {
|
||||
await revokeDevice(supabase, deviceId);
|
||||
// Skip the eager refresh: the realtime UPDATE on `devices` triggers
|
||||
// refresh() via the subscription and the optimistic row already
|
||||
// shows the badge. Avoids a list flicker between optimistic and
|
||||
// canonical state.
|
||||
} catch (err) {
|
||||
if (snapshot) {
|
||||
setState((prev) => ({ ...prev, devices: snapshot! }));
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[refresh],
|
||||
[],
|
||||
);
|
||||
|
||||
return { devices: state.devices, loading: state.loading, error: state.error, refresh, revoke };
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// Per-install setting for PIN-idle-auto-lock. 0 = disabled.
|
||||
// Values match the dropdown options (5/15/30/60 minutes).
|
||||
|
||||
const KEY = 'chatapp.autoLockMinutes.v1';
|
||||
|
||||
export type AutoLockMinutes = 0 | 5 | 15 | 30 | 60;
|
||||
|
||||
const VALID: AutoLockMinutes[] = [0, 5, 15, 30, 60];
|
||||
|
||||
export function getAutoLockMinutes(): AutoLockMinutes {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(KEY);
|
||||
if (!raw) return 0;
|
||||
const n = Number(raw);
|
||||
if (VALID.includes(n as AutoLockMinutes)) return n as AutoLockMinutes;
|
||||
return 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
type Listener = (value: AutoLockMinutes) => void;
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
export function subscribeAutoLockSetting(l: Listener): () => void {
|
||||
listeners.add(l);
|
||||
return () => listeners.delete(l);
|
||||
}
|
||||
|
||||
export function notifyAutoLockChanged(value: AutoLockMinutes): void {
|
||||
for (const l of listeners) {
|
||||
try { l(value); } catch (err) { console.warn(err); }
|
||||
}
|
||||
}
|
||||
|
||||
export function setAutoLockMinutes(value: AutoLockMinutes): void {
|
||||
try {
|
||||
window.localStorage.setItem(KEY, String(value));
|
||||
} catch {
|
||||
/* quota */
|
||||
}
|
||||
notifyAutoLockChanged(value);
|
||||
}
|
||||
@@ -43,19 +43,86 @@ async function resizeToSquare(file: File): Promise<Blob> {
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_ANIMATED_BYTES = 2 * 1024 * 1024; // 2 MB hard cap on animated uploads
|
||||
const ANIMATED_MIMES = new Set(['image/gif', 'image/apng', 'image/webp', 'image/png']);
|
||||
|
||||
export async function uploadAvatar(userId: string, file: File): Promise<string> {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
throw new Error('only image files are accepted');
|
||||
}
|
||||
// Animated formats bypass the canvas re-encode (which would strip
|
||||
// animation by sampling the first frame). We still validate dimensions
|
||||
// and size so a 40-MB animated WebP can't slip through.
|
||||
if (ANIMATED_MIMES.has(file.type) && (await isAnimated(file))) {
|
||||
if (file.size > MAX_ANIMATED_BYTES) {
|
||||
throw new Error('animated avatar too large (max 2 MB)');
|
||||
}
|
||||
const dims = await readDimensions(file);
|
||||
if (dims.width > MAX_DIM || dims.height > MAX_DIM) {
|
||||
throw new Error('animated avatar exceeds ' + MAX_DIM + 'px (got ' + dims.width + 'x' + dims.height + ')');
|
||||
}
|
||||
return uploadAvatarBlob(userId, file);
|
||||
}
|
||||
const blob = await resizeToSquare(file);
|
||||
return uploadAvatarBlob(userId, blob);
|
||||
}
|
||||
|
||||
async function readDimensions(file: File): Promise<{ width: number; height: number }> {
|
||||
const url = URL.createObjectURL(file);
|
||||
try {
|
||||
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const i = new Image();
|
||||
i.onload = () => resolve(i);
|
||||
i.onerror = () => reject(new Error('image load failed'));
|
||||
i.src = url;
|
||||
});
|
||||
return { width: img.naturalWidth, height: img.naturalHeight };
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
async function isAnimated(file: File): Promise<boolean> {
|
||||
// GIF: any GIF89a/GIF87a header is treated as potentially animated. The
|
||||
// static-GIF case (one image-descriptor block) is rare enough that
|
||||
// re-encoding wouldn't save much, so we accept the false-positives.
|
||||
if (file.type === 'image/gif') return true;
|
||||
// APNG: presence of an 'acTL' chunk inside the PNG stream. Scan the
|
||||
// first 64 KB — APNGs put acTL near the front, before IDAT.
|
||||
if (file.type === 'image/apng' || file.type === 'image/png') {
|
||||
const head = await file.slice(0, 65536).arrayBuffer();
|
||||
return containsBytes(head, [0x61, 0x63, 0x54, 0x4c]); // 'acTL'
|
||||
}
|
||||
// Animated WebP: 'ANIM' chunk in the RIFF container.
|
||||
if (file.type === 'image/webp') {
|
||||
const head = await file.slice(0, 65536).arrayBuffer();
|
||||
return containsBytes(head, [0x41, 0x4e, 0x49, 0x4d]); // 'ANIM'
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function containsBytes(buf: ArrayBuffer, needle: number[]): boolean {
|
||||
const view = new Uint8Array(buf);
|
||||
const len = view.length;
|
||||
const nlen = needle.length;
|
||||
outer: for (let i = 0; i + nlen <= len; i++) {
|
||||
for (let j = 0; j < nlen; j++) {
|
||||
if (view[i + j] !== needle[j]) continue outer;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Upload an already-cropped Blob (e.g. from ImageCropDialog) without going
|
||||
// through the legacy center-crop. Caller is responsible for sizing — the
|
||||
// dialog already clamps to MAX_DIM via its outputWidth.
|
||||
export async function uploadAvatarBlob(userId: string, blob: Blob): Promise<string> {
|
||||
const ext = blob.type === 'image/webp' ? 'webp' : 'jpg';
|
||||
const ext =
|
||||
blob.type === 'image/webp' ? 'webp' :
|
||||
blob.type === 'image/gif' ? 'gif' :
|
||||
blob.type === 'image/apng' || blob.type === 'image/png' ? 'png' :
|
||||
'jpg';
|
||||
// Random filename so old uploads don't get overwritten before we update
|
||||
// the profile row — Supabase Storage CDN caches by URL, so a fresh path
|
||||
// also forces clients to fetch the new image.
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
__resetForTests,
|
||||
clearDraft,
|
||||
getDraftSync,
|
||||
hasDraft,
|
||||
hydrateDrafts,
|
||||
setDraft,
|
||||
} from './composerDraftStore';
|
||||
|
||||
const sqlExecuteMock = vi.fn().mockResolvedValue(undefined);
|
||||
const sqlSelectMock = vi.fn().mockResolvedValue([]);
|
||||
const sqlLoadMock = vi.fn().mockResolvedValue('mock-handle');
|
||||
|
||||
vi.stubGlobal('window', {
|
||||
electronAPI: {
|
||||
platform: 'electron-chatapp-v1',
|
||||
sqlLoad: sqlLoadMock,
|
||||
sqlExecute: sqlExecuteMock,
|
||||
sqlSelect: sqlSelectMock,
|
||||
},
|
||||
});
|
||||
|
||||
describe('composerDraftStore', () => {
|
||||
beforeEach(() => {
|
||||
sqlExecuteMock.mockClear();
|
||||
sqlSelectMock.mockClear();
|
||||
sqlLoadMock.mockClear();
|
||||
__resetForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
__resetForTests();
|
||||
});
|
||||
|
||||
it('returns null for an unknown conversation', () => {
|
||||
expect(getDraftSync('unknown')).toBeNull();
|
||||
expect(hasDraft('unknown')).toBe(false);
|
||||
});
|
||||
|
||||
it('stores and returns a draft synchronously after set', () => {
|
||||
setDraft('a', { text: 'hi', replyToId: null });
|
||||
const draft = getDraftSync('a');
|
||||
expect(draft).not.toBeNull();
|
||||
expect(draft?.text).toBe('hi');
|
||||
expect(draft?.replyToId).toBeNull();
|
||||
expect(hasDraft('a')).toBe(true);
|
||||
});
|
||||
|
||||
it('isolates drafts per conversation', () => {
|
||||
setDraft('a', { text: 'one', replyToId: null });
|
||||
setDraft('b', { text: 'two', replyToId: 'msg-9' });
|
||||
expect(getDraftSync('a')?.text).toBe('one');
|
||||
expect(getDraftSync('b')?.replyToId).toBe('msg-9');
|
||||
});
|
||||
|
||||
it('clearDraft removes the draft from memory', () => {
|
||||
setDraft('a', { text: 'one', replyToId: null });
|
||||
clearDraft('a');
|
||||
expect(getDraftSync('a')).toBeNull();
|
||||
expect(hasDraft('a')).toBe(false);
|
||||
});
|
||||
|
||||
it('treats an empty-string text + null reply as "no draft"', () => {
|
||||
setDraft('a', { text: '', replyToId: null });
|
||||
expect(getDraftSync('a')).toBeNull();
|
||||
expect(hasDraft('a')).toBe(false);
|
||||
});
|
||||
|
||||
it('hydrateDrafts populates the in-memory map from SQLite rows', async () => {
|
||||
sqlSelectMock.mockResolvedValueOnce([
|
||||
{ conversation_id: 'a', text: 'persisted', reply_to_id: 'msg-1', updated_at: '2026-05-17T00:00:00Z' },
|
||||
]);
|
||||
await hydrateDrafts();
|
||||
expect(getDraftSync('a')?.text).toBe('persisted');
|
||||
expect(getDraftSync('a')?.replyToId).toBe('msg-1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
// Composer-draft persistence. Two-tier semantics:
|
||||
// * In-memory `Map<convId, Draft>` for instant synchronous reads on
|
||||
// mount (mirrors the `messageMemoryCache` pattern from Phase 7).
|
||||
// * SQLite (`composer_drafts` table, schema in `messageCache.ts`) for
|
||||
// cross-restart persistence. Writes are debounced and fire-and-forget
|
||||
// — losing the last 400ms of typing on a hard crash is acceptable;
|
||||
// blocking the keystroke handler is not.
|
||||
//
|
||||
// Attachments are intentionally NOT serialized:
|
||||
// * Files don't round-trip through SQLite cleanly (binary blobs blow
|
||||
// up the cache size).
|
||||
// * `replyToId` IS persisted; the consuming page looks up the actual
|
||||
// message by id at render time.
|
||||
|
||||
import { isTauriRuntime } from './globalShortcut';
|
||||
|
||||
const DB_NAME = 'chatapp-cache';
|
||||
const WRITE_DEBOUNCE_MS = 400;
|
||||
|
||||
interface Draft {
|
||||
text: string;
|
||||
replyToId: string | null;
|
||||
}
|
||||
|
||||
interface DraftRow {
|
||||
conversation_id: string;
|
||||
text: string;
|
||||
reply_to_id: string | null;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
const drafts = new Map<string, Draft>();
|
||||
const pendingWrites = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
let handlePromise: Promise<string | null> | null = null;
|
||||
|
||||
async function getHandle(): Promise<string | null> {
|
||||
if (handlePromise) return handlePromise;
|
||||
if (!isTauriRuntime()) {
|
||||
handlePromise = Promise.resolve(null);
|
||||
return handlePromise;
|
||||
}
|
||||
handlePromise = (async () => {
|
||||
try {
|
||||
const handle = await window.electronAPI.sqlLoad({ name: DB_NAME });
|
||||
// Self-contained DDL — the same statement also runs from
|
||||
// `messageCache.ts`'s init path, but we don't want to depend on
|
||||
// call order. SQLite's `CREATE TABLE IF NOT EXISTS` is idempotent
|
||||
// so the double-creation is safe.
|
||||
await window.electronAPI.sqlExecute({
|
||||
handle,
|
||||
query:
|
||||
`CREATE TABLE IF NOT EXISTS composer_drafts (
|
||||
conversation_id TEXT PRIMARY KEY,
|
||||
text TEXT NOT NULL,
|
||||
reply_to_id TEXT,
|
||||
updated_at TEXT NOT NULL
|
||||
)`,
|
||||
bindings: [],
|
||||
});
|
||||
return handle;
|
||||
} catch (err: unknown) {
|
||||
console.warn('composerDraftStore: sqlLoad failed', err);
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
return handlePromise;
|
||||
}
|
||||
|
||||
export function getDraftSync(conversationId: string): Draft | null {
|
||||
const stored = drafts.get(conversationId);
|
||||
if (!stored) return null;
|
||||
return { text: stored.text, replyToId: stored.replyToId };
|
||||
}
|
||||
|
||||
export function hasDraft(conversationId: string): boolean {
|
||||
return drafts.has(conversationId);
|
||||
}
|
||||
|
||||
export function setDraft(conversationId: string, draft: Draft): void {
|
||||
if (draft.text.length === 0 && draft.replyToId === null) {
|
||||
if (drafts.has(conversationId)) {
|
||||
drafts.delete(conversationId);
|
||||
scheduleWrite(conversationId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
drafts.set(conversationId, { text: draft.text, replyToId: draft.replyToId });
|
||||
scheduleWrite(conversationId);
|
||||
}
|
||||
|
||||
export function clearDraft(conversationId: string): void {
|
||||
if (!drafts.has(conversationId)) return;
|
||||
drafts.delete(conversationId);
|
||||
scheduleWrite(conversationId);
|
||||
}
|
||||
|
||||
function scheduleWrite(conversationId: string): void {
|
||||
const existing = pendingWrites.get(conversationId);
|
||||
if (existing) clearTimeout(existing);
|
||||
const timer = setTimeout(() => {
|
||||
pendingWrites.delete(conversationId);
|
||||
void flushOne(conversationId);
|
||||
}, WRITE_DEBOUNCE_MS);
|
||||
pendingWrites.set(conversationId, timer);
|
||||
}
|
||||
|
||||
async function flushOne(conversationId: string): Promise<void> {
|
||||
const handle = await getHandle();
|
||||
if (!handle) return;
|
||||
const draft = drafts.get(conversationId);
|
||||
try {
|
||||
if (draft) {
|
||||
await window.electronAPI.sqlExecute({
|
||||
handle,
|
||||
query:
|
||||
`INSERT INTO composer_drafts (conversation_id, text, reply_to_id, updated_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT(conversation_id) DO UPDATE SET
|
||||
text = excluded.text,
|
||||
reply_to_id = excluded.reply_to_id,
|
||||
updated_at = excluded.updated_at`,
|
||||
bindings: [conversationId, draft.text, draft.replyToId, new Date().toISOString()],
|
||||
});
|
||||
} else {
|
||||
await window.electronAPI.sqlExecute({
|
||||
handle,
|
||||
query: 'DELETE FROM composer_drafts WHERE conversation_id = $1',
|
||||
bindings: [conversationId],
|
||||
});
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.warn('composerDraftStore: flush failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function hydrateDrafts(): Promise<void> {
|
||||
const handle = await getHandle();
|
||||
if (!handle) return;
|
||||
try {
|
||||
const rows = (await window.electronAPI.sqlSelect({
|
||||
handle,
|
||||
query: 'SELECT conversation_id, text, reply_to_id, updated_at FROM composer_drafts',
|
||||
bindings: [],
|
||||
})) as unknown as DraftRow[];
|
||||
for (const r of rows) {
|
||||
if (!r.conversation_id || typeof r.text !== 'string') continue;
|
||||
if (r.text.length === 0 && r.reply_to_id === null) continue;
|
||||
drafts.set(r.conversation_id, { text: r.text, replyToId: r.reply_to_id });
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.warn('composerDraftStore: hydrate failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
export function __resetForTests(): void {
|
||||
for (const t of pendingWrites.values()) clearTimeout(t);
|
||||
pendingWrites.clear();
|
||||
drafts.clear();
|
||||
handlePromise = null;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Main-thread wrapper around the crypto Web Worker (Argon2id pwhash +
|
||||
// sealed user-key open). Each unlock attempt spawns a fresh one-shot worker
|
||||
// — workers are cheap and the pwhash is a one-time cost per login, so we
|
||||
// avoid the bookkeeping needed for a persistent request queue.
|
||||
//
|
||||
// Falls back to inline (main-thread) `openUserKey` when the `Worker`
|
||||
// constructor is unavailable (e.g. vitest's jsdom environment, strict CSPs).
|
||||
// The fallback path is identical in semantics to the worker path; the only
|
||||
// difference is whether it blocks the main thread.
|
||||
//
|
||||
// Why not a long-lived worker? The KDF cost dwarfs the spawn cost (~1-2 s
|
||||
// vs. a few ms), and PIN-unlock happens at most once per session. Keeping a
|
||||
// worker resident would also require a request-id correlation map which the
|
||||
// decrypt.worker uses (because per-message decrypts are high-volume).
|
||||
|
||||
import { openUserKey } from '@chat-app/shared/crypto';
|
||||
|
||||
import type {
|
||||
OpenUserKeyInput,
|
||||
OpenUserKeyResult,
|
||||
} from '../workers/crypto.worker';
|
||||
|
||||
type WorkerResponse =
|
||||
| { ok: true; result: OpenUserKeyResult }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export type { OpenUserKeyInput, OpenUserKeyResult };
|
||||
|
||||
export async function openUserKeyInWorker(
|
||||
input: OpenUserKeyInput,
|
||||
): Promise<OpenUserKeyResult> {
|
||||
const worker = new Worker(
|
||||
new URL('../workers/crypto.worker.ts', import.meta.url),
|
||||
{ type: 'module' },
|
||||
);
|
||||
try {
|
||||
return await new Promise<OpenUserKeyResult>((resolve, reject) => {
|
||||
worker.addEventListener('message', (ev: MessageEvent<WorkerResponse>) => {
|
||||
const msg = ev.data;
|
||||
if (msg && msg.ok) resolve(msg.result);
|
||||
else reject(new Error(msg?.error ?? 'crypto worker returned malformed response'));
|
||||
});
|
||||
worker.addEventListener('error', (ev: ErrorEvent) => {
|
||||
reject(new Error(ev.message || 'crypto worker error'));
|
||||
});
|
||||
worker.postMessage({ op: 'openUserKey', input });
|
||||
});
|
||||
} finally {
|
||||
worker.terminate();
|
||||
}
|
||||
}
|
||||
|
||||
// Public entry point: route through the worker when possible, fall back to
|
||||
// the synchronous-on-main-thread path otherwise. Callers should prefer this
|
||||
// over importing `openUserKey` directly so we get the worker speedup
|
||||
// everywhere it's available.
|
||||
export async function openUserKeyMaybeWorker(
|
||||
input: OpenUserKeyInput,
|
||||
): Promise<Uint8Array> {
|
||||
if (typeof Worker === 'undefined') {
|
||||
return openUserKey(input);
|
||||
}
|
||||
try {
|
||||
const result = await openUserKeyInWorker(input);
|
||||
return result.privateKey;
|
||||
} catch (err) {
|
||||
// If the worker spawn or message round-trip fails (e.g. CSP blocks
|
||||
// module workers in some packaging modes), fall back to inline so the
|
||||
// unlock still succeeds — just with a brief main-thread hitch.
|
||||
console.warn('[cryptoWorker] worker path failed, falling back to inline', err);
|
||||
return openUserKey(input);
|
||||
}
|
||||
}
|
||||
@@ -55,3 +55,51 @@ function renameToWebp(original: string): string {
|
||||
export async function compressImages(files: File[]): Promise<File[]> {
|
||||
return Promise.all(files.map((f) => compressImage(f)));
|
||||
}
|
||||
|
||||
// Bandwidth threshold for thumb generation. Below ~50KB the WebP overhead
|
||||
// of a fresh re-encode can exceed the original; not worth a second upload.
|
||||
const THUMB_SKIP_BELOW_BYTES = 50 * 1024;
|
||||
const THUMB_MAX_DIM = 320;
|
||||
const THUMB_QUALITY = 0.7;
|
||||
// Animated formats lose motion when redrawn onto a Canvas, so we skip them
|
||||
// and let the receiver render the full file. GIF is the dominant case; the
|
||||
// rest stay too (apng/animated-webp).
|
||||
const THUMB_ANIMATED_MIME = /^image\/(gif|apng)$/;
|
||||
|
||||
// Generates a small WebP preview thumb (max 320×320) from an image file.
|
||||
// Used by the send path so each image attachment can ship a tiny inline
|
||||
// preview alongside the encrypted full blob. Returns `null` when:
|
||||
// - the input isn't an image,
|
||||
// - the input is animated (GIF/APNG — would lose motion),
|
||||
// - the input is already small enough that a thumb wouldn't save bandwidth,
|
||||
// - OffscreenCanvas / createImageBitmap aren't available, or
|
||||
// - decode/encode threw (corrupt input).
|
||||
// The caller treats `null` as "skip thumb" and uploads only the full blob.
|
||||
export async function generateWebPThumb(
|
||||
file: File,
|
||||
maxDim: number = THUMB_MAX_DIM,
|
||||
): Promise<Blob | null> {
|
||||
if (!file.type.startsWith('image/')) return null;
|
||||
if (THUMB_ANIMATED_MIME.test(file.type)) return null;
|
||||
if (file.size < THUMB_SKIP_BELOW_BYTES) return null;
|
||||
if (typeof createImageBitmap !== 'function') return null;
|
||||
if (typeof OffscreenCanvas !== 'function') return null;
|
||||
try {
|
||||
const bitmap = await createImageBitmap(file);
|
||||
const ratio = Math.min(maxDim / bitmap.width, maxDim / bitmap.height, 1);
|
||||
const w = Math.max(1, Math.round(bitmap.width * ratio));
|
||||
const h = Math.max(1, Math.round(bitmap.height * ratio));
|
||||
const canvas = new OffscreenCanvas(w, h);
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
bitmap.close();
|
||||
return null;
|
||||
}
|
||||
ctx.drawImage(bitmap, 0, 0, w, h);
|
||||
bitmap.close();
|
||||
return await canvas.convertToBlob({ type: 'image/webp', quality: THUMB_QUALITY });
|
||||
} catch (err: unknown) {
|
||||
console.warn('generateWebPThumb failed', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
// Discord-style live captions. Uses the browser's SpeechRecognition API to
|
||||
// transcribe the LOCAL user's mic, then broadcasts each interim/final result
|
||||
// to peers via the LiveKit DataChannel. Receivers store and display them.
|
||||
//
|
||||
// Privacy note: speech recognition runs in the browser. On Chromium-based
|
||||
// runtimes (incl. Tauri's WebView2 on Windows) this calls into the browser's
|
||||
// own engine, which today reaches Google's cloud — same trade-off as Discord.
|
||||
// We ship a hard off switch and require an explicit user toggle.
|
||||
|
||||
const STORAGE_KEY = 'chatapp.liveCaptions.v1';
|
||||
|
||||
export interface LiveCaptionsSettings {
|
||||
enabled: boolean;
|
||||
/** BCP-47 language tag, e.g. "de-DE" or "en-US". Auto-detect uses navigator.language. */
|
||||
lang: string | null;
|
||||
}
|
||||
|
||||
const DEFAULTS: LiveCaptionsSettings = {
|
||||
enabled: false,
|
||||
lang: null,
|
||||
};
|
||||
|
||||
type Listener = (s: LiveCaptionsSettings) => void;
|
||||
const listeners = new Set<Listener>();
|
||||
let cached: LiveCaptionsSettings | null = null;
|
||||
|
||||
function read(): LiveCaptionsSettings {
|
||||
if (cached) return cached;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
cached = DEFAULTS;
|
||||
return cached;
|
||||
}
|
||||
const parsed = JSON.parse(raw) as Partial<LiveCaptionsSettings>;
|
||||
cached = {
|
||||
enabled: typeof parsed.enabled === 'boolean' ? parsed.enabled : DEFAULTS.enabled,
|
||||
lang:
|
||||
typeof parsed.lang === 'string' && parsed.lang.length > 0
|
||||
? parsed.lang
|
||||
: DEFAULTS.lang,
|
||||
};
|
||||
return cached;
|
||||
} catch {
|
||||
cached = DEFAULTS;
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
function write(s: LiveCaptionsSettings): void {
|
||||
cached = s;
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
|
||||
} catch {
|
||||
/* quota / private mode */
|
||||
}
|
||||
for (const l of listeners) l(s);
|
||||
}
|
||||
|
||||
export function getLiveCaptionsSettings(): LiveCaptionsSettings {
|
||||
return read();
|
||||
}
|
||||
|
||||
export function updateLiveCaptionsSettings(
|
||||
patch: Partial<LiveCaptionsSettings>,
|
||||
): LiveCaptionsSettings {
|
||||
const next = { ...read(), ...patch };
|
||||
write(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function subscribeLiveCaptionsSettings(listener: Listener): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
// Browser feature-detect. Chromium ships under both names; Firefox lacks it
|
||||
// outright. Returns the constructor or null.
|
||||
type SpeechRecognitionCtor = new () => SpeechRecognitionLike;
|
||||
interface SpeechRecognitionLike extends EventTarget {
|
||||
continuous: boolean;
|
||||
interimResults: boolean;
|
||||
lang: string;
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
abort: () => void;
|
||||
onresult: ((e: SpeechRecognitionEventLike) => void) | null;
|
||||
onerror: ((e: SpeechRecognitionErrorLike) => void) | null;
|
||||
onend: (() => void) | null;
|
||||
}
|
||||
interface SpeechRecognitionEventLike {
|
||||
resultIndex: number;
|
||||
results: ArrayLike<{
|
||||
isFinal: boolean;
|
||||
[index: number]: { transcript: string };
|
||||
length: number;
|
||||
}>;
|
||||
}
|
||||
interface SpeechRecognitionErrorLike {
|
||||
error: string;
|
||||
}
|
||||
|
||||
export function getSpeechRecognitionCtor(): SpeechRecognitionCtor | null {
|
||||
const w = window as unknown as {
|
||||
SpeechRecognition?: SpeechRecognitionCtor;
|
||||
webkitSpeechRecognition?: SpeechRecognitionCtor;
|
||||
};
|
||||
return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;
|
||||
}
|
||||
|
||||
export function isLiveCaptionsSupported(): boolean {
|
||||
return getSpeechRecognitionCtor() !== null;
|
||||
}
|
||||
|
||||
export type {
|
||||
SpeechRecognitionLike,
|
||||
SpeechRecognitionEventLike,
|
||||
SpeechRecognitionErrorLike,
|
||||
};
|
||||
@@ -16,6 +16,7 @@ const PRESERVE_LOCAL_STORAGE = new Set([
|
||||
'chatapp.locale',
|
||||
'chatapp.installId',
|
||||
'chatapp.wipeOnClose.v1',
|
||||
'chatapp.autoLockMinutes.v1',
|
||||
'i18nextLng',
|
||||
]);
|
||||
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,61 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import type { DecryptedMessage } from '@chat-app/shared/chat';
|
||||
|
||||
import {
|
||||
__resetForTests,
|
||||
getCachedMessages,
|
||||
hasCachedMessages,
|
||||
setCachedMessages,
|
||||
} from './messageMemoryCache';
|
||||
|
||||
function msg(id: string): DecryptedMessage {
|
||||
return {
|
||||
id,
|
||||
conversationId: 'conv-1',
|
||||
senderId: 'sender-1',
|
||||
senderDeviceId: null,
|
||||
replyToId: null,
|
||||
editedAt: null,
|
||||
deletedAt: null,
|
||||
createdAt: '2026-05-17T00:00:00Z',
|
||||
plaintext: 'hi ' + id,
|
||||
};
|
||||
}
|
||||
|
||||
describe('messageMemoryCache', () => {
|
||||
afterEach(() => {
|
||||
__resetForTests();
|
||||
});
|
||||
|
||||
it('returns empty array when nothing is cached', () => {
|
||||
expect(getCachedMessages('unknown')).toEqual([]);
|
||||
expect(hasCachedMessages('unknown')).toBe(false);
|
||||
});
|
||||
|
||||
it('stores and returns messages per conversation', () => {
|
||||
setCachedMessages('a', [msg('m1'), msg('m2')]);
|
||||
expect(getCachedMessages('a').map((m) => m.id)).toEqual(['m1', 'm2']);
|
||||
expect(hasCachedMessages('a')).toBe(true);
|
||||
});
|
||||
|
||||
it('isolates conversations', () => {
|
||||
setCachedMessages('a', [msg('m1')]);
|
||||
setCachedMessages('b', [msg('m9')]);
|
||||
expect(getCachedMessages('a').map((m) => m.id)).toEqual(['m1']);
|
||||
expect(getCachedMessages('b').map((m) => m.id)).toEqual(['m9']);
|
||||
});
|
||||
|
||||
it('overwrites prior cache when set again', () => {
|
||||
setCachedMessages('a', [msg('m1')]);
|
||||
setCachedMessages('a', [msg('m1'), msg('m2')]);
|
||||
expect(getCachedMessages('a').map((m) => m.id)).toEqual(['m1', 'm2']);
|
||||
});
|
||||
|
||||
it('treats an explicit empty list as "cached"', () => {
|
||||
// A conversation that genuinely has zero messages should still be
|
||||
// flagged as cached so the hook skips the loading spinner on re-entry.
|
||||
setCachedMessages('a', []);
|
||||
expect(hasCachedMessages('a')).toBe(true);
|
||||
expect(getCachedMessages('a')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
// In-memory cache of the most-recently-rendered messages for each
|
||||
// conversation. Survives React component unmount/remount (used by
|
||||
// `useConversationMessages` to initialize state synchronously when
|
||||
// ConversationPage is remounted on chat switch). Session-scoped — lost
|
||||
// on full app reload. The SQLite cache (`messageCache.ts`) is still the
|
||||
// source of truth for cross-session persistence; this layer just shaves
|
||||
// off the round-trip-to-disk spinner flash.
|
||||
//
|
||||
// Two-tier semantics:
|
||||
// * `hasCachedMessages(id)` returns true even for a known-empty chat
|
||||
// so the hook can suppress the loading spinner on re-entry.
|
||||
// * `getCachedMessages(id)` returns a defensive copy so callers can't
|
||||
// mutate the cached array.
|
||||
|
||||
import type { DecryptedMessage } from '@chat-app/shared/chat';
|
||||
|
||||
const cache = new Map<string, DecryptedMessage[]>();
|
||||
|
||||
export function getCachedMessages(conversationId: string): DecryptedMessage[] {
|
||||
const stored = cache.get(conversationId);
|
||||
return stored ? stored.slice() : [];
|
||||
}
|
||||
|
||||
export function hasCachedMessages(conversationId: string): boolean {
|
||||
return cache.has(conversationId);
|
||||
}
|
||||
|
||||
export function setCachedMessages(
|
||||
conversationId: string,
|
||||
messages: DecryptedMessage[],
|
||||
): void {
|
||||
cache.set(conversationId, messages.slice());
|
||||
}
|
||||
|
||||
export function __resetForTests(): void {
|
||||
cache.clear();
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Plays a soundboard entry to the local default audio output. Used when no
|
||||
// call pipeline is active (the in-call path routes via the LiveKit
|
||||
// publishing pipeline so peers hear; this path is local-only). Fetches the
|
||||
// blob via getSoundBlob and creates a short-lived object URL for the audio
|
||||
// element.
|
||||
|
||||
import { getSoundBlob, type SoundboardEntry } from './soundboardStorage';
|
||||
|
||||
const activeAudios = new Set<HTMLAudioElement>();
|
||||
|
||||
export async function playSoundboardLocal(entry: SoundboardEntry): Promise<void> {
|
||||
const blob = await getSoundBlob(entry.id);
|
||||
if (!blob) return;
|
||||
const src = URL.createObjectURL(blob);
|
||||
const el = new Audio(src);
|
||||
// Use the per-entry gain as local volume. SoundboardEntry exposes `gain`
|
||||
// (0..1) which mirrors the value used in the in-call pipeline.
|
||||
el.volume = Math.max(0, Math.min(1, entry.gain));
|
||||
activeAudios.add(el);
|
||||
const cleanup = () => {
|
||||
activeAudios.delete(el);
|
||||
URL.revokeObjectURL(src);
|
||||
};
|
||||
el.addEventListener('ended', cleanup);
|
||||
el.addEventListener('error', cleanup);
|
||||
try {
|
||||
await el.play();
|
||||
} catch (err) {
|
||||
cleanup();
|
||||
console.warn('soundboardLocalPlay failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
export function stopSoundboardLocal(): void {
|
||||
for (const el of activeAudios) {
|
||||
try {
|
||||
el.pause();
|
||||
el.currentTime = 0;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
activeAudios.clear();
|
||||
}
|
||||
@@ -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,
|
||||
@@ -32,6 +33,11 @@ import {
|
||||
shouldGiveUp,
|
||||
subscribeOutbox,
|
||||
} from './messageOutbox';
|
||||
import {
|
||||
getCachedMessages,
|
||||
hasCachedMessages,
|
||||
setCachedMessages,
|
||||
} from './messageMemoryCache';
|
||||
import { supabase } from './supabase';
|
||||
import { cachedUserKey } from './userIdentity';
|
||||
|
||||
@@ -74,14 +80,27 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
text: string,
|
||||
images?: File[],
|
||||
replyToId?: string | null,
|
||||
opts?: { viewOnce?: boolean },
|
||||
opts?: { viewOnceFlags?: boolean[] },
|
||||
) => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
pending: OutboxItem[];
|
||||
retryPending: (id: string) => void;
|
||||
cancelPending: (id: string) => void;
|
||||
} {
|
||||
const [state, setState] = useState<State>({ messages: [], loading: true, error: null });
|
||||
// Initialize from the in-memory cache so a previously-viewed chat shows
|
||||
// content on the very first render after the parent remounts on `:id`
|
||||
// change. `loading` stays true ONLY for never-seen conversations (cache
|
||||
// miss) so the spinner doesn't flash on every chat switch.
|
||||
const [state, setState] = useState<State>(() => {
|
||||
if (conversationId && hasCachedMessages(conversationId)) {
|
||||
return {
|
||||
messages: getCachedMessages(conversationId),
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
return { messages: [], loading: true, error: null };
|
||||
});
|
||||
const [pending, setPending] = useState<OutboxItem[]>(() =>
|
||||
conversationId ? getOutbox(conversationId) : [],
|
||||
);
|
||||
@@ -228,6 +247,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
const rows = await fetchConversationMessages(supabase, conversationId);
|
||||
const decrypted = await decryptBatch(rows);
|
||||
setState({ messages: decrypted, loading: false, error: null });
|
||||
setCachedMessages(conversationId, decrypted);
|
||||
// Persist the fresh batch to the local cache so next conversation
|
||||
// switch / app start can hydrate instantly. Fire-and-forget — cache
|
||||
// write failure is never user-visible.
|
||||
@@ -247,12 +267,17 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
// when the server response lands. On cache-miss this is a ~5ms no-op.
|
||||
useEffect(() => {
|
||||
if (!conversationId) return;
|
||||
// Memory cache already populated state synchronously — skip the disk
|
||||
// round-trip entirely. The canonical data lands shortly via refresh();
|
||||
// the SQLite cache only matters for cold-start hydration.
|
||||
if (hasCachedMessages(conversationId)) return;
|
||||
let cancelled = false;
|
||||
void loadCachedMessages(conversationId).then((cached) => {
|
||||
if (cancelled || cached.length === 0) return;
|
||||
setState((prev) => {
|
||||
// Don't clobber a fresh server response that already landed.
|
||||
if (prev.messages.length > 0) return prev;
|
||||
setCachedMessages(conversationId, cached);
|
||||
return { messages: cached, loading: false, error: null };
|
||||
});
|
||||
});
|
||||
@@ -337,7 +362,9 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
if (!decrypted) return;
|
||||
setState((prev) => {
|
||||
if (prev.messages.some((m) => m.id === decrypted!.id)) return prev;
|
||||
return { ...prev, messages: [...prev.messages, decrypted!] };
|
||||
const next = [...prev.messages, decrypted!];
|
||||
if (conversationId) setCachedMessages(conversationId, next);
|
||||
return { ...prev, messages: next };
|
||||
});
|
||||
},
|
||||
[conversationId, deviceId, decryptBatch],
|
||||
@@ -357,6 +384,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
editedAt: partial.editedAt,
|
||||
deletedAt: partial.deletedAt,
|
||||
};
|
||||
if (conversationId) setCachedMessages(conversationId, next);
|
||||
return { ...prev, messages: next };
|
||||
});
|
||||
if (partial.editedAt && !partial.deletedAt) {
|
||||
@@ -420,6 +448,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
if (idx === -1) return prev;
|
||||
const next = [...prev.messages];
|
||||
next[idx] = decrypted!;
|
||||
if (conversationId) setCachedMessages(conversationId, next);
|
||||
return { ...prev, messages: next };
|
||||
});
|
||||
}
|
||||
@@ -427,14 +456,18 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
[conversationId, deviceId, decryptBatch],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback((row: Record<string, unknown>) => {
|
||||
const id = String(row.id);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
messages: prev.messages.filter((m) => m.id !== id),
|
||||
}));
|
||||
void deleteCachedMessage(id);
|
||||
}, []);
|
||||
const handleDelete = useCallback(
|
||||
(row: Record<string, unknown>) => {
|
||||
const id = String(row.id);
|
||||
setState((prev) => {
|
||||
const next = prev.messages.filter((m) => m.id !== id);
|
||||
if (conversationId) setCachedMessages(conversationId, next);
|
||||
return { ...prev, messages: next };
|
||||
});
|
||||
void deleteCachedMessage(id);
|
||||
},
|
||||
[conversationId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!conversationId || !userId || !deviceId) return;
|
||||
@@ -551,13 +584,12 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
});
|
||||
setState((prev) => {
|
||||
if (prev.messages.some((m) => m.id === msg.id)) return prev;
|
||||
return {
|
||||
...prev,
|
||||
messages: [
|
||||
...prev.messages,
|
||||
{ ...msg, plaintext: text } as DecryptedMessage,
|
||||
],
|
||||
};
|
||||
const next = [
|
||||
...prev.messages,
|
||||
{ ...msg, plaintext: text } as DecryptedMessage,
|
||||
];
|
||||
setCachedMessages(convId, next);
|
||||
return { ...prev, messages: next };
|
||||
});
|
||||
},
|
||||
[],
|
||||
@@ -568,7 +600,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
text: string,
|
||||
images: File[] = [],
|
||||
replyToId: string | null = null,
|
||||
opts: { viewOnce?: boolean } = {},
|
||||
opts: { viewOnceFlags?: boolean[] } = {},
|
||||
) => {
|
||||
const trimmed = text.trim();
|
||||
if ((!trimmed && images.length === 0) || !conversationId || !userId || !deviceId) return;
|
||||
@@ -625,13 +657,23 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
|
||||
// 1. Upload + encrypt each image. Collect handles + raw blob nonces
|
||||
// (so the public attachment row can reference the blob-level nonce).
|
||||
// P7.T4: view-once is now a per-attachment flag rather than a
|
||||
// composer-wide toggle. `opts.viewOnceFlags` is a parallel array;
|
||||
// missing entries (or whole-array absence) default to false.
|
||||
const handles: AttachmentHandle[] = [];
|
||||
const blobNonceHexByHandleId = new Map<string, string>();
|
||||
for (const file of images) {
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
const file = images[i]!;
|
||||
if (file.size > MAX_ATTACHMENT_BYTES) {
|
||||
throw new Error('attachment exceeds max size (10 MB)');
|
||||
}
|
||||
const dims = await readImageDimensions(file);
|
||||
// Phase 6B: generate a small WebP preview thumb so the receiver's
|
||||
// bubble loads fast (typical 320×240 WebP is 10–30KB vs the full
|
||||
// image's 1–10MB). `generateWebPThumb` short-circuits to null on
|
||||
// non-images, animated formats, and small files — and on failure;
|
||||
// the upload helper then just skips the second upload.
|
||||
const thumbBlob = await generateWebPThumb(file);
|
||||
const res = await encryptAndUploadAttachment({
|
||||
client: supabase,
|
||||
conversationId,
|
||||
@@ -640,13 +682,14 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
sizeBytes: file.size,
|
||||
...(dims.width !== undefined ? { width: dims.width } : {}),
|
||||
...(dims.height !== undefined ? { height: dims.height } : {}),
|
||||
...(thumbBlob ? { thumbBlob } : {}),
|
||||
});
|
||||
// Stamp the view-once flag on each handle the caller requested it
|
||||
// for. The flag rides inside the encrypted payload (so peers can
|
||||
// render the locked card without leaking who-sent-what to the
|
||||
// server) AND lands on the public message_attachments row via
|
||||
// insertAttachmentRow below (where the mark-viewed RPC enforces it).
|
||||
if (opts.viewOnce) {
|
||||
// Stamp the view-once flag on each handle the caller flagged. The
|
||||
// flag rides inside the encrypted payload (so peers can render the
|
||||
// locked card without leaking who-sent-what to the server) AND
|
||||
// lands on the public message_attachments row via insertAttachmentRow
|
||||
// below (where the mark-viewed RPC enforces it).
|
||||
if (opts.viewOnceFlags?.[i]) {
|
||||
res.handle.viewOnce = true;
|
||||
}
|
||||
handles.push(res.handle);
|
||||
@@ -674,16 +717,15 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
: JSON.stringify({ v: 1, text: trimmed, attachments: handles });
|
||||
setState((prev) => {
|
||||
if (prev.messages.some((m) => m.id === msg.id)) return prev;
|
||||
return {
|
||||
...prev,
|
||||
messages: [
|
||||
...prev.messages,
|
||||
{
|
||||
...msg,
|
||||
plaintext: attachmentsPayload,
|
||||
} as DecryptedMessage,
|
||||
],
|
||||
};
|
||||
const next = [
|
||||
...prev.messages,
|
||||
{
|
||||
...msg,
|
||||
plaintext: attachmentsPayload,
|
||||
} as DecryptedMessage,
|
||||
];
|
||||
setCachedMessages(conversationId, next);
|
||||
return { ...prev, messages: next };
|
||||
});
|
||||
|
||||
// 4. Insert public attachment metadata rows pointing at the new message.
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
// Hook that runs SpeechRecognition on the local mic when live-captions are
|
||||
// enabled and a Room is connected. Each interim/final result is broadcast as
|
||||
// a `caption`-typed message via the LiveKit DataChannel so peers can render
|
||||
// it. Recognition stops cleanly when the call ends or the toggle flips off.
|
||||
|
||||
import type { Room } from 'livekit-client';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import {
|
||||
type LiveCaptionsSettings,
|
||||
getLiveCaptionsSettings,
|
||||
getSpeechRecognitionCtor,
|
||||
type SpeechRecognitionEventLike,
|
||||
type SpeechRecognitionLike,
|
||||
subscribeLiveCaptionsSettings,
|
||||
} from './liveCaptions';
|
||||
|
||||
interface Args {
|
||||
room: Room | null;
|
||||
/** True while we're connected and want captions to flow. */
|
||||
active: boolean;
|
||||
/** Callback fired locally for our own captions so the overlay can show
|
||||
* them without going through the SFU round-trip. */
|
||||
onLocalCaption: (text: string, final: boolean) => void;
|
||||
}
|
||||
|
||||
export function useLiveCaptions({ room, active, onLocalCaption }: Args): void {
|
||||
const recognitionRef = useRef<SpeechRecognitionLike | null>(null);
|
||||
const settingsRef = useRef<LiveCaptionsSettings>(getLiveCaptionsSettings());
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeLiveCaptionsSettings((s) => {
|
||||
settingsRef.current = s;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const Ctor = getSpeechRecognitionCtor();
|
||||
if (!Ctor) return; // unsupported runtime
|
||||
if (!active || !room) return;
|
||||
if (!getLiveCaptionsSettings().enabled) return;
|
||||
|
||||
const send = (text: string, final: boolean) => {
|
||||
onLocalCaption(text, final);
|
||||
try {
|
||||
const payload = new TextEncoder().encode(
|
||||
JSON.stringify({ type: 'caption', captionText: text, captionFinal: final }),
|
||||
);
|
||||
// Reliable channel — captions are infrequent enough to afford it,
|
||||
// and dropping interims looks worse than slight lag.
|
||||
void room.localParticipant.publishData(payload, { reliable: true });
|
||||
} catch {
|
||||
/* ignore — best-effort */
|
||||
}
|
||||
};
|
||||
|
||||
const start = () => {
|
||||
const r = new Ctor();
|
||||
r.continuous = true;
|
||||
r.interimResults = true;
|
||||
const lang = settingsRef.current.lang ?? navigator.language ?? 'de-DE';
|
||||
r.lang = lang;
|
||||
r.onresult = (e: SpeechRecognitionEventLike) => {
|
||||
// Pull whichever results arrived since last fire. Interim fires
|
||||
// many times per second; the final one is sticky and persists.
|
||||
for (let i = e.resultIndex; i < e.results.length; i++) {
|
||||
const result = e.results[i];
|
||||
if (!result || result.length === 0) continue;
|
||||
const alt = result[0];
|
||||
if (!alt) continue;
|
||||
const transcript = alt.transcript.trim();
|
||||
if (!transcript) continue;
|
||||
send(transcript, result.isFinal);
|
||||
}
|
||||
};
|
||||
r.onerror = () => {
|
||||
// Recoverable: stop + retry on next effect cycle. `not-allowed` and
|
||||
// `service-not-allowed` are permission-permanent — bail.
|
||||
try {
|
||||
r.stop();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
r.onend = () => {
|
||||
// SpeechRecognition tends to auto-stop after silence — if we still
|
||||
// want captions, restart it. Guard against tear-down race.
|
||||
if (recognitionRef.current === r && getLiveCaptionsSettings().enabled) {
|
||||
try {
|
||||
r.start();
|
||||
} catch {
|
||||
/* already running or browser refused */
|
||||
}
|
||||
}
|
||||
};
|
||||
try {
|
||||
r.start();
|
||||
recognitionRef.current = r;
|
||||
} catch {
|
||||
// Some browsers throw when start() is called too soon after a
|
||||
// previous abort — wait a tick and retry.
|
||||
window.setTimeout(() => {
|
||||
try {
|
||||
r.start();
|
||||
recognitionRef.current = r;
|
||||
} catch {
|
||||
/* give up */
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
};
|
||||
|
||||
start();
|
||||
|
||||
const unsub = subscribeLiveCaptionsSettings((s) => {
|
||||
const cur = recognitionRef.current;
|
||||
if (!s.enabled && cur) {
|
||||
recognitionRef.current = null;
|
||||
try {
|
||||
cur.abort();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
} else if (s.enabled && !cur) {
|
||||
start();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsub();
|
||||
const cur = recognitionRef.current;
|
||||
recognitionRef.current = null;
|
||||
if (cur) {
|
||||
try {
|
||||
cur.abort();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [active, room, onLocalCaption]);
|
||||
}
|
||||
@@ -1,12 +1,26 @@
|
||||
import { listPinnedMessages, type PinnedMessage } from '@chat-app/shared/chat';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { supabase } from './supabase';
|
||||
|
||||
export interface UsePinnedMessagesResult {
|
||||
pins: PinnedMessage[];
|
||||
// Optimistic insert. Caller flips the UI immediately; server insert +
|
||||
// realtime echo will reconcile (dedup'd by messageId). Returns the
|
||||
// previous snapshot so the caller can roll back on error.
|
||||
applyOptimisticPin: (messageId: string, pinnedBy: string) => PinnedMessage[];
|
||||
applyOptimisticUnpin: (messageId: string) => PinnedMessage[];
|
||||
// Hard restore for rollback after a failed server call.
|
||||
restorePins: (snapshot: PinnedMessage[]) => void;
|
||||
}
|
||||
|
||||
// Live list of pinned messages for one conversation. Subscribes to the
|
||||
// `pinned_messages` realtime channel for the conv so the header pill +
|
||||
// side-panel update without a refetch.
|
||||
export function usePinnedMessages(conversationId: string | undefined): PinnedMessage[] {
|
||||
// side-panel update without a refetch. The `applyOptimistic*` helpers let
|
||||
// callers flip local state synchronously on user action so the pin button
|
||||
// doesn't appear unresponsive while the ~100-200ms server roundtrip + the
|
||||
// realtime refetch round complete.
|
||||
export function usePinnedMessages(conversationId: string | undefined): UsePinnedMessagesResult {
|
||||
const [pins, setPins] = useState<PinnedMessage[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -44,5 +58,42 @@ export function usePinnedMessages(conversationId: string | undefined): PinnedMes
|
||||
};
|
||||
}, [conversationId]);
|
||||
|
||||
return pins;
|
||||
const applyOptimisticPin = useCallback<UsePinnedMessagesResult['applyOptimisticPin']>(
|
||||
(messageId, pinnedBy) => {
|
||||
if (!conversationId) return pins;
|
||||
let snapshot: PinnedMessage[] = pins;
|
||||
setPins((prev) => {
|
||||
snapshot = prev;
|
||||
if (prev.some((p) => p.messageId === messageId)) return prev;
|
||||
const optimistic: PinnedMessage = {
|
||||
conversationId,
|
||||
messageId,
|
||||
pinnedBy,
|
||||
pinnedAt: new Date().toISOString(),
|
||||
};
|
||||
// Newest first matches the listPinnedMessages order.
|
||||
return [optimistic, ...prev];
|
||||
});
|
||||
return snapshot;
|
||||
},
|
||||
[conversationId, pins],
|
||||
);
|
||||
|
||||
const applyOptimisticUnpin = useCallback<UsePinnedMessagesResult['applyOptimisticUnpin']>(
|
||||
(messageId) => {
|
||||
let snapshot: PinnedMessage[] = pins;
|
||||
setPins((prev) => {
|
||||
snapshot = prev;
|
||||
return prev.filter((p) => p.messageId !== messageId);
|
||||
});
|
||||
return snapshot;
|
||||
},
|
||||
[pins],
|
||||
);
|
||||
|
||||
const restorePins = useCallback<UsePinnedMessagesResult['restorePins']>((snapshot) => {
|
||||
setPins(snapshot);
|
||||
}, []);
|
||||
|
||||
return { pins, applyOptimisticPin, applyOptimisticUnpin, restorePins };
|
||||
}
|
||||
|
||||
@@ -4,10 +4,11 @@ import {
|
||||
} from '@chat-app/shared/auth';
|
||||
import {
|
||||
generateRecoveryCode, generateUserKeyPair, getCryptoBackend, normalizeRecoveryCode,
|
||||
openUserKey, sealUserKey,
|
||||
sealUserKey,
|
||||
} from '@chat-app/shared/crypto';
|
||||
import { migrateOwnLegacyBundles } from '@chat-app/shared/chat';
|
||||
|
||||
import { openUserKeyMaybeWorker } from './cryptoWorker';
|
||||
import { devLocalSecretStore } from './secretStore';
|
||||
import { supabase } from './supabase';
|
||||
|
||||
@@ -71,7 +72,7 @@ export async function loadOrUnlockUserKey(p: UnlockParams): Promise<UnlockOutcom
|
||||
if (!sealed || !salt) throw new Error('no recovery blob configured');
|
||||
let priv: Uint8Array;
|
||||
try {
|
||||
priv = await openUserKey({ sealed, pin: secret, salt, kdfParams: remote.kdfParams });
|
||||
priv = await openUserKeyMaybeWorker({ sealed, pin: secret, salt, kdfParams: remote.kdfParams });
|
||||
} catch (err) {
|
||||
await recordPinAttempt(supabase, p.userId, false, p.isRecoveryCode === true).catch(() => {});
|
||||
throw err;
|
||||
@@ -212,7 +213,7 @@ async function runLegacyMigration(
|
||||
}
|
||||
}
|
||||
|
||||
console.info(
|
||||
console.debug(
|
||||
'[crypto-migration] vault scan:',
|
||||
'serverDevices=' + report.serverDevices,
|
||||
'keysFromServerList=' + report.strongholdKeysFromServerDevices,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// Persists the preferred voice-message playback rate across sessions.
|
||||
// localStorage is fine here — non-sensitive, single source of truth per
|
||||
// device, no cross-device sync needed.
|
||||
|
||||
const KEY = 'chatapp:voice-speed';
|
||||
const ALLOWED = [1, 1.5, 2] as const;
|
||||
export type VoiceSpeed = (typeof ALLOWED)[number];
|
||||
|
||||
export function getVoiceSpeed(): VoiceSpeed {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(KEY);
|
||||
if (!raw) return 1;
|
||||
const parsed = Number(raw);
|
||||
if (ALLOWED.includes(parsed as VoiceSpeed)) return parsed as VoiceSpeed;
|
||||
} catch {
|
||||
/* localStorage unavailable */
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
export function setVoiceSpeed(speed: VoiceSpeed): void {
|
||||
try {
|
||||
window.localStorage.setItem(KEY, String(speed));
|
||||
} catch {
|
||||
/* localStorage unavailable — best effort */
|
||||
}
|
||||
}
|
||||
|
||||
export const VOICE_SPEEDS = ALLOWED;
|
||||
@@ -0,0 +1,81 @@
|
||||
// Live-cursor pubsub for the multi-user whiteboard. Uses Supabase's
|
||||
// `broadcast` channel rather than `presence` because we want fire-and-forget
|
||||
// position updates (no need to track join/leave) and presence has higher
|
||||
// minimum latency due to its diff-and-merge semantics.
|
||||
//
|
||||
// Throttled to ~30 fps so a continuous drag doesn't flood the channel.
|
||||
|
||||
import type { RealtimeChannel } from '@supabase/supabase-js';
|
||||
|
||||
import { supabase } from './supabase';
|
||||
|
||||
const THROTTLE_MS = 33; // ~30 fps
|
||||
|
||||
export interface CursorEvent {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
// logical canvas coordinates (matches WhiteboardCanvas internal space)
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface CursorSession {
|
||||
send: (x: number, y: number) => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export function openCursorSession(
|
||||
whiteboardId: string,
|
||||
self: { userId: string; displayName: string },
|
||||
onCursor: (ev: CursorEvent) => void,
|
||||
): CursorSession {
|
||||
const channel: RealtimeChannel = supabase.channel('wb-cursor:' + whiteboardId, {
|
||||
config: { broadcast: { self: false } },
|
||||
});
|
||||
channel.on('broadcast', { event: 'cursor' }, (payload) => {
|
||||
const ev = payload.payload as CursorEvent | undefined;
|
||||
if (!ev || ev.userId === self.userId) return;
|
||||
onCursor(ev);
|
||||
});
|
||||
channel.subscribe();
|
||||
|
||||
let lastSentAt = 0;
|
||||
let pending: { x: number; y: number } | null = null;
|
||||
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const flush = (): void => {
|
||||
flushTimer = null;
|
||||
if (!pending) return;
|
||||
const { x, y } = pending;
|
||||
pending = null;
|
||||
lastSentAt = Date.now();
|
||||
void channel.send({
|
||||
type: 'broadcast',
|
||||
event: 'cursor',
|
||||
payload: { userId: self.userId, displayName: self.displayName, x, y } satisfies CursorEvent,
|
||||
});
|
||||
};
|
||||
|
||||
const send = (x: number, y: number): void => {
|
||||
const now = Date.now();
|
||||
const since = now - lastSentAt;
|
||||
if (since >= THROTTLE_MS) {
|
||||
pending = { x, y };
|
||||
flush();
|
||||
} else {
|
||||
pending = { x, y };
|
||||
if (flushTimer === null) {
|
||||
flushTimer = setTimeout(flush, THROTTLE_MS - since);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const close = (): void => {
|
||||
if (flushTimer !== null) clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
pending = null;
|
||||
void supabase.removeChannel(channel);
|
||||
};
|
||||
|
||||
return { send, close };
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
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 { ComposerActionsMenu } from '../components/ComposerActionsMenu';
|
||||
import { ConversationHeader } from '../components/ConversationHeader';
|
||||
import { EmojiPicker } from '../components/EmojiPicker';
|
||||
import { EmptyState } from '../components/EmptyState';
|
||||
@@ -15,10 +17,10 @@ import {
|
||||
ArrowRightIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
EyeIcon,
|
||||
EyeOffIcon,
|
||||
PencilIcon,
|
||||
PlusIcon,
|
||||
PollIcon,
|
||||
ReplyIcon,
|
||||
SearchIcon,
|
||||
SendIcon,
|
||||
@@ -76,8 +78,16 @@ import { markRead as markMessagesReadRemote, useMessageReads } from '../lib/useM
|
||||
import { usePeerPresence } from '../lib/usePeerPresence';
|
||||
import { usePinnedMessages } from '../lib/usePinnedMessages';
|
||||
import { useTypingChannel } from '../lib/useTypingChannel';
|
||||
import { clearDraft, getDraftSync, setDraft } from '../lib/composerDraftStore';
|
||||
|
||||
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
|
||||
@@ -85,14 +95,29 @@ const STICK_THRESHOLD = 80;
|
||||
// change on every parent render.
|
||||
const EMPTY_REACTIONS: AggregatedReaction[] = [];
|
||||
|
||||
// Per-conversation scroll memory. Module-scoped so it survives re-mounts
|
||||
// of ConversationPage when the route param (`id`) changes — switching
|
||||
// chats unmounts/remounts the page in our router setup. Session-only
|
||||
// (lost on reload, like Discord). The `stickToBottom` flag is preserved
|
||||
// alongside the 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 }>();
|
||||
// Per-conversation scroll memory. Module-scoped so it survives the
|
||||
// per-id remount of ConversationPage (see `ConversationRoute` in
|
||||
// App.tsx). Session-only (lost on reload, like Discord). The
|
||||
// `stickToBottom` flag is preserved alongside the topmost-visible row
|
||||
// index so a chat the user left at the bottom keeps auto-following new
|
||||
// messages when they return; a chat scrolled up returns to roughly the
|
||||
// same row the user was reading.
|
||||
//
|
||||
// We track the topmost-visible row index rather than a pixel `scrollTop`
|
||||
// because `react-virtuoso` virtualizes the list — the underlying scroll
|
||||
// element's pixel offset depends on dynamically-measured row heights and
|
||||
// is not stable across remounts. Using a row index restores the user's
|
||||
// reading position even if some rows above re-render at different heights.
|
||||
const scrollPositions = new Map<string, { topmostIndex: number; stickToBottom: boolean }>();
|
||||
|
||||
/** 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() {
|
||||
const { t } = useTranslation(['app', 'errors']);
|
||||
@@ -176,11 +201,18 @@ export function ConversationPage() {
|
||||
});
|
||||
}, [id, messages, myId]);
|
||||
|
||||
const [text, setText] = useState('');
|
||||
const [text, setText] = useState<string>(() => {
|
||||
if (!id) return '';
|
||||
return getDraftSync(id)?.text ?? '';
|
||||
});
|
||||
const [sending, setSending] = useState(false);
|
||||
const [sendError, setSendError] = useState<string | null>(null);
|
||||
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 [infoPanelOpen, setInfoPanelOpen] = useState(false);
|
||||
const [mediaDrawerOpen, setMediaDrawerOpen] = useState(false);
|
||||
@@ -223,28 +255,36 @@ export function ConversationPage() {
|
||||
const [mentionState, setMentionState] = useState<{ query: string; start: number } | null>(null);
|
||||
const [emojiOpen, setEmojiOpen] = useState(false);
|
||||
const [gifPickerOpen, setGifPickerOpen] = useState(false);
|
||||
// Sticky toggle: when on, the next image(s) sent are marked view-once.
|
||||
// Auto-clears on a successful send so the composer doesn't accidentally
|
||||
// burn the message-after-next.
|
||||
const [viewOnceNext, setViewOnceNext] = useState(false);
|
||||
const pins = usePinnedMessages(id);
|
||||
const [actionsMenuOpen, setActionsMenuOpen] = useState(false);
|
||||
const actionsMenuAnchorRef = useRef<HTMLButtonElement>(null);
|
||||
const { pins, applyOptimisticPin, applyOptimisticUnpin, restorePins } = usePinnedMessages(id);
|
||||
const [pinnedPanelOpen, setPinnedPanelOpen] = useState(false);
|
||||
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(
|
||||
async (messageId: string) => {
|
||||
if (!id || !myId) return;
|
||||
const wasPinned = pinnedIds.has(messageId);
|
||||
const snapshot = wasPinned
|
||||
? applyOptimisticUnpin(messageId)
|
||||
: applyOptimisticPin(messageId, myId);
|
||||
try {
|
||||
if (pinnedIds.has(messageId)) {
|
||||
if (wasPinned) {
|
||||
await unpinMessage(supabase, id, messageId);
|
||||
} else {
|
||||
await pinMessage(supabase, id, messageId, myId);
|
||||
}
|
||||
} catch (err) {
|
||||
restorePins(snapshot);
|
||||
console.warn('pin toggle failed', err);
|
||||
}
|
||||
},
|
||||
[id, myId, pinnedIds],
|
||||
[id, myId, pinnedIds, applyOptimisticPin, applyOptimisticUnpin, restorePins],
|
||||
);
|
||||
|
||||
const handleGifPick = useCallback(
|
||||
@@ -263,25 +303,25 @@ 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);
|
||||
|
||||
useEffect(() => {
|
||||
setReplyTo(null);
|
||||
setForwardTarget(null);
|
||||
setSearchOpen(false);
|
||||
setMediaDrawerOpen(false);
|
||||
setPollDialogOpen(false);
|
||||
setSearchQuery('');
|
||||
setDisplayCount(150);
|
||||
setFirstUnreadId(null);
|
||||
setFirstUnreadJumpDismissed(false);
|
||||
setNewMessagesWhileAway(0);
|
||||
previousMessageIdsRef.current = new Set();
|
||||
firstUnreadComputedRef.current = false;
|
||||
}, [id]);
|
||||
if (!id) return;
|
||||
const draft = getDraftSync(id);
|
||||
const savedReplyToId = draft?.replyToId ?? null;
|
||||
if (!savedReplyToId) return;
|
||||
if (replyTo?.id === savedReplyToId) return;
|
||||
const match = messages.find((m) => m.id === savedReplyToId);
|
||||
if (match) setReplyTo(match);
|
||||
}, [id, messages, replyTo?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
setDraft(id, { text, replyToId: replyTo?.id ?? null });
|
||||
}, [id, text, replyTo?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (firstUnreadComputedRef.current) return;
|
||||
@@ -309,21 +349,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 +426,103 @@ 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]);
|
||||
|
||||
// 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 (savedPositionRef.current === null && id) {
|
||||
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);
|
||||
window.setTimeout(
|
||||
() => setHighlightedId((cur) => (cur === targetId ? null : cur)),
|
||||
1600,
|
||||
);
|
||||
},
|
||||
[messages, displayCount],
|
||||
);
|
||||
|
||||
const handleReply = useCallback((m: DecryptedMessage) => {
|
||||
setReplyTo(m);
|
||||
@@ -549,101 +673,106 @@ 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);
|
||||
// (savedPositionRef declared earlier — see TDZ note above the
|
||||
// initialTopMostIndex useMemo.)
|
||||
|
||||
// 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).
|
||||
// Initialize from the current pending count rather than 0 so we don't
|
||||
// fire scrollToIndex(LAST) on the very first render of a chat that
|
||||
// already has outbox-queued items. Only growth of `pending.length`
|
||||
// across renders should trigger the snap-to-bottom (i.e., the user
|
||||
// just submitted something new).
|
||||
const lastPendingCountRef = useRef(pending.length);
|
||||
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;
|
||||
setSending(true);
|
||||
setSendError(null);
|
||||
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('');
|
||||
setAttachments([]);
|
||||
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 = '';
|
||||
setStickToBottom(true);
|
||||
notifyStopTyping();
|
||||
if (id) clearDraft(id);
|
||||
} catch (err: unknown) {
|
||||
const code = extractErrorCode(err);
|
||||
setSendError(
|
||||
@@ -754,13 +883,15 @@ export function ConversationPage() {
|
||||
|
||||
async function ingestFiles(files: File[]) {
|
||||
const compressed = await compressImages(files);
|
||||
const next: File[] = [];
|
||||
const next: PendingAttachment[] = [];
|
||||
for (const f of compressed) {
|
||||
if (f.size > 10 * 1024 * 1024) {
|
||||
setSendError('Datei zu groß (max 10 MB)');
|
||||
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));
|
||||
}
|
||||
@@ -879,39 +1010,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 +1086,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 +1133,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>
|
||||
|
||||
@@ -1072,13 +1224,22 @@ export function ConversationPage() {
|
||||
|
||||
{attachments.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-2">
|
||||
{attachments.map((file, idx) => (
|
||||
{attachments.map((a, idx) => (
|
||||
<AttachmentPreview
|
||||
key={idx}
|
||||
file={file}
|
||||
file={a.file}
|
||||
viewOnce={a.viewOnce}
|
||||
onRemove={() => setAttachments((prev) => prev.filter((_, i) => i !== idx))}
|
||||
{...(file.type.startsWith('image/')
|
||||
? { onEdit: () => setAnnotatingIndex(idx) }
|
||||
{...(a.file.type.startsWith('image/')
|
||||
? {
|
||||
onEdit: () => setAnnotatingIndex(idx),
|
||||
onToggleViewOnce: () =>
|
||||
setAttachments((prev) =>
|
||||
prev.map((x, i) =>
|
||||
i === idx ? { ...x, viewOnce: !x.viewOnce } : x,
|
||||
),
|
||||
),
|
||||
}
|
||||
: {})}
|
||||
/>
|
||||
))}
|
||||
@@ -1117,55 +1278,32 @@ export function ConversationPage() {
|
||||
className="hidden"
|
||||
onChange={(e) => handleFilesChosen(e.target.files)}
|
||||
/>
|
||||
{/* [+] popover trigger — opens ComposerActionsMenu (file/poll/whiteboard/watch/game) */}
|
||||
<button
|
||||
ref={actionsMenuAnchorRef}
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
aria-label="Datei anhängen"
|
||||
title="Datei anhängen"
|
||||
onClick={() => setActionsMenuOpen((v) => !v)}
|
||||
aria-label={t('app:composer.more_actions', { defaultValue: 'Mehr Aktionen' })}
|
||||
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]"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
<ComposerActionsMenu
|
||||
anchorRef={actionsMenuAnchorRef}
|
||||
open={actionsMenuOpen}
|
||||
onClose={() => setActionsMenuOpen(false)}
|
||||
onAttachFile={() => fileInputRef.current?.click()}
|
||||
onCreatePoll={() => {
|
||||
setPollError(null);
|
||||
setPollDialogOpen(true);
|
||||
}}
|
||||
aria-label="Umfrage erstellen"
|
||||
title="Umfrage erstellen"
|
||||
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]"
|
||||
>
|
||||
<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>
|
||||
onCreateWhiteboard={() => void handleCreateWhiteboard()}
|
||||
onStartWatchTogether={() => setWatchDialogOpen(true)}
|
||||
onStartGame={() => setGameDialogOpen(true)}
|
||||
canStartGame={conversation?.members?.length === 2}
|
||||
/>
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
@@ -1211,20 +1349,6 @@ export function ConversationPage() {
|
||||
onPick={(gif) => void handleGifPick(gif)}
|
||||
/>
|
||||
</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
|
||||
disabled={sending}
|
||||
onComplete={async (file) => {
|
||||
@@ -1348,9 +1472,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)}
|
||||
/>
|
||||
@@ -1358,10 +1485,17 @@ export function ConversationPage() {
|
||||
{annotatingIndex !== null && attachments[annotatingIndex] && (
|
||||
<Suspense fallback={null}>
|
||||
<ImageAnnotator
|
||||
file={attachments[annotatingIndex]!}
|
||||
file={attachments[annotatingIndex]!.file}
|
||||
onCancel={() => setAnnotatingIndex(null)}
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
@@ -1718,13 +1852,18 @@ function Banner({ children }: { children: React.ReactNode }) {
|
||||
|
||||
function AttachmentPreview({
|
||||
file,
|
||||
viewOnce,
|
||||
onRemove,
|
||||
onEdit,
|
||||
onToggleViewOnce,
|
||||
}: {
|
||||
file: File;
|
||||
viewOnce: boolean;
|
||||
onRemove: () => void;
|
||||
onEdit?: () => void;
|
||||
onToggleViewOnce?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const isImage = file.type.startsWith('image/');
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
@@ -1757,6 +1896,42 @@ function AttachmentPreview({
|
||||
<PencilIcon className="h-3 w-3" />
|
||||
</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
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
@@ -1769,32 +1944,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// Web Worker — runs Argon2id pwhash + sealed user-key open off the main
|
||||
// thread. PIN-unlock used to freeze the UI for ~1-2 s on mid-hardware while
|
||||
// the moderate-preset KDF ran; pushing it here keeps the unlock screen
|
||||
// responsive.
|
||||
//
|
||||
// The worker bundles its own libsodium-wrappers-sumo instance and registers
|
||||
// it as the shared CryptoBackend inside this worker realm — there is no
|
||||
// shared state with the main thread, so we initialise once per worker and
|
||||
// re-use it across messages (the client wrapper currently spawns one-shot,
|
||||
// but the worker is safe to keep alive too).
|
||||
//
|
||||
// Message protocol (one-shot RPC):
|
||||
// request: { op: 'openUserKey', input: OpenUserKeyInput }
|
||||
// response: { ok: true, result: { privateKey: Uint8Array } }
|
||||
// | { ok: false, error: string }
|
||||
//
|
||||
// The private key bytes are transferred (zero-copy) back to the caller via
|
||||
// the structured-clone Transferable list; the worker's view of the buffer is
|
||||
// detached on transfer which also clears the only worker-side reference.
|
||||
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
import { setCryptoBackend, openUserKey } from '@chat-app/shared/crypto';
|
||||
import type { KdfParams } from '@chat-app/shared/crypto';
|
||||
import sodium from 'libsodium-wrappers-sumo';
|
||||
|
||||
import { createLibsodiumBackend } from '../lib/cryptoBackend';
|
||||
|
||||
export interface OpenUserKeyInput {
|
||||
sealed: Uint8Array;
|
||||
pin: string;
|
||||
salt: Uint8Array;
|
||||
kdfParams: KdfParams;
|
||||
}
|
||||
|
||||
export interface OpenUserKeyResult {
|
||||
privateKey: Uint8Array;
|
||||
}
|
||||
|
||||
type WorkerRequest = { op: 'openUserKey'; input: OpenUserKeyInput };
|
||||
type WorkerResponse =
|
||||
| { ok: true; result: OpenUserKeyResult }
|
||||
| { ok: false; error: string };
|
||||
|
||||
let backendReady: Promise<void> | null = null;
|
||||
|
||||
async function ensureBackend(): Promise<void> {
|
||||
if (!backendReady) {
|
||||
backendReady = (async () => {
|
||||
await sodium.ready;
|
||||
setCryptoBackend(await createLibsodiumBackend());
|
||||
})();
|
||||
}
|
||||
return backendReady;
|
||||
}
|
||||
|
||||
self.addEventListener('message', (ev: MessageEvent<WorkerRequest>) => {
|
||||
const msg = ev.data;
|
||||
void (async () => {
|
||||
try {
|
||||
if (!msg || msg.op !== 'openUserKey') {
|
||||
throw new Error('unknown op: ' + String((msg as { op?: unknown })?.op));
|
||||
}
|
||||
await ensureBackend();
|
||||
const privateKey = await openUserKey(msg.input);
|
||||
const response: WorkerResponse = { ok: true, result: { privateKey } };
|
||||
const transfers: Transferable[] = [];
|
||||
if (privateKey?.buffer instanceof ArrayBuffer) {
|
||||
transfers.push(privateKey.buffer);
|
||||
}
|
||||
(self as unknown as Worker).postMessage(response, transfers);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const response: WorkerResponse = { ok: false, error: message };
|
||||
(self as unknown as Worker).postMessage(response);
|
||||
}
|
||||
})();
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
|
||||
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
|
||||
# The following patterns were generated by expo-cli
|
||||
|
||||
expo-env.d.ts
|
||||
# @end expo-cli
|
||||
@@ -0,0 +1,703 @@
|
||||
# Chat-Switch Flicker — Fix Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Eliminate the visible flicker / scroll-jump when switching between conversations so the transition matches Discord's "instant content swap" feel.
|
||||
|
||||
**Architecture:** Two complementary fixes that, together, kill seven independent state-bleed root causes:
|
||||
|
||||
1. **Force a fresh `ConversationPage` instance per `:id` route param** via a wrapper component that reads `useParams` and passes `key={id}`. Today the same React component instance handles every conversation because React Router does not remount the route element when only a param changes — that is what lets chat A's state (messages, scroll position, composer drafts, Virtuoso scroll cache) bleed into chat B's first render.
|
||||
2. **Add a module-scoped in-memory cache** (`Map<convId, DecryptedMessage[]>`) in `useConversationMessages` so the freshly-mounted hook starts with prior data synchronously on the first render. SQLite cache still hydrates async for never-seen chats. Net effect: instant content for any previously-visited chat, no spinner-flash.
|
||||
|
||||
**Tech Stack:** React 18, React Router v6 (v7_startTransition opt-in), react-virtuoso, better-sqlite3 (Electron main-process bridge via `window.electronAPI.sql*`), Vitest.
|
||||
|
||||
---
|
||||
|
||||
## Root-Cause Findings (Phase 1 evidence)
|
||||
|
||||
| # | Symptom | File:line | Why it happens |
|
||||
|---|---------|-----------|----------------|
|
||||
| RC1 | **Ghost messages of previous chat** for 50–300 ms | `apps/desktop/src/lib/useConversationMessages.ts:85, 220-243, 249-263` | `useState<State>({messages: [], loading: true})` only runs on first mount. On `conversationId` change, the state still holds chat A's messages; `refresh()` skips `loading: true` because `prev.messages.length > 0`; the cache effect also bails (`if (prev.messages.length > 0) return prev`). Chat A's data renders under chat B's id until the network round-trip finishes. |
|
||||
| RC2 | **Scroll jumps to wrong row** on switch | `apps/desktop/src/pages/ConversationPage.tsx:1023, 1034, 470-481` | Virtuoso instance is preserved across switches (parent isn't remounted). `initialTopMostItemIndex` is only honoured on Virtuoso's first mount, so chat A's last scroll position is what Virtuoso tries to keep when chat B's rows replace chat A's. |
|
||||
| RC3 | **Saved positions corrupted across switches** | `apps/desktop/src/pages/ConversationPage.tsx:705-716` | While chat A's rows are still rendered under chat B's id (RC1 window), `rangeChanged` fires for chat A's visible range and writes the index into `scrollPositions[chatBid]`. Next time you re-enter chat B, it restores chat A's row index. |
|
||||
| RC4 | **Stale composer / reply / search / unread state** for one render | `apps/desktop/src/pages/ConversationPage.tsx:307-320` | `useEffect([id])` resets `replyTo`, `displayCount`, `firstUnreadId`, etc. — but effects fire **after** the first render of the new id. The first paint of chat B briefly shows chat A's reply preview and `displayCount`. |
|
||||
| RC5 | **Phantom scroll-to-bottom** after switching | `apps/desktop/src/pages/ConversationPage.tsx:736-746` | `lastPendingCountRef` is never reset per-chat. Switching from a chat with 0 pending to a chat with N pending triggers `pending.length > lastPendingCountRef.current` → `scrollToIndex(LAST)` even though that pending state was always there. |
|
||||
| RC6 | **`newMessagesWhileAway` briefly off** | `apps/desktop/src/pages/ConversationPage.tsx:335-346` | `previousMessageIdsRef.current` contains chat A's ids on the first render of chat B → the diff classifies every chat B message as "new while away" until the reset effect lands. |
|
||||
| RC7 | **Cache load loses race with network refresh** | `apps/desktop/src/lib/useConversationMessages.ts:249-263` | Because state still holds chat A's messages, the cache-effect bails. Then refresh writes chat B's network result. Then the now-irrelevant `loadCachedMessages(chatB)` promise resolves and would no-op (length check still > 0 by then) — but the path is fragile and depends on timing. |
|
||||
|
||||
**All of RC1, RC3, RC4, RC5, RC6, RC7 are fixed in one stroke by Task 3 (`key={id}` remount).** RC2 is fixed because Virtuoso unmounts with its parent and re-applies `initialTopMostItemIndex` on the fresh mount. The remaining flicker after a remount — the brief spinner before async cache hydration completes — is eliminated by Task 2 (in-memory cache, synchronous on first render).
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- **Create**: `apps/desktop/src/lib/messageMemoryCache.ts` — small module-scoped Map plus `get` / `set` / `has` API. Lets us unit-test the cache logic without rendering the hook.
|
||||
- **Create**: `apps/desktop/src/lib/messageMemoryCache.test.ts` — vitest unit tests for the cache.
|
||||
- **Modify**: `apps/desktop/src/lib/useConversationMessages.ts` — initialize `useState` from `messageMemoryCache`, sync to it on every state change.
|
||||
- **Modify**: `apps/desktop/src/App.tsx` — add `ConversationRoute` wrapper that reads `useParams` and renders `<ConversationPage key={id} />`.
|
||||
- **Modify**: `apps/desktop/src/pages/ConversationPage.tsx` — delete the now-redundant `useEffect([id])` reset block and update the `scrollPositions` doc comment.
|
||||
|
||||
Each task below is self-contained and can be committed independently.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: In-memory message cache helper
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/desktop/src/lib/messageMemoryCache.ts`
|
||||
- Test: `apps/desktop/src/lib/messageMemoryCache.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `apps/desktop/src/lib/messageMemoryCache.test.ts`:
|
||||
|
||||
```ts
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import type { DecryptedMessage } from '@chat-app/shared/chat';
|
||||
|
||||
import {
|
||||
__resetForTests,
|
||||
getCachedMessages,
|
||||
hasCachedMessages,
|
||||
setCachedMessages,
|
||||
} from './messageMemoryCache';
|
||||
|
||||
function msg(id: string): DecryptedMessage {
|
||||
return {
|
||||
id,
|
||||
conversationId: 'conv-1',
|
||||
senderId: 'sender-1',
|
||||
senderDeviceId: null,
|
||||
replyToId: null,
|
||||
editedAt: null,
|
||||
deletedAt: null,
|
||||
createdAt: '2026-05-17T00:00:00Z',
|
||||
ciphertext: new Uint8Array(),
|
||||
nonce: new Uint8Array(),
|
||||
keyVersion: 1,
|
||||
plaintext: 'hi ' + id,
|
||||
};
|
||||
}
|
||||
|
||||
describe('messageMemoryCache', () => {
|
||||
afterEach(() => {
|
||||
__resetForTests();
|
||||
});
|
||||
|
||||
it('returns empty array when nothing is cached', () => {
|
||||
expect(getCachedMessages('unknown')).toEqual([]);
|
||||
expect(hasCachedMessages('unknown')).toBe(false);
|
||||
});
|
||||
|
||||
it('stores and returns messages per conversation', () => {
|
||||
setCachedMessages('a', [msg('m1'), msg('m2')]);
|
||||
expect(getCachedMessages('a').map((m) => m.id)).toEqual(['m1', 'm2']);
|
||||
expect(hasCachedMessages('a')).toBe(true);
|
||||
});
|
||||
|
||||
it('isolates conversations', () => {
|
||||
setCachedMessages('a', [msg('m1')]);
|
||||
setCachedMessages('b', [msg('m9')]);
|
||||
expect(getCachedMessages('a').map((m) => m.id)).toEqual(['m1']);
|
||||
expect(getCachedMessages('b').map((m) => m.id)).toEqual(['m9']);
|
||||
});
|
||||
|
||||
it('overwrites prior cache when set again', () => {
|
||||
setCachedMessages('a', [msg('m1')]);
|
||||
setCachedMessages('a', [msg('m1'), msg('m2')]);
|
||||
expect(getCachedMessages('a').map((m) => m.id)).toEqual(['m1', 'm2']);
|
||||
});
|
||||
|
||||
it('treats an explicit empty list as "cached"', () => {
|
||||
// A conversation that genuinely has zero messages should still be
|
||||
// flagged as cached so the hook skips the loading spinner on re-entry.
|
||||
setCachedMessages('a', []);
|
||||
expect(hasCachedMessages('a')).toBe(true);
|
||||
expect(getCachedMessages('a')).toEqual([]);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pnpm --filter @chat-app/desktop test -- messageMemoryCache`
|
||||
|
||||
Expected: FAIL — module `./messageMemoryCache` does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement the helper**
|
||||
|
||||
Create `apps/desktop/src/lib/messageMemoryCache.ts`:
|
||||
|
||||
```ts
|
||||
// In-memory cache of the most-recently-rendered messages for each
|
||||
// conversation. Survives React component unmount/remount (used by
|
||||
// `useConversationMessages` to initialize state synchronously when
|
||||
// ConversationPage is remounted on chat switch). Session-scoped — lost
|
||||
// on full app reload. The SQLite cache (`messageCache.ts`) is still the
|
||||
// source of truth for cross-session persistence; this layer just shaves
|
||||
// off the round-trip-to-disk spinner flash.
|
||||
//
|
||||
// Two-tier semantics:
|
||||
// * `hasCachedMessages(id)` returns true even for a known-empty chat
|
||||
// so the hook can suppress the loading spinner on re-entry.
|
||||
// * `getCachedMessages(id)` returns a defensive copy so callers can't
|
||||
// mutate the cached array.
|
||||
|
||||
import type { DecryptedMessage } from '@chat-app/shared/chat';
|
||||
|
||||
const cache = new Map<string, DecryptedMessage[]>();
|
||||
|
||||
export function getCachedMessages(conversationId: string): DecryptedMessage[] {
|
||||
const stored = cache.get(conversationId);
|
||||
return stored ? stored.slice() : [];
|
||||
}
|
||||
|
||||
export function hasCachedMessages(conversationId: string): boolean {
|
||||
return cache.has(conversationId);
|
||||
}
|
||||
|
||||
export function setCachedMessages(
|
||||
conversationId: string,
|
||||
messages: DecryptedMessage[],
|
||||
): void {
|
||||
cache.set(conversationId, messages.slice());
|
||||
}
|
||||
|
||||
export function __resetForTests(): void {
|
||||
cache.clear();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `pnpm --filter @chat-app/desktop test -- messageMemoryCache`
|
||||
|
||||
Expected: PASS — all five test cases.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/desktop/src/lib/messageMemoryCache.ts apps/desktop/src/lib/messageMemoryCache.test.ts
|
||||
git commit -m "feat(chat-switch): in-memory message cache helper"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Wire the memory cache into `useConversationMessages`
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/desktop/src/lib/useConversationMessages.ts:85` (initial state)
|
||||
- Modify: `apps/desktop/src/lib/useConversationMessages.ts:226-243` (refresh) — sync to memory cache
|
||||
- Modify: `apps/desktop/src/lib/useConversationMessages.ts:249-263` (SQLite cache effect) — skip on memory-hit, sync after hydrate
|
||||
- Modify: `apps/desktop/src/lib/useConversationMessages.ts:339, 350, 419, 433, 553` (realtime + optimistic-send paths) — keep cache in sync with state
|
||||
|
||||
The hook keeps using `setState` everywhere — we just mirror writes into the memory cache and read from it on first render. No behavioural change for other code paths.
|
||||
|
||||
- [ ] **Step 1: Import the helper and initialize state from cache**
|
||||
|
||||
In `apps/desktop/src/lib/useConversationMessages.ts`, add the import near the other local-lib imports (around line 36):
|
||||
|
||||
```ts
|
||||
import {
|
||||
getCachedMessages,
|
||||
hasCachedMessages,
|
||||
setCachedMessages,
|
||||
} from './messageMemoryCache';
|
||||
```
|
||||
|
||||
Replace the initial `useState` at line 85:
|
||||
|
||||
```ts
|
||||
const [state, setState] = useState<State>({ messages: [], loading: true, error: null });
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```ts
|
||||
// Initialize from the in-memory cache so a previously-viewed chat shows
|
||||
// content on the very first render after the parent remounts on `:id`
|
||||
// change. `loading` stays true ONLY for never-seen conversations (cache
|
||||
// miss) so the spinner doesn't flash on every chat switch.
|
||||
const [state, setState] = useState<State>(() => {
|
||||
if (conversationId && hasCachedMessages(conversationId)) {
|
||||
return {
|
||||
messages: getCachedMessages(conversationId),
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
return { messages: [], loading: true, error: null };
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Mirror successful refreshes into the memory cache**
|
||||
|
||||
In the `refresh` function (around line 229-235), replace:
|
||||
|
||||
```ts
|
||||
const rows = await fetchConversationMessages(supabase, conversationId);
|
||||
const decrypted = await decryptBatch(rows);
|
||||
setState({ messages: decrypted, loading: false, error: null });
|
||||
// Persist the fresh batch to the local cache so next conversation
|
||||
// switch / app start can hydrate instantly. Fire-and-forget — cache
|
||||
// write failure is never user-visible.
|
||||
void persistMessages(conversationId, decrypted);
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```ts
|
||||
const rows = await fetchConversationMessages(supabase, conversationId);
|
||||
const decrypted = await decryptBatch(rows);
|
||||
setState({ messages: decrypted, loading: false, error: null });
|
||||
setCachedMessages(conversationId, decrypted);
|
||||
// Persist the fresh batch to the local cache so next conversation
|
||||
// switch / app start can hydrate instantly. Fire-and-forget — cache
|
||||
// write failure is never user-visible.
|
||||
void persistMessages(conversationId, decrypted);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Skip SQLite hydration on memory-cache hit, mirror cold-miss into memory**
|
||||
|
||||
Replace the cache-hydration effect (around line 249-263):
|
||||
|
||||
```ts
|
||||
useEffect(() => {
|
||||
if (!conversationId) return;
|
||||
let cancelled = false;
|
||||
void loadCachedMessages(conversationId).then((cached) => {
|
||||
if (cancelled || cached.length === 0) return;
|
||||
setState((prev) => {
|
||||
// Don't clobber a fresh server response that already landed.
|
||||
if (prev.messages.length > 0) return prev;
|
||||
return { messages: cached, loading: false, error: null };
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [conversationId]);
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```ts
|
||||
useEffect(() => {
|
||||
if (!conversationId) return;
|
||||
// Memory cache already populated state synchronously — skip the disk
|
||||
// round-trip entirely. The canonical data lands shortly via refresh();
|
||||
// the SQLite cache only matters for cold-start hydration.
|
||||
if (hasCachedMessages(conversationId)) return;
|
||||
let cancelled = false;
|
||||
void loadCachedMessages(conversationId).then((cached) => {
|
||||
if (cancelled || cached.length === 0) return;
|
||||
setState((prev) => {
|
||||
// Don't clobber a fresh server response that already landed.
|
||||
if (prev.messages.length > 0) return prev;
|
||||
setCachedMessages(conversationId, cached);
|
||||
return { messages: cached, loading: false, error: null };
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [conversationId]);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Mirror realtime INSERT into the memory cache**
|
||||
|
||||
In `handleInsert` (around line 339), replace:
|
||||
|
||||
```ts
|
||||
setState((prev) => {
|
||||
if (prev.messages.some((m) => m.id === decrypted!.id)) return prev;
|
||||
return { ...prev, messages: [...prev.messages, decrypted!] };
|
||||
});
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```ts
|
||||
setState((prev) => {
|
||||
if (prev.messages.some((m) => m.id === decrypted!.id)) return prev;
|
||||
const next = [...prev.messages, decrypted!];
|
||||
if (conversationId) setCachedMessages(conversationId, next);
|
||||
return { ...prev, messages: next };
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Mirror realtime UPDATE (both partial and re-decrypt paths)**
|
||||
|
||||
In `handleUpdate` partial-update path (around line 350-362), replace:
|
||||
|
||||
```ts
|
||||
setState((prev) => {
|
||||
const idx = prev.messages.findIndex((m) => m.id === partial.id);
|
||||
if (idx === -1) return prev;
|
||||
const existing = prev.messages[idx];
|
||||
if (!existing) return prev;
|
||||
const next = [...prev.messages];
|
||||
next[idx] = {
|
||||
...existing,
|
||||
editedAt: partial.editedAt,
|
||||
deletedAt: partial.deletedAt,
|
||||
};
|
||||
return { ...prev, messages: next };
|
||||
});
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```ts
|
||||
setState((prev) => {
|
||||
const idx = prev.messages.findIndex((m) => m.id === partial.id);
|
||||
if (idx === -1) return prev;
|
||||
const existing = prev.messages[idx];
|
||||
if (!existing) return prev;
|
||||
const next = [...prev.messages];
|
||||
next[idx] = {
|
||||
...existing,
|
||||
editedAt: partial.editedAt,
|
||||
deletedAt: partial.deletedAt,
|
||||
};
|
||||
if (conversationId) setCachedMessages(conversationId, next);
|
||||
return { ...prev, messages: next };
|
||||
});
|
||||
```
|
||||
|
||||
In the same function's re-decrypt path (around line 419-425), replace:
|
||||
|
||||
```ts
|
||||
setState((prev) => {
|
||||
const idx = prev.messages.findIndex((m) => m.id === decrypted!.id);
|
||||
if (idx === -1) return prev;
|
||||
const next = [...prev.messages];
|
||||
next[idx] = decrypted!;
|
||||
return { ...prev, messages: next };
|
||||
});
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```ts
|
||||
setState((prev) => {
|
||||
const idx = prev.messages.findIndex((m) => m.id === decrypted!.id);
|
||||
if (idx === -1) return prev;
|
||||
const next = [...prev.messages];
|
||||
next[idx] = decrypted!;
|
||||
if (conversationId) setCachedMessages(conversationId, next);
|
||||
return { ...prev, messages: next };
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Mirror realtime DELETE**
|
||||
|
||||
Replace `handleDelete` (around line 431-438):
|
||||
|
||||
```ts
|
||||
const handleDelete = useCallback((row: Record<string, unknown>) => {
|
||||
const id = String(row.id);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
messages: prev.messages.filter((m) => m.id !== id),
|
||||
}));
|
||||
void deleteCachedMessage(id);
|
||||
}, []);
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```ts
|
||||
const handleDelete = useCallback(
|
||||
(row: Record<string, unknown>) => {
|
||||
const id = String(row.id);
|
||||
setState((prev) => {
|
||||
const next = prev.messages.filter((m) => m.id !== id);
|
||||
if (conversationId) setCachedMessages(conversationId, next);
|
||||
return { ...prev, messages: next };
|
||||
});
|
||||
void deleteCachedMessage(id);
|
||||
},
|
||||
[conversationId],
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Mirror optimistic send (sendText)**
|
||||
|
||||
In `sendText` (around line 553-562), replace:
|
||||
|
||||
```ts
|
||||
setState((prev) => {
|
||||
if (prev.messages.some((m) => m.id === msg.id)) return prev;
|
||||
return {
|
||||
...prev,
|
||||
messages: [
|
||||
...prev.messages,
|
||||
{ ...msg, plaintext: text } as DecryptedMessage,
|
||||
],
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```ts
|
||||
setState((prev) => {
|
||||
if (prev.messages.some((m) => m.id === msg.id)) return prev;
|
||||
const next = [
|
||||
...prev.messages,
|
||||
{ ...msg, plaintext: text } as DecryptedMessage,
|
||||
];
|
||||
setCachedMessages(convId, next);
|
||||
return { ...prev, messages: next };
|
||||
});
|
||||
```
|
||||
|
||||
(`convId` is already a parameter of `sendText` — no extra capture needed.)
|
||||
|
||||
- [ ] **Step 8: Run all desktop tests to verify nothing regressed**
|
||||
|
||||
Run: `pnpm --filter @chat-app/desktop test`
|
||||
|
||||
Expected: PASS — existing tests still green, the new `messageMemoryCache` tests still pass.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/desktop/src/lib/useConversationMessages.ts
|
||||
git commit -m "feat(chat-switch): hydrate useConversationMessages from in-memory cache"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Force fresh `ConversationPage` mount per `:id`
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/desktop/src/App.tsx:2` (import `useParams`)
|
||||
- Modify: `apps/desktop/src/App.tsx:55-61` area (add wrapper)
|
||||
- Modify: `apps/desktop/src/App.tsx:113-120` (the `:id` route)
|
||||
|
||||
- [ ] **Step 1: Add `useParams` to the router import**
|
||||
|
||||
Change line 2:
|
||||
|
||||
```ts
|
||||
import { HashRouter, Navigate, Outlet, Route, Routes } from 'react-router-dom';
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```ts
|
||||
import { HashRouter, Navigate, Outlet, Route, Routes, useParams } from 'react-router-dom';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the wrapper component**
|
||||
|
||||
Below the `RouteBoundary` function (around line 61), add:
|
||||
|
||||
```tsx
|
||||
// Forces a fresh `ConversationPage` instance per `:id` so React unmounts
|
||||
// the previous conversation entirely on switch. Without this, the same
|
||||
// component instance handles every conversation, which leaks state
|
||||
// between chats (messages, scroll position, composer drafts) for one
|
||||
// render frame and gives the "flicker" we're trying to remove.
|
||||
// `useConversationMessages` re-hydrates from `messageMemoryCache` on the
|
||||
// fresh mount so previously-visited chats still render instantly.
|
||||
function ConversationRoute() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
return <ConversationPage key={id ?? '__no_id__'} />;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Use the wrapper in the route definition**
|
||||
|
||||
Replace lines 113-120:
|
||||
|
||||
```tsx
|
||||
<Route
|
||||
path=":id"
|
||||
element={
|
||||
<ErrorBoundary scope="conversation">
|
||||
<ConversationPage />
|
||||
</ErrorBoundary>
|
||||
}
|
||||
/>
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```tsx
|
||||
<Route
|
||||
path=":id"
|
||||
element={
|
||||
<ErrorBoundary scope="conversation">
|
||||
<ConversationRoute />
|
||||
</ErrorBoundary>
|
||||
}
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Typecheck**
|
||||
|
||||
Run: `pnpm --filter @chat-app/desktop typecheck`
|
||||
|
||||
Expected: PASS — no type errors.
|
||||
|
||||
- [ ] **Step 5: Manual smoke test in dev**
|
||||
|
||||
Run: `pnpm desktop:dev`
|
||||
|
||||
In the app:
|
||||
1. Open two conversations with cached messages.
|
||||
2. Toggle between them rapidly (5+ switches).
|
||||
3. Verify: no "ghost" of the previous chat's last message ever appears, and the spinner does **not** flash on switch.
|
||||
4. Scroll chat A up by ~10 messages, switch to B, switch back to A. The Virtuoso list lands at the same scroll row, not the bottom and not at row 0.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/desktop/src/App.tsx
|
||||
git commit -m "fix(chat-switch): remount ConversationPage per conversation id"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Drop redundant id-change reset effect & update doc comment
|
||||
|
||||
Because the parent is remounted per id (Task 3), every state in ConversationPage is already fresh on chat switch. The manual reset effect and related comments are now misleading dead weight.
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/desktop/src/pages/ConversationPage.tsx:97-110` — update `scrollPositions` doc comment
|
||||
- Modify: `apps/desktop/src/pages/ConversationPage.tsx:307-320` — delete the reset effect
|
||||
|
||||
- [ ] **Step 1: Update the `scrollPositions` doc comment**
|
||||
|
||||
Replace lines 97-110:
|
||||
|
||||
```ts
|
||||
// Per-conversation scroll memory. Module-scoped so it survives re-mounts
|
||||
// of ConversationPage when the route param (`id`) changes — switching
|
||||
// chats unmounts/remounts the page in our router setup. Session-only
|
||||
// (lost on reload, like Discord). The `stickToBottom` flag is preserved
|
||||
// alongside the topmost-visible row index so a chat the user left at the
|
||||
// bottom keeps auto-following new messages when they return; a chat
|
||||
// scrolled up returns to roughly the same row the user was reading.
|
||||
//
|
||||
// We track the topmost-visible row index rather than a pixel `scrollTop`
|
||||
// because `react-virtuoso` virtualizes the list — the underlying scroll
|
||||
// element's pixel offset depends on dynamically-measured row heights and
|
||||
// is not stable across re-mounts. Using a row index restores the user's
|
||||
// reading position even if some rows above re-render at different heights.
|
||||
const scrollPositions = new Map<string, { topmostIndex: number; stickToBottom: boolean }>();
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```ts
|
||||
// Per-conversation scroll memory. Module-scoped so it survives the
|
||||
// per-id remount of ConversationPage (see `ConversationRoute` in
|
||||
// App.tsx). Session-only (lost on reload, like Discord). The
|
||||
// `stickToBottom` flag is preserved alongside the topmost-visible row
|
||||
// index so a chat the user left at the bottom keeps auto-following new
|
||||
// messages when they return; a chat scrolled up returns to roughly the
|
||||
// same row the user was reading.
|
||||
//
|
||||
// We track the topmost-visible row index rather than a pixel `scrollTop`
|
||||
// because `react-virtuoso` virtualizes the list — the underlying scroll
|
||||
// element's pixel offset depends on dynamically-measured row heights and
|
||||
// is not stable across remounts. Using a row index restores the user's
|
||||
// reading position even if some rows above re-render at different heights.
|
||||
const scrollPositions = new Map<string, { topmostIndex: number; stickToBottom: boolean }>();
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Delete the manual reset effect**
|
||||
|
||||
Delete lines 307-320 entirely (the effect that resets `replyTo`, `forwardTarget`, `searchOpen`, `mediaDrawerOpen`, `pollDialogOpen`, `searchQuery`, `displayCount`, `firstUnreadId`, `firstUnreadJumpDismissed`, `newMessagesWhileAway`, `previousMessageIdsRef`, `firstUnreadComputedRef`):
|
||||
|
||||
```ts
|
||||
useEffect(() => {
|
||||
setReplyTo(null);
|
||||
setForwardTarget(null);
|
||||
setSearchOpen(false);
|
||||
setMediaDrawerOpen(false);
|
||||
setPollDialogOpen(false);
|
||||
setSearchQuery('');
|
||||
setDisplayCount(150);
|
||||
setFirstUnreadId(null);
|
||||
setFirstUnreadJumpDismissed(false);
|
||||
setNewMessagesWhileAway(0);
|
||||
previousMessageIdsRef.current = new Set();
|
||||
firstUnreadComputedRef.current = false;
|
||||
}, [id]);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Typecheck + tests**
|
||||
|
||||
Run in parallel:
|
||||
|
||||
```bash
|
||||
pnpm --filter @chat-app/desktop typecheck
|
||||
pnpm --filter @chat-app/desktop test
|
||||
```
|
||||
|
||||
Expected: both PASS.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/desktop/src/pages/ConversationPage.tsx
|
||||
git commit -m "refactor(chat-switch): drop redundant id-change reset effect"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Final QA in dev mode
|
||||
|
||||
Verification only — no code changes, no commit.
|
||||
|
||||
- [ ] **Step 1: Start dev**
|
||||
|
||||
Run: `pnpm desktop:dev`
|
||||
|
||||
- [ ] **Step 2: Confirm each fix landed**
|
||||
|
||||
Switch repeatedly between three chats (A, B, C). All of the following must hold:
|
||||
|
||||
| Behaviour | Pass criteria |
|
||||
|-----------|---------------|
|
||||
| Ghost messages | Never see chat A's messages under chat B's header. |
|
||||
| Spinner flash | First visit to a chat = spinner OK. Subsequent visits = no spinner. |
|
||||
| Scroll restore | Chat A scrolled up 10 rows → switch to B → back to A → lands at row 10 ±1. |
|
||||
| Composer state | Type "draft" in chat A composer → switch to B → composer is empty (drafts intentionally don't persist). |
|
||||
| Reply preview | Click reply on chat A → switch to B → no reply preview in B. |
|
||||
| Pending bubbles | Send while offline → bubble shows in correct chat → switch away and back → bubble still in same chat. |
|
||||
| Realtime updates | Send a message in chat A from another device → it lands in chat A list → switch to B → switch back to A → message is still there (verifies memory cache stays in sync with realtime inserts). |
|
||||
|
||||
- [ ] **Step 3: If any check fails**
|
||||
|
||||
Open `superpowers:systematic-debugging` and form a single new hypothesis per failing check. Do NOT layer fixes — return to Phase 1, gather evidence, then patch.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (post-write checklist)
|
||||
|
||||
**Spec coverage**: Each RC1–RC7 is addressed:
|
||||
- RC1 (state bleed) → Task 3 (key-based remount) + Task 2 (memory cache so the fresh mount is instant).
|
||||
- RC2 (Virtuoso scroll) → Task 3 (Virtuoso unmounts with parent, `initialTopMostItemIndex` re-applied on fresh mount).
|
||||
- RC3 (scrollPositions corruption) → Task 3 (no more cross-chat render under wrong id).
|
||||
- RC4 (stale local state for one render) → Task 3 + Task 4 (cleanup).
|
||||
- RC5 (phantom scroll-to-bottom) → Task 3 (fresh ref).
|
||||
- RC6 (newMessagesWhileAway misfire) → Task 3 (fresh ref).
|
||||
- RC7 (cache vs network race) → Task 2 (deterministic init from memory cache, SQLite hydration only on cold cache miss).
|
||||
|
||||
**Placeholders**: none — every step lists exact files, exact code, exact commands.
|
||||
|
||||
**Type consistency**: `ConversationRoute` is the only new component; `messageMemoryCache` exports (`getCachedMessages`, `hasCachedMessages`, `setCachedMessages`, `__resetForTests`) match the test imports exactly.
|
||||
|
||||
---
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Cross-fade animation between chats.** A small `view-transition` or opacity tween could further polish the swap, but the user reported jumpiness, not the absence of an animation. Tackle in a follow-up if it still feels too "snap-y" after this lands.
|
||||
- **Persisting composer drafts per chat.** Today every chat-switch loses the in-progress draft. The remount in Task 3 *preserves* that behaviour deliberately (no regression). A draft-persistence feature is a separate spec.
|
||||
- **Mobile (`apps/mobile`).** The mobile chat list uses a different virtualization stack; this plan only covers `apps/desktop`.
|
||||
@@ -0,0 +1,138 @@
|
||||
# Phase 7 — Composer Redesign (Hybrid)
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
|
||||
|
||||
**Goal:** Reduce composer toolbar from 9 cluttered icons to 5 hierarchically-organized buttons. Move "creative activities" (Whiteboard, Watch-Together, Mini-Games) into a `+` popover. Move View-Once from global composer toggle to per-attachment flag in the upload preview.
|
||||
|
||||
**Rollback anchor:** tag `pre-phase7-composer` (set in T1).
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Rollback anchor
|
||||
|
||||
- [ ] Run:
|
||||
```bash
|
||||
cd "D:\Programmieren\ChatApp-Electron\chat-app"
|
||||
git tag -a pre-phase7-composer -m "Rollback anchor before Phase 7 composer redesign"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `<ComposerActionsMenu>` popover component
|
||||
|
||||
**Files:** Create `apps/desktop/src/components/ComposerActionsMenu.tsx`
|
||||
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
interface Props {
|
||||
anchorRef: React.RefObject<HTMLButtonElement | null>;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onAttachFile: () => void;
|
||||
onCreatePoll: () => void;
|
||||
onCreateWhiteboard: () => void;
|
||||
onStartWatchTogether: () => void;
|
||||
onStartGame: () => void;
|
||||
canStartGame?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
Layout (floating panel anchored above `anchorRef`):
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ 📎 Bild / Datei │
|
||||
│ 📊 Umfrage │
|
||||
├─────────────────────────────┤
|
||||
│ AKTIVITÄTEN │
|
||||
│ ✏ Whiteboard │
|
||||
│ 📺 Watch Together │
|
||||
│ 🎮 Spiel starten │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
- Use existing icons from `apps/desktop/src/components/icons.tsx` (grep for `PaperclipIcon`/`PlusIcon`, `PollIcon`, `MonitorShareIcon`, `PlayBoxIcon`, `GameIcon`).
|
||||
- Click outside or `Esc` → `onClose`.
|
||||
- Disabled items: `opacity-50 cursor-not-allowed` + `title` hint (e.g. "Spiele nur in 1:1-Chats").
|
||||
- Each row ≥ 44px tall, `role="menu"`/`role="menuitem"`, arrow-up/down keyboard nav.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Refactor ConversationPage composer
|
||||
|
||||
**Files:** Modify `apps/desktop/src/pages/ConversationPage.tsx`
|
||||
|
||||
**Target layout:**
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────┐
|
||||
│ [+] [😊] [GIF] [🎤] Nachricht schreiben… [→] │
|
||||
└────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Changes:
|
||||
1. **Remove** inline buttons for: file-attach, poll, whiteboard, watch-together, game-picker.
|
||||
2. **Add** a `+` button at position 1 with a `useRef` anchor.
|
||||
3. **State:** `const [menuOpen, setMenuOpen] = useState(false);` + render `<ComposerActionsMenu>` with the existing handlers wired (`handleCreateWhiteboard`, `handleStartWatchTogether`, `handleStartGame`, `() => setPollDialogOpen(true)`, `() => fileInputRef.current?.click()`).
|
||||
4. **Remove** the standalone View-Once toggle button (moves to T4 per-attachment).
|
||||
5. **Keep inline:** Emoji picker, GIF picker, voice mic, send arrow.
|
||||
6. **Auto-close menu** after any item action.
|
||||
7. Pass `canStartGame={conversation?.members?.length === 2}` so the dropdown reflects the DM-only constraint.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: View-Once per-attachment in `AttachmentPreview`
|
||||
|
||||
**Files:**
|
||||
- Modify `apps/desktop/src/pages/ConversationPage.tsx` (`AttachmentPreview` component + the `attachments[]` state shape).
|
||||
- Modify `apps/desktop/src/hooks/useConversationMessages.ts` (`send()` signature + per-attachment handling).
|
||||
- Possibly extend the per-attachment encrypt/upload helper if it still treats `viewOnce` as a per-message flag.
|
||||
|
||||
**Behavior:**
|
||||
|
||||
Add a third hover-button on each image preview next to `✏` and `✕`: a `👁` icon that toggles `viewOnce` per attachment.
|
||||
- Active: icon switches (e.g. crossed-eye) + small `1×` badge in lower-right corner of the thumb.
|
||||
- Image-only (`file.type.startsWith('image/')`). Hidden on non-image previews.
|
||||
|
||||
**State refactor:**
|
||||
|
||||
Change `attachments: File[]` → `attachments: Array<{ file: File; viewOnce: boolean }>`. Every consumer site updated:
|
||||
- `setAttachments((prev) => [...prev, ...newOnes.map((f) => ({ file: f, viewOnce: false }))])`
|
||||
- `attachments.map((a, idx) => <AttachmentPreview file={a.file} ... onToggleViewOnce={() => setAttachments(prev => prev.map((x, i) => i === idx ? { ...x, viewOnce: !x.viewOnce } : x))} />)`
|
||||
- `setAttachments((prev) => prev.filter((_, i) => i !== idx))` — unchanged shape
|
||||
|
||||
**Send path:**
|
||||
|
||||
The `send()` currently accepts a `viewOnce` option that applies globally. Refactor so the per-attachment flag flows through:
|
||||
- Either change `send(payload, attachments, replyTo, { viewOnce })` → `send(payload, attachmentsWithFlags, replyTo)` where each entry carries its own `viewOnce`
|
||||
- OR pass a parallel `viewOnceFlags: boolean[]` array aligned with attachments
|
||||
|
||||
The encrypt/upload helper already supports per-attachment `view_once` (P2.T14 column `message_attachments.view_once`). The renderer just needs to pass the right flag per row.
|
||||
|
||||
**Grep first** to find the existing wiring: `Grep -rn "view_once\|viewOnce" apps/desktop/src/ packages/shared/src/chat/` — adapt to what's actually there.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Cleanup + Final gate
|
||||
|
||||
- [ ] `pnpm --filter @chat-app/desktop typecheck` — green
|
||||
- [ ] `pnpm --filter @chat-app/shared test -- --run` — green (71 tests)
|
||||
- [ ] `pnpm --filter @chat-app/desktop test -- --run` — green
|
||||
- [ ] `git status` — clean
|
||||
- [ ] Tag `phase7-done`
|
||||
- [ ] Report smoke-test points:
|
||||
1. Composer shows 5 inline buttons (was 9)
|
||||
2. Click `+` → popover opens; click anywhere outside or `Esc` closes it
|
||||
3. Attach image → preview shows ✏/👁/✕ on hover
|
||||
4. Toggle 👁 on attachment-1 only → recipient sees attachment-1 as view-once, attachment-2 normally
|
||||
5. Everything else unchanged (emoji, GIF, voice, send, edit-message, etc.)
|
||||
|
||||
---
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No emoji-as-icon (uses existing SVG icons).
|
||||
- No slash-commands (deferred to potential Phase 7B).
|
||||
- No reordering of inline buttons beyond the spec.
|
||||
- No per-attachment poll-attach (polls remain message-level).
|
||||
File diff suppressed because it is too large
Load Diff
@@ -35,6 +35,14 @@ export interface AttachmentHandle {
|
||||
/** UUID of the first non-sender who viewed. Populated alongside `viewedAt`
|
||||
* by the mark-viewed RPC so the renderer can render attribution. */
|
||||
viewedBy?: string | null;
|
||||
/** Storage path of the encrypted WebP preview thumb (max 320×320). The
|
||||
* thumb shares the per-attachment symmetric key with the full blob but
|
||||
* uses its own nonce. Absent on pre-Phase-6B messages — the receiver
|
||||
* falls through to downloading the full blob in that case. */
|
||||
thumbStoragePath?: string;
|
||||
/** Base-64 nonce that decrypts `<id>-thumb.bin`. Always present iff
|
||||
* `thumbStoragePath` is set. */
|
||||
thumbNonceB64?: string;
|
||||
}
|
||||
|
||||
export type CallEventStatus = 'ended' | 'missed' | 'declined';
|
||||
@@ -241,6 +249,13 @@ export interface EncryptedAttachmentResult {
|
||||
// Encrypt + upload a blob. Does NOT insert the message_attachments row —
|
||||
// the caller combines this with a message insert so everything commits
|
||||
// atomically at the application layer.
|
||||
//
|
||||
// When `thumbBlob` is supplied (Phase 6B image-thumbnail path) a second
|
||||
// ciphertext is uploaded to `<conversationId>/<id>-thumb.bin` encrypted
|
||||
// with the SAME per-attachment key + a fresh nonce. The handle's
|
||||
// `thumbStoragePath` / `thumbNonceB64` get populated so the receiver
|
||||
// can prefer the thumb for inline preview. Thumb-upload failure is
|
||||
// non-fatal — we log and fall through so the full image still posts.
|
||||
export async function encryptAndUploadAttachment(params: {
|
||||
client: AppSupabaseClient;
|
||||
conversationId: string;
|
||||
@@ -249,6 +264,7 @@ export async function encryptAndUploadAttachment(params: {
|
||||
sizeBytes: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
thumbBlob?: Blob | null;
|
||||
}): Promise<EncryptedAttachmentResult> {
|
||||
const backend = getCryptoBackend();
|
||||
|
||||
@@ -268,6 +284,35 @@ export async function encryptAndUploadAttachment(params: {
|
||||
});
|
||||
if (error) throw error;
|
||||
|
||||
let thumbStoragePath: string | undefined;
|
||||
let thumbNonceB64: string | undefined;
|
||||
if (params.thumbBlob) {
|
||||
try {
|
||||
const thumbBytes = new Uint8Array(await params.thumbBlob.arrayBuffer());
|
||||
const thumbNonce = backend.randomBytes(backend.secretboxNonceLength);
|
||||
const thumbCipher = backend.secretbox(thumbBytes, thumbNonce, key);
|
||||
const thumbPath = params.conversationId + '/' + id + '-thumb.bin';
|
||||
const { error: thumbErr } = await params.client.storage
|
||||
.from(ATTACHMENT_BUCKET)
|
||||
.upload(thumbPath, thumbCipher, {
|
||||
contentType: 'application/octet-stream',
|
||||
upsert: false,
|
||||
});
|
||||
if (thumbErr) {
|
||||
// Non-fatal: log and continue with full-only handle. Receiver will
|
||||
// fall back to fetching the full blob.
|
||||
console.warn('thumb upload failed', thumbErr);
|
||||
} else {
|
||||
thumbStoragePath = thumbPath;
|
||||
thumbNonceB64 = await toBase64(thumbNonce);
|
||||
}
|
||||
// Wipe nonce buffer.
|
||||
for (let i = 0; i < thumbNonce.length; i++) thumbNonce[i] = 0;
|
||||
} catch (err: unknown) {
|
||||
console.warn('thumb encrypt/upload failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
const handle: AttachmentHandle = {
|
||||
id,
|
||||
storagePath,
|
||||
@@ -277,6 +322,8 @@ export async function encryptAndUploadAttachment(params: {
|
||||
...(params.height !== undefined ? { height: params.height } : {}),
|
||||
keyB64: await toBase64(key),
|
||||
nonceB64: await toBase64(nonce),
|
||||
...(thumbStoragePath !== undefined ? { thumbStoragePath } : {}),
|
||||
...(thumbNonceB64 !== undefined ? { thumbNonceB64 } : {}),
|
||||
};
|
||||
|
||||
return { handle, key, nonce };
|
||||
@@ -309,6 +356,51 @@ export async function downloadAndDecryptAttachment(params: {
|
||||
return new Blob([copy.buffer], { type: params.handle.mimeType });
|
||||
}
|
||||
|
||||
// Download + decrypt the small WebP preview thumb that the sender uploaded
|
||||
// alongside an image attachment (Phase 6B optimisation). Returns `null` if
|
||||
// the handle has no thumb metadata (pre-Phase-6B message) or if the thumb
|
||||
// blob is missing from storage — caller falls back to the full image.
|
||||
//
|
||||
// We deliberately swallow ANY download error (missing object, transient
|
||||
// 5xx) so the receiver gracefully degrades to the full-blob path; only a
|
||||
// successful decrypt-failure throws, since that signals a real corruption.
|
||||
export async function downloadAndDecryptAttachmentThumb(params: {
|
||||
client: AppSupabaseClient;
|
||||
handle: AttachmentHandle;
|
||||
}): Promise<Blob | null> {
|
||||
if (!params.handle.thumbStoragePath || !params.handle.thumbNonceB64) {
|
||||
return null;
|
||||
}
|
||||
const backend = getCryptoBackend();
|
||||
|
||||
let data: Blob | null = null;
|
||||
try {
|
||||
const res = await params.client.storage
|
||||
.from(ATTACHMENT_BUCKET)
|
||||
.download(params.handle.thumbStoragePath);
|
||||
if (res.error) {
|
||||
// Likely 404 — sender failed to upload thumb, or it's been GC'd.
|
||||
// Receiver falls back to full image.
|
||||
return null;
|
||||
}
|
||||
data = res.data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!data) return null;
|
||||
|
||||
const ciphertext = new Uint8Array(await data.arrayBuffer());
|
||||
const key = await fromBase64(params.handle.keyB64);
|
||||
const nonce = await fromBase64(params.handle.thumbNonceB64);
|
||||
const plainBytes = backend.secretboxOpen(ciphertext, nonce, key);
|
||||
for (let i = 0; i < key.length; i++) key[i] = 0;
|
||||
for (let i = 0; i < nonce.length; i++) nonce[i] = 0;
|
||||
const copy = new Uint8Array(plainBytes.byteLength);
|
||||
copy.set(plainBytes);
|
||||
// Thumbs are always image/webp regardless of original mime.
|
||||
return new Blob([copy.buffer], { type: 'image/webp' });
|
||||
}
|
||||
|
||||
// Insert the public metadata row for an attachment. The ciphertext itself has
|
||||
// already been uploaded to storage under `handle.storagePath`.
|
||||
export async function insertAttachmentRow(
|
||||
|
||||
@@ -57,7 +57,7 @@ export async function migrateOwnLegacyBundles(params: MigrateParams): Promise<Mi
|
||||
attempted: 0, noStrongholdKey: 0, decryptFailed: 0, rpcFailed: 0,
|
||||
};
|
||||
if (params.ownLegacyDeviceIds.length === 0) {
|
||||
console.info('[crypto-migration] no legacy device-ids to consider — skipping');
|
||||
console.debug('[crypto-migration] no legacy device-ids to consider — skipping');
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ export async function migrateOwnLegacyBundles(params: MigrateParams): Promise<Mi
|
||||
.not('recipient_device_id', 'is', null);
|
||||
if (error) throw error;
|
||||
const rows = (rowsRaw ?? []) as LegacyRow[];
|
||||
console.info('[crypto-migration] legacy rows visible to me: ' + rows.length);
|
||||
console.debug('[crypto-migration] legacy rows visible to me: ' + rows.length);
|
||||
if (rows.length === 0) return result;
|
||||
|
||||
const senderDeviceIds = Array.from(new Set(rows.map((r) => r.sender_device_id).filter(Boolean)));
|
||||
@@ -136,13 +136,26 @@ export async function migrateOwnLegacyBundles(params: MigrateParams): Promise<Mi
|
||||
result.migratedConversations += 1;
|
||||
}
|
||||
|
||||
console.info(
|
||||
'[crypto-migration] result:',
|
||||
'attempted=' + result.attempted,
|
||||
'migrated=' + result.migratedConversations,
|
||||
'noKey=' + result.noStrongholdKey,
|
||||
'decryptFail=' + result.decryptFailed,
|
||||
'rpcFail=' + result.rpcFailed,
|
||||
);
|
||||
// If anything was actually migrated this run, leave it as console.info
|
||||
// so it's visible in default consoles. If we only re-failed on already-
|
||||
// unrecoverable rows (no local stronghold key), demote to debug — the
|
||||
// migration is idempotent but the noisy "noKey=N" line scared the user
|
||||
// who thought migration was already done.
|
||||
if (result.migratedConversations > 0 || result.decryptFailed > 0 || result.rpcFailed > 0) {
|
||||
console.info(
|
||||
'[crypto-migration] result:',
|
||||
'attempted=' + result.attempted,
|
||||
'migrated=' + result.migratedConversations,
|
||||
'noKey=' + result.noStrongholdKey,
|
||||
'decryptFail=' + result.decryptFailed,
|
||||
'rpcFail=' + result.rpcFailed,
|
||||
);
|
||||
} else {
|
||||
console.debug(
|
||||
'[crypto-migration] result (all unrecoverable, expected on stale clients):',
|
||||
'attempted=' + result.attempted,
|
||||
'noKey=' + result.noStrongholdKey,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -53,8 +53,6 @@
|
||||
"archive": "Archivieren",
|
||||
"unarchive": "Entarchivieren",
|
||||
"archived_title": "Archiv",
|
||||
"show_archived": "Archiv anzeigen",
|
||||
"show_active": "Aktive anzeigen",
|
||||
"archived_empty_title": "Nichts archiviert",
|
||||
"archived_empty_subtitle": "Archivierte Unterhaltungen erscheinen hier.",
|
||||
"mute": "Stummschalten",
|
||||
@@ -84,8 +82,6 @@
|
||||
"join": "Beitreten",
|
||||
"in_call": "Im Anruf",
|
||||
"waiting_for_peers": "Warte auf andere…",
|
||||
"voice_connected": "Sprachchat verbunden",
|
||||
"still_live": "Anruf läuft noch",
|
||||
"share_screen": "Bildschirm teilen",
|
||||
"stop_share_screen": "Screen-Share stoppen",
|
||||
"is_sharing_screen": "{{name}} teilt den Bildschirm",
|
||||
@@ -133,11 +129,9 @@
|
||||
"action_unfriend": "Entfernen",
|
||||
"action_accept": "Annehmen",
|
||||
"action_decline": "Ablehnen",
|
||||
"action_cancel": "Abbrechen",
|
||||
"confirm_unfriend": "Diesen Freund entfernen?"
|
||||
"action_cancel": "Abbrechen"
|
||||
},
|
||||
"admin": {
|
||||
"nav": "Admin",
|
||||
"title": "Admin-Panel",
|
||||
"settings_title": "Globale Einstellungen",
|
||||
"invites_enabled": "Neue Registrierungen erlauben",
|
||||
@@ -188,15 +182,11 @@
|
||||
"screen_share_quality": "Qualität",
|
||||
"screen_share_hint": "WebRTC passt Bitrate + Auflösung dynamisch an die Netzwerkqualität an (SVC/VP9). Die Werte sind Obergrenzen. Änderungen greifen beim nächsten Call.",
|
||||
"language": "Sprache",
|
||||
"presence": "Status",
|
||||
"show_read_receipts": "Lesebestätigungen anzeigen",
|
||||
"show_read_receipts_hint": "Wenn aus, sehen andere nicht wann du ihre Nachrichten gelesen hast — und du siehst nicht wann sie deine gelesen haben.",
|
||||
"allow_dms_strangers": "DMs von Fremden erlauben",
|
||||
"allow_dms_strangers_hint": "Wenn aus, können dich nur Freunde direkt anschreiben.",
|
||||
"this_device": "Dieses Gerät",
|
||||
"danger_zone": "Gefahrenzone",
|
||||
"sign_out": "Abmelden",
|
||||
"section_ringtone": "Klingelton",
|
||||
"ringtone_incoming": "Eingehender Anruf",
|
||||
"ringtone_default_active": "Standard-Klingelton (Doppelton)",
|
||||
"ringtone_custom_active": "{{name}} · {{size}} MB",
|
||||
|
||||
@@ -50,28 +50,8 @@
|
||||
"footer_studio": "Supabase Studio",
|
||||
"legal_note": "Mit der Registrierung akzeptierst du, dass der Server nur Chiffretext sieht.",
|
||||
"signed_in": {
|
||||
"title": "Angemeldet",
|
||||
"session_active": "Sitzung aktiv",
|
||||
"user_id": "Benutzer-ID",
|
||||
"email": "E-Mail",
|
||||
"username": "Benutzername",
|
||||
"display_name": "Anzeigename",
|
||||
"admin": "Admin",
|
||||
"yes": "Ja",
|
||||
"no": "Nein",
|
||||
"sign_out": "Abmelden",
|
||||
"device_active": "Aktives Gerät",
|
||||
"device_platform": "Plattform",
|
||||
"device_registered_at": "Registriert"
|
||||
},
|
||||
"device": {
|
||||
"title": "Dieses Gerät registrieren",
|
||||
"subtitle": "Erzeugt ein X25519-Schlüsselpaar. Der private Schlüssel bleibt auf diesem Gerät.",
|
||||
"name_label": "Gerätename",
|
||||
"name_hint": "Erscheint in deiner Geräteliste. Wähle einen erkennbaren Namen.",
|
||||
"name_placeholder": "Dennis Laptop",
|
||||
"cta": "Gerät registrieren",
|
||||
"cta_loading": "Schlüsselpaar wird erzeugt…",
|
||||
"security_note_dev": "Dev-Build: Privater Schlüssel liegt unverschlüsselt im localStorage. Stronghold folgt vor dem Release."
|
||||
"display_name": "Anzeigename"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
{
|
||||
"app_name": "ChatApp",
|
||||
"loading": "Lädt…",
|
||||
"finalising_session": "Sitzung wird abgeschlossen…",
|
||||
"cancel": "Abbrechen",
|
||||
"save": "Speichern",
|
||||
"close": "Schließen",
|
||||
"retry": "Wiederholen",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"idle": "Abwesend",
|
||||
"dnd": "Nicht stören",
|
||||
"invisible": "Unsichtbar",
|
||||
"local_stack_online": "Lokaler Stack online",
|
||||
"dev_build": "Dev-Build"
|
||||
}
|
||||
|
||||
@@ -53,8 +53,6 @@
|
||||
"archive": "Archive",
|
||||
"unarchive": "Unarchive",
|
||||
"archived_title": "Archive",
|
||||
"show_archived": "Show archive",
|
||||
"show_active": "Show active",
|
||||
"archived_empty_title": "Nothing archived",
|
||||
"archived_empty_subtitle": "Archived conversations appear here.",
|
||||
"mute": "Mute",
|
||||
@@ -84,8 +82,6 @@
|
||||
"join": "Join",
|
||||
"in_call": "In call",
|
||||
"waiting_for_peers": "Waiting for others…",
|
||||
"voice_connected": "Voice connected",
|
||||
"still_live": "Call still live",
|
||||
"share_screen": "Share screen",
|
||||
"stop_share_screen": "Stop sharing",
|
||||
"is_sharing_screen": "{{name}} is sharing their screen",
|
||||
@@ -133,11 +129,9 @@
|
||||
"action_unfriend": "Unfriend",
|
||||
"action_accept": "Accept",
|
||||
"action_decline": "Decline",
|
||||
"action_cancel": "Cancel",
|
||||
"confirm_unfriend": "Remove this friend?"
|
||||
"action_cancel": "Cancel"
|
||||
},
|
||||
"admin": {
|
||||
"nav": "Admin",
|
||||
"title": "Admin panel",
|
||||
"settings_title": "Global settings",
|
||||
"invites_enabled": "Allow new signups",
|
||||
@@ -188,15 +182,11 @@
|
||||
"screen_share_quality": "Quality",
|
||||
"screen_share_hint": "WebRTC dynamically adjusts bitrate + resolution to match network conditions (SVC/VP9). Values are upper bounds. Changes apply on the next call.",
|
||||
"language": "Language",
|
||||
"presence": "Presence",
|
||||
"show_read_receipts": "Show read receipts",
|
||||
"show_read_receipts_hint": "When off, others can't see when you read their messages — and you won't see when they read yours.",
|
||||
"allow_dms_strangers": "Allow DMs from strangers",
|
||||
"allow_dms_strangers_hint": "When off, only friends can DM you.",
|
||||
"this_device": "This device",
|
||||
"danger_zone": "Danger zone",
|
||||
"sign_out": "Sign out",
|
||||
"section_ringtone": "Ringtone",
|
||||
"ringtone_incoming": "Incoming call",
|
||||
"ringtone_default_active": "Default ringtone (double beep)",
|
||||
"ringtone_custom_active": "{{name}} · {{size}} MB",
|
||||
|
||||
@@ -50,28 +50,8 @@
|
||||
"footer_studio": "Supabase Studio",
|
||||
"legal_note": "By signing up you accept that the server sees only ciphertext.",
|
||||
"signed_in": {
|
||||
"title": "Signed in",
|
||||
"session_active": "Session active",
|
||||
"user_id": "User ID",
|
||||
"email": "Email",
|
||||
"username": "Username",
|
||||
"display_name": "Display name",
|
||||
"admin": "Admin",
|
||||
"yes": "yes",
|
||||
"no": "no",
|
||||
"sign_out": "Sign out",
|
||||
"device_active": "Active device",
|
||||
"device_platform": "Platform",
|
||||
"device_registered_at": "Registered"
|
||||
},
|
||||
"device": {
|
||||
"title": "Register this device",
|
||||
"subtitle": "Generates an X25519 keypair. Private key stays on this device.",
|
||||
"name_label": "Device name",
|
||||
"name_hint": "Shown to you in your device list. Keep it recognisable.",
|
||||
"name_placeholder": "Dennis Laptop",
|
||||
"cta": "Register device",
|
||||
"cta_loading": "Generating keypair…",
|
||||
"security_note_dev": "Dev build: private key stored unencrypted in localStorage. Stronghold comes before release."
|
||||
"display_name": "Display name"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
{
|
||||
"app_name": "ChatApp",
|
||||
"loading": "Loading…",
|
||||
"finalising_session": "Finalising session…",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"close": "Close",
|
||||
"retry": "Retry",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"idle": "Idle",
|
||||
"dnd": "Do not disturb",
|
||||
"invisible": "Invisible",
|
||||
"local_stack_online": "local stack online",
|
||||
"dev_build": "dev build"
|
||||
}
|
||||
|
||||
Generated
+198
@@ -98,6 +98,9 @@ importers:
|
||||
react-router-dom:
|
||||
specifier: ^6.28.0
|
||||
version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react-virtuoso:
|
||||
specifier: ^4.18.7
|
||||
version: 4.18.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
zustand:
|
||||
specifier: ^5.0.1
|
||||
version: 5.0.12(@types/react@18.3.28)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1))
|
||||
@@ -141,6 +144,9 @@ importers:
|
||||
rimraf:
|
||||
specifier: ^6.0.0
|
||||
version: 6.1.3
|
||||
rollup-plugin-visualizer:
|
||||
specifier: ^7.0.1
|
||||
version: 7.0.1(rollup@4.60.1)
|
||||
tailwindcss:
|
||||
specifier: ^3.4.15
|
||||
version: 3.4.19
|
||||
@@ -2612,6 +2618,10 @@ packages:
|
||||
builder-util@25.1.7:
|
||||
resolution: {integrity: sha512-7jPjzBwEGRbwNcep0gGNpLXG9P94VA3CPAZQCzxkFXiV2GMQKlziMbY//rXPI7WKfhsvGgFXjTcXdBEwgXw9ww==}
|
||||
|
||||
bundle-name@4.1.0:
|
||||
resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
bytes@3.1.2:
|
||||
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -2757,6 +2767,10 @@ packages:
|
||||
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
cliui@9.0.1:
|
||||
resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
clone-deep@4.0.1:
|
||||
resolution: {integrity: sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -2988,6 +3002,14 @@ packages:
|
||||
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
default-browser-id@5.0.1:
|
||||
resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
default-browser@5.5.0:
|
||||
resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
default-gateway@4.2.0:
|
||||
resolution: {integrity: sha512-h6sMrVB1VMWVrW13mSc6ia/DwYYw5MN6+exNu1OaJeFac5aSAvwM7lZ0NVfTABuSkQelr4h5oebg3KB1XPdjgA==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -3007,6 +3029,10 @@ packages:
|
||||
resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
define-lazy-prop@3.0.0:
|
||||
resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
define-properties@1.2.1:
|
||||
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -3128,6 +3154,9 @@ packages:
|
||||
engines: {node: '>= 12.20.55'}
|
||||
hasBin: true
|
||||
|
||||
emoji-regex@10.6.0:
|
||||
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
|
||||
|
||||
emoji-regex@8.0.0:
|
||||
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
||||
|
||||
@@ -3719,6 +3748,10 @@ packages:
|
||||
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
||||
engines: {node: 6.* || 8.* || >= 10.*}
|
||||
|
||||
get-east-asian-width@1.6.0:
|
||||
resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
get-intrinsic@1.3.0:
|
||||
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -4051,6 +4084,11 @@ packages:
|
||||
engines: {node: '>=8'}
|
||||
hasBin: true
|
||||
|
||||
is-docker@3.0.0:
|
||||
resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
hasBin: true
|
||||
|
||||
is-extglob@2.1.1:
|
||||
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -4071,6 +4109,15 @@ packages:
|
||||
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
is-in-ssh@1.0.0:
|
||||
resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
is-inside-container@1.0.0:
|
||||
resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==}
|
||||
engines: {node: '>=14.16'}
|
||||
hasBin: true
|
||||
|
||||
is-interactive@1.0.0:
|
||||
resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -4169,6 +4216,10 @@ packages:
|
||||
resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
is-wsl@3.1.1:
|
||||
resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
isarray@1.0.0:
|
||||
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
|
||||
|
||||
@@ -4962,6 +5013,10 @@ packages:
|
||||
resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
open@11.0.0:
|
||||
resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
open@7.4.2:
|
||||
resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -5187,6 +5242,10 @@ packages:
|
||||
resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
powershell-utils@0.1.0:
|
||||
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
prebuild-install@7.1.3:
|
||||
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -5420,6 +5479,12 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^18.3.1
|
||||
|
||||
react-virtuoso@4.18.7:
|
||||
resolution: {integrity: sha512-xNF5zDGEEIMB7cKwcen/pLig0YDf6OnfFrVgKFa7sHPf9fRem0CaLshyObbBcP88jzn0enavL39EgplgdyT21g==}
|
||||
peerDependencies:
|
||||
react: '>=16 || >=17 || >= 18 || >= 19'
|
||||
react-dom: '>=16 || >=17 || >= 18 || >=19'
|
||||
|
||||
react@18.3.1:
|
||||
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -5577,11 +5642,28 @@ packages:
|
||||
resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==}
|
||||
engines: {node: '>=8.0'}
|
||||
|
||||
rollup-plugin-visualizer@7.0.1:
|
||||
resolution: {integrity: sha512-UJUT4+1Ho4OcWmPYU3sYXgUqI8B8Ayfe06MX7y0qCJ1K8aGoKtR/NDd/2nZqM7ADkrzny+I99Ul7GgyoiVNAgg==}
|
||||
engines: {node: '>=22'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
rolldown: 1.x || ^1.0.0-beta || ^1.0.0-rc
|
||||
rollup: 2.x || 3.x || 4.x
|
||||
peerDependenciesMeta:
|
||||
rolldown:
|
||||
optional: true
|
||||
rollup:
|
||||
optional: true
|
||||
|
||||
rollup@4.60.1:
|
||||
resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==}
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
hasBin: true
|
||||
|
||||
run-applescript@7.1.0:
|
||||
resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
run-parallel@1.2.0:
|
||||
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
||||
|
||||
@@ -5817,6 +5899,10 @@ packages:
|
||||
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
source-map@0.7.6:
|
||||
resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
split-on-first@1.1.0:
|
||||
resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -5884,6 +5970,10 @@ packages:
|
||||
resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
string-width@7.2.0:
|
||||
resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
string.prototype.trim@1.2.10:
|
||||
resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -6470,6 +6560,10 @@ packages:
|
||||
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
wrap-ansi@9.0.2:
|
||||
resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
wrappy@1.0.2:
|
||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||
|
||||
@@ -6515,6 +6609,10 @@ packages:
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
wsl-utils@0.3.1:
|
||||
resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
xcode@3.0.1:
|
||||
resolution: {integrity: sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
@@ -6556,10 +6654,18 @@ packages:
|
||||
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
yargs-parser@22.0.0:
|
||||
resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
|
||||
|
||||
yargs@17.7.2:
|
||||
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
yargs@18.0.0:
|
||||
resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
|
||||
|
||||
yauzl@2.10.0:
|
||||
resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
|
||||
|
||||
@@ -9557,6 +9663,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
bundle-name@4.1.0:
|
||||
dependencies:
|
||||
run-applescript: 7.1.0
|
||||
|
||||
bytes@3.1.2: {}
|
||||
|
||||
cac@6.7.14: {}
|
||||
@@ -9741,6 +9851,12 @@ snapshots:
|
||||
strip-ansi: 6.0.1
|
||||
wrap-ansi: 7.0.0
|
||||
|
||||
cliui@9.0.1:
|
||||
dependencies:
|
||||
string-width: 7.2.0
|
||||
strip-ansi: 7.2.0
|
||||
wrap-ansi: 9.0.2
|
||||
|
||||
clone-deep@4.0.1:
|
||||
dependencies:
|
||||
is-plain-object: 2.0.4
|
||||
@@ -9960,6 +10076,13 @@ snapshots:
|
||||
|
||||
deepmerge@4.3.1: {}
|
||||
|
||||
default-browser-id@5.0.1: {}
|
||||
|
||||
default-browser@5.5.0:
|
||||
dependencies:
|
||||
bundle-name: 4.1.0
|
||||
default-browser-id: 5.0.1
|
||||
|
||||
default-gateway@4.2.0:
|
||||
dependencies:
|
||||
execa: 1.0.0
|
||||
@@ -9979,6 +10102,8 @@ snapshots:
|
||||
|
||||
define-lazy-prop@2.0.0: {}
|
||||
|
||||
define-lazy-prop@3.0.0: {}
|
||||
|
||||
define-properties@1.2.1:
|
||||
dependencies:
|
||||
define-data-property: 1.1.4
|
||||
@@ -10152,6 +10277,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
emoji-regex@10.6.0: {}
|
||||
|
||||
emoji-regex@8.0.0: {}
|
||||
|
||||
emoji-regex@9.2.2: {}
|
||||
@@ -10936,6 +11063,8 @@ snapshots:
|
||||
|
||||
get-caller-file@2.0.5: {}
|
||||
|
||||
get-east-asian-width@1.6.0: {}
|
||||
|
||||
get-intrinsic@1.3.0:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
@@ -11305,6 +11434,8 @@ snapshots:
|
||||
|
||||
is-docker@2.2.1: {}
|
||||
|
||||
is-docker@3.0.0: {}
|
||||
|
||||
is-extglob@2.1.1: {}
|
||||
|
||||
is-finalizationregistry@1.1.1:
|
||||
@@ -11325,6 +11456,12 @@ snapshots:
|
||||
dependencies:
|
||||
is-extglob: 2.1.1
|
||||
|
||||
is-in-ssh@1.0.0: {}
|
||||
|
||||
is-inside-container@1.0.0:
|
||||
dependencies:
|
||||
is-docker: 3.0.0
|
||||
|
||||
is-interactive@1.0.0: {}
|
||||
|
||||
is-lambda@1.0.1: {}
|
||||
@@ -11406,6 +11543,10 @@ snapshots:
|
||||
dependencies:
|
||||
is-docker: 2.2.1
|
||||
|
||||
is-wsl@3.1.1:
|
||||
dependencies:
|
||||
is-inside-container: 1.0.0
|
||||
|
||||
isarray@1.0.0: {}
|
||||
|
||||
isarray@2.0.5: {}
|
||||
@@ -12338,6 +12479,15 @@ snapshots:
|
||||
dependencies:
|
||||
mimic-fn: 2.1.0
|
||||
|
||||
open@11.0.0:
|
||||
dependencies:
|
||||
default-browser: 5.5.0
|
||||
define-lazy-prop: 3.0.0
|
||||
is-in-ssh: 1.0.0
|
||||
is-inside-container: 1.0.0
|
||||
powershell-utils: 0.1.0
|
||||
wsl-utils: 0.3.1
|
||||
|
||||
open@7.4.2:
|
||||
dependencies:
|
||||
is-docker: 2.2.1
|
||||
@@ -12539,6 +12689,8 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
powershell-utils@0.1.0: {}
|
||||
|
||||
prebuild-install@7.1.3:
|
||||
dependencies:
|
||||
detect-libc: 2.1.2
|
||||
@@ -12820,6 +12972,11 @@ snapshots:
|
||||
react-shallow-renderer: 16.15.0(react@18.3.1)
|
||||
scheduler: 0.23.2
|
||||
|
||||
react-virtuoso@4.18.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
react@18.3.1:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
@@ -13004,6 +13161,15 @@ snapshots:
|
||||
sprintf-js: 1.1.3
|
||||
optional: true
|
||||
|
||||
rollup-plugin-visualizer@7.0.1(rollup@4.60.1):
|
||||
dependencies:
|
||||
open: 11.0.0
|
||||
picomatch: 4.0.4
|
||||
source-map: 0.7.6
|
||||
yargs: 18.0.0
|
||||
optionalDependencies:
|
||||
rollup: 4.60.1
|
||||
|
||||
rollup@4.60.1:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
@@ -13035,6 +13201,8 @@ snapshots:
|
||||
'@rollup/rollup-win32-x64-msvc': 4.60.1
|
||||
fsevents: 2.3.3
|
||||
|
||||
run-applescript@7.1.0: {}
|
||||
|
||||
run-parallel@1.2.0:
|
||||
dependencies:
|
||||
queue-microtask: 1.2.3
|
||||
@@ -13294,6 +13462,8 @@ snapshots:
|
||||
|
||||
source-map@0.6.1: {}
|
||||
|
||||
source-map@0.7.6: {}
|
||||
|
||||
split-on-first@1.1.0: {}
|
||||
|
||||
sprintf-js@1.0.3: {}
|
||||
@@ -13350,6 +13520,12 @@ snapshots:
|
||||
emoji-regex: 9.2.2
|
||||
strip-ansi: 7.2.0
|
||||
|
||||
string-width@7.2.0:
|
||||
dependencies:
|
||||
emoji-regex: 10.6.0
|
||||
get-east-asian-width: 1.6.0
|
||||
strip-ansi: 7.2.0
|
||||
|
||||
string.prototype.trim@1.2.10:
|
||||
dependencies:
|
||||
call-bind: 1.0.9
|
||||
@@ -13993,6 +14169,12 @@ snapshots:
|
||||
string-width: 5.1.2
|
||||
strip-ansi: 7.2.0
|
||||
|
||||
wrap-ansi@9.0.2:
|
||||
dependencies:
|
||||
ansi-styles: 6.2.3
|
||||
string-width: 7.2.0
|
||||
strip-ansi: 7.2.0
|
||||
|
||||
wrappy@1.0.2: {}
|
||||
|
||||
write-file-atomic@2.4.3:
|
||||
@@ -14014,6 +14196,11 @@ snapshots:
|
||||
|
||||
ws@8.20.0: {}
|
||||
|
||||
wsl-utils@0.3.1:
|
||||
dependencies:
|
||||
is-wsl: 3.1.1
|
||||
powershell-utils: 0.1.0
|
||||
|
||||
xcode@3.0.1:
|
||||
dependencies:
|
||||
simple-plist: 1.3.1
|
||||
@@ -14042,6 +14229,8 @@ snapshots:
|
||||
|
||||
yargs-parser@21.1.1: {}
|
||||
|
||||
yargs-parser@22.0.0: {}
|
||||
|
||||
yargs@17.7.2:
|
||||
dependencies:
|
||||
cliui: 8.0.1
|
||||
@@ -14052,6 +14241,15 @@ snapshots:
|
||||
y18n: 5.0.8
|
||||
yargs-parser: 21.1.1
|
||||
|
||||
yargs@18.0.0:
|
||||
dependencies:
|
||||
cliui: 9.0.1
|
||||
escalade: 3.2.0
|
||||
get-caller-file: 2.0.5
|
||||
string-width: 7.2.0
|
||||
y18n: 5.0.8
|
||||
yargs-parser: 22.0.0
|
||||
|
||||
yauzl@2.10.0:
|
||||
dependencies:
|
||||
buffer-crc32: 0.2.13
|
||||
|
||||
Reference in New Issue
Block a user