Files
ChatApp/apps/desktop/src/lib/useTypingChannel.ts
T
2026-04-18 23:11:35 +02:00

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();
}