Compare commits

..

21 Commits

Author SHA1 Message Date
byGalax 588b843904 chore(desktop): release v0.21.4 2026-05-21 23:12:38 +02:00
byGalax dbf8030e93 fix(conv-key): rotate-instead-of-share + cache invalidation; fire-first realtime inserts (no sound-vs-text gap)
Friend-DM "Nachricht nicht lesbar" recurred even after v0.21.3 because the
proactive sweep called shareConvKeyToUser, which reads the module-level
conv-key cache first. After a server-side cleanup the cache still held the
stale locally-bootstrapped key, so each side wrapped its own different key
for the peer and the bundles diverged anew.

Switch the sweep to rotate_conv_key when any peer's user-id bundle is
missing at the active version: a fresh symmetric key is generated, wrapped
for every member at their CURRENT pubkey, and the active version is bumped
under a row-level FOR UPDATE lock. Concurrent rotations are race-safe — the
loser sees "new version must be greater" and bails; the winner's bundles
propagate via realtime.

Realtime conversation_keys subscription now invalidates the cache for the
affected (conversationId, key_version) on any INSERT/UPDATE/DELETE — so
admin cleanups, peer rotations, or device wraps can no longer leave a
stale entry in this client's session cache.

queueInsert now fires the first event of a quiet period immediately and
only collapses follow-up bursts. BATCH_WINDOW_MS dropped 250 → 80 ms.
This closes the ~250 ms gap between the notification sound and the
message body appearing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:10:36 +02:00
byGalax 615770722e chore(desktop): release v0.21.3 2026-05-21 22:52:50 +02:00
byGalax f1cba99b9e fix(conv-key): bootstrap re-fetches canonical key after share to handle concurrent race 2026-05-21 22:51:17 +02:00
byGalax f60c5c676a chore(desktop): release v0.21.2 2026-05-18 15:40:56 +02:00
byGalax 8e6be3256d fix(conv-key): rotate on unwrap failure (post-reset_user_key recovery) 2026-05-18 15:38:31 +02:00
byGalax 508c53b451 chore(desktop): release v0.21.1 2026-05-18 14:40:29 +02:00
byGalax e2f86bc377 fix(chat): snap to bottom after send to mask composer-shrink layout shift 2026-05-18 14:29:31 +02:00
byGalax 92a6e01a26 fix(chat): instant scroll + larger at-bottom threshold + footer spacer (Discord-clean) 2026-05-18 14:21:04 +02:00
byGalax 3d959aaadf fix(chat): auto-rotate stuck conv-keys on chat open (receive-side recovery) 2026-05-18 14:03:35 +02:00
byGalax 787437c3f1 chore(desktop): release v0.21.0 2026-05-17 20:04:00 +02:00
byGalax be5647281d chore(mobile): track expo-generated .gitignore 2026-05-17 20:00:18 +02:00
byGalax fc8fc275cb fix(soundboard): mount-once hotkey registration to avoid call-state churn 2026-05-17 17:49:08 +02:00
byGalax e19a71e892 feat(profile): preserve animation on GIF/APNG/animated-WebP avatar uploads
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 17:38:07 +02:00
byGalax 11869be443 feat(soundboard): hotkeys fire outside calls with local-only playback
Lifts the `state.kind === 'connected'` guard from the soundboard hotkey
useEffect so OS-level shortcuts are always registered. Inside a call the
existing `playSoundboard` path routes audio into the LiveKit pipeline so
peers hear; outside a call the new `playSoundboardLocal` helper fetches
the blob via `getSoundBlob`, creates a short-lived object URL, and plays
through a fresh HTMLAudioElement on the system default output.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 17:33:50 +02:00
byGalax 3fa8b6dbc1 feat(call): shared annotation overlay on screen share 2026-05-17 17:30:05 +02:00
byGalax f9f8e3fdb8 feat(whiteboard): live cursors via broadcast channel 2026-05-17 17:24:34 +02:00
byGalax 4ed3f04300 fix(view-once): hold-to-view pattern + content-protection during reveal 2026-05-17 17:21:22 +02:00
byGalax a7ffcbff83 feat(voice): playback-speed toggle (1x/1.5x/2x) with per-user default 2026-05-17 17:09:01 +02:00
byGalax 82600915f1 feat(composer): persist text + reply target per chat across restarts 2026-05-17 17:06:43 +02:00
byGalax 93098a74ca docs(phase8): feature batch implementation plan 2026-05-17 16:54:56 +02:00
26 changed files with 2976 additions and 127 deletions
+11
View File
@@ -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
+2
View File
@@ -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';
@@ -274,6 +275,7 @@ if (!gotLock) {
registerTray(mainWindow);
registerUpdater(mainWindow);
registerWindowFullscreen(mainWindow);
registerWindowContentProtection(mainWindow);
registerAudioLoopback(mainWindow);
});
@@ -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
View File
@@ -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;
+4
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@chat-app/desktop",
"version": "0.20.1",
"version": "0.21.4",
"private": true,
"description": "Electron desktop client (Windows / macOS / Linux)",
"type": "module",
+6 -1
View File
@@ -1,4 +1,4 @@
import { lazy, Suspense } from 'react';
import { lazy, Suspense, useEffect } from 'react';
import { HashRouter, Navigate, Outlet, Route, Routes, useParams } from 'react-router-dom';
import { AppShell } from './components/AppShell';
@@ -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';
@@ -73,6 +74,10 @@ function ConversationRoute() {
}
export function App() {
useEffect(() => {
void hydrateDrafts();
}, []);
return (
<ErrorBoundary scope="root">
<ThemeProvider>
@@ -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"
@@ -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,6 +98,7 @@ export function ScreenShareViewer({
</div>
{watching ? (
<div className="relative h-full w-full flex-1">
<video
ref={videoRef}
autoPlay
@@ -109,8 +112,14 @@ export function ScreenShareViewer({
// 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"
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"
+76 -15
View File
@@ -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,6 +159,10 @@ export function WhiteboardCanvas({
};
return (
<div
className="relative"
style={{ aspectRatio: logicalWidth + ' / ' + logicalHeight, width: '100%' }}
>
<canvas
ref={canvasRef}
onPointerDown={handlePointerDown}
@@ -109,9 +170,40 @@ export function WhiteboardCanvas({
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={{ aspectRatio: logicalWidth + ' / ' + logicalHeight, width: '100%' }}
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(
@@ -80,6 +80,7 @@ export function WhiteboardModal({ whiteboardId, onClose }: Props) {
color={color}
width={width}
onStroke={(payload) => void insertStroke(payload)}
whiteboardId={whiteboardId}
/>
)}
</div>
+27 -5
View File
@@ -98,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,
@@ -2257,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 150 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 });
+68 -1
View File
@@ -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');
});
});
+160
View File
@@ -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;
}
Binary file not shown.
@@ -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();
}
+121 -48
View File
@@ -1,16 +1,16 @@
import { fetchPeerPublicKeys } from '@chat-app/shared/auth';
import {
type AttachmentHandle,
clearConvKeyCache,
type DecryptedMessage,
decryptMessages,
encryptAndUploadAttachment,
fetchConversationMessages,
getOrCreateConvKey,
insertAttachmentRow,
MAX_ATTACHMENT_BYTES,
type MessageWithCipher,
rotateConvKey,
sendEncryptedMessage,
shareConvKeyToUser,
tryGetConvKey,
} from '@chat-app/shared/chat';
import { bytesToPgHex, pgBytesToBytes } from '@chat-app/shared/supabase';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
@@ -125,12 +125,25 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
});
}, [userId]);
// Proactive rewrap sweep: when a conversation opens, walk every accepted
// member and ensure the active conv-key has a `recipient_user_id` bundle
// for them. Members who are missing one (typically peers who haven't yet
// migrated to the per-user key model) get a best-effort wrap from the
// local conv-key handle. Closes the legacy migration gap so peer B can
// read on first unlock without manual intervention from A.
// Proactive rewrap sweep: when a conversation opens, ensure the active
// conv-key has a `recipient_user_id` bundle for every accepted member.
//
// If any peer is missing a bundle at the active version, the previous
// implementation called `shareConvKeyToUser` for each missing peer
// that helper reads from the module-level conv-key cache first, and if
// the cache held a STALE locally-generated key (from a buggy bootstrap
// race in an earlier app version), the stale key got propagated to the
// peer's row. Both sides then encrypt with mutually un-mergeable keys
// and every message is "Nachricht nicht lesbar" forever (incident:
// conv aae12d84).
//
// The replacement: when any peer is missing, call `rotateConvKey` once.
// Rotation generates a fresh symmetric key locally, fetches each member's
// CURRENT pubkey, wraps the fresh key for everyone, and atomically bumps
// `active_key_version` via the `rotate_conv_key` RPC (FOR UPDATE lock
// serialises concurrent rotations). This bypasses the cache entirely:
// the new version's cache entry is the just-rotated key, and the stale
// entry at the old version is irrelevant because nobody reads it any more.
useEffect(() => {
if (!conversationId || !userId) return;
let cancelled = false;
@@ -154,31 +167,41 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
// db-types snapshot predates the active_key_version column; cast via unknown.
const version = (convRow as unknown as { active_key_version: number }).active_key_version;
const handle = await tryGetConvKey(supabase, conversationId, userId, priv, version);
if (!handle || cancelled) return;
// First, make sure we have a usable handle for the active version
// (this auto-rotates if we're locked out of our own bundle — the
// recovery path added in v0.21.1/v0.21.2).
const handle = await getOrCreateConvKey(supabase, conversationId, {
userId,
privateKey: priv,
});
if (cancelled) return;
if (handle.keyVersion > version) return; // already rotated by helper
// Check membership state on the server.
const { data: members, error: mErr } = await supabase
.from('conversation_members')
.select('user_id, accepted')
.eq('conversation_id', conversationId);
if (mErr || !members) return;
const memberIds = (members as Array<{ user_id: string; accepted: boolean }>)
const peerIds = (members as Array<{ user_id: string; accepted: boolean }>)
.filter((m) => m.accepted && m.user_id !== userId)
.map((m) => m.user_id);
if (memberIds.length === 0) return;
if (peerIds.length === 0) return;
const peers = await fetchPeerPublicKeys(supabase, memberIds);
for (const peer of peers) {
if (cancelled) return;
const { count, error: cntErr } = await (
// Count how many of the peers have a recipient_user_id bundle at
// the active version. If any are missing, rotate to V+1 — the
// rotation will wrap a fresh key for every accepted member with a
// user_keys row.
const { data: existingRows, error: rowsErr } = await (
supabase as unknown as {
from: (t: string) => {
select: (s: string, o?: object) => {
eq: (...a: unknown[]) => {
eq: (...a: unknown[]) => {
eq: (
...a: unknown[]
) => Promise<{ count: number | null; error: unknown }>;
select: (s: string) => {
eq: (c: string, v: string) => {
eq: (c: string, v: number) => {
in: (c: string, v: string[]) => Promise<{
data: Array<{ recipient_user_id: string }> | null;
error: unknown;
}>;
};
};
};
@@ -186,24 +209,34 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
}
)
.from('conversation_keys')
.select('recipient_user_id', { count: 'exact', head: true })
.select('recipient_user_id')
.eq('conversation_id', conversationId)
.eq('recipient_user_id', peer.userId)
.eq('key_version', version);
if (cntErr) continue;
if ((count ?? 0) === 0) {
try {
await shareConvKeyToUser(
supabase,
conversationId,
peer.userId,
peer.publicKey,
{ userId, privateKey: priv },
.eq('key_version', version)
.in('recipient_user_id', peerIds);
if (rowsErr) return;
const wrappedPeerIds = new Set(
(existingRows ?? []).map((r) => r.recipient_user_id),
);
const missing = peerIds.filter((id) => !wrappedPeerIds.has(id));
if (missing.length === 0) return;
// At least one peer is missing a bundle — rotate. We deliberately do
// NOT use the cached conv-key here. The rotation generates a fresh
// key wrapped to every current member's CURRENT pubkey, so any
// staleness in the local cache for the OLD version is irrelevant
// going forward.
try {
await rotateConvKey(supabase, conversationId, {
userId,
privateKey: priv,
});
} catch (err) {
console.warn('proactive rewrap failed for', peer.userId, err);
}
}
// Most likely cause: a concurrent peer also called rotate and
// won the race; their bumped active_key_version makes our
// `p_new_version <= cur_version` and the RPC raises. That's fine —
// the next chat-open / send will fetch the new active version and
// unwrap the bundle that peer wrapped for us.
console.warn('proactive rotate failed (likely concurrent rotation)', err);
}
} catch (err) {
console.warn('proactive rewrap sweep failed', err);
@@ -474,11 +507,14 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
void refresh();
// Batch INSERT bursts so a paste / backfill doesn't fire N parallel
// refetches + decrypts. If more than BATCH_BURST_THRESHOLD ids arrive
// within BATCH_WINDOW_MS, collapse to a single refresh() which pulls
// the last 100 in one query — cheaper and keeps order stable. For
// lone inserts the per-id path stays so latency is unchanged.
const BATCH_WINDOW_MS = 250;
// refetches + decrypts. The first event in a quiet period fires
// `handleInsert` immediately so single incoming messages don't sit
// behind a debounce timer (previous behaviour: 250 ms blank between
// notification-sound and message body). Subsequent events arriving
// within BATCH_WINDOW_MS of the first are buffered; if the burst grows
// past BATCH_BURST_THRESHOLD the buffered tail collapses into one
// `refresh()` instead of N individual refetches.
const BATCH_WINDOW_MS = 80;
const BATCH_BURST_THRESHOLD = 3;
let burstBuffer: Array<Record<string, unknown>> = [];
let burstTimer: number | null = null;
@@ -497,6 +533,15 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
}
};
const queueInsert = (row: Record<string, unknown>) => {
if (burstBuffer.length === 0 && burstTimer === null) {
// First event in a quiet period — fire immediately so the user sees
// the message right when they hear the notification sound. Arm a
// short window in case a burst follows; follow-ups go through the
// buffer and may collapse into a refresh.
void handleInsert(row);
burstTimer = window.setTimeout(flushBurst, BATCH_WINDOW_MS);
return;
}
burstBuffer.push(row);
if (burstTimer === null) {
burstTimer = window.setTimeout(flushBurst, BATCH_WINDOW_MS);
@@ -523,18 +568,46 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
}
},
)
// When a peer device wraps the conversation-key for us (e.g. we just
// registered a fresh device), re-decrypt the visible messages.
// Any conversation_keys change for this conv invalidates the cached
// conv-key for the affected version. The module-level cache in
// shared/chat/convKeys.ts otherwise holds the previously-unwrapped key
// forever within a session — which is exactly what propagated the
// stale local bootstrap key in conv aae12d84, recreating divergent
// bundles after a server-side cleanup. Clearing on any INSERT/UPDATE/
// DELETE for the conv forces the next `getOrCreateConvKey` /
// `tryGetConvKey` call to re-fetch the canonical bundle from the
// server. Cheap (a single Map.delete), defensive, and avoids stale-
// cache propagation across all of {peer rotation, device wrap, admin
// cleanup}.
//
// We also keep the historical "device wrap → refresh" trigger so a
// freshly-registered device of our own re-decrypts in place.
.on(
'postgres_changes',
{
event: 'INSERT',
event: '*',
schema: 'public',
table: 'conversation_keys',
filter: 'conversation_id=eq.' + conversationId,
},
(payload: { new: { recipient_device_id?: string } }) => {
if (payload.new?.recipient_device_id === deviceId) {
(payload: {
eventType: 'INSERT' | 'UPDATE' | 'DELETE';
new: { recipient_device_id?: string; key_version?: number };
old: { recipient_device_id?: string; key_version?: number };
}) => {
const v =
payload.eventType === 'DELETE'
? payload.old?.key_version
: payload.new?.key_version;
if (typeof v === 'number') {
clearConvKeyCache(conversationId, v);
} else {
clearConvKeyCache(conversationId);
}
if (
payload.eventType === 'INSERT' &&
payload.new?.recipient_device_id === deviceId
) {
void refresh();
}
},
@@ -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;
+81
View File
@@ -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 };
}
+68 -7
View File
@@ -78,6 +78,7 @@ 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';
// Discriminated union for rows inside the virtualized message list. Keeping
// pending bubbles and the "load older" tile inside the same Virtuoso
@@ -200,7 +201,10 @@ 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);
@@ -304,6 +308,21 @@ export function ConversationPage() {
const fileInputRef = useRef<HTMLInputElement>(null);
const composerRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
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;
if (!id || messages.length === 0) return;
@@ -735,6 +754,24 @@ export function ConversationPage() {
lastPendingCountRef.current = pending.length;
}, [pending.length]);
// Snap the viewport back to the bottom after a send. The composer
// shrinks (cleared text, dismissed reply preview, dropped attachment
// thumbs) which lets the Virtuoso area grow vertically — leaving the
// just-sent bubble visibly above the new bottom for a frame.
// `requestAnimationFrame` defers the scroll until React has committed
// the composer-height change, so Virtuoso's ResizeObserver has
// already seen the new viewport and `index: 'LAST', align: 'end'`
// targets the correct bottom edge.
const snapToBottom = useCallback(() => {
window.requestAnimationFrame(() => {
virtuosoRef.current?.scrollToIndex({
index: 'LAST',
align: 'end',
behavior: 'auto',
});
});
}, []);
async function handleSend(e?: React.FormEvent) {
e?.preventDefault();
if ((!text.trim() && attachments.length === 0) || sending) return;
@@ -753,6 +790,8 @@ export function ConversationPage() {
if (fileInputRef.current) fileInputRef.current.value = '';
setStickToBottom(true);
notifyStopTyping();
if (id) clearDraft(id);
snapToBottom();
} catch (err: unknown) {
const code = extractErrorCode(err);
setSendError(
@@ -778,13 +817,14 @@ export function ConversationPage() {
setReplyTo(null);
setStickToBottom(true);
notifyStopTyping();
snapToBottom();
} catch (err: unknown) {
setPollError(err instanceof Error ? err.message : 'Umfrage konnte nicht gesendet werden');
} finally {
setPollSending(false);
}
},
[send, replyTo?.id, notifyStopTyping],
[send, replyTo?.id, notifyStopTyping, snapToBottom],
);
const handleCreateWhiteboard = useCallback(async () => {
@@ -797,12 +837,13 @@ export function ConversationPage() {
setReplyTo(null);
setStickToBottom(true);
setOpenWhiteboardId(board.id);
snapToBottom();
} catch (err: unknown) {
setSendError(err instanceof Error ? err.message : 'Whiteboard konnte nicht angelegt werden');
} finally {
setCreatingWhiteboard(false);
}
}, [id, creatingWhiteboard, send, replyTo?.id]);
}, [id, creatingWhiteboard, send, replyTo?.id, snapToBottom]);
const handleStartWatchTogether = useCallback(async () => {
if (!id) return;
@@ -822,12 +863,13 @@ export function ConversationPage() {
setWatchDialogOpen(false);
setWatchUrl('');
setOpenWatchSessionId(ws.id);
snapToBottom();
} catch (err: unknown) {
setWatchError(err instanceof Error ? err.message : 'Konnte Watch-Together nicht starten');
} finally {
setWatchCreating(false);
}
}, [id, watchUrl, send, replyTo?.id]);
}, [id, watchUrl, send, replyTo?.id, snapToBottom]);
const handleStartGame = useCallback(async (gameType: GameType) => {
if (!id) return;
@@ -854,12 +896,13 @@ export function ConversationPage() {
setStickToBottom(true);
setGameDialogOpen(false);
setOpenGameId(game.id);
snapToBottom();
} catch (err: unknown) {
setGameError(err instanceof Error ? err.message : 'Konnte Spiel nicht starten');
} finally {
setGameCreating(false);
}
}, [id, conversation, myId, send, replyTo?.id]);
}, [id, conversation, myId, send, replyTo?.id, snapToBottom]);
async function ingestFiles(files: File[]) {
const compressed = await compressImages(files);
@@ -1026,14 +1069,32 @@ export function ConversationPage() {
// 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)}
//
// We deliberately use 'auto' (instant) rather than 'smooth':
// with a smooth scroll animation, atBottomStateChange fires
// `false` mid-animation (scrollTop is briefly above the new
// bottom) and then `true` after settle — that flips
// stickToBottom twice, flashing the "Zum neuesten" pill and
// re-rendering the whole list. Instant scroll has zero
// mid-animation state so the cascade never happens.
followOutput={(isAtBottom) => (isAtBottom ? 'auto' : false)}
atBottomStateChange={handleAtBottomStateChange}
atBottomThreshold={80}
// 250 px tolerance — large enough that appending a tall row
// (image, voice note, grouped attachments) doesn't push the
// user out of the at-bottom zone. The previous 80 px flipped
// stickToBottom on nearly every typical message arrival.
atBottomThreshold={250}
rangeChanged={handleRangeChanged}
startReached={handleStartReached}
// Render rows just outside the viewport so fast scrolling
// doesn't briefly flash empty space.
increaseViewportBy={400}
// Visual breathing space below the last message so a bubble
// bottom doesn't sit flush against the composer top — matches
// Discord's chat-pane bottom padding.
components={{
Footer: () => <div style={{ height: '12px' }} />,
}}
itemContent={(_index, row) => {
if (row.kind === 'loader') {
return (
+6
View File
@@ -0,0 +1,6 @@
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
# The following patterns were generated by expo-cli
expo-env.d.ts
# @end expo-cli
File diff suppressed because it is too large Load Diff
+78 -9
View File
@@ -28,7 +28,28 @@ export interface ConvKeyHandle {
const cache = new Map<string, ConvKeyHandle>();
const cacheKey = (convId: string, v: number) => convId + '@' + v;
export function clearConvKeyCache(): void { cache.clear(); }
// Clear the in-memory conv-key cache. Three modes:
// * no args → clear everything (e.g. on logout)
// * convId only → clear all key-version entries for this conversation
// * convId + v → clear just the specific (conv, version) entry
//
// Callers that observe a peer rotation or a server-side conv-keys mutation
// MUST invalidate the affected entries so subsequent `getOrCreateConvKey` /
// `tryGetConvKey` calls re-fetch the canonical bundle from the server
// instead of returning a now-stale cached key.
export function clearConvKeyCache(conversationId?: string, keyVersion?: number): void {
if (conversationId === undefined) {
cache.clear();
return;
}
if (keyVersion !== undefined) {
cache.delete(cacheKey(conversationId, keyVersion));
return;
}
for (const key of Array.from(cache.keys())) {
if (key.startsWith(conversationId + '@')) cache.delete(key);
}
}
async function listMemberPublicKeys(
client: AppSupabaseClient,
@@ -110,7 +131,26 @@ export async function bootstrapConvKey(
p_bundles: bundles,
});
if (error) throw error;
const handle = { conversationId, keyVersion, key: convKey };
// `share_conv_keys` uses `ON CONFLICT (conv, recipient_user_id, key_version)
// DO NOTHING`. If a concurrent peer bootstrapped first at the same version,
// OUR INSERTs were silently skipped server-side and the row on the server
// holds THEIR conv-key, not ours. Trusting the locally-generated key here
// would leave both clients with mutually un-decryptable bundles (each
// encrypting/decrypting with its own key — exactly the bug that broke
// conv aae12d84). Re-fetch our own bundle and unwrap to get the CANONICAL
// server key. Whoever wrote first wins; the loser converges.
const ownBundle = await fetchKeyBundle(client, conversationId, own.userId, keyVersion);
if (!ownBundle) {
throw new Error('bootstrapConvKey: own bundle missing after share_conv_keys');
}
const canonicalKey = await unwrapConvKey(
ownBundle.encryptedKey,
ownBundle.nonce,
ownBundle.sender.senderPublicKey,
own.privateKey,
);
const handle = { conversationId, keyVersion, key: canonicalKey };
cache.set(cacheKey(conversationId, keyVersion), handle);
return handle;
}
@@ -125,12 +165,25 @@ export async function getOrCreateConvKey(
if (cached) return cached;
const bundle = await fetchKeyBundle(client, conversationId, own.userId, version);
if (bundle) {
try {
const key = await unwrapConvKey(
bundle.encryptedKey, bundle.nonce, bundle.sender.senderPublicKey, own.privateKey,
);
const handle = { conversationId, keyVersion: version, key };
cache.set(cacheKey(conversationId, version), handle);
return handle;
} catch (err) {
// A bundle exists for us but our current private key cannot unwrap it.
// The most common cause is `reset_user_key`: a fresh user-key pair was
// generated locally while the on-server bundle is still wrapped against
// the previous public key. Treat this the same as "no bundle for me" —
// mint a fresh conv-key at version+1 wrapped to our CURRENT key. Old
// messages stay unreadable for us; new ones flow.
console.warn(
'[conv-key] unwrap own bundle failed at v' + version + ' — auto-rotating',
err,
);
}
}
const { count, error: cntErr } = await rawFrom(client, 'conversation_keys')
.select('recipient_user_id', { count: 'exact', head: true })
@@ -138,12 +191,13 @@ export async function getOrCreateConvKey(
.eq('key_version', version);
if (cntErr) throw cntErr;
if ((count ?? 0) > 0) {
// Rows exist for this version, but none for me. Either I lost the device-key
// that originally received my bundle, or my own bundle was wiped by the
// 0.18.0 reset_user_key bug. Either way, the only way out is to mint a fresh
// conv-key at version+1 and wrap it for everyone we can. Old messages stay
// unreadable for me; new ones flow.
console.info('[conv-key] no bundle for me at v' + version + ' — auto-rotating');
// Rows exist for this version, but none usable for me. Either I lost the
// device-key that originally received my bundle, my own bundle was wiped
// by the 0.18.0 reset_user_key bug, or my key was reset and the existing
// bundle is unwrappable (handled in the try/catch above). The only way
// out is to mint a fresh conv-key at version+1 and wrap it for everyone
// we can. Old messages stay unreadable for me; new ones flow.
console.info('[conv-key] no usable bundle for me at v' + version + ' — auto-rotating');
return rotateConvKey(client, conversationId, own);
}
return bootstrapConvKey(client, conversationId, own, version);
@@ -248,9 +302,24 @@ export async function tryGetConvKey(
if (cached) return cached;
const bundle = await fetchKeyBundle(client, conversationId, ownUserId, keyVersion);
if (!bundle) return null;
const key = await unwrapConvKey(
let key: Uint8Array;
try {
key = await unwrapConvKey(
bundle.encryptedKey, bundle.nonce, bundle.sender.senderPublicKey, ownPrivateKey,
);
} catch (err) {
// Bundle exists but the current private key doesn't unwrap it (typically
// after `reset_user_key`). Return null so the caller treats the message
// as un-decryptable instead of throwing and killing the whole batch.
// The conversation will be auto-rotated to a fresh key on the next send
// or chat open via `getOrCreateConvKey`'s own recovery path.
console.warn(
'[conv-key] tryGetConvKey unwrap failed at v' + keyVersion +
' (conv=' + conversationId.slice(0, 8) + ') — marking as un-decryptable',
err,
);
return null;
}
const handle = { conversationId, keyVersion, key };
cache.set(cacheKey(conversationId, keyVersion), handle);
return handle;