Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f60c5c676a | |||
| 8e6be3256d | |||
| 508c53b451 | |||
| e2f86bc377 | |||
| 92a6e01a26 | |||
| 3d959aaadf | |||
| 787437c3f1 | |||
| be5647281d | |||
| fc8fc275cb | |||
| e19a71e892 | |||
| 11869be443 | |||
| 3fa8b6dbc1 | |||
| f9f8e3fdb8 | |||
| 4ed3f04300 | |||
| a7ffcbff83 | |||
| 82600915f1 | |||
| 93098a74ca |
@@ -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';
|
||||
|
||||
@@ -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
@@ -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.20.1",
|
||||
"version": "0.21.2",
|
||||
"private": true,
|
||||
"description": "Electron desktop client (Windows / macOS / Linux)",
|
||||
"type": "module",
|
||||
|
||||
@@ -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,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,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>
|
||||
|
||||
@@ -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 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 });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
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();
|
||||
}
|
||||
@@ -5,12 +5,12 @@ import {
|
||||
decryptMessages,
|
||||
encryptAndUploadAttachment,
|
||||
fetchConversationMessages,
|
||||
getOrCreateConvKey,
|
||||
insertAttachmentRow,
|
||||
MAX_ATTACHMENT_BYTES,
|
||||
type MessageWithCipher,
|
||||
sendEncryptedMessage,
|
||||
shareConvKeyToUser,
|
||||
tryGetConvKey,
|
||||
} from '@chat-app/shared/chat';
|
||||
import { bytesToPgHex, pgBytesToBytes } from '@chat-app/shared/supabase';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
@@ -154,8 +154,24 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
// db-types snapshot predates the active_key_version column; cast via unknown.
|
||||
const version = (convRow as unknown as { active_key_version: number }).active_key_version;
|
||||
|
||||
const handle = await tryGetConvKey(supabase, conversationId, userId, priv, version);
|
||||
if (!handle || cancelled) return;
|
||||
// Use `getOrCreateConvKey` rather than `tryGetConvKey` so that if we
|
||||
// can't unwrap our bundle at the active version (we lost the device
|
||||
// key, or the bundle was wiped by the 0.18.0 reset_user_key bug, or
|
||||
// we only ever had a legacy `recipient_device_id` row), the helper
|
||||
// auto-rotates the conv-key to version+1 and wraps fresh bundles
|
||||
// for every member with a `user_keys` row. This is the only path
|
||||
// that recovers stuck-legacy conversations on the receive side —
|
||||
// `tryGetConvKey` just returned null and left the chat permanently
|
||||
// un-decryptable for the locked-out party.
|
||||
const handle = await getOrCreateConvKey(supabase, conversationId, {
|
||||
userId,
|
||||
privateKey: priv,
|
||||
});
|
||||
if (cancelled) return;
|
||||
// If the helper rotated, all current members with a `user_keys`
|
||||
// public key were already wrapped by `rotateConvKey`. No further
|
||||
// sweep work is needed.
|
||||
if (handle.keyVersion > version) return;
|
||||
|
||||
const { data: members, error: mErr } = await supabase
|
||||
.from('conversation_members')
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
@@ -125,12 +125,25 @@ export async function getOrCreateConvKey(
|
||||
if (cached) return cached;
|
||||
const bundle = await fetchKeyBundle(client, conversationId, own.userId, version);
|
||||
if (bundle) {
|
||||
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;
|
||||
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 +151,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 +262,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(
|
||||
bundle.encryptedKey, bundle.nonce, bundle.sender.senderPublicKey, ownPrivateKey,
|
||||
);
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user