From 961ca05d339b4e6de3a4837d7cc7be5aeada9962 Mon Sep 17 00:00:00 2001 From: yassinedorbozgithub Date: Wed, 29 Jan 2025 19:42:10 +0100 Subject: [PATCH] fix: enhance broadcast channel logic --- frontend/src/contexts/auth.context.tsx | 11 +- .../contexts/broadcast-channel.context.tsx | 111 ++++++++++++++---- frontend/src/hooks/entities/auth-hooks.ts | 13 +- frontend/src/hooks/useBroadcastChannel.ts | 104 ---------------- frontend/src/hooks/useTabUuid.ts | 14 ++- frontend/src/pages/_app.tsx | 2 +- widget/src/UiChatWidget.tsx | 2 +- widget/src/hooks/useBroadcastChannel.ts | 104 ---------------- widget/src/hooks/useTabUuid.ts | 14 ++- .../providers/BroadcastChannelProvider.tsx | 110 +++++++++++++---- widget/src/providers/ChatProvider.tsx | 10 +- 11 files changed, 211 insertions(+), 284 deletions(-) delete mode 100644 frontend/src/hooks/useBroadcastChannel.ts delete mode 100644 widget/src/hooks/useBroadcastChannel.ts diff --git a/frontend/src/contexts/auth.context.tsx b/frontend/src/contexts/auth.context.tsx index e2ac9227..eca0700f 100644 --- a/frontend/src/contexts/auth.context.tsx +++ b/frontend/src/contexts/auth.context.tsx @@ -21,13 +21,13 @@ import { Progress } from "@/app-components/displays/Progress"; import { useLogout } from "@/hooks/entities/auth-hooks"; import { useApiClient } from "@/hooks/useApiClient"; import { CURRENT_USER_KEY, PUBLIC_PATHS } from "@/hooks/useAuth"; -import { useBroadcastChannel } from "@/hooks/useBroadcastChannel"; -import { useTabUuid } from "@/hooks/useTabUuid"; import { useTranslate } from "@/hooks/useTranslate"; import { RouterType } from "@/services/types"; import { IUser } from "@/types/user.types"; import { getFromQuery } from "@/utils/URL"; +import { useBroadcastChannel } from "./broadcast-channel.context"; + export interface AuthContextValue { user: IUser | undefined; isAuthenticated: boolean; @@ -109,11 +109,10 @@ export const AuthProvider = ({ children }: AuthProviderProps): JSX.Element => { setIsReady(true); }, []); - const tabUuidRef = useTabUuid(); - const { useBroadcast } = useBroadcastChannel(); + const { subscribe } = useBroadcastChannel(); - useBroadcast("session", (e) => { - if (e.data.value === "logout" && e.data.uuid !== tabUuidRef.current) { + subscribe((message) => { + if (message.data === "logout") { router.reload(); } }); diff --git a/frontend/src/contexts/broadcast-channel.context.tsx b/frontend/src/contexts/broadcast-channel.context.tsx index b11fd2df..e6b8e588 100644 --- a/frontend/src/contexts/broadcast-channel.context.tsx +++ b/frontend/src/contexts/broadcast-channel.context.tsx @@ -6,43 +6,112 @@ * 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 { + createContext, + FC, + ReactNode, + useContext, + useEffect, + useMemo, + useRef, +} from "react"; -import { useBroadcast } from "@/hooks/useBroadcastChannel"; - -export interface IBroadcastChannelContext { - useBroadcast: ( - channelName: string, - handleMessage?: (event: MessageEvent) => void, - handleMessageError?: (event: MessageEvent) => void, - ) => (data: BroadcastChannelData) => void; -} +import { useTabUuid } from "@/hooks/useTabUuid"; export type BroadcastChannelData = { - uuid: string; - value: string | number | boolean | Record | undefined | null; + tabId: string; + data: string | number | boolean | Record | undefined | null; }; export interface IBroadcastChannelProps { - children?: React.ReactNode; + channelName: string; + children?: ReactNode; } -export const BroadcastChannelContext = createContext({ - useBroadcast: () => () => {}, +export const BroadcastChannelContext = createContext<{ + subscribers: ((message: BroadcastChannelData) => void)[]; + subscribe: (callback: (message: BroadcastChannelData) => void) => () => void; + postMessage: (message: BroadcastChannelData) => void; +}>({ + subscribers: [], + subscribe: () => () => {}, + postMessage: () => {}, }); -export const BroadcastChannelProvider: React.FC = ({ +export const BroadcastChannelProvider: FC = ({ + channelName, children, }) => { + const channelRef = useRef(null); + const subscribersRef = useRef void>>( + [], + ); + const tabUuidRef = useTabUuid(); + + useEffect(() => { + const channel = new BroadcastChannel(channelName); + + channelRef.current = channel; + + const handleMessage = (event: MessageEvent) => { + const { tabId, data } = event.data; + + if (tabId === tabUuidRef.current) return; + + subscribersRef.current.forEach((callback) => callback(data)); + }; + + channel.addEventListener("message", handleMessage); + + return () => { + channel.removeEventListener("message", handleMessage); + channel.close(); + }; + }, [channelName, tabUuidRef]); + + const postMessage = (message: BroadcastChannelData) => { + channelRef.current?.postMessage({ + tabId: tabUuidRef.current, + data: message, + }); + }; + const subscribe = (callback: (message: BroadcastChannelData) => void) => { + subscribersRef.current.push(callback); + + return () => { + const index = subscribersRef.current.indexOf(callback); + + if (index !== -1) { + subscribersRef.current.splice(index, 1); + } + }; + }; + const contextValue = useMemo( + () => ({ + subscribers: subscribersRef.current, + subscribe, + postMessage, + }), + [], + ); + return ( - + {children} ); }; export default BroadcastChannelProvider; + +export const useBroadcastChannel = () => { + const context = useContext(BroadcastChannelContext); + + if (context === undefined) { + throw new Error( + "useBroadcastChannel must be used within a BroadcastChannelProvider", + ); + } + + return context; +}; diff --git a/frontend/src/hooks/entities/auth-hooks.ts b/frontend/src/hooks/entities/auth-hooks.ts index f3687549..105718c9 100755 --- a/frontend/src/hooks/entities/auth-hooks.ts +++ b/frontend/src/hooks/entities/auth-hooks.ts @@ -9,6 +9,7 @@ import { useEffect } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; +import { useBroadcastChannel } from "@/contexts/broadcast-channel.context"; import { EntityType, TMutationOptions } from "@/services/types"; import { ILoginAttributes } from "@/types/auth/login.types"; import { @@ -22,7 +23,6 @@ import { useSocket } from "@/websocket/socket-hooks"; import { useFind } from "../crud/useFind"; import { useApiClient } from "../useApiClient"; import { CURRENT_USER_KEY, useAuth, useLogoutRedirection } from "../useAuth"; -import { useBroadcastChannel } from "../useBroadcastChannel"; import { useTabUuid } from "../useTabUuid"; import { useToast } from "../useToast"; import { useTranslate } from "../useTranslate"; @@ -60,10 +60,8 @@ export const useLogout = ( const { logoutRedirection } = useLogoutRedirection(); const { toast } = useToast(); const { t } = useTranslate(); - const { useBroadcast } = useBroadcastChannel(); - const broadcastLogoutAcrossTabs = useBroadcast("session"); - const tabUuidRef = useTabUuid(); - const tabUuid = tabUuidRef.current; + const { postMessage } = useBroadcastChannel(); + const uuid = useTabUuid(); return useMutation({ ...options, @@ -74,10 +72,7 @@ export const useLogout = ( }, onSuccess: async () => { queryClient.removeQueries([CURRENT_USER_KEY]); - broadcastLogoutAcrossTabs({ - value: "logout", - uuid: tabUuid || "", - }); + postMessage({ data: "logout", tabId: uuid.current || "" }); await logoutRedirection(); toast.success(t("message.logout_success")); }, diff --git a/frontend/src/hooks/useBroadcastChannel.ts b/frontend/src/hooks/useBroadcastChannel.ts deleted file mode 100644 index 81ec012c..00000000 --- a/frontend/src/hooks/useBroadcastChannel.ts +++ /dev/null @@ -1,104 +0,0 @@ -/* - * 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. - * 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 * as React from "react"; -import { useContext } from "react"; - -import { - BroadcastChannelContext, - BroadcastChannelData, - IBroadcastChannelContext, -} from "@/contexts/broadcast-channel.context"; - -export const useBroadcast = < - T extends BroadcastChannelData = BroadcastChannelData, ->( - channelName: string, - handleMessage?: (event: MessageEvent) => void, - handleMessageError?: (event: MessageEvent) => void, -): ((data: T) => void) => { - const channelRef = React.useRef( - typeof window !== "undefined" && "BroadcastChannel" in window - ? new BroadcastChannel(channelName + "-channel") - : null, - ); - - useChannelEventListener(channelRef.current, "message", handleMessage); - useChannelEventListener( - channelRef.current, - "messageerror", - handleMessageError, - ); - - return (data: T) => channelRef.current?.postMessage(data); -}; - -export const useBroadcastState = < - T extends BroadcastChannelData = BroadcastChannelData, ->( - channelName: string, - initialState: T, -): [T, React.Dispatch>, boolean] => { - const [isPending, startTransition] = React.useTransition(); - const [state, setState] = React.useState(initialState); - const broadcast = useBroadcast(channelName, (ev) => setState(ev.data)); - const updateState: React.Dispatch> = - React.useCallback( - (input) => { - setState((prev) => { - const newState = typeof input === "function" ? input(prev) : input; - - startTransition(() => broadcast(newState)); - - return newState; - }); - }, - [broadcast], - ); - - return [state, updateState, isPending]; -}; - -const useChannelEventListener = ( - channel: BroadcastChannel | null, - event: K, - handler?: (e: BroadcastChannelEventMap[K]) => void, -) => { - const callbackRef = React.useRef(handler); - - if (callbackRef.current !== handler) { - callbackRef.current = handler; - } - - React.useEffect(() => { - const callback = callbackRef.current; - - if (!channel || !callback) { - return; - } - - channel.addEventListener(event, callback); - - return () => { - channel.close(); - 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/frontend/src/hooks/useTabUuid.ts b/frontend/src/hooks/useTabUuid.ts index 2a8fdeb4..5f86619f 100644 --- a/frontend/src/hooks/useTabUuid.ts +++ b/frontend/src/hooks/useTabUuid.ts @@ -11,18 +11,20 @@ import { useRef } from "react"; import { generateId } from "@/utils/generateId"; const getOrCreateTabId = () => { - let tabId = sessionStorage.getItem("tab_uuid"); + let storedTabId = sessionStorage.getItem("tab_uuid"); - if (!tabId) { - tabId = generateId(); - sessionStorage.setItem("tab_uuid", tabId); + if (storedTabId) { + return storedTabId; } - return tabId; + storedTabId = generateId(); + sessionStorage.setItem("tab_uuid", storedTabId); + + return storedTabId; }; export const useTabUuid = () => { - const tabUuidRef = useRef(getOrCreateTabId()); + const tabUuidRef = useRef(getOrCreateTabId()); return tabUuidRef; }; diff --git a/frontend/src/pages/_app.tsx b/frontend/src/pages/_app.tsx index 21c31c6e..583f91f1 100644 --- a/frontend/src/pages/_app.tsx +++ b/frontend/src/pages/_app.tsx @@ -84,7 +84,7 @@ const App = ({ Component, pageProps }: TAppPropsWithLayout) => { - + diff --git a/widget/src/UiChatWidget.tsx b/widget/src/UiChatWidget.tsx index 0e636a19..9dab8432 100644 --- a/widget/src/UiChatWidget.tsx +++ b/widget/src/UiChatWidget.tsx @@ -42,7 +42,7 @@ function UiChatWidget({ - + ( - channelName: string, - handleMessage?: (event: MessageEvent) => void, - handleMessageError?: (event: MessageEvent) => void, -): ((data: T) => void) => { - const channelRef = React.useRef( - typeof window !== "undefined" && "BroadcastChannel" in window - ? new BroadcastChannel(channelName + "-channel") - : null, - ); - - useChannelEventListener(channelRef.current, "message", handleMessage); - useChannelEventListener( - channelRef.current, - "messageerror", - handleMessageError, - ); - - return (data: T) => channelRef.current?.postMessage(data); -}; - -export const useBroadcastState = < - T extends BroadcastChannelData = BroadcastChannelData, ->( - channelName: string, - initialState: T, -): [T, React.Dispatch>, boolean] => { - const [isPending, startTransition] = React.useTransition(); - const [state, setState] = React.useState(initialState); - const broadcast = useBroadcast(channelName, (ev) => setState(ev.data)); - const updateState: React.Dispatch> = - React.useCallback( - (input) => { - setState((prev) => { - const newState = typeof input === "function" ? input(prev) : input; - - startTransition(() => broadcast(newState)); - - return newState; - }); - }, - [broadcast], - ); - - return [state, updateState, isPending]; -}; - -const useChannelEventListener = ( - channel: BroadcastChannel | null, - event: K, - handler?: (e: BroadcastChannelEventMap[K]) => void, -) => { - const callbackRef = React.useRef(handler); - - if (callbackRef.current !== handler) { - callbackRef.current = handler; - } - - React.useEffect(() => { - const callback = callbackRef.current; - - if (!channel || !callback) { - return; - } - - channel.addEventListener(event, callback); - - return () => { - channel.close(); - 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/hooks/useTabUuid.ts b/widget/src/hooks/useTabUuid.ts index 759db158..3f0b4018 100644 --- a/widget/src/hooks/useTabUuid.ts +++ b/widget/src/hooks/useTabUuid.ts @@ -11,18 +11,20 @@ import { useRef } from "react"; import { generateId } from "../utils/generateId"; const getOrCreateTabId = () => { - let tabId = sessionStorage.getItem("tab_uuid"); + let storedTabId = sessionStorage.getItem("tab_uuid"); - if (!tabId) { - tabId = generateId(); - sessionStorage.setItem("tab_uuid", tabId); + if (storedTabId) { + return storedTabId; } - return tabId; + storedTabId = generateId(); + sessionStorage.setItem("tab_uuid", storedTabId); + + return storedTabId; }; export const useTabUuid = () => { - const tabUuidRef = useRef(getOrCreateTabId()); + const tabUuidRef = useRef(getOrCreateTabId()); return tabUuidRef; }; diff --git a/widget/src/providers/BroadcastChannelProvider.tsx b/widget/src/providers/BroadcastChannelProvider.tsx index 21af88c8..c6d3fce7 100644 --- a/widget/src/providers/BroadcastChannelProvider.tsx +++ b/widget/src/providers/BroadcastChannelProvider.tsx @@ -6,44 +6,114 @@ * 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, ReactNode } from "react"; +import { + createContext, + FC, + ReactNode, + useContext, + useEffect, + useMemo, + useRef, +} from "react"; -import { useBroadcast } from "../hooks/useBroadcastChannel"; - -export interface IBroadcastChannelContext { - useBroadcast: ( - channelName: string, - handleMessage?: (event: MessageEvent) => void, - handleMessageError?: (event: MessageEvent) => void, - ) => (data: BroadcastChannelData) => void; -} +import { useTabUuid } from "../hooks/useTabUuid"; export type BroadcastChannelData = { - uuid: string; - value: string | number | boolean | Record | undefined | null; + tabId: string; + data: string | number | boolean | Record | undefined | null; }; export interface IBroadcastChannelProps { + channelName: string; children?: ReactNode; } -export const BroadcastChannelContext = createContext({ - useBroadcast: () => () => {}, +export const BroadcastChannelContext = createContext<{ + subscribers: ((message: BroadcastChannelData) => void)[]; + subscribe: (callback: (message: BroadcastChannelData) => void) => () => void; + postMessage: (message: BroadcastChannelData) => void; +}>({ + subscribers: [], + subscribe: () => () => {}, + postMessage: () => {}, }); -export const BroadcastChannelProvider: React.FC = ({ +export const BroadcastChannelProvider: FC = ({ + // eslint-disable-next-line react/prop-types + channelName, // eslint-disable-next-line react/prop-types children, }) => { + const channelRef = useRef(null); + const subscribersRef = useRef void>>( + [], + ); + const tabUuidRef = useTabUuid(); + + useEffect(() => { + const channel = new BroadcastChannel(channelName); + + channelRef.current = channel; + + const handleMessage = (event: MessageEvent) => { + const { tabId, data } = event.data; + + if (tabId === tabUuidRef.current) return; + + subscribersRef.current.forEach((callback) => callback(data)); + }; + + channel.addEventListener("message", handleMessage); + + return () => { + channel.removeEventListener("message", handleMessage); + channel.close(); + }; + }, [channelName, tabUuidRef]); + + const postMessage = (message: BroadcastChannelData) => { + channelRef.current?.postMessage({ + tabId: tabUuidRef.current, + data: message, + }); + }; + const subscribe = (callback: (message: BroadcastChannelData) => void) => { + subscribersRef.current.push(callback); + + return () => { + const index = subscribersRef.current.indexOf(callback); + + if (index !== -1) { + subscribersRef.current.splice(index, 1); + } + }; + }; + const contextValue = useMemo( + () => ({ + subscribers: subscribersRef.current, + subscribe, + postMessage, + }), + [], + ); + return ( - + {children} ); }; export default BroadcastChannelProvider; + +export const useBroadcastChannel = () => { + const context = useContext(BroadcastChannelContext); + + if (context === undefined) { + throw new Error( + "useBroadcastChannel must be used within a BroadcastChannelProvider", + ); + } + + return context; +}; diff --git a/widget/src/providers/ChatProvider.tsx b/widget/src/providers/ChatProvider.tsx index ff408791..9c4b952f 100644 --- a/widget/src/providers/ChatProvider.tsx +++ b/widget/src/providers/ChatProvider.tsx @@ -16,8 +16,6 @@ import React, { useState, } from "react"; -import { useBroadcastChannel } from "../hooks/useBroadcastChannel"; -import { useTabUuid } from "../hooks/useTabUuid"; import { StdEventType } from "../types/chat-io-messages.types"; import { Direction, @@ -31,6 +29,7 @@ import { } from "../types/message.types"; import { ConnectionState, OutgoingMessageState } from "../types/state.types"; +import { useBroadcastChannel } from "./BroadcastChannelProvider"; import { useConfig } from "./ConfigProvider"; import { useSettings } from "./SettingsProvider"; import { useSocket, useSubscribe } from "./SocketProvider"; @@ -453,11 +452,10 @@ const ChatProvider: React.FC<{ setMessage, handleSubscription, }; - const tabUuidRef = useTabUuid(); - const { useBroadcast } = useBroadcastChannel(); + const { subscribe } = useBroadcastChannel(); - useBroadcast("session", ({ data }) => { - if (data.value === "logout" && data.uuid !== tabUuidRef.current) { + subscribe(({ data }) => { + if (data === "logout") { socketCtx.socket.disconnect(); } });