96 lines
2.9 KiB
TypeScript
96 lines
2.9 KiB
TypeScript
import type { RealtimeChannel } from '@supabase/supabase-js';
|
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
|
|
import { supabase } from './supabase';
|
|
|
|
// Typing events live on a Realtime BROADCAST channel — no DB writes.
|
|
// Each typer pings once every 2s while actively typing. Receivers keep a
|
|
// per-user timestamp and show the indicator for 4s after the last ping.
|
|
|
|
const SEND_THROTTLE_MS = 2000;
|
|
const RECEIVE_TTL_MS = 4000;
|
|
|
|
export interface UseTypingChannel {
|
|
typingUserIds: string[];
|
|
notifyTyping: () => void;
|
|
notifyStopTyping: () => void;
|
|
}
|
|
|
|
export function useTypingChannel(
|
|
conversationId: string | undefined,
|
|
myId: string | undefined,
|
|
): UseTypingChannel {
|
|
const [typingUserIds, setTypingUserIds] = useState<string[]>([]);
|
|
const channelRef = useRef<RealtimeChannel | null>(null);
|
|
const lastSentRef = useRef(0);
|
|
const receivedRef = useRef<Map<string, number>>(new Map());
|
|
|
|
useEffect(() => {
|
|
receivedRef.current = new Map();
|
|
setTypingUserIds([]);
|
|
|
|
if (!conversationId || !myId) return;
|
|
|
|
const channel = supabase.channel('typing:' + conversationId, {
|
|
config: { broadcast: { self: false } },
|
|
});
|
|
channelRef.current = channel;
|
|
|
|
channel.on('broadcast', { event: 'typing' }, (msg) => {
|
|
const payload = msg.payload as { userId?: string; stop?: boolean };
|
|
const uid = payload.userId;
|
|
if (!uid || uid === myId) return;
|
|
if (payload.stop) {
|
|
receivedRef.current.delete(uid);
|
|
} else {
|
|
receivedRef.current.set(uid, Date.now());
|
|
}
|
|
setTypingUserIds(collectRecent(receivedRef.current));
|
|
});
|
|
|
|
void channel.subscribe();
|
|
|
|
const interval = window.setInterval(() => {
|
|
const active = collectRecent(receivedRef.current);
|
|
setTypingUserIds((prev) => {
|
|
if (prev.length === active.length && prev.every((v, i) => v === active[i])) return prev;
|
|
return active;
|
|
});
|
|
}, 1000);
|
|
|
|
return () => {
|
|
window.clearInterval(interval);
|
|
void supabase.removeChannel(channel);
|
|
channelRef.current = null;
|
|
};
|
|
}, [conversationId, myId]);
|
|
|
|
const notifyTyping = useCallback(() => {
|
|
const ch = channelRef.current;
|
|
if (!ch || !myId) return;
|
|
const now = Date.now();
|
|
if (now - lastSentRef.current < SEND_THROTTLE_MS) return;
|
|
lastSentRef.current = now;
|
|
void ch.send({ type: 'broadcast', event: 'typing', payload: { userId: myId } });
|
|
}, [myId]);
|
|
|
|
const notifyStopTyping = useCallback(() => {
|
|
const ch = channelRef.current;
|
|
if (!ch || !myId) return;
|
|
lastSentRef.current = 0;
|
|
void ch.send({ type: 'broadcast', event: 'typing', payload: { userId: myId, stop: true } });
|
|
}, [myId]);
|
|
|
|
return { typingUserIds, notifyTyping, notifyStopTyping };
|
|
}
|
|
|
|
function collectRecent(map: Map<string, number>): string[] {
|
|
const now = Date.now();
|
|
const out: string[] = [];
|
|
for (const [uid, ts] of map) {
|
|
if (now - ts < RECEIVE_TTL_MS) out.push(uid);
|
|
else map.delete(uid);
|
|
}
|
|
return out.sort();
|
|
}
|