feat(P4B.T3): useWhiteboardStrokes hook with INSERT + bulk DELETE realtime

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
byGalax
2026-05-16 20:25:22 +02:00
parent 3df7cc01ea
commit 2243c646ac
@@ -0,0 +1,130 @@
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.
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.
} 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,
};
}