Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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.
|
// 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
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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,6 +98,7 @@ export function ScreenShareViewer({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{watching ? (
|
{watching ? (
|
||||||
|
<div className="relative h-full w-full flex-1">
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
autoPlay
|
autoPlay
|
||||||
@@ -109,8 +112,14 @@ export function ScreenShareViewer({
|
|||||||
// modes (where the video covers the whole tile).
|
// modes (where the video covers the whole tile).
|
||||||
onContextMenu={(e) => e.preventDefault()}
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
onDoubleClick={toggleFullscreen}
|
onDoubleClick={toggleFullscreen}
|
||||||
className="block h-full w-full flex-1 cursor-zoom-in bg-black object-contain"
|
className="block h-full w-full cursor-zoom-in bg-black object-contain"
|
||||||
/>
|
/>
|
||||||
|
<ScreenShareAnnotations
|
||||||
|
shareKey={share.participantId}
|
||||||
|
enabled={annotateEnabled}
|
||||||
|
onToggleEnabled={setAnnotateEnabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -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,6 +159,10 @@ export function WhiteboardCanvas({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div
|
||||||
|
className="relative"
|
||||||
|
style={{ aspectRatio: logicalWidth + ' / ' + logicalHeight, width: '100%' }}
|
||||||
|
>
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
onPointerDown={handlePointerDown}
|
onPointerDown={handlePointerDown}
|
||||||
@@ -109,9 +170,40 @@ export function WhiteboardCanvas({
|
|||||||
onPointerUp={handlePointerUp}
|
onPointerUp={handlePointerUp}
|
||||||
onPointerLeave={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"
|
className="block max-h-full max-w-full cursor-crosshair touch-none rounded-lg border border-line/40 bg-white shadow-2xl"
|
||||||
style={{ aspectRatio: logicalWidth + ' / ' + logicalHeight, width: '100%' }}
|
style={{ width: '100%', height: '100%' }}
|
||||||
/>
|
/>
|
||||||
|
{Array.from(remoteCursors.values()).map((c) => {
|
||||||
|
const pctX = (c.x / logicalWidth) * 100;
|
||||||
|
const pctY = (c.y / logicalHeight) * 100;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={c.userId}
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute"
|
||||||
|
style={{ left: pctX + '%', top: pctY + '%', transform: 'translate(-2px, -2px)' }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="block h-2 w-2 rounded-full border-2 border-white shadow"
|
||||||
|
style={{ backgroundColor: colorForUserId(c.userId) }}
|
||||||
|
/>
|
||||||
|
<span className="ml-2 inline-block translate-y-[-2px] rounded-full bg-black/60 px-1.5 py-0.5 text-[9px] font-semibold text-white">
|
||||||
|
{c.displayName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorForUserId(userId: string): string {
|
||||||
|
// Deterministic hue from the user id so each collaborator gets a stable
|
||||||
|
// colour across sessions. Saturation/lightness fixed to keep the cursor
|
||||||
|
// legible against the white canvas.
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < userId.length; i++) hash = (hash * 31 + userId.charCodeAt(i)) | 0;
|
||||||
|
const hue = Math.abs(hash) % 360;
|
||||||
|
return 'hsl(' + hue + ', 70%, 50%)';
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderStroke(
|
function renderStroke(
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 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(() => {
|
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 });
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -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 { 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(
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user