204 lines
5.4 KiB
TypeScript
204 lines
5.4 KiB
TypeScript
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type ReactNode,
|
|
} from "react";
|
|
import { useChannel } from "@/hooks/use-channel";
|
|
import { useAuthStore } from "@/stores/auth-store";
|
|
|
|
export type ComposingMode = "recording" | "typing" | "screen";
|
|
|
|
export interface ComposingUser {
|
|
humanId: string;
|
|
mode: ComposingMode;
|
|
lastSeen: number;
|
|
}
|
|
|
|
interface StreamPresenceContextValue {
|
|
onlineHumanIds: Set<string>;
|
|
composingUsers: ComposingUser[];
|
|
startComposing: (mode: ComposingMode) => void;
|
|
stopComposing: () => void;
|
|
}
|
|
|
|
const COMPOSING_TIMEOUT_MS = 10_000;
|
|
const COMPOSING_HEARTBEAT_MS = 5_000;
|
|
const COMPOSING_CLEANUP_INTERVAL_MS = 2_000;
|
|
|
|
const StreamPresenceContext = createContext<StreamPresenceContextValue | null>(
|
|
null,
|
|
);
|
|
|
|
interface StreamPresenceProviderProps {
|
|
networkId: string;
|
|
streamId: string;
|
|
children: ReactNode;
|
|
}
|
|
|
|
export function StreamPresenceProvider({
|
|
networkId,
|
|
streamId,
|
|
children,
|
|
}: StreamPresenceProviderProps) {
|
|
const channelId = `stream:${networkId}:${streamId}`;
|
|
const { presence, messages, sendMessage } = useChannel(channelId);
|
|
const currentUserId = useAuthStore((s) => s.user?.id);
|
|
|
|
const onlineHumanIds = useMemo(() => new Set(presence), [presence]);
|
|
|
|
// --- Composing state ---
|
|
const [composingUsers, setComposingUsers] = useState<ComposingUser[]>([]);
|
|
const composingMapRef = useRef(new Map<string, ComposingUser>());
|
|
const processedCountRef = useRef(0);
|
|
|
|
// Process new messages incrementally — slicing the messages array means
|
|
// we don't re-scan the whole history every render.
|
|
useEffect(() => {
|
|
if (messages.length <= processedCountRef.current) return;
|
|
|
|
const newMessages = messages.slice(processedCountRef.current);
|
|
processedCountRef.current = messages.length;
|
|
|
|
let changed = false;
|
|
const map = composingMapRef.current;
|
|
|
|
for (const msg of newMessages) {
|
|
const payload = msg.payload as
|
|
| { type: string; mode?: string }
|
|
| undefined;
|
|
if (!payload?.type) continue;
|
|
if (msg.humanId === currentUserId) continue;
|
|
|
|
if (payload.type === "composing_start" && payload.mode) {
|
|
map.set(msg.humanId, {
|
|
humanId: msg.humanId,
|
|
mode: payload.mode as ComposingMode,
|
|
lastSeen: Date.now(),
|
|
});
|
|
changed = true;
|
|
} else if (payload.type === "composing_stop") {
|
|
if (map.delete(msg.humanId)) changed = true;
|
|
}
|
|
}
|
|
|
|
if (changed) {
|
|
setComposingUsers(Array.from(map.values()));
|
|
}
|
|
}, [messages, currentUserId]);
|
|
|
|
// Drop composing entries when a user leaves the channel — covers the
|
|
// "they backgrounded the app without sending stop" case.
|
|
useEffect(() => {
|
|
const map = composingMapRef.current;
|
|
const onlineSet = new Set(presence);
|
|
let changed = false;
|
|
|
|
for (const humanId of map.keys()) {
|
|
if (!onlineSet.has(humanId)) {
|
|
map.delete(humanId);
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
if (changed) {
|
|
setComposingUsers(Array.from(map.values()));
|
|
}
|
|
}, [presence]);
|
|
|
|
// Sweep stale composing entries (last heartbeat > 10s ago).
|
|
useEffect(() => {
|
|
const interval = setInterval(() => {
|
|
const map = composingMapRef.current;
|
|
const now = Date.now();
|
|
let changed = false;
|
|
|
|
for (const [humanId, entry] of map) {
|
|
if (now - entry.lastSeen > COMPOSING_TIMEOUT_MS) {
|
|
map.delete(humanId);
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
if (changed) {
|
|
setComposingUsers(Array.from(map.values()));
|
|
}
|
|
}, COMPOSING_CLEANUP_INTERVAL_MS);
|
|
|
|
return () => clearInterval(interval);
|
|
}, []);
|
|
|
|
// --- Composing broadcast ---
|
|
const heartbeatRef = useRef<ReturnType<typeof setInterval> | undefined>(
|
|
undefined,
|
|
);
|
|
|
|
const startComposing = useCallback(
|
|
(mode: ComposingMode) => {
|
|
sendMessage({ type: "composing_start", mode });
|
|
if (heartbeatRef.current) clearInterval(heartbeatRef.current);
|
|
heartbeatRef.current = setInterval(() => {
|
|
sendMessage({ type: "composing_start", mode });
|
|
}, COMPOSING_HEARTBEAT_MS);
|
|
},
|
|
[sendMessage],
|
|
);
|
|
|
|
const stopComposing = useCallback(() => {
|
|
if (heartbeatRef.current) clearInterval(heartbeatRef.current);
|
|
heartbeatRef.current = undefined;
|
|
sendMessage({ type: "composing_stop" });
|
|
}, [sendMessage]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (heartbeatRef.current) clearInterval(heartbeatRef.current);
|
|
};
|
|
}, []);
|
|
|
|
const value = useMemo<StreamPresenceContextValue>(
|
|
() => ({
|
|
onlineHumanIds,
|
|
composingUsers,
|
|
startComposing,
|
|
stopComposing,
|
|
}),
|
|
[onlineHumanIds, composingUsers, startComposing, stopComposing],
|
|
);
|
|
|
|
return (
|
|
<StreamPresenceContext.Provider value={value}>
|
|
{children}
|
|
</StreamPresenceContext.Provider>
|
|
);
|
|
}
|
|
|
|
function useStreamPresenceContext() {
|
|
const ctx = useContext(StreamPresenceContext);
|
|
if (!ctx) {
|
|
throw new Error(
|
|
"useStreamPresence must be used within a StreamPresenceProvider",
|
|
);
|
|
}
|
|
return ctx;
|
|
}
|
|
|
|
export function useStreamPresence() {
|
|
const { onlineHumanIds } = useStreamPresenceContext();
|
|
return { onlineHumanIds };
|
|
}
|
|
|
|
export function useStreamComposing() {
|
|
const { composingUsers } = useStreamPresenceContext();
|
|
return { composingUsers };
|
|
}
|
|
|
|
export function useStreamComposingBroadcast() {
|
|
const { startComposing, stopComposing } = useStreamPresenceContext();
|
|
return { startComposing, stopComposing };
|
|
}
|