# Phase 8 — Feature Batch Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Ship six independent feature improvements in one batch: - **A1** Composer-drafts cross-restart per chat - **A2** Voice-message playback-speed control - **B1** View-once "Halten zum Sehen" + screenshot-block (Windows + macOS) - **C1** Multi-user whiteboard live cursors - **C2** Screen-share annotation overlay (shared between viewers) - **D1** Soundboard hotkeys always-on (also fire outside calls, play locally) - **D2** Animated avatars (GIF/APNG/animated-WebP upload bypasses the canvas re-encode) **Architecture:** Each task is self-contained — different files, different domains, no shared state. They can be implemented and committed in any order. **Tech Stack:** Electron 33, React 18, Supabase realtime (broadcast for cursors and annotations), better-sqlite3 (drafts), Web Audio API (voice speed, soundboard local play), Vitest. --- ## Pre-Flight: What's already there The research phase before writing this plan confirmed: - **Voice-waveform + scrub is already done** (`AttachmentAudio.tsx:68-128`) — only playback-speed is missing. Out of scope here. - **Soundboard hotkeys are already wired** (`CallContext.tsx:2262`, `soundboardHotkeys.ts`) but gated on `state.kind === 'connected'`. Task D1 lifts that gate. - **Multi-user whiteboard realtime works** (`useWhiteboardStrokes.ts:48-94` subscribes to `whiteboard_strokes` INSERT). Task C1 adds presence-channel live cursors on top; in-progress stroke broadcast is explicitly **out of scope** (deferred to a phase 9 if needed). - **Animated-avatar display already works natively** in `` because `` renders animated GIF/APNG/WebP. The only blocker is `avatarUpload.ts:9-44` which re-encodes via `canvas.toBlob` and strips animation. Task D2 bypasses that for animated MIME types. - **View-once bug root cause** (`ViewOnceImage.tsx:55-63`): `setRevealedAt(res.viewedAt)` fires before `setFullscreen(true)`, React re-renders with `burned=true` and returns the tombstone early — the dialog never paints. Task B1 reorders + switches to a hold-to-view pattern. --- # Group A — Messaging UX ## Task A1: Composer drafts cross-restart per chat **Goal:** When the user types in a chat then switches away (or restarts the app), the typed text + reply target are restored on return. Discord-style. **Files:** - Create: `apps/desktop/src/lib/composerDraftStore.ts` - Create: `apps/desktop/src/lib/composerDraftStore.test.ts` - Modify: `apps/desktop/src/lib/messageCache.ts` (add a new table to the existing SQLite init) - Modify: `apps/desktop/src/pages/ConversationPage.tsx` (initial `useState` for `text`, `replyTo`; write effect on change; clear on send) - Modify: `apps/desktop/src/App.tsx` (call `hydrateDrafts()` once on app boot) **Scope:** - Persist: `text` (string) + `replyToId` (string | null) per `conversationId`. Survives app restart. - Don't persist: pending file attachments (kept in-RAM only — Files don't serialize cleanly, session-scope is the pragmatic balance). - Clear behaviour: cleared on successful send; preserved on cancel/escape; preserved when switching chats. ### Steps - [ ] **Step 1: Add a `composer_drafts` table to the SQLite bootstrap** In `apps/desktop/src/lib/messageCache.ts` inside `getHandle()` add to the schema bootstrap (after the existing `CREATE TABLE IF NOT EXISTS messages` block, around line 39): ```ts await execute( handle, `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 );`, ); ``` - [ ] **Step 2: Write the failing test for the draft store** Create `apps/desktop/src/lib/composerDraftStore.test.ts`: ```ts 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: { 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'); }); }); ``` - [ ] **Step 3: Implement the store** Create `apps/desktop/src/lib/composerDraftStore.ts`: ```ts // Composer-draft persistence. Two-tier semantics: // * In-memory `Map` 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(); const pendingWrites = new Map>(); let handlePromise: Promise | null = null; async function getHandle(): Promise { if (handlePromise) return handlePromise; if (!isTauriRuntime()) { handlePromise = Promise.resolve(null); return handlePromise; } handlePromise = (async () => { try { return await window.electronAPI.sqlLoad({ name: DB_NAME }); } 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 { 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 { 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 || (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; } ``` - [ ] **Step 4: Run tests to confirm 6/6 pass** Run: `pnpm --filter @chat-app/desktop test -- composerDraftStore` - [ ] **Step 5: Hydrate drafts on app boot** In `apps/desktop/src/App.tsx`, inside the `App()` component (right at the top), add the boot effect. Add the import near the others: ```ts import { hydrateDrafts } from './lib/composerDraftStore'; ``` Inside `App()` before the `return`: ```ts useEffect(() => { void hydrateDrafts(); }, []); ``` Add `useEffect` to the React import on line 1. - [ ] **Step 6: Wire the store into ConversationPage** In `apps/desktop/src/pages/ConversationPage.tsx`, add to the imports near the other local-lib imports (around line 67): ```ts import { clearDraft, getDraftSync, setDraft } from '../lib/composerDraftStore'; ``` Replace the `text` state declaration (line 203): ```ts const [text, setText] = useState(''); ``` with: ```ts const [text, setText] = useState(() => { if (!id) return ''; return getDraftSync(id)?.text ?? ''; }); ``` Below the existing `replyTo` state (around line 229), add a resolver effect that maps a saved `replyToId` back to a `DecryptedMessage` once messages have loaded: ```ts 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]); ``` Add a write effect that mirrors `text` + `replyTo` changes to the store: ```ts useEffect(() => { if (!id) return; setDraft(id, { text, replyToId: replyTo?.id ?? null }); }, [id, text, replyTo?.id]); ``` In the `handleSend` function (around line 748), after the successful send right after `setText('')`, add: ```ts if (id) clearDraft(id); ``` - [ ] **Step 7: Typecheck + tests** ```bash pnpm --filter @chat-app/desktop typecheck pnpm --filter @chat-app/desktop test ``` Both must pass. - [ ] **Step 8: Commit** ```bash git add apps/desktop/src/lib/composerDraftStore.ts apps/desktop/src/lib/composerDraftStore.test.ts apps/desktop/src/lib/messageCache.ts apps/desktop/src/pages/ConversationPage.tsx apps/desktop/src/App.tsx git commit -m "feat(composer): persist text + reply target per chat across restarts" ``` --- ## Task A2: Voice-message playback speed **Goal:** Add a 1× / 1.5× / 2× toggle to the voice-message player. Remember last-used speed per user. **Files:** - Modify: `apps/desktop/src/components/AttachmentAudio.tsx` (add speed state + chip UI + applies to `audioRef.current.playbackRate`) - Create: `apps/desktop/src/lib/voiceSpeedSettings.ts` — small localStorage wrapper for the default ### Steps - [ ] **Step 1: Create the settings helper** Create `apps/desktop/src/lib/voiceSpeedSettings.ts`: ```ts // 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; ``` - [ ] **Step 2: Wire into AttachmentAudio** In `apps/desktop/src/components/AttachmentAudio.tsx`, add to imports: ```ts import { getVoiceSpeed, setVoiceSpeed, VOICE_SPEEDS, type VoiceSpeed } from '../lib/voiceSpeedSettings'; ``` Add a state hook near the other `useState` declarations (around line 25): ```ts const [speed, setSpeed] = useState(() => getVoiceSpeed()); ``` Add an effect that pushes the speed onto the audio element whenever either changes (right below the existing `arrayBuf` effect around line 106): ```ts useEffect(() => { const el = audioRef.current; if (!el) return; el.playbackRate = speed; }, [speed, blobUrl]); ``` Add a speed-chip group inside the player's flex row, right after the play/pause button closes (after the closing `` around line 156, before the `
` block): ```tsx
{VOICE_SPEEDS.map((s) => { const active = s === speed; return ( ); })}
``` - [ ] **Step 3: Manual smoke** `pnpm desktop:dev`. Send / open a voice message. Click 1.5×, then 2×, then back to 1×. Pitch should adjust accordingly. Refresh the app — the chip group should land on the last-used speed. - [ ] **Step 4: Typecheck + tests** ```bash pnpm --filter @chat-app/desktop typecheck pnpm --filter @chat-app/desktop test ``` - [ ] **Step 5: Commit** ```bash git add apps/desktop/src/lib/voiceSpeedSettings.ts apps/desktop/src/components/AttachmentAudio.tsx git commit -m "feat(voice): playback-speed toggle (1x/1.5x/2x) with per-user default" ``` --- # Group B — View-Once Hardening ## Task B1: Fix the instant-close bug + hold-to-view + screenshot block **Goal:** 1. Fix the bug where clicking a view-once attachment closes it before the user sees anything. 2. Switch to a "hold to view" pattern: image visible only while the mouse is held down. 3. Block desktop-OS screenshots / screen recording during the reveal window via Electron's `setContentProtection(true)`. **Files:** - Modify: `apps/desktop/src/components/ViewOnceImage.tsx` — rewrite the reveal flow - Modify: `apps/desktop/electron/main.ts` — add IPC handler for content-protection toggle - Modify: `apps/desktop/electron/ipc-types.ts` — declare the new channel - Modify: `apps/desktop/electron/preload.ts` — expose to renderer - Modify: `apps/desktop/electron/preload-types.d.ts` — type the renderer API ### Root cause `ViewOnceImage.tsx:55-63`: ```ts const handleOpen = async (): Promise => { try { const res = await markAttachmentViewed(supabase, attachmentId); if (res.viewedAt) setRevealedAt(res.viewedAt); // ← sets burned = true } catch (err) { console.warn('mark-viewed failed', err); } setFullscreen(true); // ← never paints; component returns tombstone above }; ``` Once `revealedAt` is set, the early return `if (burned && !isSender) return tombstone` fires on the next render and the fullscreen dialog never paints. The new flow defers `markAttachmentViewed` until the user releases the hold gesture, so the tombstone only swaps in AFTER the image has actually been viewed. ### Steps - [ ] **Step 1: Add the IPC channel constants and types** Read the current `apps/desktop/electron/ipc-types.ts` first to confirm the pattern (existing `CHANNELS` shape, exported types). Then add to the `CHANNELS` constant: ```ts WINDOW_SET_CONTENT_PROTECTION: 'window:set-content-protection', ``` Export a new typed args interface: ```ts export interface WindowSetContentProtectionArgs { enabled: boolean; } ``` - [ ] **Step 2: Wire the main-process handler** In `apps/desktop/electron/main.ts`, find the main-window setup (grep for `BrowserWindow` and `ipcMain.handle` to find the right scope). Inside the same scope that registers other ipcMain handlers, add: ```ts 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 { mainWindow.setContentProtection(args.enabled); } catch (err) { console.warn('setContentProtection failed', err); } }, ); ``` Replace `mainWindow` with the actual variable name used in `main.ts` — read the file first to confirm. - [ ] **Step 3: Expose to the renderer via preload** In `apps/desktop/electron/preload.ts`, add to the `electronAPI` object (mirror the existing pattern): ```ts setContentProtection: (enabled: boolean) => ipcRenderer.invoke(CHANNELS.WINDOW_SET_CONTENT_PROTECTION, { enabled }), ``` In `apps/desktop/electron/preload-types.d.ts`, add the new method to the `ElectronAPI` interface: ```ts setContentProtection: (enabled: boolean) => Promise; ``` - [ ] **Step 4: Rewrite ViewOnceImage with hold-to-view** Replace the entire `ViewOnceImage` component body in `apps/desktop/src/components/ViewOnceImage.tsx`. Change the React import on line 1 to: ```ts import { useEffect, useRef, useState } from 'react'; ``` Replace lines 17-87 (the component body) with: ```tsx // Three states: // 1. viewedAt is null AND user is recipient → blurred lock card. Press-and- // hold reveals the image fullscreen; release closes it AND fires the // mark-viewed RPC. // 2. viewedAt is set → tombstone "Angesehen am …". // 3. user is sender → normal image, tombstone update appears once recipient // burns it. // // While revealed, the renderer window enables content-protection // (`win.setContentProtection(true)`) so OS-level screen capture (OBS, Win/Cmd // snipping tools, screen recorders) sees a black/empty surface. Re-enabled // on release / unmount. export function ViewOnceImage({ attachmentId, viewedAt, isSender, src }: Props) { const [revealedAt, setRevealedAt] = useState(viewedAt); const [revealing, setRevealing] = useState(false); const burnedRef = useRef(false); const burned = revealedAt !== null; // Tear down screen-capture protection if the component unmounts mid-reveal. useEffect(() => { return () => { if (revealing) { void window.electronAPI?.setContentProtection?.(false).catch(() => {}); } }; }, [revealing]); if (burned && !isSender) { return (
Angesehen am {new Date(revealedAt).toLocaleString()}
); } if (isSender) { return (
Einmal ansehen {burned && ( Angesehen )}
); } const startReveal = async (): Promise => { if (burnedRef.current) return; burnedRef.current = true; try { await window.electronAPI?.setContentProtection?.(true); } catch (err) { console.warn('setContentProtection enable failed', err); } setRevealing(true); }; const endReveal = async (): Promise => { if (!revealing) return; setRevealing(false); try { await window.electronAPI?.setContentProtection?.(false); } catch (err) { console.warn('setContentProtection disable failed', err); } // Fire-and-forget mark-viewed. Updates the local tombstone on success; // an error leaves the bubble in "lock card" state so the user can retry. try { const res = await markAttachmentViewed(supabase, attachmentId); if (res.viewedAt) setRevealedAt(res.viewedAt); } catch (err) { console.warn('mark-viewed failed', err); burnedRef.current = false; // allow retry on error } }; return ( <> {revealing && (
Loslassen zum Schließen — Aufnahme blockiert
)} ); } ``` - [ ] **Step 5: Build the Electron side + test reveal** Rebuild the electron bundle: ```bash pnpm --filter @chat-app/desktop run build:win ``` Then in dev mode: ```bash pnpm desktop:dev ``` Test: 1. Receive a view-once image from another account 2. Press-and-hold the lock card → image appears fullscreen 3. While holding, try Win+PrtScr — the OS clipboard should NOT contain the image (instead a black rectangle where the window was) 4. Release → image closes, tombstone "Angesehen am …" appears in the next render after the mark-viewed RPC returns - [ ] **Step 6: Typecheck** ```bash pnpm --filter @chat-app/desktop typecheck ``` - [ ] **Step 7: Commit** ```bash git add apps/desktop/electron/ipc-types.ts apps/desktop/electron/main.ts apps/desktop/electron/preload.ts apps/desktop/electron/preload-types.d.ts apps/desktop/src/components/ViewOnceImage.tsx git commit -m "fix(view-once): hold-to-view pattern + content-protection during reveal" ``` --- # Group C — Multi-User Collab ## Task C1: Live cursors on the whiteboard **Goal:** Every user editing the same whiteboard sees the others' pointer positions in real-time, as small coloured dots with their display name. Strokes themselves are unchanged (they already sync via `whiteboard_strokes` INSERT subscription). **Files:** - Create: `apps/desktop/src/lib/whiteboardCursors.ts` — Supabase broadcast wrapper for cursor presence - Modify: `apps/desktop/src/components/WhiteboardCanvas.tsx` — emit cursor on pointerMove, render others' cursors as overlay - Modify: `apps/desktop/src/components/WhiteboardModal.tsx` — thread `whiteboardId` through to enable cursor sync **Out of scope:** in-progress stroke broadcast (deferred to a phase 9). Performance reason: 60 events/sec/user × N users with growing payloads is meaningfully heavier than presence-only cursors. ### Steps - [ ] **Step 1: Create the cursor broadcast helper** Create `apps/desktop/src/lib/whiteboardCursors.ts`: ```ts // 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 | 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 }; } ``` - [ ] **Step 2: Render others' cursors in WhiteboardCanvas** In `apps/desktop/src/components/WhiteboardCanvas.tsx`, add imports: ```ts import { useAuth } from '../context/AuthContext'; import { openCursorSession, type CursorEvent, type CursorSession } from '../lib/whiteboardCursors'; ``` Extend the `Props` interface to add an optional `whiteboardId`: ```ts interface Props { strokes: WhiteboardStroke[]; tool: WhiteboardTool; color: WhiteboardColor; width: WhiteboardWidth; onStroke: (payload: WhiteboardStrokePayload) => void; logicalWidth?: number; logicalHeight?: number; /** Enables live-cursor broadcast when set. */ whiteboardId?: string | null; } ``` Update the function signature to accept the new prop. Inside the component, before the existing pointer handlers, add: ```ts const { session } = useAuth(); const [remoteCursors, setRemoteCursors] = useState>( () => new Map(), ); const cursorSessionRef = useRef(null); useEffect(() => { if (!whiteboardId) return; const me = session?.user; if (!me) return; const displayName = (me.user_metadata as Record | null)?.['display_name'] as string | undefined ?? 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]); // 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]); ``` Modify `handlePointerMove` to also broadcast the cursor (the function around line 83): ```ts const handlePointerMove = (e: React.PointerEvent) => { const [x, y] = canvasPoint(e); cursorSessionRef.current?.send(x, y); if (!draftRef.current) return; draftRef.current.points.push([x, y, Date.now() - strokeStartRef.current]); forceTick((n) => n + 1); }; ``` Wrap the existing `` in a relatively-positioned container and render cursor markers on top. Replace the existing `return (...)` block with: ```tsx return (
{Array.from(remoteCursors.values()).map((c) => { const pctX = (c.x / logicalWidth) * 100; const pctY = (c.y / logicalHeight) * 100; return ( ); })}
); ``` Add a helper at the bottom of the file (after `renderStroke`): ```ts 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%)'; } ``` - [ ] **Step 3: Pass whiteboardId through WhiteboardModal** In `apps/desktop/src/components/WhiteboardModal.tsx`, pass the new prop to `` (around line 77): ```tsx void insertStroke(payload)} whiteboardId={whiteboardId} /> ``` - [ ] **Step 4: Typecheck + manual smoke** ```bash pnpm --filter @chat-app/desktop typecheck pnpm desktop:dev ``` Open the same whiteboard in two windows (or two devices). Move the mouse in one — the other should show a coloured dot tracking your position, with the display name label. Stop moving for 2 seconds — the cursor fades. - [ ] **Step 5: Commit** ```bash git add apps/desktop/src/lib/whiteboardCursors.ts apps/desktop/src/components/WhiteboardCanvas.tsx apps/desktop/src/components/WhiteboardModal.tsx git commit -m "feat(whiteboard): live cursors via broadcast channel" ``` --- ## Task C2: Screen-share annotation overlay **Goal:** While viewing someone's screen share inside a call, any participant can draw on top of the video stream. All viewers + the sharer see the strokes live, with auto-fade after 8 seconds. **Files:** - Create: `apps/desktop/src/components/ScreenShareAnnotations.tsx` — canvas overlay component - Modify: `apps/desktop/src/components/ScreenShareViewer.tsx` — wrap the `