diff --git a/apps/desktop/src/hooks/useWhiteboardStrokes.ts b/apps/desktop/src/hooks/useWhiteboardStrokes.ts new file mode 100644 index 0000000..e719c5c --- /dev/null +++ b/apps/desktop/src/hooks/useWhiteboardStrokes.ts @@ -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; + clearAll: () => Promise; +} { + const [state, setState] = useState({ 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, + }; +}