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:
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user