import { type BroadcastDriver, type ConnectionStatus } from "laravel-echo"; import { type DependencyList, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { echo } from "../config"; import type { BroadcastNotification, Channel, ChannelData, ChannelReturnType, Connection, EventName, InferEventPayload, ModelEvents, ModelPayload, } from "../types"; import { toArray } from "../util"; const channels: Record> = {}; const subscribeToChannel = ( channel: Channel, ): Connection => { const instance = echo(); if (channel.visibility === "presence") { return instance.join(channel.name); } if (channel.visibility === "private") { return instance.private(channel.name); } return instance.channel(channel.name); }; const leaveChannel = (channel: Channel, leaveAll: boolean): void => { if (!channels[channel.id]) { return; } channels[channel.id].count -= 1; if (channels[channel.id].count > 0) { return; } if (leaveAll) { echo().leave(channel.name); } else { echo().leaveChannel(channel.id); } delete channels[channel.id]; }; const resolveChannelSubscription = ( channel: Channel, ): Connection => { if (channels[channel.id]) { channels[channel.id].count += 1; return channels[channel.id].connection; } const channelSubscription = subscribeToChannel(channel); channels[channel.id] = { count: 1, connection: channelSubscription, }; return channelSubscription; }; // Overload for automatic type inference from event name export function useEcho< TEvent extends EventName = EventName, TDriver extends BroadcastDriver = BroadcastDriver, TVisibility extends Channel["visibility"] = "private", >( channelName: string, event: TEvent, callback: (payload: InferEventPayload) => void, dependencies?: DependencyList, visibility?: TVisibility, ): { leaveChannel: (leaveAll?: boolean) => void; leave: () => void; stopListening: () => void; listen: () => void; channel: () => ChannelReturnType | null; }; // Overload for multiple events with automatic type inference export function useEcho< TEvent extends EventName = EventName, TDriver extends BroadcastDriver = BroadcastDriver, TVisibility extends Channel["visibility"] = "private", >( channelName: string, event: TEvent[], callback: (payload: InferEventPayload) => void, dependencies?: DependencyList, visibility?: TVisibility, ): { leaveChannel: (leaveAll?: boolean) => void; leave: () => void; stopListening: () => void; listen: () => void; channel: () => ChannelReturnType | null; }; // Overload for explicit payload type (backward compatibility) export function useEcho< TPayload, TDriver extends BroadcastDriver = BroadcastDriver, TVisibility extends Channel["visibility"] = "private", >( channelName: string, event: string | string[], callback: (payload: TPayload) => void, dependencies?: DependencyList, visibility?: TVisibility, ): { leaveChannel: (leaveAll?: boolean) => void; leave: () => void; stopListening: () => void; listen: () => void; channel: () => ChannelReturnType | null; }; // Implementation export function useEcho< TPayload, TDriver extends BroadcastDriver = BroadcastDriver, TVisibility extends Channel["visibility"] = "private", >( channelName: string, event: string | string[] = [], callback: (payload: TPayload) => void = () => {}, dependencies: DependencyList = [], visibility: TVisibility = "private" as TVisibility, ) { const channel: Channel = useMemo( () => ({ name: channelName, id: ["private", "presence"].includes(visibility) ? `${visibility}-${channelName}` : channelName, visibility, }), [channelName, visibility], ); // callback and dependencies are parameters meant to be used directly // eslint-disable-next-line react-hooks/exhaustive-deps const callbackFunc = useCallback(callback, dependencies); const listening = useRef(false); const subscription = useRef | null>(null); const eventKey = Array.isArray(event) ? JSON.stringify(event) : event; // Using eventKey instead of event to stabilize array dependencies // eslint-disable-next-line react-hooks/exhaustive-deps const events = useMemo(() => toArray(event), [eventKey]); const stopListening = useCallback(() => { if (!listening.current || !subscription.current) { return; } events.forEach((e) => { subscription.current!.stopListening(e, callbackFunc); }); listening.current = false; }, [events, callbackFunc]); const listen = useCallback(() => { if (listening.current || !subscription.current) { return; } events.forEach((e) => { subscription.current!.listen(e, callbackFunc); }); listening.current = true; }, [events, callbackFunc]); const tearDown = useCallback( (leaveAll: boolean = false) => { stopListening(); leaveChannel(channel, leaveAll); }, [stopListening, channel], ); const leave = useCallback(() => { tearDown(true); }, [tearDown]); useEffect(() => { subscription.current = resolveChannelSubscription(channel); listen(); return tearDown; }, [listen, tearDown, channel]); return useMemo( () => ({ /** * Leave the channel */ leaveChannel: tearDown, /** * Leave the channel and also its associated private and presence channels */ leave, /** * Stop listening for event(s) without leaving the channel */ stopListening, /** * Listen for event(s) */ listen, /** * Channel instance */ channel: () => subscription.current as ChannelReturnType< TDriver, TVisibility > | null, }), [leave, listen, stopListening, tearDown], ); } export const useEchoNotification = < TPayload, TDriver extends BroadcastDriver = BroadcastDriver, >( channelName: string, callback: (payload: BroadcastNotification) => void = () => {}, event: string | string[] = [], dependencies: DependencyList = [], ) => { const result = useEcho, TDriver, "private">( channelName, [], callback, dependencies, "private", ); const eventKey = Array.isArray(event) ? JSON.stringify(event) : event; const events = useMemo(() => { return toArray(event) .map((e) => { if (e.includes(".")) { return [e, e.replace(/\./g, "\\")]; } return [e, e.replace(/\\/g, ".")]; }) .flat(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [eventKey]); const listening = useRef(false); // eslint-disable-next-line react-hooks/exhaustive-deps const memoizedCallback = useCallback(callback, dependencies); const cb = useCallback( (notification: BroadcastNotification) => { if (!listening.current) { return; } if (events.length === 0 || events.includes(notification.type)) { memoizedCallback(notification); } }, [memoizedCallback, events], ); const listen = useCallback(() => { if (listening.current) { return; } const ch = result.channel(); if (!ch) { return; } ch.notification(cb); listening.current = true; }, [cb, result]); const stopListening = useCallback(() => { if (!listening.current) { return; } const ch = result.channel(); if (!ch) { listening.current = false; return; } ch.stopListeningForNotification(cb); listening.current = false; }, [cb, result]); useEffect(() => { listen(); return () => stopListening(); }, [listen, stopListening]); return useMemo( () => ({ ...result, /** * Stop listening for notification events */ stopListening, /** * Listen for notification events */ listen, }), [result, stopListening, listen], ); }; export const useEchoPresence = < TPayload, TDriver extends BroadcastDriver = BroadcastDriver, >( channelName: string, event: string | string[] = [], callback: (payload: TPayload) => void = () => {}, dependencies: DependencyList = [], ) => { return useEcho( channelName, event, callback, dependencies, "presence", ); }; export const useEchoPublic = < TPayload, TDriver extends BroadcastDriver = BroadcastDriver, >( channelName: string, event: string | string[] = [], callback: (payload: TPayload) => void = () => {}, dependencies: DependencyList = [], ) => { return useEcho( channelName, event, callback, dependencies, "public", ); }; export const useEchoModel = < TPayload, TModel extends string, TDriver extends BroadcastDriver = BroadcastDriver, >( model: TModel, identifier: string | number, event: ModelEvents | ModelEvents[] = [], callback: (payload: ModelPayload) => void = () => {}, dependencies: DependencyList = [], ) => { return useEcho, TDriver, "private">( `${model}.${identifier}`, toArray(event).map((e) => (e.startsWith(".") ? e : `.${e}`)), callback, dependencies, "private", ); }; const useConnectionChange = ( callback: (status: ConnectionStatus) => void, invokeOnMount: boolean = true, ): void => { const callbackRef = useRef(callback); useLayoutEffect(() => { callbackRef.current = callback; }); useEffect(() => { if (invokeOnMount) { callbackRef.current(echo().connectionStatus()); } return echo().connector.onConnectionChange((status) => callbackRef.current(status), ); }, []); // eslint-disable-line react-hooks/exhaustive-deps }; export const useConnectionStatus = (): ConnectionStatus => { const [status, setStatus] = useState(() => echo().connectionStatus(), ); useConnectionChange((newStatus) => { setStatus(newStatus); }); return status; }; export const useSocketId = (): string | undefined => { const [socketId, setSocketId] = useState(() => echo().socketId(), ); useConnectionChange(() => { setSocketId(echo().socketId()); }, false); return socketId; };