diff --git a/src/App.tsx b/src/App.tsx index 016ebb8ce..ed88bb665 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -35,6 +35,7 @@ const App = (props: Props) => { enableSourceMessage={props.enableSourceMessage} enableEmojiFeedback={props.enableEmojiFeedback} enableMention={props.enableMention} + enableMobileView={props.enableMobileView} customUserAgentParam={props.customUserAgentParam} autoOpen={props.autoOpen} renderWidgetToggleButton={props.renderWidgetToggleButton} diff --git a/src/components/Chat.tsx b/src/components/Chat.tsx index 368ea9277..d5ffbaf16 100644 --- a/src/components/Chat.tsx +++ b/src/components/Chat.tsx @@ -1,177 +1,23 @@ import '@sendbird/uikit-react/dist/index.css'; import '../css/index.css'; -import SBProvider from '@sendbird/uikit-react/SendbirdProvider'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { useMemo, useRef } from 'react'; -import { ThemeProvider, useTheme } from 'styled-components'; +import { GroupChannelProvider } from '@sendbird/uikit-react/GroupChannel/context'; -import { type Props as ChatWidgetProps } from './ChatAiWidget'; -import CustomChannel from './CustomChannel'; -import ErrorContainer from './ErrorContainer'; -import { generateCSSVariables } from '../colors'; -import { - useConstantState, - ConstantStateProvider, -} from '../context/ConstantContext'; -import { useChannelStyle } from '../hooks/useChannelStyle'; +import { CustomChannelComponent } from './CustomChannelComponent'; +import { useManualGroupChannelCreation } from '../hooks/useGroupChannel'; +import useWidgetButtonActivityTimeout from '../hooks/useWidgetButtonActivityTimeout'; import useWidgetLocalStorage from '../hooks/useWidgetLocalStorage'; -import { getTheme } from '../theme'; -import { assert, isMobile } from '../utils'; -const CHAT_AI_WIDGET_KEY = import.meta.env.VITE_CHAT_AI_WIDGET_KEY; - -const SBComponent = () => { - const { - applicationId, - botId, - userNickName, - configureSession, - enableMention, - enableEmojiFeedback, - customUserAgentParam, - stringSet, - } = useConstantState(); - - assert( - applicationId !== null && botId !== null, - 'applicationId and botId must be provided' - ); - const sdkInitParams = useMemo( - () => ({ - appStateToggleEnabled: false, - }), - [] - ); - - const { theme, isError, errorMessage } = useChannelStyle({ - appId: applicationId, - botId: botId, - }); - const { sessionToken, userId } = useWidgetLocalStorage(); - const globalTheme = useTheme(); - const customColorSet = useMemo(() => { - if (!globalTheme.accentColor) return undefined; - - return ['light', 'dark'].reduce((acc, cur) => { - return { - ...acc, - ...generateCSSVariables(globalTheme.accentColor, cur), - }; - }, {}); - }, [globalTheme.accentColor]); - - const userAgentCustomParams = useRef({ - ...customUserAgentParam, - 'chat-ai-widget': 'True', - 'chat-ai-widget-key': CHAT_AI_WIDGET_KEY, - }); - - if (isError) { - return ; - } - return ( - - <> - -
- - - ); -}; - -interface Props extends ChatWidgetProps { - isOpen: boolean; - setIsOpen: (isOpen: boolean) => void; -} - -export const Chat = ({ - applicationId, - botId, - isOpen = true, - setIsOpen, - ...constantProps -}: Props) => { - // If env is not provided, prop will be used instead. - // But Either should be provided. - const CHAT_WIDGET_APP_ID = - import.meta.env.VITE_CHAT_WIDGET_APP_ID ?? applicationId; - const CHAT_WIDGET_BOT_ID = import.meta.env.VITE_CHAT_WIDGET_BOT_ID ?? botId; - - assert( - CHAT_WIDGET_APP_ID !== null && CHAT_WIDGET_BOT_ID !== null, - 'applicationId and botId must be provided' - ); - - const { theme, accentColor, botMessageBGColor } = useChannelStyle({ - appId: CHAT_WIDGET_APP_ID, - botId: CHAT_WIDGET_BOT_ID, - }); - - const globalTheme = getTheme({ - // get accentColor & botMessageBGColor from API response - accentColor, - botMessageBGColor, - })[theme]; +const Chat = () => { + useWidgetButtonActivityTimeout(); + useManualGroupChannelCreation(); + const { channelUrl } = useWidgetLocalStorage(); return ( - - - {isOpen && } - - + + +
+ ); }; -/** - * NOTE: External purpose only. - * Do not use this component directly. Use Chat instead for internal use. - */ -export default function ChatClient(props: Props) { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { - refetchOnWindowFocus: false, - refetchOnReconnect: false, - staleTime: 5000, - }, - }, - }); - - return ( - - - - ); -} +export default Chat; diff --git a/src/components/ChatAiWidget.tsx b/src/components/ChatAiWidget.tsx index 3e5dd49ee..800c6a59d 100644 --- a/src/components/ChatAiWidget.tsx +++ b/src/components/ChatAiWidget.tsx @@ -1,16 +1,15 @@ import '@sendbird/uikit-react/dist/index.css'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { useEffect, useRef, useState } from 'react'; -import styled, { css } from 'styled-components'; +import styled from 'styled-components'; -import { Chat } from './Chat'; +import Chat from './Chat'; +import ProviderContainer, { + type ProviderContainerProps, +} from './ProviderContainer'; +import WidgetToggleButton from './WidgetToggleButton'; import WidgetWindow from './WidgetWindow'; -import { getColorBasedOnSaturation } from '../colors'; -import { Constant, MAX_Z_INDEX } from '../const'; -import { useChannelStyle } from '../hooks/useChannelStyle'; +import { MAX_Z_INDEX } from '../const'; +import { useWidgetOpen } from '../context/WidgetOpenContext'; import useMobileView from '../hooks/useMobileView'; -import { ReactComponent as ArrowDownIcon } from '../icons/ic-arrow-down.svg'; -import { ReactComponent as ChatBotIcon } from '../icons/icon-widget-chatbot.svg'; import { isMobile } from '../utils'; const MobileContainer = styled.div<{ width: number }>` @@ -24,189 +23,35 @@ const MobileContainer = styled.div<{ width: number }>` background-color: white; `; -const StyledWidgetButtonWrapper = styled.button<{ accentColor: string }>` - position: fixed; - z-index: ${MAX_Z_INDEX}; - bottom: 24px; - right: 24px; - width: 48px; - height: 48px; - background: ${({ accentColor }) => accentColor}; - border-radius: 50%; - color: white; - transition: all 0.3s cubic-bezier(0.31, -0.105, 0.43, 1.4); - border: none; - display: flex; - justify-content: center; - align-items: center; - box-shadow: 0px 16px 24px 2px rgba(33, 33, 33, 0.12), - 0px 6px 30px 5px rgba(33, 33, 33, 0.08), - 0px 6px 10px -5px rgba(33, 33, 33, 0.04); - - span { - position: absolute; - transition: transform 0.16s linear 0s, opacity 0.08s linear 0s; - width: 32px; - height: 32px; - user-select: none; - display: flex; - justify-content: center; - align-items: center; - - svg { - path { - fill: ${({ accentColor }) => getColorBasedOnSaturation(accentColor)}; - } - } - } - - &:hover { - transition: transform 250ms cubic-bezier(0.33, 0, 0, 1); - transform: scale(1.1); - } - - &:active { - transform: scale(0.8); - } - - svg { - path { - fill: ${({ accentColor }) => getColorBasedOnSaturation(accentColor)}; - } - } -`; - -const StyledWidgetIcon = styled.span<{ isOpen: boolean }>` - ${({ isOpen }) => { - return isOpen - ? css` - opacity: 0; - transform: rotate(30deg) scale(0); - ` - : css` - opacity: 1; - transform: rotate(0deg); - `; - }} -`; - -const StyledArrowIcon = styled.span<{ isOpen: boolean }>` - ${({ isOpen }) => { - return isOpen - ? css` - transform: rotate(0deg); - ` - : css` - transform: rotate(-90deg) scale(0); - `; - }} -`; - -interface ToggleButtonProps { - onClick: () => void; - accentColor: string; - isOpen: boolean; -} -const WidgetToggleButton = ({ - onClick, - accentColor, - isOpen, -}: ToggleButtonProps) => { +const DesktopComponent = () => { return ( - - - - - - - - + <> + + + + + ); }; -export interface Props extends Partial { - applicationId: string; - botId: string; - hashedKey?: string; - autoOpen?: boolean; - renderWidgetToggleButton?: (props: ToggleButtonProps) => React.ReactElement; -} - -const Component = (props: Props) => { - const { isFetching, ...channelStyle } = useChannelStyle({ - appId: props.applicationId, - botId: props.botId, - }); - const [isOpen, setIsOpen] = useState( - isMobile - ? // we don't want to open the widget window automatically on mobile view - false - : props.autoOpen ?? channelStyle.autoOpen ?? false - ); - const { width: mobileContainerWidth } = useMobileView({ - enableMobileView: props.enableMobileView, - isWidgetOpen: isOpen, - }); - const timer = useRef(null); +const MobileComponent = () => { + const { isOpen } = useWidgetOpen(); + const { width: mobileContainerWidth } = useMobileView(); - const buttonClickHandler = () => { - if (timer.current !== null) { - clearTimeout(timer.current as NodeJS.Timeout); - timer.current = null; - } - setIsOpen((prev) => !prev); - }; - - useEffect(() => { - if (!isMobile && (props.autoOpen || channelStyle.autoOpen)) { - timer.current = setTimeout(() => setIsOpen(true), 200); - } - }, [channelStyle.autoOpen, props.autoOpen]); - - const toggleButtonProps = { - onClick: buttonClickHandler, - accentColor: channelStyle.accentColor, - isOpen, - }; - return isMobile && isOpen ? ( + if (!isOpen) { + return ; + } + return ( - + - ) : ( - <> - - {props.renderWidgetToggleButton?.(toggleButtonProps) || - (!isFetching && )} - ); }; -export default function ChatAiWidget(props: Props) { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { - refetchOnWindowFocus: false, - refetchOnReconnect: false, - staleTime: 5000, - }, - }, - }); - const CHAT_WIDGET_APP_ID = - import.meta.env.VITE_CHAT_WIDGET_APP_ID ?? props.applicationId; - const CHAT_WIDGET_BOT_ID = - import.meta.env.VITE_CHAT_WIDGET_BOT_ID ?? props.botId; - +export default function ChatAiWidget(props: ProviderContainerProps) { return ( - - - + + {isMobile ? : } + ); } diff --git a/src/components/ChatBottom.tsx b/src/components/ChatBottom.tsx index 4498839b6..04307e58a 100644 --- a/src/components/ChatBottom.tsx +++ b/src/components/ChatBottom.tsx @@ -37,13 +37,8 @@ const Highlighter = styled.a` // link: https://dashboard.sendbird.com/auth/signup export default function ChatBottom() { - const { chatBottomContent, applicationId, botId } = useConstantState(); - const { theme } = useChannelStyle({ - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - appId: applicationId!, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - botId: botId!, - }); + const { chatBottomContent } = useConstantState(); + const { theme } = useChannelStyle(); return ( diff --git a/src/components/CustomChannel.tsx b/src/components/CustomChannel.tsx deleted file mode 100644 index dfc70898f..000000000 --- a/src/components/CustomChannel.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { GroupChannelProvider } from '@sendbird/uikit-react/GroupChannel/context'; - -import { CustomChannelComponent } from './CustomChannelComponent'; -import LoadingScreen from './LoadingScreen'; -import { useManualGroupChannelCreation } from '../hooks/useGroupChannel'; -import useWidgetLocalStorage from '../hooks/useWidgetLocalStorage'; - -export default function CustomChannel() { - useManualGroupChannelCreation(); - - const { channelUrl } = useWidgetLocalStorage(); - if (channelUrl == null) { - return ; - } - return ( - - - - ); -} diff --git a/src/components/CustomChannelComponent.tsx b/src/components/CustomChannelComponent.tsx index 0452b4571..f9966b72a 100644 --- a/src/components/CustomChannelComponent.tsx +++ b/src/components/CustomChannelComponent.tsx @@ -43,10 +43,6 @@ const Root = styled.div` isStaticReplyVisible ? '65px' : '50px'}; } - .sendbird-place-holder__body { - display: block; - } - .sendbird-message-input-wrapper { width: 100%; } diff --git a/src/components/CustomChannelHeader.tsx b/src/components/CustomChannelHeader.tsx index 7e6408873..0a0d69bf6 100644 --- a/src/components/CustomChannelHeader.tsx +++ b/src/components/CustomChannelHeader.tsx @@ -8,6 +8,7 @@ import styled from 'styled-components'; import BetaLogo from './BetaLogo'; import BotProfileImage from './BotProfileImage'; import { useConstantState } from '../context/ConstantContext'; +import { useWidgetOpen } from '../context/WidgetOpenContext'; import { ReactComponent as CloseButton } from '../icons/ic-widget-close.svg'; import { isMobile, isEmpty } from '../utils'; @@ -72,7 +73,7 @@ export default function CustomChannelHeader({ }: Props) { const { betaMark, customBetaMarkText, customRefreshComponent } = useConstantState(); - const { setIsOpen } = useConstantState(); + const { setIsOpen } = useWidgetOpen(); async function handleRenewButtonClick() { try { @@ -130,7 +131,7 @@ export default function CustomChannelHeader({ /> {isMobile && ( - setIsOpen?.(false)}>Close + setIsOpen(false)}>Close )} diff --git a/src/components/ProviderContainer.tsx b/src/components/ProviderContainer.tsx new file mode 100644 index 000000000..a2b55d0b0 --- /dev/null +++ b/src/components/ProviderContainer.tsx @@ -0,0 +1,139 @@ +import SBProvider from '@sendbird/uikit-react/SendbirdProvider'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useRef, useMemo } from 'react'; +import { ThemeProvider } from 'styled-components'; + +import { generateCSSVariables } from '../colors'; +import { type Constant } from '../const'; +import { + useConstantState, + ConstantStateProvider, +} from '../context/ConstantContext'; +import { WidgetOpenProvider } from '../context/WidgetOpenContext'; +import { useChannelStyle } from '../hooks/useChannelStyle'; +import useWidgetLocalStorage from '../hooks/useWidgetLocalStorage'; +import { getTheme } from '../theme'; +import { isMobile } from '../utils'; + +const CHAT_AI_WIDGET_KEY = import.meta.env.VITE_CHAT_AI_WIDGET_KEY; + +const SBComponent = ({ children }: { children: React.ReactElement }) => { + const { + applicationId, + userNickName, + configureSession, + enableMention, + enableEmojiFeedback, + customUserAgentParam, + stringSet, + ...restConstantProps + } = useConstantState(); + + const userAgentCustomParams = useRef({ + ...customUserAgentParam, + 'chat-ai-widget': 'True', + 'chat-ai-widget-key': CHAT_AI_WIDGET_KEY, + }); + + const { isFetching, theme, accentColor, botMessageBGColor, autoOpen } = + useChannelStyle(); + + const styledTheme = getTheme({ + accentColor, + botMessageBGColor, + })[theme]; + + const customColorSet = useMemo(() => { + if (!accentColor) return undefined; + + return ['light', 'dark'].reduce((acc, cur) => { + return { + ...acc, + ...generateCSSVariables(accentColor, cur), + }; + }, {}); + }, [accentColor]); + const { sessionToken, userId } = useWidgetLocalStorage(); + + if (isFetching) { + return null; + } + + return ( + + + + {children} + + + + ); +}; + +export interface ProviderContainerProps extends Partial { + applicationId: string; + botId: string; + children: React.ReactElement; + hashedKey?: string; +} + +export default function ProviderContainer(props: ProviderContainerProps) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + refetchOnReconnect: false, + staleTime: 5000, + }, + }, + }); + // If env is not provided, prop will be used instead. + // But Either should be provided. + const CHAT_WIDGET_APP_ID = + import.meta.env.VITE_CHAT_WIDGET_APP_ID ?? props.applicationId; + const CHAT_WIDGET_BOT_ID = + import.meta.env.VITE_CHAT_WIDGET_BOT_ID ?? props.botId; + + return ( + + + {props.children} + + + ); +} diff --git a/src/components/WidgetToggleButton.tsx b/src/components/WidgetToggleButton.tsx new file mode 100644 index 000000000..035526653 --- /dev/null +++ b/src/components/WidgetToggleButton.tsx @@ -0,0 +1,145 @@ +import { useEffect, useRef } from 'react'; +import styled, { css } from 'styled-components'; + +import { getColorBasedOnSaturation } from '../colors'; +import { MAX_Z_INDEX } from '../const'; +import { useConstantState } from '../context/ConstantContext'; +import { useWidgetOpen } from '../context/WidgetOpenContext'; +import { useChannelStyle } from '../hooks/useChannelStyle'; +import { ReactComponent as ArrowDownIcon } from '../icons/ic-arrow-down.svg'; +import { ReactComponent as ChatBotIcon } from '../icons/icon-widget-chatbot.svg'; +import { isMobile } from '../utils'; + +const StyledWidgetButtonWrapper = styled.button<{ accentColor: string }>` + position: fixed; + z-index: ${MAX_Z_INDEX}; + bottom: 24px; + right: 24px; + width: 48px; + height: 48px; + background: ${({ accentColor }) => accentColor}; + border-radius: 50%; + color: white; + transition: all 0.3s cubic-bezier(0.31, -0.105, 0.43, 1.4); + border: none; + display: flex; + justify-content: center; + align-items: center; + box-shadow: 0px 16px 24px 2px rgba(33, 33, 33, 0.12), + 0px 6px 30px 5px rgba(33, 33, 33, 0.08), + 0px 6px 10px -5px rgba(33, 33, 33, 0.04); + + span { + position: absolute; + transition: transform 0.16s linear 0s, opacity 0.08s linear 0s; + width: 32px; + height: 32px; + user-select: none; + display: flex; + justify-content: center; + align-items: center; + + svg { + path { + fill: ${({ accentColor }) => getColorBasedOnSaturation(accentColor)}; + } + } + } + + &:hover { + transition: transform 250ms cubic-bezier(0.33, 0, 0, 1); + transform: scale(1.1); + } + + &:active { + transform: scale(0.8); + } + + svg { + path { + fill: ${({ accentColor }) => getColorBasedOnSaturation(accentColor)}; + } + } +`; + +const StyledWidgetIcon = styled.span<{ isOpen: boolean }>` + ${({ isOpen }) => { + return isOpen + ? css` + opacity: 0; + transform: rotate(30deg) scale(0); + ` + : css` + opacity: 1; + transform: rotate(0deg); + `; + }} +`; + +const StyledArrowIcon = styled.span<{ isOpen: boolean }>` + ${({ isOpen }) => { + return isOpen + ? css` + transform: rotate(0deg); + ` + : css` + transform: rotate(-90deg) scale(0); + `; + }} +`; + +interface ToggleButtonProps { + onClick: () => void; + accentColor: string; + isOpen: boolean; +} + +const StyledButton = ({ onClick, accentColor, isOpen }: ToggleButtonProps) => { + return ( + + + + + + + + + ); +}; + +export default function WidgetToggleButton() { + const { isFetching, ...channelStyle } = useChannelStyle(); + const { autoOpen, renderWidgetToggleButton } = useConstantState(); + const { isOpen, setIsOpen } = useWidgetOpen(); + const timer = useRef(null); + + const buttonClickHandler = () => { + if (timer.current !== null) { + clearTimeout(timer.current as NodeJS.Timeout); + timer.current = null; + } + setIsOpen((prev) => !prev); + }; + + useEffect(() => { + if (!isMobile && (autoOpen || channelStyle.autoOpen)) { + timer.current = setTimeout(() => setIsOpen(true), 100); + } + }, [channelStyle.autoOpen, autoOpen]); + + const toggleButtonProps = { + onClick: buttonClickHandler, + accentColor: channelStyle.accentColor, + isOpen, + }; + + if (typeof renderWidgetToggleButton === 'function') { + return renderWidgetToggleButton(toggleButtonProps); + } + + return !isFetching ? : null; +} diff --git a/src/components/WidgetWindow.tsx b/src/components/WidgetWindow.tsx index 959f758b6..20057362b 100644 --- a/src/components/WidgetWindow.tsx +++ b/src/components/WidgetWindow.tsx @@ -1,9 +1,8 @@ -import { Dispatch, SetStateAction, useState } from 'react'; +import { useState } from 'react'; import styled, { css } from 'styled-components'; -import { Chat } from './Chat'; -import { type Props as ChatWidgetProps } from './ChatAiWidget'; import { MAX_Z_INDEX } from '../const'; +import { useWidgetOpen } from '../context/WidgetOpenContext'; import { ReactComponent as CloseIcon } from '../icons/ic-widget-close.svg'; import { ReactComponent as CollapseIcon } from '../icons/icon-collapse.svg'; import { ReactComponent as ExpandIcon } from '../icons/icon-expand.svg'; @@ -88,17 +87,10 @@ const StyledCloseButton = styled.button` justify-content: center; `; -interface WidgetProps { - isOpen: boolean; - setIsOpen: Dispatch>; -} - -const WidgetWindow = ({ - isOpen, - setIsOpen, - ...props -}: WidgetProps & ChatWidgetProps) => { +const WidgetWindow = ({ children }: { children: React.ReactNode }) => { + const { isOpen, setIsOpen } = useWidgetOpen(); const [isExpanded, setIsExpanded] = useState(false); + return ( )} - setIsOpen(() => false)}> + setIsOpen(false)}> - + {children} ); }; diff --git a/src/components/WidgetWindowExternal.tsx b/src/components/WidgetWindowExternal.tsx new file mode 100644 index 000000000..4d29ec8aa --- /dev/null +++ b/src/components/WidgetWindowExternal.tsx @@ -0,0 +1,18 @@ +import Chat from './Chat'; +import ProviderContainer, { + type ProviderContainerProps, +} from './ProviderContainer'; + +/** + * NOTE: External purpose only. + * Do not use this component directly. Use Chat instead for internal use. + */ +function WidgetWindowExternal(props: ProviderContainerProps) { + return ( + + + + ); +} + +export default WidgetWindowExternal; diff --git a/src/const.ts b/src/const.ts index b12e6205b..0f0853947 100644 --- a/src/const.ts +++ b/src/const.ts @@ -94,6 +94,11 @@ export interface Constant { configureSession?: ConfigureSession; stringSet?: Partial; customUserAgentParam?: Record; + autoOpen?: boolean; + renderWidgetToggleButton?: (props: { + onClick: () => void; + isOpen: boolean; + }) => React.ReactElement; } export interface SuggestedReply { diff --git a/src/context/ConstantContext.tsx b/src/context/ConstantContext.tsx index b0d1579a4..7fc5ba821 100644 --- a/src/context/ConstantContext.tsx +++ b/src/context/ConstantContext.tsx @@ -8,7 +8,6 @@ const initialState = DEFAULT_CONSTANT; interface ConstantContextProps extends Constant { applicationId: string | null; botId: string | null; - setIsOpen?: (isOpen: boolean) => void; } const ConstantContext = createContext({ applicationId: null, @@ -23,7 +22,6 @@ export const ConstantStateProvider = (props: ProviderProps) => { () => ({ applicationId: props.applicationId, botId: props.botId, - setIsOpen: props.setIsOpen, botNickName: props.botNickName ?? initialState.botNickName, userNickName: props.userNickName ?? initialState.userNickName, betaMark: props.betaMark ?? initialState.betaMark, @@ -81,6 +79,8 @@ export const ConstantStateProvider = (props: ProviderProps) => { props.enableEmojiFeedback ?? initialState.enableEmojiFeedback, enableMention: props.enableMention ?? initialState.enableMention, enableMobileView: props.enableMobileView ?? initialState.enableMobileView, + autoOpen: props.autoOpen, + renderWidgetToggleButton: props.renderWidgetToggleButton, }), [props] ); diff --git a/src/context/WidgetOpenContext.tsx b/src/context/WidgetOpenContext.tsx new file mode 100644 index 000000000..a5a624c96 --- /dev/null +++ b/src/context/WidgetOpenContext.tsx @@ -0,0 +1,39 @@ +import React, { + createContext, + useContext, + useState, + Dispatch, + SetStateAction, +} from 'react'; + +import { noop } from '../utils'; + +interface Props { + isOpen: boolean; + setIsOpen: Dispatch>; +} + +const WidgetOpenContext = createContext({ + isOpen: false, + setIsOpen: noop, +}); + +type ProviderProps = React.PropsWithChildren; + +export const WidgetOpenProvider: React.FC = ({ children }) => { + const [isOpen, setIsOpen] = useState(false); + + return ( + + {children} + + ); +}; + +export const useWidgetOpen = (): Props => { + const context = useContext(WidgetOpenContext); + if (context === undefined) { + throw new Error('useWidgetOpen must be used within an WidgetOpenProvider'); + } + return context; +}; diff --git a/src/hooks/useChannelStyle.ts b/src/hooks/useChannelStyle.ts index 867273e38..9bb92e509 100644 --- a/src/hooks/useChannelStyle.ts +++ b/src/hooks/useChannelStyle.ts @@ -2,11 +2,11 @@ import { useQuery } from '@tanstack/react-query'; import { useMemo } from 'react'; import useWidgetLocalStorage, { - CHAT_AI_WIDGET_LOCAL_STORAGE_KEY, type WidgetLocalStorageValue, + saveToLocalStorage, } from './useWidgetLocalStorage'; import { useConstantState } from '../context/ConstantContext'; -import { localStorageHelper, isPastTime } from '../utils'; +import { isPastTime } from '../utils'; const DEFAULT_CHANNEL_STYLE = { theme: 'dark', @@ -24,7 +24,7 @@ interface BotStyleResponse { }; }; user?: { - expire_at: string; + expire_at: number; user_id: string; session_token: string; }; @@ -36,24 +36,24 @@ interface BotStyleResponse { export function isUserAndChannelCreationNeeded( userAndChannelInfo: WidgetLocalStorageValue ) { - const isInfoMissing = userAndChannelInfo == null; + const isInfoMissing = userAndChannelInfo?.userId == null; const isInfoExpired = userAndChannelInfo != null && isPastTime(userAndChannelInfo.expireAt); return isInfoMissing || isInfoExpired; } -export const useChannelStyle = ({ - appId, - botId, -}: { - appId: string; - botId: string; -}) => { - const { userId, configureSession } = useConstantState(); +export const useChannelStyle = () => { + const { + applicationId: appId, + botId, + userId, + configureSession, + } = useConstantState(); const manualChannelCreationNeeded = userId != null && configureSession != null; const userAndChannelInfoFromStorage = useWidgetLocalStorage(); + const newUserAndChannelCreationNeeded = !manualChannelCreationNeeded && isUserAndChannelCreationNeeded(userAndChannelInfoFromStorage); @@ -77,15 +77,12 @@ export const useChannelStyle = ({ const { bot_style, user, channel } = data; if (user != null && channel != null) { - localStorageHelper().setItem( - CHAT_AI_WIDGET_LOCAL_STORAGE_KEY, - JSON.stringify({ - expireAt: user.expire_at, - userId: user.user_id, - sessionToken: user.session_token, - channelUrl: channel.channel_url, - }) - ); + saveToLocalStorage({ + expireAt: user.expire_at, + userId: user.user_id, + sessionToken: user.session_token, + channelUrl: channel.channel_url, + }); } return { autoOpen: bot_style.auto_open, diff --git a/src/hooks/useGroupChannel.ts b/src/hooks/useGroupChannel.ts index 8ed4e052a..352b25ca6 100644 --- a/src/hooks/useGroupChannel.ts +++ b/src/hooks/useGroupChannel.ts @@ -5,9 +5,9 @@ import { import { default as useSendbirdStateContext } from '@sendbird/uikit-react/useSendbirdStateContext'; import { useQuery } from '@tanstack/react-query'; -import { CHAT_AI_WIDGET_LOCAL_STORAGE_KEY } from './useWidgetLocalStorage'; +import { saveToLocalStorage } from './useWidgetLocalStorage'; import { useConstantState } from '../context/ConstantContext'; -import { localStorageHelper, getDateNDaysLater, assert } from '../utils'; +import { getDateNDaysLater, assert } from '../utils'; /** * This hook is used to create a group channel manually @@ -56,17 +56,14 @@ export const useManualGroupChannelCreation = () => { data: paramData, }; const channel = await sb?.groupChannel?.createChannel(params); - localStorageHelper().setItem( - CHAT_AI_WIDGET_LOCAL_STORAGE_KEY, - JSON.stringify({ - channelUrl: channel.url, - expireAt: getDateNDaysLater(30), - userId: customUserId, - // there's no sessionToken in this case since we don't know the value of it - // but instead, it should be handled by configureSession that user provides - sessionToken: undefined, - }) - ); + saveToLocalStorage({ + channelUrl: channel.url, + expireAt: getDateNDaysLater(30), + userId: customUserId, + // there's no sessionToken in this case since we don't know the value of it + // but instead, it should be handled by configureSession that user provides + sessionToken: undefined, + }); } catch (error) { console.error(error); throw new Error('Failed to create a new channel'); diff --git a/src/hooks/useMobileView.ts b/src/hooks/useMobileView.ts index 4f9398f13..8e917437d 100644 --- a/src/hooks/useMobileView.ts +++ b/src/hooks/useMobileView.ts @@ -1,15 +1,12 @@ import { useEffect, useState, useMemo } from 'react'; +import { useConstantState } from '../context/ConstantContext'; +import { useWidgetOpen } from '../context/WidgetOpenContext'; import { isMobile } from '../utils'; -interface Param { - enableMobileView?: boolean; - isWidgetOpen: boolean; -} -export default function useMobileView({ - enableMobileView = true, - isWidgetOpen, -}: Param) { +export default function useMobileView() { + const { enableMobileView } = useConstantState(); + const { isOpen: isWidgetOpen } = useWidgetOpen(); const [dimensions, setDimensions] = useState({ width: window.innerWidth, height: window.innerHeight, diff --git a/src/hooks/useWidgetButtonActivityTimeout.ts b/src/hooks/useWidgetButtonActivityTimeout.ts new file mode 100644 index 000000000..f6e8fb5df --- /dev/null +++ b/src/hooks/useWidgetButtonActivityTimeout.ts @@ -0,0 +1,60 @@ +import { default as useSendbirdStateContext } from '@sendbird/uikit-react/useSendbirdStateContext'; +import { useEffect, useRef } from 'react'; + +import { useChannelStyle } from './useChannelStyle'; + +const WS_IDLE_TIMEOUT = 6000 * 3; + +/** + * This hook is used to disconnect the websocket connection + * when the widget button is not clicked for a certain amount of time + */ +function useWidgetButtonActivityTimeout() { + const channelStyle = useChannelStyle(); + const timerRef = useRef(null); + const startTimeRef = useRef(Date.now()); + const store = useSendbirdStateContext(); + const sdk = store.stores.sdkStore.sdk; + + useEffect(() => { + const button = document.getElementById('aichatbot-widget-button'); + if ( + !button || + // We only need to run this logic when autoOpen is disabled + channelStyle?.autoOpen + ) { + return; + } + + const handleClick = () => { + const currentTime = Date.now(); + const elapsedTime = currentTime - startTimeRef.current; + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + + if (elapsedTime >= WS_IDLE_TIMEOUT) { + sdk?.reconnect(); + } + // Remove the event listener to prevent multiple event listeners + // We only need this logic to run once + button.removeEventListener('click', handleClick); + }; + + button.addEventListener('click', handleClick); + + timerRef.current = setTimeout(() => { + sdk?.disconnectWebSocket(); + }, WS_IDLE_TIMEOUT); + + return () => { + button.removeEventListener('click', handleClick); + if (timerRef.current) { + clearTimeout(timerRef.current); + } + }; + }, [sdk?.reconnect]); +} + +export default useWidgetButtonActivityTimeout; diff --git a/src/hooks/useWidgetLocalStorage.ts b/src/hooks/useWidgetLocalStorage.ts index 43b21d270..e2374e3b1 100644 --- a/src/hooks/useWidgetLocalStorage.ts +++ b/src/hooks/useWidgetLocalStorage.ts @@ -4,35 +4,67 @@ import { localStorageHelper } from '../utils'; export const CHAT_AI_WIDGET_LOCAL_STORAGE_KEY = '@sendbird/chat-ai-widget'; -function parseValue(value: string | null) { - return value != null && value !== '' ? JSON.parse(value) : null; +export function saveToLocalStorage(value: WidgetLocalStorageValue) { + const stringifiedValue = JSON.stringify(value); + localStorageHelper().setItem( + CHAT_AI_WIDGET_LOCAL_STORAGE_KEY, + stringifiedValue + ); + window.dispatchEvent( + new CustomEvent('localStorageChange', { + detail: { + key: CHAT_AI_WIDGET_LOCAL_STORAGE_KEY, + value: stringifiedValue, + }, + }) + ); } export type WidgetLocalStorageValue = { - sessionToken: string; - userId: string; - channelUrl: string; + userId: string | null; + channelUrl: string | null; expireAt: number; + sessionToken?: string; +}; + +const DEFAULT_VALUE = { + userId: null, + channelUrl: null, + expireAt: 0, }; -function useWidgetLocalStorage() { + +function parseValue(value: string | null) { + return value != null && value !== '' ? JSON.parse(value) : null; +} + +function useWidgetLocalStorage(): WidgetLocalStorageValue { const key = CHAT_AI_WIDGET_LOCAL_STORAGE_KEY; - const [value, setValue] = useState(() => - parseValue(localStorageHelper().getItem(key)) + const [value, setValue] = useState( + () => parseValue(localStorageHelper().getItem(key)) || DEFAULT_VALUE ); + useEffect(() => { - const handleStorageChange = () => { - const storedValue = parseValue(localStorageHelper().getItem(key)); - setValue(storedValue); + const handleCustomStorageChange = (event: any) => { + if (event.detail.key === key) { + const storedValue = parseValue(event.detail.value); + setValue(storedValue); + } }; - window.addEventListener('storage', handleStorageChange); + // The 'localStorageChange' event is a CustomEvent, + // distinct from the standard 'storage' event, which is only triggered by changes in other tabs. + // This custom event allows us to detect localStorage changes within the same tab in real-time. + window.addEventListener('localStorageChange', handleCustomStorageChange); return () => { - window.removeEventListener('storage', handleStorageChange); + window.removeEventListener( + 'localStorageChange', + handleCustomStorageChange + ); }; }, []); - return value as WidgetLocalStorageValue; + return value; } export default useWidgetLocalStorage; diff --git a/src/index.ts b/src/index.ts index dd14ae73c..e3927b2cd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,3 @@ -export { - default as ChatAiWidget, - type Props as ChatAiWidgetConfigs, -} from './components/ChatAiWidget'; -export { default as Chat } from './components/Chat'; +export { default as ChatAiWidget } from './components/ChatAiWidget'; +export { type ProviderContainerProps as ChatAiWidgetConfigs } from './components/ProviderContainer'; +export { default as ChatWindow } from './components/WidgetWindowExternal'; diff --git a/src/utils/index.ts b/src/utils/index.ts index 24f847bd7..43499da0c 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -218,12 +218,10 @@ export function isPastTime(timestamp: number): boolean { return timestamp < currentTime; } -export function getDateNDaysLater(daysToAdd: number): Date { +export function getDateNDaysLater(daysToAdd: number): number { const millisecondsPerDay = 24 * 60 * 60 * 1000; // 24hours in milliseconds const currentDate = new Date(); - const futureDate = new Date( - currentDate.getTime() + daysToAdd * millisecondsPerDay - ); + const futureDate = currentDate.getTime() + daysToAdd * millisecondsPerDay; return futureDate; }