feat(P5A.T3): useWatchSession hook with realtime + throttled push
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
endWatchSession,
|
||||
getWatchSession,
|
||||
updateWatchSessionState,
|
||||
type WatchSession,
|
||||
type WatchSessionState,
|
||||
} from '@chat-app/shared/chat';
|
||||
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
const PUSH_THROTTLE_MS = 500;
|
||||
|
||||
export function useWatchSession(sessionId: string | null): {
|
||||
session: WatchSession | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
pushState: (state: WatchSessionState) => void;
|
||||
endSession: () => Promise<void>;
|
||||
} {
|
||||
const [session, setSession] = useState<WatchSession | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const pendingRef = useRef<WatchSessionState | null>(null);
|
||||
const lastPushAtRef = useRef<number>(0);
|
||||
const pushTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) {
|
||||
setSession(null);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const fresh = await getWatchSession(supabase, sessionId);
|
||||
if (!cancelled) {
|
||||
setSession(fresh);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
setError(err instanceof Error ? err.message : 'failed to load session');
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
const channel = supabase
|
||||
.channel('watch_session:' + sessionId)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'UPDATE',
|
||||
schema: 'public',
|
||||
table: 'conversation_watch_sessions',
|
||||
filter: 'id=eq.' + sessionId,
|
||||
},
|
||||
(payload) => {
|
||||
const row = payload.new as {
|
||||
id?: string;
|
||||
ended_at?: string | null;
|
||||
current_state?: unknown;
|
||||
} | null;
|
||||
if (!row?.id) return;
|
||||
const raw = (row.current_state ?? {}) as Partial<{
|
||||
playing: boolean;
|
||||
position_seconds: number;
|
||||
updated_at_ms: number;
|
||||
}>;
|
||||
setSession((cur) => {
|
||||
if (!cur) return cur;
|
||||
return {
|
||||
...cur,
|
||||
endedAt: row.ended_at ?? null,
|
||||
currentState: {
|
||||
playing: typeof raw.playing === 'boolean' ? raw.playing : cur.currentState.playing,
|
||||
positionSeconds:
|
||||
typeof raw.position_seconds === 'number'
|
||||
? raw.position_seconds
|
||||
: cur.currentState.positionSeconds,
|
||||
updatedAtMs:
|
||||
typeof raw.updated_at_ms === 'number'
|
||||
? raw.updated_at_ms
|
||||
: cur.currentState.updatedAtMs,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
void supabase.removeChannel(channel);
|
||||
if (pushTimerRef.current !== null) {
|
||||
window.clearTimeout(pushTimerRef.current);
|
||||
pushTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
// Throttled writer: keeps the latest state in pendingRef; fires at most
|
||||
// once per PUSH_THROTTLE_MS. Trailing-edge push guarantees the final
|
||||
// state is always sent even when a rapid burst stops before the leading-
|
||||
// edge timeout expires.
|
||||
const pushState = useCallback((state: WatchSessionState) => {
|
||||
if (!sessionId) return;
|
||||
pendingRef.current = state;
|
||||
const now = Date.now();
|
||||
const elapsed = now - lastPushAtRef.current;
|
||||
if (elapsed >= PUSH_THROTTLE_MS) {
|
||||
lastPushAtRef.current = now;
|
||||
const toPush = pendingRef.current;
|
||||
pendingRef.current = null;
|
||||
void updateWatchSessionState(supabase, sessionId, toPush).catch((err) => {
|
||||
console.warn('updateWatchSessionState failed', err);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (pushTimerRef.current !== null) window.clearTimeout(pushTimerRef.current);
|
||||
pushTimerRef.current = window.setTimeout(() => {
|
||||
pushTimerRef.current = null;
|
||||
const toPush = pendingRef.current;
|
||||
if (!toPush) return;
|
||||
pendingRef.current = null;
|
||||
lastPushAtRef.current = Date.now();
|
||||
void updateWatchSessionState(supabase, sessionId, toPush).catch((err) => {
|
||||
console.warn('updateWatchSessionState trailing failed', err);
|
||||
});
|
||||
}, PUSH_THROTTLE_MS - elapsed);
|
||||
}, [sessionId]);
|
||||
|
||||
const endSession = useCallback(async () => {
|
||||
if (!sessionId) return;
|
||||
await endWatchSession(supabase, sessionId);
|
||||
}, [sessionId]);
|
||||
|
||||
return { session, loading, error, pushState, endSession };
|
||||
}
|
||||
Reference in New Issue
Block a user