diff --git a/frontend/src/contexts/broadcast-channel.tsx b/frontend/src/contexts/broadcast-channel.context.tsx similarity index 100% rename from frontend/src/contexts/broadcast-channel.tsx rename to frontend/src/contexts/broadcast-channel.context.tsx diff --git a/frontend/src/hooks/useBroadcastChannel.tsx b/frontend/src/hooks/useBroadcastChannel.ts similarity index 98% rename from frontend/src/hooks/useBroadcastChannel.tsx rename to frontend/src/hooks/useBroadcastChannel.ts index b933ca53..81ec012c 100644 --- a/frontend/src/hooks/useBroadcastChannel.tsx +++ b/frontend/src/hooks/useBroadcastChannel.ts @@ -13,7 +13,7 @@ import { BroadcastChannelContext, BroadcastChannelData, IBroadcastChannelContext, -} from "@/contexts/broadcast-channel"; +} from "@/contexts/broadcast-channel.context"; export const useBroadcast = < T extends BroadcastChannelData = BroadcastChannelData, diff --git a/frontend/src/pages/_app.tsx b/frontend/src/pages/_app.tsx index 4f69a198..21c31c6e 100644 --- a/frontend/src/pages/_app.tsx +++ b/frontend/src/pages/_app.tsx @@ -19,7 +19,7 @@ import { ReactQueryDevtools } from "react-query/devtools"; import { SnackbarCloseButton } from "@/app-components/displays/Toast/CloseButton"; import { ApiClientProvider } from "@/contexts/apiClient.context"; import { AuthProvider } from "@/contexts/auth.context"; -import BroadcastChannelProvider from "@/contexts/broadcast-channel"; +import BroadcastChannelProvider from "@/contexts/broadcast-channel.context"; import { ConfigProvider } from "@/contexts/config.context"; import { PermissionProvider } from "@/contexts/permission.context"; import { SettingsProvider } from "@/contexts/setting.context"; diff --git a/widget/src/UiChatWidget.tsx b/widget/src/UiChatWidget.tsx index 96dc5de4..0e636a19 100644 --- a/widget/src/UiChatWidget.tsx +++ b/widget/src/UiChatWidget.tsx @@ -10,6 +10,7 @@ import { PropsWithChildren } from "react"; import Launcher from "./components/Launcher"; import UserSubscription from "./components/UserSubscription"; +import BroadcastChannelProvider from "./providers/BroadcastChannelProvider"; import ChatProvider from "./providers/ChatProvider"; import { ColorProvider } from "./providers/ColorProvider"; import { ConfigProvider } from "./providers/ConfigProvider"; @@ -41,17 +42,19 @@ function UiChatWidget({ - - - - - + + + + + + + diff --git a/widget/src/hooks/useBroadcastChannel.ts b/widget/src/hooks/useBroadcastChannel.ts index 4d4417a2..d23578e1 100644 --- a/widget/src/hooks/useBroadcastChannel.ts +++ b/widget/src/hooks/useBroadcastChannel.ts @@ -1,5 +1,5 @@ /* - * Copyright © 2025 Hexastack. All rights reserved. + * Copyright © 2024 Hexastack. All rights reserved. * * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. @@ -7,27 +7,21 @@ */ import * as React from "react"; +import { useContext } from "react"; -export type BroadcastChannelData = { - uuid: string; - value: string | number | boolean | Record | undefined | null; -}; +import { + BroadcastChannelContext, + BroadcastChannelData, + IBroadcastChannelContext, +} from "../providers/BroadcastChannelProvider"; -/** - * React hook to create and manage a Broadcast Channel across multiple browser windows. - * - * @param channelName Static name of channel used across the browser windows. - * @param handleMessage Callback to handle the event generated when `message` is received. - * @param handleMessageError [optional] Callback to handle the event generated when `error` is received. - * @returns A function to send/post message on the channel. - */ -export function useBroadcastChannel< +export const useBroadcast = < T extends BroadcastChannelData = BroadcastChannelData, >( channelName: string, handleMessage?: (event: MessageEvent) => void, handleMessageError?: (event: MessageEvent) => void, -): (data: T) => void { +): ((data: T) => void) => { const channelRef = React.useRef( typeof window !== "undefined" && "BroadcastChannel" in window ? new BroadcastChannel(channelName + "-channel") @@ -42,26 +36,17 @@ export function useBroadcastChannel< ); return (data: T) => channelRef.current?.postMessage(data); -} +}; -/** - * React hook to manage state across browser windows. Has the similar signature as `React.useState`. - * - * @param channelName Static name of channel used across the browser windows. - * @param initialState Initial state. - * @returns Tuple of state and setter for the state. - */ -export function useBroadcastState< +export const useBroadcastState = < T extends BroadcastChannelData = BroadcastChannelData, >( channelName: string, initialState: T, -): [T, React.Dispatch>, boolean] { +): [T, React.Dispatch>, boolean] => { const [isPending, startTransition] = React.useTransition(); const [state, setState] = React.useState(initialState); - const broadcast = useBroadcastChannel(channelName, (ev) => - setState(ev.data), - ); + const broadcast = useBroadcast(channelName, (ev) => setState(ev.data)); const updateState: React.Dispatch> = React.useCallback( (input) => { @@ -77,16 +62,13 @@ export function useBroadcastState< ); return [state, updateState, isPending]; -} +}; -// Helpers - -/** Hook to subscribe/unsubscribe from channel events. */ -function useChannelEventListener( +const useChannelEventListener = ( channel: BroadcastChannel | null, event: K, handler?: (e: BroadcastChannelEventMap[K]) => void, -) { +) => { const callbackRef = React.useRef(handler); if (callbackRef.current !== handler) { @@ -107,4 +89,16 @@ function useChannelEventListener( channel.removeEventListener(event, callback); }; }, [channel, event]); -} +}; + +export const useBroadcastChannel = (): IBroadcastChannelContext => { + const context = useContext(BroadcastChannelContext); + + if (!context) { + throw new Error( + "useBroadcastChannel must be used within an BroadcastChannelContext", + ); + } + + return context; +}; diff --git a/widget/src/providers/BroadcastChannelProvider.tsx b/widget/src/providers/BroadcastChannelProvider.tsx new file mode 100644 index 00000000..ba323639 --- /dev/null +++ b/widget/src/providers/BroadcastChannelProvider.tsx @@ -0,0 +1,49 @@ +/* + * Copyright © 2025 Hexastack. All rights reserved. + * + * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: + * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. + * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). + */ + +import { createContext } from "react"; + +import { useBroadcast } from "../hooks/useBroadcastChannel"; + +export interface IBroadcastChannelContext { + useBroadcast: ( + channelName: string, + handleMessage?: (event: MessageEvent) => void, + handleMessageError?: (event: MessageEvent) => void, + ) => (data: BroadcastChannelData) => void; +} + +export type BroadcastChannelData = { + uuid: string; + value: string | number | boolean | Record | undefined | null; +}; + +export interface IBroadcastChannelProps { + children?: React.ReactNode; +} + +export const BroadcastChannelContext = createContext({ + useBroadcast: () => () => {}, +}); + +export const BroadcastChannelProvider: React.FC = ({ + // eslint-disable-next-line react/prop-types + children, +}) => { + return ( + + {children} + + ); +}; + +export default BroadcastChannelProvider; diff --git a/widget/src/providers/ChatProvider.tsx b/widget/src/providers/ChatProvider.tsx index cf05da4f..ff408791 100644 --- a/widget/src/providers/ChatProvider.tsx +++ b/widget/src/providers/ChatProvider.tsx @@ -454,10 +454,10 @@ const ChatProvider: React.FC<{ handleSubscription, }; const tabUuidRef = useTabUuid(); - const tabUuid = tabUuidRef.current; + const { useBroadcast } = useBroadcastChannel(); - useBroadcastChannel("session", ({ data }) => { - if (data.value === "logout" && data.uuid !== tabUuid) { + useBroadcast("session", ({ data }) => { + if (data.value === "logout" && data.uuid !== tabUuidRef.current) { socketCtx.socket.disconnect(); } });