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;
}