From 93098a74caca13700cdbf1f3ee89647e8f35b1c4 Mon Sep 17 00:00:00 2001 From: byGalax Date: Sun, 17 May 2026 16:54:56 +0200 Subject: [PATCH] docs(phase8): feature batch implementation plan --- .../plans/2026-05-17-phase8-feature-batch.md | 1680 +++++++++++++++++ 1 file changed, 1680 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-17-phase8-feature-batch.md diff --git a/docs/superpowers/plans/2026-05-17-phase8-feature-batch.md b/docs/superpowers/plans/2026-05-17-phase8-feature-batch.md new file mode 100644 index 0000000..3ad6ce2 --- /dev/null +++ b/docs/superpowers/plans/2026-05-17-phase8-feature-batch.md @@ -0,0 +1,1680 @@ +# 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 `