1314 lines
44 KiB
Markdown
1314 lines
44 KiB
Markdown
# Phase 4B — Whiteboard (Snapshot-Sync)
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Per-conversation collaborative whiteboard. Composer → "✏ Whiteboard" creates a new whiteboard which appears as a permanent message bubble in the chat. Clicking "Öffnen" launches a full-screen canvas modal where everyone in the conversation can draw with pen/eraser; every stroke is one row insert into `whiteboard_strokes` and arrives at every other participant via realtime postgres_changes. Bubble + history are permanent — anyone in the conv can reopen later.
|
|
|
|
**Architecture:**
|
|
- Two new public tables: `conversation_whiteboards (id, conversation_id, owner_user_id, created_at)` and `whiteboard_strokes (id, whiteboard_id, author_user_id, stroke_json jsonb, created_at)`. RLS via the existing `is_conversation_member(cid)` helper. Both tables added to `supabase_realtime` publication.
|
|
- A new `MessagePayload` variant `WhiteboardPayload` (`{ v: 1, type: 'whiteboard', whiteboard_id: string }`). When the user clicks "✏ Whiteboard", the renderer INSERTs a row into `conversation_whiteboards`, then calls the existing `send()` with `createWhiteboardPayload(id)` — a normal message row is created with the encrypted payload, and `MessageBubble` dispatches on `parsed.kind === 'whiteboard'` to render the "Öffnen" bubble.
|
|
- `WhiteboardModal` is a full-screen overlay with a `<canvas>` rendering the base white background plus every stroke from `useWhiteboardStrokes(whiteboardId)` (initial fetch + realtime). Pen tool inserts strokes on `pointerup`; eraser tool inserts a stroke with `tool: 'eraser'` that the renderer paints in white over previous strokes. "Clear all" (with confirm) deletes every stroke in the whiteboard via a single `DELETE WHERE whiteboard_id = ?` round-trip.
|
|
|
|
**Tech Stack:** PostgreSQL + RLS + Supabase Realtime (postgres_changes); React 18 + Canvas; reuses the existing message-bubble dispatch + `send()` pipeline; no new dependencies.
|
|
|
|
**Non-goals:**
|
|
- No CRDT / OT — last-writer-wins is fine because strokes are append-only and never edited.
|
|
- No undo/redo (collaborative; each user can issue Clear if needed).
|
|
- No stroke encryption (server reads stroke JSON; gated by RLS).
|
|
- No live cursor presence (just the strokes themselves).
|
|
- No image export/snapshot — the bubble shows a static "Whiteboard" tile, not a rendered preview.
|
|
|
|
---
|
|
|
|
## Pre-flight
|
|
|
|
- [ ] **Verify clean working tree on `main`**
|
|
|
|
Run: `cd "D:\Programmieren\ChatApp-Electron\chat-app" && git status`
|
|
Expected: clean (ignored `.env.local` is fine).
|
|
|
|
- [ ] **Confirm tooling is green**
|
|
|
|
Run: `pnpm --filter @chat-app/shared typecheck && pnpm --filter @chat-app/desktop typecheck && pnpm --filter @chat-app/shared test -- --run`
|
|
Expected: all green; 33 shared tests pass.
|
|
|
|
---
|
|
|
|
## Task 1: SQL migration — whiteboard tables + RLS + realtime publication
|
|
|
|
**Why:** Server-side authoritative state. RLS reuses the existing `is_conversation_member(cid)` helper so we don't duplicate access-control logic.
|
|
|
|
**Files:**
|
|
- Create: `supabase/migrations/20260516000006_whiteboards.sql`
|
|
|
|
Must be idempotent — `create table if not exists`, `drop policy if exists` + `create policy`, `do $$ ... if not exists $$` for publication adds.
|
|
|
|
- [ ] **Step 1: Write the SQL migration**
|
|
|
|
Create `supabase/migrations/20260516000006_whiteboards.sql`:
|
|
|
|
```sql
|
|
-- Phase 4B: per-conversation whiteboards.
|
|
--
|
|
-- conversation_whiteboards: one row per whiteboard. Created by a member; the
|
|
-- bubble that appears in the chat message timeline is a normal `messages`
|
|
-- row whose plaintext payload is `{v:1, type:'whiteboard', whiteboard_id:<id>}`.
|
|
--
|
|
-- whiteboard_strokes: append-only stream of drawing operations. Each row is
|
|
-- one user gesture (pen-down → pen-up); stroke_json carries the tool/color/
|
|
-- width/points. Realtime subscribers replay rows in created_at order.
|
|
|
|
create table if not exists public.conversation_whiteboards (
|
|
id uuid primary key default gen_random_uuid(),
|
|
conversation_id uuid not null references public.conversations(id) on delete cascade,
|
|
owner_user_id uuid not null references auth.users(id) on delete cascade,
|
|
created_at timestamptz not null default now()
|
|
);
|
|
|
|
create index if not exists conversation_whiteboards_conv_idx
|
|
on public.conversation_whiteboards(conversation_id, created_at desc);
|
|
|
|
create table if not exists public.whiteboard_strokes (
|
|
id uuid primary key default gen_random_uuid(),
|
|
whiteboard_id uuid not null references public.conversation_whiteboards(id) on delete cascade,
|
|
author_user_id uuid not null references auth.users(id) on delete cascade,
|
|
stroke_json jsonb not null,
|
|
created_at timestamptz not null default now()
|
|
);
|
|
|
|
create index if not exists whiteboard_strokes_board_idx
|
|
on public.whiteboard_strokes(whiteboard_id, created_at asc);
|
|
|
|
alter table public.conversation_whiteboards enable row level security;
|
|
alter table public.whiteboard_strokes enable row level security;
|
|
|
|
drop policy if exists conversation_whiteboards_select on public.conversation_whiteboards;
|
|
drop policy if exists conversation_whiteboards_insert on public.conversation_whiteboards;
|
|
drop policy if exists conversation_whiteboards_delete on public.conversation_whiteboards;
|
|
drop policy if exists whiteboard_strokes_select on public.whiteboard_strokes;
|
|
drop policy if exists whiteboard_strokes_insert on public.whiteboard_strokes;
|
|
drop policy if exists whiteboard_strokes_delete on public.whiteboard_strokes;
|
|
|
|
create policy conversation_whiteboards_select
|
|
on public.conversation_whiteboards
|
|
for select
|
|
using (public.is_conversation_member(conversation_id));
|
|
|
|
create policy conversation_whiteboards_insert
|
|
on public.conversation_whiteboards
|
|
for insert
|
|
with check (
|
|
public.is_conversation_member(conversation_id)
|
|
and owner_user_id = auth.uid()
|
|
);
|
|
|
|
create policy conversation_whiteboards_delete
|
|
on public.conversation_whiteboards
|
|
for delete
|
|
using (owner_user_id = auth.uid());
|
|
|
|
create policy whiteboard_strokes_select
|
|
on public.whiteboard_strokes
|
|
for select
|
|
using (
|
|
exists (
|
|
select 1
|
|
from public.conversation_whiteboards w
|
|
where w.id = whiteboard_strokes.whiteboard_id
|
|
and public.is_conversation_member(w.conversation_id)
|
|
)
|
|
);
|
|
|
|
create policy whiteboard_strokes_insert
|
|
on public.whiteboard_strokes
|
|
for insert
|
|
with check (
|
|
author_user_id = auth.uid()
|
|
and exists (
|
|
select 1
|
|
from public.conversation_whiteboards w
|
|
where w.id = whiteboard_id
|
|
and public.is_conversation_member(w.conversation_id)
|
|
)
|
|
);
|
|
|
|
create policy whiteboard_strokes_delete
|
|
on public.whiteboard_strokes
|
|
for delete
|
|
using (
|
|
exists (
|
|
select 1
|
|
from public.conversation_whiteboards w
|
|
where w.id = whiteboard_strokes.whiteboard_id
|
|
and public.is_conversation_member(w.conversation_id)
|
|
)
|
|
);
|
|
|
|
alter table public.conversation_whiteboards replica identity full;
|
|
alter table public.whiteboard_strokes replica identity full;
|
|
|
|
do $$
|
|
begin
|
|
if not exists (
|
|
select 1
|
|
from pg_publication_tables
|
|
where pubname = 'supabase_realtime'
|
|
and schemaname = 'public'
|
|
and tablename = 'conversation_whiteboards'
|
|
) then
|
|
execute 'alter publication supabase_realtime add table public.conversation_whiteboards';
|
|
end if;
|
|
|
|
if not exists (
|
|
select 1
|
|
from pg_publication_tables
|
|
where pubname = 'supabase_realtime'
|
|
and schemaname = 'public'
|
|
and tablename = 'whiteboard_strokes'
|
|
) then
|
|
execute 'alter publication supabase_realtime add table public.whiteboard_strokes';
|
|
end if;
|
|
end
|
|
$$;
|
|
```
|
|
|
|
- [ ] **Step 2: Commit + push to prod**
|
|
|
|
```bash
|
|
cd "D:\Programmieren\ChatApp-Electron\chat-app"
|
|
git add supabase/migrations/20260516000006_whiteboards.sql
|
|
git commit -m "feat(P4B.T1): whiteboards + whiteboard_strokes tables with RLS + realtime"
|
|
bash scripts/prod/push-migrations.sh whiteboards
|
|
```
|
|
|
|
Expect the push script to print `applying /tmp/migrations/20260516000006_whiteboards.sql` and end with `done.`. If it errors, STOP and report.
|
|
|
|
---
|
|
|
|
## Task 2: Shared payload type + wrapper helpers + tests
|
|
|
|
**Why:** Centralize the wire format. `MessagePayload` union must include the whiteboard variant so `parseMessagePayload` doesn't fall back to text; helper functions wrap the queries so the renderer never builds raw SQL.
|
|
|
|
**Files:**
|
|
- Modify: `packages/shared/src/chat/attachments.ts` — add `WhiteboardPayload` to the union + `parseMessagePayload` branch
|
|
- Create: `packages/shared/src/chat/whiteboards.ts` — CRUD wrappers
|
|
- Create: `packages/shared/src/chat/whiteboards.test.ts` — unit tests
|
|
|
|
- [ ] **Step 1: Extend the payload union in `packages/shared/src/chat/attachments.ts`**
|
|
|
|
After the existing `PollPayload` interface (line ~72), add:
|
|
|
|
```ts
|
|
export interface WhiteboardPayload {
|
|
v: 1;
|
|
type: 'whiteboard';
|
|
whiteboard_id: string;
|
|
}
|
|
```
|
|
|
|
Extend the union (line ~79):
|
|
```ts
|
|
export type MessagePayload =
|
|
| TextMessagePayload
|
|
| CallEventPayload
|
|
| PollPayload
|
|
| WhiteboardPayload;
|
|
```
|
|
|
|
Extend `ParsedMessagePayload` (line ~81) with:
|
|
```ts
|
|
| {
|
|
kind: 'whiteboard';
|
|
whiteboardId: string;
|
|
}
|
|
```
|
|
|
|
In `parseMessagePayload` (line ~111), after the `obj.type === 'poll'` branch and BEFORE the text fallback, add:
|
|
```ts
|
|
if (obj.type === 'whiteboard') {
|
|
const p = obj as Partial<WhiteboardPayload>;
|
|
const id = typeof p.whiteboard_id === 'string' && p.whiteboard_id.length > 0
|
|
? p.whiteboard_id
|
|
: '';
|
|
return { kind: 'whiteboard', whiteboardId: id };
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create the wrapper**
|
|
|
|
Create `packages/shared/src/chat/whiteboards.ts`:
|
|
|
|
```ts
|
|
import type { AppSupabaseClient } from '../supabase/client';
|
|
|
|
export interface Whiteboard {
|
|
id: string;
|
|
conversationId: string;
|
|
ownerUserId: string;
|
|
createdAt: string;
|
|
}
|
|
|
|
export interface WhiteboardStroke {
|
|
id: string;
|
|
whiteboardId: string;
|
|
authorUserId: string;
|
|
strokeJson: unknown;
|
|
createdAt: string;
|
|
}
|
|
|
|
export async function createWhiteboard(
|
|
client: AppSupabaseClient,
|
|
conversationId: string,
|
|
): Promise<Whiteboard> {
|
|
const { data: session } = await client.auth.getUser();
|
|
if (!session.user) throw new Error('not authenticated');
|
|
const { data, error } = await client
|
|
.from('conversation_whiteboards')
|
|
.insert({
|
|
conversation_id: conversationId,
|
|
owner_user_id: session.user.id,
|
|
})
|
|
.select('id, conversation_id, owner_user_id, created_at')
|
|
.single();
|
|
if (error) throw error;
|
|
return {
|
|
id: data.id,
|
|
conversationId: data.conversation_id,
|
|
ownerUserId: data.owner_user_id,
|
|
createdAt: data.created_at,
|
|
};
|
|
}
|
|
|
|
export async function listWhiteboardStrokes(
|
|
client: AppSupabaseClient,
|
|
whiteboardId: string,
|
|
): Promise<WhiteboardStroke[]> {
|
|
const { data, error } = await client
|
|
.from('whiteboard_strokes')
|
|
.select('id, whiteboard_id, author_user_id, stroke_json, created_at')
|
|
.eq('whiteboard_id', whiteboardId)
|
|
.order('created_at', { ascending: true });
|
|
if (error) throw error;
|
|
return data.map((row) => ({
|
|
id: row.id,
|
|
whiteboardId: row.whiteboard_id,
|
|
authorUserId: row.author_user_id,
|
|
strokeJson: row.stroke_json,
|
|
createdAt: row.created_at,
|
|
}));
|
|
}
|
|
|
|
export async function insertWhiteboardStroke(
|
|
client: AppSupabaseClient,
|
|
params: { whiteboardId: string; strokeJson: unknown },
|
|
): Promise<WhiteboardStroke> {
|
|
const { data: session } = await client.auth.getUser();
|
|
if (!session.user) throw new Error('not authenticated');
|
|
const { data, error } = await client
|
|
.from('whiteboard_strokes')
|
|
.insert({
|
|
whiteboard_id: params.whiteboardId,
|
|
author_user_id: session.user.id,
|
|
stroke_json: params.strokeJson as object,
|
|
})
|
|
.select('id, whiteboard_id, author_user_id, stroke_json, created_at')
|
|
.single();
|
|
if (error) throw error;
|
|
return {
|
|
id: data.id,
|
|
whiteboardId: data.whiteboard_id,
|
|
authorUserId: data.author_user_id,
|
|
strokeJson: data.stroke_json,
|
|
createdAt: data.created_at,
|
|
};
|
|
}
|
|
|
|
export async function clearWhiteboardStrokes(
|
|
client: AppSupabaseClient,
|
|
whiteboardId: string,
|
|
): Promise<void> {
|
|
const { error } = await client
|
|
.from('whiteboard_strokes')
|
|
.delete()
|
|
.eq('whiteboard_id', whiteboardId);
|
|
if (error) throw error;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Write the tests**
|
|
|
|
Create `packages/shared/src/chat/whiteboards.test.ts`:
|
|
|
|
```ts
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
import {
|
|
clearWhiteboardStrokes,
|
|
createWhiteboard,
|
|
insertWhiteboardStroke,
|
|
listWhiteboardStrokes,
|
|
} from './whiteboards';
|
|
|
|
function makeClient(opts: {
|
|
user?: { id: string } | null;
|
|
insertReturn?: { data: unknown; error: unknown };
|
|
selectReturn?: { data: unknown[]; error: unknown };
|
|
deleteReturn?: { error: unknown };
|
|
}): any {
|
|
const single = vi.fn().mockResolvedValue(opts.insertReturn ?? { data: {}, error: null });
|
|
const order = vi.fn().mockResolvedValue(opts.selectReturn ?? { data: [], error: null });
|
|
const eqDelete = vi.fn().mockResolvedValue(opts.deleteReturn ?? { error: null });
|
|
const insertSelect = vi.fn().mockReturnValue({ single });
|
|
const insertChain = vi.fn().mockReturnValue({ select: insertSelect });
|
|
const selectChain = vi.fn().mockReturnValue({
|
|
eq: vi.fn().mockReturnValue({ order }),
|
|
});
|
|
const deleteChain = vi.fn().mockReturnValue({ eq: eqDelete });
|
|
const from = vi.fn().mockReturnValue({
|
|
insert: insertChain,
|
|
select: selectChain,
|
|
delete: deleteChain,
|
|
});
|
|
return {
|
|
auth: { getUser: vi.fn().mockResolvedValue({ data: { user: opts.user ?? { id: 'u-1' } } }) },
|
|
from,
|
|
};
|
|
}
|
|
|
|
describe('createWhiteboard', () => {
|
|
it('inserts with owner = current user', async () => {
|
|
const client = makeClient({
|
|
insertReturn: {
|
|
data: {
|
|
id: 'w-1',
|
|
conversation_id: 'c-1',
|
|
owner_user_id: 'u-1',
|
|
created_at: '2026-05-16T00:00:00Z',
|
|
},
|
|
error: null,
|
|
},
|
|
});
|
|
const out = await createWhiteboard(client, 'c-1');
|
|
expect(out).toEqual({
|
|
id: 'w-1',
|
|
conversationId: 'c-1',
|
|
ownerUserId: 'u-1',
|
|
createdAt: '2026-05-16T00:00:00Z',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('listWhiteboardStrokes', () => {
|
|
it('maps DB rows to camelCase + ascending order', async () => {
|
|
const client = makeClient({
|
|
selectReturn: {
|
|
data: [
|
|
{
|
|
id: 's-1',
|
|
whiteboard_id: 'w-1',
|
|
author_user_id: 'u-1',
|
|
stroke_json: { tool: 'pen' },
|
|
created_at: '2026-05-16T00:00:00Z',
|
|
},
|
|
],
|
|
error: null,
|
|
},
|
|
});
|
|
const out = await listWhiteboardStrokes(client, 'w-1');
|
|
expect(out).toEqual([
|
|
{
|
|
id: 's-1',
|
|
whiteboardId: 'w-1',
|
|
authorUserId: 'u-1',
|
|
strokeJson: { tool: 'pen' },
|
|
createdAt: '2026-05-16T00:00:00Z',
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('insertWhiteboardStroke', () => {
|
|
it('writes author + stroke_json', async () => {
|
|
const client = makeClient({
|
|
insertReturn: {
|
|
data: {
|
|
id: 's-2',
|
|
whiteboard_id: 'w-1',
|
|
author_user_id: 'u-1',
|
|
stroke_json: { tool: 'pen', points: [[0, 0, 0]] },
|
|
created_at: '2026-05-16T00:00:01Z',
|
|
},
|
|
error: null,
|
|
},
|
|
});
|
|
const out = await insertWhiteboardStroke(client, {
|
|
whiteboardId: 'w-1',
|
|
strokeJson: { tool: 'pen', points: [[0, 0, 0]] },
|
|
});
|
|
expect(out.id).toBe('s-2');
|
|
expect((out.strokeJson as { tool: string }).tool).toBe('pen');
|
|
});
|
|
});
|
|
|
|
describe('clearWhiteboardStrokes', () => {
|
|
it('does not throw on a successful delete', async () => {
|
|
const client = makeClient({ deleteReturn: { error: null } });
|
|
await expect(clearWhiteboardStrokes(client, 'w-1')).resolves.toBeUndefined();
|
|
});
|
|
|
|
it('throws when delete returns an error', async () => {
|
|
const client = makeClient({ deleteReturn: { error: { message: 'rls denied' } as any } });
|
|
await expect(clearWhiteboardStrokes(client, 'w-1')).rejects.toBeTruthy();
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 4: Re-export from `@chat-app/shared/chat`**
|
|
|
|
Find the chat index: `Glob packages/shared/src/chat/index.ts`. If it exists, add:
|
|
```ts
|
|
export * from './whiteboards';
|
|
```
|
|
If exports happen via `packages/shared/src/index.ts` instead, match the existing pattern there.
|
|
|
|
Verify the renderer can import from `@chat-app/shared/chat` — match how `usePinnedMessages.ts` imports its wrapper (P2.T3): `Grep -n "from '@chat-app/shared" apps/desktop/src/hooks/usePinnedMessages.ts`.
|
|
|
|
- [ ] **Step 5: Run tests + typecheck**
|
|
|
|
```
|
|
pnpm --filter @chat-app/shared test -- --run whiteboards
|
|
pnpm --filter @chat-app/shared typecheck
|
|
pnpm --filter @chat-app/shared test -- --run
|
|
```
|
|
Expected: 4 new tests pass, full suite at 37/37.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add packages/shared/src/chat/attachments.ts packages/shared/src/chat/whiteboards.ts packages/shared/src/chat/whiteboards.test.ts
|
|
# include the index file if you touched it
|
|
git commit -m "feat(P4B.T2): WhiteboardPayload + shared whiteboards CRUD wrappers + tests"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: `useWhiteboardStrokes` hook — initial fetch + realtime INSERT/DELETE subscription + insert helper
|
|
|
|
**Why:** The canvas needs a live, ordered stream of strokes. Following the `usePinnedMessages` (P2.T3) and `useOwnDevices` (P3.T5) patterns.
|
|
|
|
**Files:**
|
|
- Create: `apps/desktop/src/hooks/useWhiteboardStrokes.ts`
|
|
|
|
- [ ] **Step 1: Write the hook**
|
|
|
|
Create `apps/desktop/src/hooks/useWhiteboardStrokes.ts`:
|
|
|
|
```ts
|
|
import { useCallback, useEffect, useState } from 'react';
|
|
|
|
import {
|
|
clearWhiteboardStrokes,
|
|
insertWhiteboardStroke,
|
|
listWhiteboardStrokes,
|
|
type WhiteboardStroke,
|
|
} from '@chat-app/shared/chat';
|
|
|
|
import { supabase } from '../lib/supabase';
|
|
|
|
interface State {
|
|
strokes: WhiteboardStroke[];
|
|
loading: boolean;
|
|
error: string | null;
|
|
}
|
|
|
|
export function useWhiteboardStrokes(whiteboardId: string | null): {
|
|
strokes: WhiteboardStroke[];
|
|
loading: boolean;
|
|
error: string | null;
|
|
insertStroke: (strokeJson: unknown) => Promise<void>;
|
|
clearAll: () => Promise<void>;
|
|
} {
|
|
const [state, setState] = useState<State>({ strokes: [], loading: true, error: null });
|
|
|
|
useEffect(() => {
|
|
if (!whiteboardId) {
|
|
setState({ strokes: [], loading: false, error: null });
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
void (async () => {
|
|
try {
|
|
setState((s) => ({ ...s, loading: true, error: null }));
|
|
const list = await listWhiteboardStrokes(supabase, whiteboardId);
|
|
if (!cancelled) setState({ strokes: list, loading: false, error: null });
|
|
} catch (err) {
|
|
if (!cancelled) {
|
|
setState({
|
|
strokes: [],
|
|
loading: false,
|
|
error: err instanceof Error ? err.message : 'failed to load strokes',
|
|
});
|
|
}
|
|
}
|
|
})();
|
|
const channel = supabase
|
|
.channel('whiteboard:' + whiteboardId)
|
|
.on(
|
|
'postgres_changes',
|
|
{
|
|
event: 'INSERT',
|
|
schema: 'public',
|
|
table: 'whiteboard_strokes',
|
|
filter: 'whiteboard_id=eq.' + whiteboardId,
|
|
},
|
|
(payload) => {
|
|
const row = payload.new as {
|
|
id?: string;
|
|
whiteboard_id?: string;
|
|
author_user_id?: string;
|
|
stroke_json?: unknown;
|
|
created_at?: string;
|
|
} | null;
|
|
if (!row?.id || !row.whiteboard_id || !row.author_user_id || !row.created_at) return;
|
|
const next: WhiteboardStroke = {
|
|
id: row.id,
|
|
whiteboardId: row.whiteboard_id,
|
|
authorUserId: row.author_user_id,
|
|
strokeJson: row.stroke_json,
|
|
createdAt: row.created_at,
|
|
};
|
|
setState((s) => {
|
|
if (s.strokes.some((x) => x.id === next.id)) return s;
|
|
return { ...s, strokes: [...s.strokes, next] };
|
|
});
|
|
},
|
|
)
|
|
.on(
|
|
'postgres_changes',
|
|
{
|
|
event: 'DELETE',
|
|
schema: 'public',
|
|
table: 'whiteboard_strokes',
|
|
filter: 'whiteboard_id=eq.' + whiteboardId,
|
|
},
|
|
() => {
|
|
// Bulk delete via "Clear all" — drop everything; future inserts
|
|
// come back via the INSERT branch above. Simpler than tracking
|
|
// per-id deletes.
|
|
setState((s) => ({ ...s, strokes: [] }));
|
|
},
|
|
)
|
|
.subscribe();
|
|
return () => {
|
|
cancelled = true;
|
|
void supabase.removeChannel(channel);
|
|
};
|
|
}, [whiteboardId]);
|
|
|
|
const insertStroke = useCallback(
|
|
async (strokeJson: unknown) => {
|
|
if (!whiteboardId) return;
|
|
try {
|
|
await insertWhiteboardStroke(supabase, { whiteboardId, strokeJson });
|
|
// No optimistic append — realtime echoes the row back in <150ms and
|
|
// optimistic-then-echo creates duplicate-render races.
|
|
} catch (err) {
|
|
console.error('insertWhiteboardStroke failed', err);
|
|
setState((s) => ({
|
|
...s,
|
|
error: err instanceof Error ? err.message : 'stroke insert failed',
|
|
}));
|
|
}
|
|
},
|
|
[whiteboardId],
|
|
);
|
|
|
|
const clearAll = useCallback(async () => {
|
|
if (!whiteboardId) return;
|
|
await clearWhiteboardStrokes(supabase, whiteboardId);
|
|
}, [whiteboardId]);
|
|
|
|
return {
|
|
strokes: state.strokes,
|
|
loading: state.loading,
|
|
error: state.error,
|
|
insertStroke,
|
|
clearAll,
|
|
};
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Typecheck**
|
|
|
|
```
|
|
pnpm --filter @chat-app/desktop typecheck
|
|
```
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add apps/desktop/src/hooks/useWhiteboardStrokes.ts
|
|
git commit -m "feat(P4B.T3): useWhiteboardStrokes hook with INSERT + bulk DELETE realtime"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: `WhiteboardCanvas` component — pen + eraser, 6 colors, 3 widths, replays strokes
|
|
|
|
**Why:** Pure rendering + interaction. Knows nothing about networking — receives `strokes` + a `onStroke` callback.
|
|
|
|
**Files:**
|
|
- Create: `apps/desktop/src/components/WhiteboardCanvas.tsx`
|
|
|
|
- [ ] **Step 1: Write the component**
|
|
|
|
Create `apps/desktop/src/components/WhiteboardCanvas.tsx`:
|
|
|
|
```tsx
|
|
import { useEffect, useRef, useState } from 'react';
|
|
|
|
import type { WhiteboardStroke } from '@chat-app/shared/chat';
|
|
|
|
export type WhiteboardTool = 'pen' | 'eraser';
|
|
export type WhiteboardColor = '#000000' | '#ef4444' | '#3b82f6' | '#22c55e' | '#facc15' | '#a855f7';
|
|
export type WhiteboardWidth = 2 | 4 | 8;
|
|
|
|
export interface WhiteboardStrokePayload {
|
|
tool: WhiteboardTool;
|
|
color: WhiteboardColor;
|
|
width: WhiteboardWidth;
|
|
// [x, y, t-ms-since-stroke-start]
|
|
points: Array<[number, number, number]>;
|
|
}
|
|
|
|
interface Props {
|
|
strokes: WhiteboardStroke[];
|
|
tool: WhiteboardTool;
|
|
color: WhiteboardColor;
|
|
width: WhiteboardWidth;
|
|
onStroke: (payload: WhiteboardStrokePayload) => void;
|
|
logicalWidth?: number;
|
|
logicalHeight?: number;
|
|
}
|
|
|
|
const DEFAULT_LOGICAL_W = 1280;
|
|
const DEFAULT_LOGICAL_H = 720;
|
|
|
|
export function WhiteboardCanvas({
|
|
strokes,
|
|
tool,
|
|
color,
|
|
width,
|
|
onStroke,
|
|
logicalWidth = DEFAULT_LOGICAL_W,
|
|
logicalHeight = DEFAULT_LOGICAL_H,
|
|
}: Props) {
|
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
const draftRef = useRef<WhiteboardStrokePayload | null>(null);
|
|
const strokeStartRef = useRef<number>(0);
|
|
const [, forceTick] = useState(0);
|
|
|
|
useEffect(() => {
|
|
const cv = canvasRef.current;
|
|
if (!cv) return;
|
|
cv.width = logicalWidth;
|
|
cv.height = logicalHeight;
|
|
const ctx = cv.getContext('2d');
|
|
if (!ctx) return;
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.fillRect(0, 0, cv.width, cv.height);
|
|
for (const s of strokes) {
|
|
const payload = s.strokeJson as Partial<WhiteboardStrokePayload> | null;
|
|
if (payload) renderStroke(ctx, payload);
|
|
}
|
|
if (draftRef.current) renderStroke(ctx, draftRef.current);
|
|
});
|
|
|
|
function canvasPoint(e: React.PointerEvent<HTMLCanvasElement>): [number, number] {
|
|
const cv = canvasRef.current!;
|
|
const rect = cv.getBoundingClientRect();
|
|
const scaleX = cv.width / rect.width;
|
|
const scaleY = cv.height / rect.height;
|
|
return [(e.clientX - rect.left) * scaleX, (e.clientY - rect.top) * scaleY];
|
|
}
|
|
|
|
const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
|
const cv = canvasRef.current;
|
|
if (!cv) return;
|
|
cv.setPointerCapture(e.pointerId);
|
|
const [x, y] = canvasPoint(e);
|
|
strokeStartRef.current = Date.now();
|
|
draftRef.current = {
|
|
tool,
|
|
color,
|
|
width,
|
|
points: [[x, y, 0]],
|
|
};
|
|
forceTick((n) => n + 1);
|
|
};
|
|
|
|
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
|
if (!draftRef.current) return;
|
|
const [x, y] = canvasPoint(e);
|
|
draftRef.current.points.push([x, y, Date.now() - strokeStartRef.current]);
|
|
forceTick((n) => n + 1);
|
|
};
|
|
|
|
const handlePointerUp = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
|
const cv = canvasRef.current;
|
|
if (cv && cv.hasPointerCapture(e.pointerId)) cv.releasePointerCapture(e.pointerId);
|
|
const draft = draftRef.current;
|
|
draftRef.current = null;
|
|
if (!draft) return;
|
|
if (draft.points.length < 2) {
|
|
forceTick((n) => n + 1);
|
|
return;
|
|
}
|
|
onStroke(draft);
|
|
forceTick((n) => n + 1);
|
|
};
|
|
|
|
return (
|
|
<canvas
|
|
ref={canvasRef}
|
|
onPointerDown={handlePointerDown}
|
|
onPointerMove={handlePointerMove}
|
|
onPointerUp={handlePointerUp}
|
|
onPointerLeave={handlePointerUp}
|
|
className="block max-h-full max-w-full cursor-crosshair touch-none rounded-lg border border-line/40 bg-white shadow-2xl"
|
|
style={{ aspectRatio: logicalWidth + ' / ' + logicalHeight, width: '100%' }}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function renderStroke(
|
|
ctx: CanvasRenderingContext2D,
|
|
s: Partial<WhiteboardStrokePayload>,
|
|
): void {
|
|
const points = Array.isArray(s.points) ? s.points : null;
|
|
if (!points || points.length < 1) return;
|
|
|
|
ctx.save();
|
|
ctx.lineCap = 'round';
|
|
ctx.lineJoin = 'round';
|
|
ctx.lineWidth = typeof s.width === 'number' ? s.width : 4;
|
|
if (s.tool === 'eraser') {
|
|
ctx.strokeStyle = '#ffffff';
|
|
ctx.lineWidth = Math.max(8, (typeof s.width === 'number' ? s.width : 4) * 4);
|
|
} else {
|
|
ctx.strokeStyle = typeof s.color === 'string' ? s.color : '#000000';
|
|
}
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(points[0]![0], points[0]![1]);
|
|
for (let i = 1; i < points.length; i++) {
|
|
ctx.lineTo(points[i]![0], points[i]![1]);
|
|
}
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Typecheck**
|
|
|
|
```
|
|
pnpm --filter @chat-app/desktop typecheck
|
|
```
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add apps/desktop/src/components/WhiteboardCanvas.tsx
|
|
git commit -m "feat(P4B.T4): WhiteboardCanvas — pen/eraser drawing engine"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: `WhiteboardModal` — fullscreen container with toolbar + Clear-all confirm
|
|
|
|
**Why:** Wraps the canvas + toolbar + realtime hook. This is what the bubble's "Öffnen" button opens.
|
|
|
|
**Files:**
|
|
- Create: `apps/desktop/src/components/WhiteboardModal.tsx`
|
|
|
|
- [ ] **Step 1: Write the modal**
|
|
|
|
Create `apps/desktop/src/components/WhiteboardModal.tsx`:
|
|
|
|
```tsx
|
|
import { useEffect, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
import { useWhiteboardStrokes } from '../hooks/useWhiteboardStrokes';
|
|
import { XIcon } from './icons';
|
|
import {
|
|
WhiteboardCanvas,
|
|
type WhiteboardColor,
|
|
type WhiteboardTool,
|
|
type WhiteboardWidth,
|
|
} from './WhiteboardCanvas';
|
|
|
|
interface Props {
|
|
whiteboardId: string;
|
|
onClose: () => void;
|
|
}
|
|
|
|
const COLORS: WhiteboardColor[] = ['#000000', '#ef4444', '#3b82f6', '#22c55e', '#facc15', '#a855f7'];
|
|
const WIDTHS: WhiteboardWidth[] = [2, 4, 8];
|
|
|
|
export function WhiteboardModal({ whiteboardId, onClose }: Props) {
|
|
const { t } = useTranslation();
|
|
const { strokes, loading, error, insertStroke, clearAll } = useWhiteboardStrokes(whiteboardId);
|
|
const [tool, setTool] = useState<WhiteboardTool>('pen');
|
|
const [color, setColor] = useState<WhiteboardColor>('#000000');
|
|
const [width, setWidth] = useState<WhiteboardWidth>(4);
|
|
const [confirmClear, setConfirmClear] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') onClose();
|
|
};
|
|
window.addEventListener('keydown', onKey);
|
|
return () => window.removeEventListener('keydown', onKey);
|
|
}, [onClose]);
|
|
|
|
const handleConfirmClear = async () => {
|
|
setConfirmClear(false);
|
|
try {
|
|
await clearAll();
|
|
} catch (err) {
|
|
console.error('clearAll failed', err);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label={t('app:whiteboard.title', { defaultValue: 'Whiteboard' })}
|
|
className="fixed inset-0 z-[80] flex flex-col bg-ink-950/95"
|
|
>
|
|
<header className="flex shrink-0 items-center justify-between border-b border-line/40 bg-surface-2 px-4 py-2">
|
|
<h2 className="font-display text-sm font-semibold text-fg">
|
|
{t('app:whiteboard.title', { defaultValue: 'Whiteboard' })}
|
|
</h2>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
aria-label={t('app:whiteboard.close', { defaultValue: 'Schließen' })}
|
|
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
|
|
>
|
|
<XIcon className="h-3.5 w-3.5" />
|
|
</button>
|
|
</header>
|
|
|
|
<div className="flex min-h-0 flex-1 items-center justify-center overflow-hidden p-6">
|
|
{loading ? (
|
|
<p className="text-sm text-fg-muted">
|
|
{t('app:whiteboard.loading', { defaultValue: 'Lädt…' })}
|
|
</p>
|
|
) : error ? (
|
|
<p className="text-sm text-rose-400">
|
|
{t('app:whiteboard.error', { defaultValue: 'Whiteboard konnte nicht geladen werden.' })}
|
|
</p>
|
|
) : (
|
|
<WhiteboardCanvas
|
|
strokes={strokes}
|
|
tool={tool}
|
|
color={color}
|
|
width={width}
|
|
onStroke={(payload) => void insertStroke(payload)}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<footer className="flex shrink-0 flex-wrap items-center gap-4 border-t border-line/40 bg-surface-2 px-4 py-3">
|
|
<div className="flex items-center gap-1">
|
|
{(['pen', 'eraser'] as WhiteboardTool[]).map((id) => {
|
|
const label = id === 'pen' ? 'Stift' : 'Radierer';
|
|
const active = tool === id;
|
|
return (
|
|
<button
|
|
key={id}
|
|
type="button"
|
|
onClick={() => setTool(id)}
|
|
aria-pressed={active}
|
|
title={label}
|
|
className={
|
|
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-xs font-semibold transition ' +
|
|
(active
|
|
? 'bg-accent/20 text-accent ring-2 ring-accent/40'
|
|
: 'bg-surface-3 text-fg-muted hover:bg-surface hover:text-fg')
|
|
}
|
|
>
|
|
{id === 'pen' ? '✎' : '⌫'}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="h-6 w-px bg-line/40" aria-hidden />
|
|
|
|
<div className="flex items-center gap-1">
|
|
{COLORS.map((c) => {
|
|
const active = color === c;
|
|
return (
|
|
<button
|
|
key={c}
|
|
type="button"
|
|
onClick={() => setColor(c)}
|
|
aria-pressed={active}
|
|
aria-label={c}
|
|
className={
|
|
'h-6 w-6 cursor-pointer rounded-full border-2 transition ' +
|
|
(active ? 'border-fg scale-110' : 'border-line/40 hover:scale-105')
|
|
}
|
|
style={{ backgroundColor: c }}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="h-6 w-px bg-line/40" aria-hidden />
|
|
|
|
<div className="flex items-center gap-1">
|
|
{WIDTHS.map((w) => {
|
|
const active = width === w;
|
|
return (
|
|
<button
|
|
key={w}
|
|
type="button"
|
|
onClick={() => setWidth(w)}
|
|
aria-pressed={active}
|
|
title={w + 'px'}
|
|
className={
|
|
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-md transition ' +
|
|
(active
|
|
? 'bg-accent/20 ring-2 ring-accent/40'
|
|
: 'bg-surface-3 hover:bg-surface')
|
|
}
|
|
>
|
|
<div
|
|
className="rounded-full bg-fg"
|
|
style={{ width: w + 2 + 'px', height: w + 2 + 'px' }}
|
|
/>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="ml-auto flex items-center gap-2">
|
|
{confirmClear ? (
|
|
<>
|
|
<span className="text-xs text-fg-muted">
|
|
{t('app:whiteboard.confirm_clear', { defaultValue: 'Alles löschen?' })}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => setConfirmClear(false)}
|
|
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-1.5 text-xs font-medium text-fg-muted transition hover:bg-surface"
|
|
>
|
|
{t('app:whiteboard.cancel', { defaultValue: 'Abbrechen' })}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => void handleConfirmClear()}
|
|
className="cursor-pointer rounded-md bg-rose-500 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-rose-500/90"
|
|
>
|
|
{t('app:whiteboard.confirm', { defaultValue: 'Ja, löschen' })}
|
|
</button>
|
|
</>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={() => setConfirmClear(true)}
|
|
disabled={strokes.length === 0}
|
|
className="cursor-pointer rounded-md border border-rose-500/40 bg-rose-500/10 px-3 py-1.5 text-xs font-semibold text-rose-600 transition hover:bg-rose-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:text-rose-300"
|
|
>
|
|
{t('app:whiteboard.clear_all', { defaultValue: 'Alles löschen' })}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</footer>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Typecheck**
|
|
|
|
```
|
|
pnpm --filter @chat-app/desktop typecheck
|
|
```
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add apps/desktop/src/components/WhiteboardModal.tsx
|
|
git commit -m "feat(P4B.T5): WhiteboardModal — fullscreen container + toolbar + clear-confirm"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Composer button + create flow — adds "Whiteboard" entry that creates + sends the bubble
|
|
|
|
**Why:** Entry point from the composer. Reuses the existing `send()` pipeline so message delivery + read-receipts + everything else works unchanged.
|
|
|
|
**Files:**
|
|
- Modify: `apps/desktop/src/lib/conversationFeatures.ts` — add `createWhiteboardPayload`
|
|
- Modify: `apps/desktop/src/pages/ConversationPage.tsx` — add a button next to the existing poll/GIF buttons; wire the click to create-board → send-bubble; track the active modal
|
|
|
|
- [ ] **Step 1: Add a payload helper**
|
|
|
|
Read `apps/desktop/src/lib/conversationFeatures.ts` first to find the existing `createPollPayload` so the style matches. Then append:
|
|
|
|
```ts
|
|
export function createWhiteboardPayload(whiteboardId: string): WhiteboardPayload {
|
|
return {
|
|
v: 1,
|
|
type: 'whiteboard',
|
|
whiteboard_id: whiteboardId,
|
|
};
|
|
}
|
|
```
|
|
|
|
Add `WhiteboardPayload` to the existing `@chat-app/shared` import (wherever `PollPayload` comes from in this file).
|
|
|
|
- [ ] **Step 2: Wire the composer button**
|
|
|
|
In `apps/desktop/src/pages/ConversationPage.tsx`:
|
|
|
|
1. Add imports at the top:
|
|
```ts
|
|
import { createWhiteboardPayload } from '../lib/conversationFeatures';
|
|
import { WhiteboardModal } from '../components/WhiteboardModal';
|
|
import { createWhiteboard } from '@chat-app/shared/chat';
|
|
```
|
|
(Adapt the shared import path to match how T2's wrapper is exported.)
|
|
|
|
2. Near the other `useState`s (around line 162 where `pollDialogOpen` lives), add:
|
|
```ts
|
|
const [openWhiteboardId, setOpenWhiteboardId] = useState<string | null>(null);
|
|
const [creatingWhiteboard, setCreatingWhiteboard] = useState(false);
|
|
```
|
|
|
|
3. Add the create-and-send handler near `handlePollSubmit` (around line 571):
|
|
```ts
|
|
const handleCreateWhiteboard = useCallback(async () => {
|
|
if (!id || creatingWhiteboard) return;
|
|
setCreatingWhiteboard(true);
|
|
try {
|
|
const board = await createWhiteboard(supabase, id);
|
|
const payload = createWhiteboardPayload(board.id);
|
|
await send(payload, [], replyTo?.id ?? null);
|
|
setReplyTo(null);
|
|
setStickToBottom(true);
|
|
setOpenWhiteboardId(board.id);
|
|
} catch (err: unknown) {
|
|
setSendError(err instanceof Error ? err.message : 'Whiteboard konnte nicht angelegt werden');
|
|
} finally {
|
|
setCreatingWhiteboard(false);
|
|
}
|
|
}, [id, creatingWhiteboard, send, replyTo?.id]);
|
|
```
|
|
|
|
Verify `supabase` is already imported from `../lib/supabase` (very likely yes). If not, add the import.
|
|
|
|
4. Add a button next to the existing poll button (around line 976). The existing poll button JSX:
|
|
```tsx
|
|
<button type="button" onClick={() => setPollDialogOpen(true)} title="Umfrage">
|
|
<PollIcon className="..." />
|
|
</button>
|
|
```
|
|
Add immediately after:
|
|
```tsx
|
|
<button
|
|
type="button"
|
|
onClick={() => void handleCreateWhiteboard()}
|
|
disabled={creatingWhiteboard}
|
|
title={t('app:composer.whiteboard', { defaultValue: 'Whiteboard' })}
|
|
aria-label={t('app:composer.whiteboard', { defaultValue: 'Whiteboard' })}
|
|
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-md text-fg-muted transition hover:bg-surface-3 hover:text-fg disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
<WhiteboardIcon className="h-4 w-4" />
|
|
</button>
|
|
```
|
|
|
|
Add a small inline icon at the bottom of the file (close to where `AttachmentPreview` lives):
|
|
```tsx
|
|
function WhiteboardIcon(props: React.SVGProps<SVGSVGElement>) {
|
|
return (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
|
|
strokeLinecap="round" strokeLinejoin="round" {...props}>
|
|
<rect x="3" y="4" width="18" height="13" rx="2" />
|
|
<path d="M8 21h8M12 17v4" />
|
|
</svg>
|
|
);
|
|
}
|
|
```
|
|
|
|
If `Grep -n "WhiteboardIcon\b" apps/desktop/src/components/icons*` already finds an icon, import instead of inlining.
|
|
|
|
5. Render the modal at the page root. RIGHT BEFORE the outermost `</div>` of the page return, add:
|
|
```tsx
|
|
{openWhiteboardId && (
|
|
<WhiteboardModal
|
|
whiteboardId={openWhiteboardId}
|
|
onClose={() => setOpenWhiteboardId(null)}
|
|
/>
|
|
)}
|
|
```
|
|
|
|
- [ ] **Step 3: Typecheck**
|
|
|
|
```
|
|
pnpm --filter @chat-app/desktop typecheck
|
|
```
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add apps/desktop/src/lib/conversationFeatures.ts apps/desktop/src/pages/ConversationPage.tsx
|
|
git commit -m "feat(P4B.T6): composer 'Whiteboard' button creates board + sends bubble"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: `MessageBubble` — render the whiteboard kind with "Öffnen" button
|
|
|
|
**Why:** Currently a message with `parsed.kind === 'whiteboard'` would fall through to text rendering (empty body). This task wires the dispatch and signals upward to the page-owned modal state via a CustomEvent.
|
|
|
|
**Files:**
|
|
- Modify: `apps/desktop/src/components/MessageBubble.tsx`
|
|
- Modify: `apps/desktop/src/pages/ConversationPage.tsx` (one new effect listening for the event)
|
|
|
|
- [ ] **Step 1: Find the existing kind dispatch**
|
|
|
|
```
|
|
Grep -n "parsed.kind\|kind: 'poll'\|kind === 'poll'\|PollCard\|CallEventRow" apps/desktop/src/components/MessageBubble.tsx
|
|
```
|
|
|
|
Read the actual block to find a clean injection point:
|
|
```
|
|
Read apps/desktop/src/components/MessageBubble.tsx (offset 440, limit 30)
|
|
```
|
|
|
|
- [ ] **Step 2: Add a `whiteboard` render branch**
|
|
|
|
Alongside the existing `parsed.kind === 'poll'` branch in `apps/desktop/src/components/MessageBubble.tsx`, add a new branch (place it just BEFORE the poll branch for grouping):
|
|
|
|
```tsx
|
|
if (parsed.kind === 'whiteboard') {
|
|
const id = parsed.whiteboardId;
|
|
return (
|
|
<div className="flex items-center gap-3 rounded-2xl border border-accent/30 bg-accent/5 px-4 py-3">
|
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-accent/20 text-accent">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
|
|
strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
|
|
<rect x="3" y="4" width="18" height="13" rx="2" />
|
|
<path d="M8 21h8M12 17v4" />
|
|
</svg>
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-sm font-semibold text-fg">Whiteboard</div>
|
|
<div className="text-xs text-fg-muted">Gemeinsames Zeichnen</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
window.dispatchEvent(
|
|
new CustomEvent('chatapp:open-whiteboard', { detail: { id } }),
|
|
);
|
|
}}
|
|
disabled={!id}
|
|
className="cursor-pointer rounded-md bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
Öffnen
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Make ConversationPage listen for the event**
|
|
|
|
In `apps/desktop/src/pages/ConversationPage.tsx`, add an effect near other effects:
|
|
|
|
```ts
|
|
useEffect(() => {
|
|
const onOpen = (e: Event) => {
|
|
const detail = (e as CustomEvent<{ id?: string }>).detail;
|
|
if (detail?.id) setOpenWhiteboardId(detail.id);
|
|
};
|
|
window.addEventListener('chatapp:open-whiteboard', onOpen);
|
|
return () => window.removeEventListener('chatapp:open-whiteboard', onOpen);
|
|
}, []);
|
|
```
|
|
|
|
- [ ] **Step 4: Typecheck**
|
|
|
|
```
|
|
pnpm --filter @chat-app/desktop typecheck
|
|
```
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add apps/desktop/src/components/MessageBubble.tsx apps/desktop/src/pages/ConversationPage.tsx
|
|
git commit -m "feat(P4B.T7): MessageBubble renders whiteboard kind with Öffnen button"
|
|
```
|
|
|
|
---
|
|
|
|
## Final gate
|
|
|
|
- [ ] **Step 1: Typecheck both packages**
|
|
|
|
Run: `pnpm --filter @chat-app/shared typecheck && pnpm --filter @chat-app/desktop typecheck`
|
|
Expected: both PASS.
|
|
|
|
- [ ] **Step 2: Run shared tests**
|
|
|
|
Run: `pnpm --filter @chat-app/shared test -- --run`
|
|
Expected: PASS — 37 tests (33 baseline + 4 new in `whiteboards.test.ts`).
|
|
|
|
- [ ] **Step 3: Verify no uncommitted changes**
|
|
|
|
Run: `git status`
|
|
Expected: clean working tree on `main`.
|
|
|
|
- [ ] **Step 4: Report**
|
|
|
|
Report: "Phase 4B (Whiteboard) code-complete on `main` and migration applied to prod. Restart dev: composer → Whiteboard icon → new bubble appears in chat → click Öffnen → fullscreen canvas; draw + another account sees strokes in realtime; 'Alles löschen' wipes all strokes. **No release** unless you say so."
|
|
|
|
---
|
|
|
|
## Self-review (resolved inline)
|
|
|
|
1. **Spec coverage** (`docs/superpowers/specs/2026-05-16-fifteen-features-design.md` lines 110-115):
|
|
- "New tables `conversation_whiteboards` and `whiteboard_strokes`" → T1
|
|
- "composer menu → ✏ Whiteboard" → T6 button
|
|
- "starts a new whiteboard inline in the chat as a bubble (preview + author + Öffnen-button)" → T6 INSERT + T7 bubble; author comes from existing MessageBubble shell
|
|
- "Click → fullscreen modal with canvas" → T5 + T7 event hop
|
|
- "Tools: pen, eraser, 6 colors, 3 widths" → T4 types + T5 toolbar
|
|
- "Clear all with confirm dialog" → T5 confirm sequence
|
|
- "Each pen stroke (on pointerup) inserts a row into `whiteboard_strokes`" → T4 onStroke + T3 insertStroke
|
|
- "Realtime subscription on `whiteboard_id`" → T3
|
|
- "Strokes are JSON: {tool, color, width, points: [[x, y, t], …]}" → T4 `WhiteboardStrokePayload`
|
|
- "Whiteboard is permanent — bubble in the chat stays, history is preserved, anyone in the conv can reopen it later" → T7 bubble dispatches by id; new opens fetch via T3
|
|
|
|
2. **Placeholders:** none.
|
|
|
|
3. **Type consistency:**
|
|
- `WhiteboardStroke` (shared) ↔ `WhiteboardStrokePayload` (renderer): shared keeps `strokeJson: unknown`; renderer interprets via `as Partial<WhiteboardStrokePayload>`. Intentional — protects against future stroke fields without forcing a shared rebuild.
|
|
- `WhiteboardTool`/`WhiteboardColor`/`WhiteboardWidth` defined T4, consumed T5.
|
|
- `WhiteboardPayload.whiteboard_id` (snake_case wire) ↔ `ParsedMessagePayload.whiteboardId` (camelCase in-app): matches the existing `call_event` field pattern.
|
|
- `createWhiteboardPayload` returns `WhiteboardPayload` which `send()` accepts (it accepts `MessagePayload`).
|
|
|
|
4. **Realtime gotcha:** the INSERT subscription echoes back the user's own inserts. Hook deliberately does NOT optimistically append — visible lag is one realtime round-trip (~50-150ms) which is acceptable for collaborative drawing.
|