initial
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user