Compare commits

..

11 Commits

Author SHA1 Message Date
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
24 changed files with 2714 additions and 48 deletions
+11
View File
@@ -107,6 +107,11 @@ export const CHANNELS = {
// OS fullscreen so the Windows taskbar / macOS menubar gets covered. // OS fullscreen so the Windows taskbar / macOS menubar gets covered.
WINDOW_SET_FULLSCREEN: 'window:set-fullscreen', 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 // Wipe-on-close — main process pushes this to the renderer right before
// exiting if the user has enabled the Settings → Sicherheit toggle. The // exiting if the user has enabled the Settings → Sicherheit toggle. The
// renderer clears its sensitive caches (memoryWipe.ts) and acks via // renderer clears its sensitive caches (memoryWipe.ts) and acks via
@@ -271,6 +276,12 @@ export interface UpdateProgress {
total: number; total: number;
} }
// ---- Window content protection -------------------------------------------
export interface WindowSetContentProtectionArgs {
enabled: boolean;
}
// ---- Runtime marker ------------------------------------------------------ // ---- Runtime marker ------------------------------------------------------
/** Value exposed on `window.electronAPI.platform`. Used by the renderer /** 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 registerSql } from './modules/sql';
import { register as registerTray } from './modules/tray'; import { register as registerTray } from './modules/tray';
import { register as registerUpdater } from './modules/updater'; 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 { register as registerWindowFullscreen } from './modules/window-fullscreen';
import { attach as attachWindowState, loadState } from './window-state'; import { attach as attachWindowState, loadState } from './window-state';
@@ -274,6 +275,7 @@ if (!gotLock) {
registerTray(mainWindow); registerTray(mainWindow);
registerUpdater(mainWindow); registerUpdater(mainWindow);
registerWindowFullscreen(mainWindow); registerWindowFullscreen(mainWindow);
registerWindowContentProtection(mainWindow);
registerAudioLoopback(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>; 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 /** Subscribe to the main-process pre-quit notification. Used by the
* "Cache beim Schließen leeren" Settings toggle. */ * "Cache beim Schließen leeren" Settings toggle. */
onWipeBeforeQuit: (cb: () => Promise<void>) => () => void; onWipeBeforeQuit: (cb: () => Promise<void>) => () => void;
+4
View File
@@ -160,6 +160,10 @@ const api = {
setFullscreen: (enabled: boolean): Promise<void> => setFullscreen: (enabled: boolean): Promise<void> =>
ipcRenderer.invoke(CHANNELS.WINDOW_SET_FULLSCREEN, enabled), 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 ------------------------------------------------------------ // OS hostname ------------------------------------------------------------
getHostname: (): Promise<string | null> => ipcRenderer.invoke(CHANNELS.APP_HOSTNAME), getHostname: (): Promise<string | null> => ipcRenderer.invoke(CHANNELS.APP_HOSTNAME),
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@chat-app/desktop", "name": "@chat-app/desktop",
"version": "0.20.1", "version": "0.21.0",
"private": true, "private": true,
"description": "Electron desktop client (Windows / macOS / Linux)", "description": "Electron desktop client (Windows / macOS / Linux)",
"type": "module", "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 { HashRouter, Navigate, Outlet, Route, Routes, useParams } from 'react-router-dom';
import { AppShell } from './components/AppShell'; import { AppShell } from './components/AppShell';
@@ -13,6 +13,7 @@ import { CallProvider } from './context/CallContext';
import { ConversationsProvider } from './context/ConversationsContext'; import { ConversationsProvider } from './context/ConversationsContext';
import { FriendshipsProvider } from './context/FriendshipsContext'; import { FriendshipsProvider } from './context/FriendshipsContext';
import { ThemeProvider } from './context/ThemeContext'; import { ThemeProvider } from './context/ThemeContext';
import { hydrateDrafts } from './lib/composerDraftStore';
import { AuthPage } from './pages/AuthPage'; import { AuthPage } from './pages/AuthPage';
import { ChatsEmptyState, ChatsPage } from './pages/ChatsPage'; import { ChatsEmptyState, ChatsPage } from './pages/ChatsPage';
import { ConversationPage } from './pages/ConversationPage'; import { ConversationPage } from './pages/ConversationPage';
@@ -73,6 +74,10 @@ function ConversationRoute() {
} }
export function App() { export function App() {
useEffect(() => {
void hydrateDrafts();
}, []);
return ( return (
<ErrorBoundary scope="root"> <ErrorBoundary scope="root">
<ThemeProvider> <ThemeProvider>
@@ -2,6 +2,7 @@ import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/s
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache'; import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
import { getVoiceSpeed, setVoiceSpeed, VOICE_SPEEDS, type VoiceSpeed } from '../lib/voiceSpeedSettings';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
import { AlertIcon, MicIcon, SpinnerIcon } from './icons'; import { AlertIcon, MicIcon, SpinnerIcon } from './icons';
@@ -23,6 +24,7 @@ export function AttachmentAudio({ handle }: Props) {
const [duration, setDuration] = useState<number>(0); const [duration, setDuration] = useState<number>(0);
const [position, setPosition] = useState<number>(0); const [position, setPosition] = useState<number>(0);
const [playing, setPlaying] = useState(false); const [playing, setPlaying] = useState(false);
const [speed, setSpeed] = useState<VoiceSpeed>(() => getVoiceSpeed());
const audioRef = useRef<HTMLAudioElement | null>(null); const audioRef = useRef<HTMLAudioElement | null>(null);
useEffect(() => { useEffect(() => {
@@ -105,6 +107,12 @@ export function AttachmentAudio({ handle }: Props) {
}; };
}, [arrayBuf]); }, [arrayBuf]);
useEffect(() => {
const el = audioRef.current;
if (!el) return;
el.playbackRate = speed;
}, [speed, blobUrl]);
const fallbackPeaks = useMemo( const fallbackPeaks = useMemo(
() => (peaks ? null : Array.from({ length: BAR_COUNT }, () => 0.5)), () => (peaks ? null : Array.from({ length: BAR_COUNT }, () => 0.5)),
[peaks], [peaks],
@@ -154,6 +162,29 @@ export function AttachmentAudio({ handle }: Props) {
<PlayGlyph /> <PlayGlyph />
)} )}
</button> </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 className="flex min-w-0 flex-1 flex-col gap-1">
<div <div
role="slider" 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 { type RemoteScreenShare, useCall } from '../context/CallContext';
import { MonitorShareIcon } from './icons'; import { MonitorShareIcon } from './icons';
import { ScreenShareAnnotations } from './ScreenShareAnnotations';
interface ScreenShareViewerProps { interface ScreenShareViewerProps {
share: RemoteScreenShare; share: RemoteScreenShare;
@@ -34,6 +35,7 @@ export function ScreenShareViewer({
const { watchingShareUserIds, watchShare } = useCall(); const { watchingShareUserIds, watchShare } = useCall();
const watching = watchingShareUserIds.has(share.participantId); const watching = watchingShareUserIds.has(share.participantId);
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
const [annotateEnabled, setAnnotateEnabled] = useState(false);
const letter = displayName.trim().charAt(0).toUpperCase() || '?'; const letter = displayName.trim().charAt(0).toUpperCase() || '?';
useEffect(() => { useEffect(() => {
@@ -96,21 +98,28 @@ export function ScreenShareViewer({
</div> </div>
{watching ? ( {watching ? (
<video <div className="relative h-full w-full flex-1">
ref={videoRef} <video
autoPlay ref={videoRef}
playsInline autoPlay
muted playsInline
// Suppress the browser's built-in <video> context menu muted
// ("Save Video As…", PiP, …) so the right-click event bubbles // Suppress the browser's built-in <video> context menu
// to the wrapping tile div in InCallPanel — that's where the // ("Save Video As…", PiP, …) so the right-click event bubbles
// app's volume / mute menu is wired up. Without this, the // to the wrapping tile div in InCallPanel — that's where the
// native menu opens on top of ours in focus + fullscreen // app's volume / mute menu is wired up. Without this, the
// modes (where the video covers the whole tile). // native menu opens on top of ours in focus + fullscreen
onContextMenu={(e) => e.preventDefault()} // modes (where the video covers the whole tile).
onDoubleClick={toggleFullscreen} onContextMenu={(e) => e.preventDefault()}
className="block h-full w-full flex-1 cursor-zoom-in bg-black object-contain" 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 <button
type="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'; import { markAttachmentViewed } from '@chat-app/shared/chat';
@@ -16,16 +16,34 @@ interface Props {
} }
// Three states: // Three states:
// 1. viewedAt is null AND user is recipient → blurred lock card; tap opens // 1. viewedAt is null AND user is recipient → blurred lock card. Press-and-
// fullscreen lightbox AND fires the mark-viewed RPC. // hold reveals the image fullscreen; release closes it AND fires the
// mark-viewed RPC.
// 2. viewedAt is set → tombstone "Angesehen am …". // 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) { export function ViewOnceImage({ attachmentId, viewedAt, isSender, src }: Props) {
const [revealedAt, setRevealedAt] = useState<string | null>(viewedAt); 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; 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) { if (burned && !isSender) {
return ( 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"> <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 startReveal = async (): Promise<void> => {
const handleOpen = 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 { try {
const res = await markAttachmentViewed(supabase, attachmentId); const res = await markAttachmentViewed(supabase, attachmentId);
if (res.viewedAt) setRevealedAt(res.viewedAt); if (res.viewedAt) setRevealedAt(res.viewedAt);
} catch (err) { } catch (err) {
console.warn('mark-viewed failed', err); console.warn('mark-viewed failed', err);
burnedRef.current = false;
} }
setFullscreen(true);
}; };
return ( return (
<> <>
<button <button
type="button" type="button"
onClick={() => void handleOpen()} onPointerDown={() => void startReveal()}
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" 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" /> <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> </button>
{fullscreen && ( {revealing && (
<div <div
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 p-6" aria-label="Einmal-ansehen Bild"
onClick={() => setFullscreen(false)} 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> </div>
)} )}
</> </>
@@ -2,6 +2,9 @@ import { useEffect, useRef, useState } from 'react';
import type { WhiteboardStroke } from '@chat-app/shared/chat'; 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 WhiteboardTool = 'pen' | 'eraser';
export type WhiteboardColor = '#000000' | '#ef4444' | '#3b82f6' | '#22c55e' | '#facc15' | '#a855f7'; export type WhiteboardColor = '#000000' | '#ef4444' | '#3b82f6' | '#22c55e' | '#facc15' | '#a855f7';
export type WhiteboardWidth = 2 | 4 | 8; export type WhiteboardWidth = 2 | 4 | 8;
@@ -22,6 +25,8 @@ interface Props {
onStroke: (payload: WhiteboardStrokePayload) => void; onStroke: (payload: WhiteboardStrokePayload) => void;
logicalWidth?: number; logicalWidth?: number;
logicalHeight?: number; logicalHeight?: number;
/** Enables live-cursor broadcast when set. */
whiteboardId?: string | null;
} }
const DEFAULT_LOGICAL_W = 1280; const DEFAULT_LOGICAL_W = 1280;
@@ -35,12 +40,63 @@ export function WhiteboardCanvas({
onStroke, onStroke,
logicalWidth = DEFAULT_LOGICAL_W, logicalWidth = DEFAULT_LOGICAL_W,
logicalHeight = DEFAULT_LOGICAL_H, logicalHeight = DEFAULT_LOGICAL_H,
whiteboardId,
}: Props) { }: Props) {
const canvasRef = useRef<HTMLCanvasElement | null>(null); const canvasRef = useRef<HTMLCanvasElement | null>(null);
const draftRef = useRef<WhiteboardStrokePayload | null>(null); const draftRef = useRef<WhiteboardStrokePayload | null>(null);
const strokeStartRef = useRef<number>(0); const strokeStartRef = useRef<number>(0);
const [, forceTick] = useState(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(() => { useEffect(() => {
const cv = canvasRef.current; const cv = canvasRef.current;
if (!cv) return; if (!cv) return;
@@ -81,8 +137,9 @@ export function WhiteboardCanvas({
}; };
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => { const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (!draftRef.current) return;
const [x, y] = canvasPoint(e); const [x, y] = canvasPoint(e);
cursorSessionRef.current?.send(x, y);
if (!draftRef.current) return;
draftRef.current.points.push([x, y, Date.now() - strokeStartRef.current]); draftRef.current.points.push([x, y, Date.now() - strokeStartRef.current]);
forceTick((n) => n + 1); forceTick((n) => n + 1);
}; };
@@ -102,18 +159,53 @@ export function WhiteboardCanvas({
}; };
return ( return (
<canvas <div
ref={canvasRef} className="relative"
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={{ aspectRatio: logicalWidth + ' / ' + logicalHeight, width: '100%' }} 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( function renderStroke(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
s: Partial<WhiteboardStrokePayload>, s: Partial<WhiteboardStrokePayload>,
@@ -80,6 +80,7 @@ export function WhiteboardModal({ whiteboardId, onClose }: Props) {
color={color} color={color}
width={width} width={width}
onStroke={(payload) => void insertStroke(payload)} onStroke={(payload) => void insertStroke(payload)}
whiteboardId={whiteboardId}
/> />
)} )}
</div> </div>
+27 -5
View File
@@ -98,6 +98,7 @@ import {
subscribeScreenShareVolumes, subscribeScreenShareVolumes,
} from '../lib/screenShareVolumes'; } from '../lib/screenShareVolumes';
import { startSoundboardHotkeys } from '../lib/soundboardHotkeys'; import { startSoundboardHotkeys } from '../lib/soundboardHotkeys';
import { playSoundboardLocal } from '../lib/soundboardLocalPlay';
import { playEntry } from '../lib/soundboardPlayback'; import { playEntry } from '../lib/soundboardPlayback';
import { import {
getPrefs as getSoundboardPrefs, getPrefs as getSoundboardPrefs,
@@ -2257,15 +2258,36 @@ export function CallProvider({ children }: { children: ReactNode }) {
pipelineRef.current?.setMonitorGain(prefs.monitorGain); pipelineRef.current?.setMonitorGain(prefs.monitorGain);
}, []); }, []);
// Global soundboard hotkey registration — runs only while connected so the // Global soundboard hotkey registration — always-on so the OS-level
// OS-level shortcuts don't fire when the user is outside of a call. // 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(() => { useEffect(() => {
if (state.kind !== 'connected') return;
const teardown = startSoundboardHotkeys((id) => { 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; return teardown;
}, [state.kind, playSoundboard]); }, []); // eslint-disable-line react-hooks/exhaustive-deps
const setAudioInputDevice = useCallback(async (deviceId: string | null) => { const setAudioInputDevice = useCallback(async (deviceId: string | null) => {
updateAudioSettings({ inputDeviceId: deviceId }); 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> { export async function uploadAvatar(userId: string, file: File): Promise<string> {
if (!file.type.startsWith('image/')) { if (!file.type.startsWith('image/')) {
throw new Error('only image files are accepted'); 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); const blob = await resizeToSquare(file);
return uploadAvatarBlob(userId, blob); 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 // Upload an already-cropped Blob (e.g. from ImageCropDialog) without going
// through the legacy center-crop. Caller is responsible for sizing — the // through the legacy center-crop. Caller is responsible for sizing — the
// dialog already clamps to MAX_DIM via its outputWidth. // dialog already clamps to MAX_DIM via its outputWidth.
export async function uploadAvatarBlob(userId: string, blob: Blob): Promise<string> { 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 // 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 // the profile row — Supabase Storage CDN caches by URL, so a fresh path
// also forces clients to fetch the new image. // 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();
}
@@ -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 };
}
+21 -1
View File
@@ -78,6 +78,7 @@ import { markRead as markMessagesReadRemote, useMessageReads } from '../lib/useM
import { usePeerPresence } from '../lib/usePeerPresence'; import { usePeerPresence } from '../lib/usePeerPresence';
import { usePinnedMessages } from '../lib/usePinnedMessages'; import { usePinnedMessages } from '../lib/usePinnedMessages';
import { useTypingChannel } from '../lib/useTypingChannel'; import { useTypingChannel } from '../lib/useTypingChannel';
import { clearDraft, getDraftSync, setDraft } from '../lib/composerDraftStore';
// Discriminated union for rows inside the virtualized message list. Keeping // Discriminated union for rows inside the virtualized message list. Keeping
// pending bubbles and the "load older" tile inside the same Virtuoso // pending bubbles and the "load older" tile inside the same Virtuoso
@@ -200,7 +201,10 @@ export function ConversationPage() {
}); });
}, [id, messages, myId]); }, [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 [sending, setSending] = useState(false);
const [sendError, setSendError] = useState<string | null>(null); const [sendError, setSendError] = useState<string | null>(null);
const [stickToBottom, setStickToBottom] = useState(true); const [stickToBottom, setStickToBottom] = useState(true);
@@ -304,6 +308,21 @@ export function ConversationPage() {
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const composerRef = useRef<HTMLTextAreaElement>(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(() => { useEffect(() => {
if (firstUnreadComputedRef.current) return; if (firstUnreadComputedRef.current) return;
if (!id || messages.length === 0) return; if (!id || messages.length === 0) return;
@@ -753,6 +772,7 @@ export function ConversationPage() {
if (fileInputRef.current) fileInputRef.current.value = ''; if (fileInputRef.current) fileInputRef.current.value = '';
setStickToBottom(true); setStickToBottom(true);
notifyStopTyping(); notifyStopTyping();
if (id) clearDraft(id);
} catch (err: unknown) { } catch (err: unknown) {
const code = extractErrorCode(err); const code = extractErrorCode(err);
setSendError( setSendError(
+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