diff --git a/src/components/Buttons/FileAttachmentButton/FileAttachmentButton.tsx b/src/components/Buttons/FileAttachmentButton/FileAttachmentButton.tsx index 6e153b5b..aa6fa77e 100644 --- a/src/components/Buttons/FileAttachmentButton/FileAttachmentButton.tsx +++ b/src/components/Buttons/FileAttachmentButton/FileAttachmentButton.tsx @@ -18,6 +18,9 @@ import "./FileAttachmentButton.css"; * @param openChat utility function to open/close chat window * @param getCurrPath retrieves current path for the user * @param getPrevPath retrieves previous path for the user + * @param goToPath goes to specified path + * @param setTextAreaValue sets the value within the text area + * @param injectToast injects a toast message prompt * @param handleActionInput handles action input from user */ const FileAttachmentButton = ({ @@ -31,6 +34,7 @@ const FileAttachmentButton = ({ getPrevPath, goToPath, setTextAreaValue, + injectToast, handleActionInput }: { inputRef: RefObject; @@ -43,6 +47,7 @@ const FileAttachmentButton = ({ getPrevPath: () => keyof Flow | null; goToPath: (pathToGo: keyof Flow) => void; setTextAreaValue: (value: string) => void; + injectToast: (content: string | JSX.Element, timeout?: number) => void; handleActionInput: (path: keyof Flow, userInput: string, sendUserInput?: boolean) => Promise; }) => { @@ -112,7 +117,7 @@ const FileAttachmentButton = ({ } await handleActionInput(currPath, "📄 " + fileNames.join(", "), settings.fileAttachment?.sendFileName); await fileHandler({userInput: inputRef.current?.value as string, prevPath: getPrevPath(), - goToPath, setTextAreaValue, injectMessage, streamMessage, openChat, files}); + goToPath, setTextAreaValue, injectMessage, streamMessage, openChat, injectToast, files}); } }; diff --git a/src/components/ChatBotBody/ChatBotBody.css b/src/components/ChatBotBody/ChatBotBody.css index c6576631..0e422301 100644 --- a/src/components/ChatBotBody/ChatBotBody.css +++ b/src/components/ChatBotBody/ChatBotBody.css @@ -154,4 +154,20 @@ 100% { opacity: 0.4; } -} \ No newline at end of file +} + +/* Toast Container */ +.rcb-toast-prompt-container { + position: fixed; + left: 50%; + transform: translateX(-50%); + bottom: 200px; + margin: auto; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + opacity: 1; + animation: popIn 0.3s ease-in-out; + pointer-events: auto; +} diff --git a/src/components/ChatBotBody/ChatBotBody.tsx b/src/components/ChatBotBody/ChatBotBody.tsx index b72192a2..6fe60a74 100644 --- a/src/components/ChatBotBody/ChatBotBody.tsx +++ b/src/components/ChatBotBody/ChatBotBody.tsx @@ -1,10 +1,12 @@ import { RefObject, Dispatch, SetStateAction, useEffect, CSSProperties, MouseEvent } from "react"; import ChatMessagePrompt from "./ChatMessagePrompt/ChatMessagePrompt"; +import ToastPrompt from "./ToastPrompt/ToastPrompt"; import { useSettings } from "../../context/SettingsContext"; import { useStyles } from "../../context/StylesContext"; import { useMessages } from "../../context/MessagesContext"; import { Message } from "../../types/Message"; +import { Toast } from "../../types/internal/Toast"; import "./ChatBotBody.css"; @@ -21,6 +23,8 @@ import "./ChatBotBody.css"; * @param setIsScrolling setter for tracking if user is scrolling * @param unreadCount number representing unread messages count * @param setUnreadCount setter for unread messages count + * @param toasts toasts to be shown + * @param removeToast removes a toast by id */ const ChatBotBody = ({ chatBodyRef, @@ -33,6 +37,8 @@ const ChatBotBody = ({ setIsScrolling, unreadCount, setUnreadCount, + toasts, + removeToast }: { chatBodyRef: RefObject; isBotTyping: boolean; @@ -44,6 +50,8 @@ const ChatBotBody = ({ setIsScrolling: Dispatch>; unreadCount: number; setUnreadCount: Dispatch>; + toasts: Array, + removeToast: (id: string) => void; }) => { // handles settings for bot @@ -79,6 +87,16 @@ const ChatBotBody = ({ }; const botBubbleEntryStyle = settings.botBubble?.animate ? "rcb-bot-message-entry" : ""; + // styles for toast prompt container + const toastPromptContainerStyle: CSSProperties = { + bottom: (styles.chatInputContainerStyle?.height as number || 70) + + (styles.footerStyle?.height as number || 50) + 15, + width: 300, + minWidth: (styles.chatWindowStyle?.width as number || 375) / 2, + maxWidth: (styles.chatWindowStyle?.width as number || 375) - 50, + ...styles.toastPromptContainerStyle + }; + // shifts scroll position when messages are updated and when bot is typing useEffect(() => { if (!chatBodyRef.current) { @@ -284,6 +302,18 @@ const ChatBotBody = ({ chatBodyRef={chatBodyRef} isScrolling={isScrolling} setIsScrolling={setIsScrolling} unreadCount={unreadCount} /> + +
+ {toasts.map((toast) => ( + + ))} +
); }; diff --git a/src/components/ChatBotBody/ToastPrompt/ToastPrompt.css b/src/components/ChatBotBody/ToastPrompt/ToastPrompt.css new file mode 100644 index 00000000..219e6506 --- /dev/null +++ b/src/components/ChatBotBody/ToastPrompt/ToastPrompt.css @@ -0,0 +1,28 @@ +.rcb-toast-prompt-text { + padding: 6px 12px; + border-radius: 5px; + color: #7a7a7a; + font-size: 12px; + text-align: center; + background-color: #fff; + border: 0.5px solid #7a7a7a; + cursor: pointer; + transition: color 0.3s ease, border-color 0.3s ease; + z-index: 9999; + width: 100%; + margin-top: 6px; +} + +@keyframes popIn { + 0% { + transform: scale(0.8); + opacity: 0; + } + 70% { + transform: scale(1.1); + opacity: 1; + } + 100% { + transform: scale(1); + } +} diff --git a/src/components/ChatBotBody/ToastPrompt/ToastPrompt.tsx b/src/components/ChatBotBody/ToastPrompt/ToastPrompt.tsx new file mode 100644 index 00000000..1f371501 --- /dev/null +++ b/src/components/ChatBotBody/ToastPrompt/ToastPrompt.tsx @@ -0,0 +1,92 @@ +import { useEffect, useState, MouseEvent } from "react"; + +import { useSettings } from "../../../context/SettingsContext"; +import { useStyles } from "../../../context/StylesContext"; + +import "./ToastPrompt.css"; + +/** + * Provides toast message prompt with information. + * + * @param id id of the toast + * @param content content of the toast + * @param removeToast removes a toast by id + * @param timeout timeout in milliseconds (optional) for removing toast + */ +const Toast = ({ + id, + content, + removeToast, + timeout, +}: { + id: string; + content: string | JSX.Element; + removeToast: (id: string) => void; + timeout?: number; +}) => { + + // handles settings for bot + const { settings } = useSettings(); + + // handles styles for bot + const { styles } = useStyles(); + + // tracks if toast prompt is hovered + const [isHovered, setIsHovered] = useState(false); + + // styles for toast prompt hovered + const toastPromptHoveredStyle: React.CSSProperties = { + color: settings.general?.primaryColor, + borderColor: settings.general?.primaryColor, + ...styles.toastPromptHoveredStyle + }; + + useEffect(() => { + // if timeout is set, dismiss toast after specified period + if (timeout) { + const timer = setTimeout(() => { + removeToast(id); + }, timeout); + return () => clearTimeout(timer); + } + }, [id, removeToast, timeout]); + + /** + * Handles mouse enter event on toast prompt. + */ + const handleMouseEnter = () => { + setIsHovered(true); + }; + + /** + * Handles mouse leave event on toast prompt. + */ + const handleMouseLeave = () => { + setIsHovered(false); + }; + + return ( + typeof content === "string" ? ( +
{ + if (settings.toast?.dismissOnClick) { + event.preventDefault(); + removeToast(id); + } + }} + className="rcb-toast-prompt-text" + > + {content} +
+ ) : ( + <> + {content} + + ) + ); +}; + +export default Toast; diff --git a/src/components/ChatBotContainer.tsx b/src/components/ChatBotContainer.tsx index e584f3a0..a3757ba4 100644 --- a/src/components/ChatBotContainer.tsx +++ b/src/components/ChatBotContainer.tsx @@ -32,6 +32,7 @@ import { Block } from "../types/Block"; import { Flow } from "../types/Flow"; import { Message } from "../types/Message"; import { Params } from "../types/Params"; +import { Toast } from "../types/internal/Toast"; import { Button } from "../constants/Button"; import "./ChatBotContainer.css"; @@ -111,6 +112,9 @@ const ChatBotContainer = ({ flow }: { flow: Flow }) => { // tracks typing state of chat bot const [isBotTyping, setIsBotTyping] = useState(false); + // tracks toasts shown + const [toasts, setToasts] = useState>([]); + // tracks block timeout if transition is interruptable const [timeoutId, setTimeoutId] = useState>(); @@ -282,7 +286,7 @@ const ChatBotContainer = ({ flow }: { flow: Flow }) => { } const params = {prevPath: getPrevPath(), goToPath, setTextAreaValue, userInput: paramsInputRef.current, - injectMessage, streamMessage, openChat}; + injectMessage, streamMessage, openChat, injectToast}; // calls the new block for preprocessing upon change to path. const callNewBlock = async (currPath: keyof Flow, block: Block, params: Params) => { @@ -439,6 +443,35 @@ const ChatBotContainer = ({ flow }: { flow: Flow }) => { } } + /** + * Injects a new toast. + * + * @param content message to show in toast + * @param timeout optional timeout in milliseconds before toast is removed + */ + const injectToast = useCallback((content: string | JSX.Element, timeout?: number): void => { + setToasts((prevToasts: Toast[]) => { + if (prevToasts.length >= (settings.toast?.maxCount || 3)) { + // if toast array is full and forbidden to add new ones, return existing toasts + if (settings.toast?.forbidOnMax) { + return prevToasts; + } + // else remove the oldest toast + return [...prevToasts.slice(1), { id: crypto.randomUUID(), content, timeout }]; + } + return [...prevToasts, { id: crypto.randomUUID(), content, timeout }]; + }); + }, [setToasts]); + + /** + * Removes a toast. + * + * @param id id of toast to remove + */ + const removeToast = useCallback((id: string): void => { + setToasts((prevToasts) => prevToasts.filter((toast) => toast.id !== id)); + }, [setToasts]); + /** * Injects a message at the end of the messages array. * @@ -675,7 +708,7 @@ const ChatBotContainer = ({ flow }: { flow: Flow }) => { setTimeout(async () => { const params = {prevPath: getPrevPath(), goToPath, setTextAreaValue, userInput, - injectMessage, streamMessage,openChat + injectMessage, streamMessage, openChat, injectToast }; const hasNextPath = await postProcessBlock(flow, path, params, setPaths); if (!hasNextPath) { @@ -698,7 +731,7 @@ const ChatBotContainer = ({ flow }: { flow: Flow }) => { } }, settings.chatInput?.botDelay); }, [timeoutId, voiceToggledOn, settings, flow, getPrevPath, injectMessage, streamMessage, openChat, - postProcessBlock, setPaths, handleSendUserInput + postProcessBlock, setPaths, handleSendUserInput, injectToast ]); /** @@ -777,11 +810,11 @@ const ChatBotContainer = ({ flow }: { flow: Flow }) => { const fileAttachmentButtonComponentMap = useMemo(() => ({ [Button.FILE_ATTACHMENT_BUTTON]: () => createFileAttachmentButton(inputRef, flow, blockAllowsAttachment, - injectMessage, streamMessage, openChat, getCurrPath, getPrevPath, goToPath, setTextAreaValue, - handleActionInput + injectMessage, streamMessage, openChat, getCurrPath, getPrevPath, goToPath, setTextAreaValue, injectToast, + handleActionInput, ) }), [inputRef, flow, blockAllowsAttachment, injectMessage, streamMessage, openChat, - getCurrPath, getPrevPath, goToPath, handleActionInput + getCurrPath, getPrevPath, goToPath, handleActionInput, injectToast ]); const buttonComponentMap = useMemo(() => ({ @@ -857,8 +890,8 @@ const ChatBotContainer = ({ flow }: { flow: Flow }) => { {settings.general?.showInputRow && void; injectMessage: (content: string | JSX.Element, sender?: string) => Promise; streamMessage: (content: string | JSX.Element, sender?: string) => Promise; + injectToast: (content: string | JSX.Element, timeout?: number) => void; openChat: (isOpen: boolean) => void; files?: FileList; } \ No newline at end of file diff --git a/src/types/Settings.ts b/src/types/Settings.ts index 79a53eca..c73b5231 100644 --- a/src/types/Settings.ts +++ b/src/types/Settings.ts @@ -128,6 +128,11 @@ export type Settings = { icon?: string; list?: string[] ; }, + toast?: { + maxCount?: number; + forbidOnMax?: boolean; + dismissOnClick?: boolean; + }, advance?: { useAdvancedMessages?: boolean; useAdvancedSettings?: boolean; diff --git a/src/types/Styles.ts b/src/types/Styles.ts index fc76a613..cbac814f 100644 --- a/src/types/Styles.ts +++ b/src/types/Styles.ts @@ -59,4 +59,7 @@ export type Styles = { sendIconStyle?: React.CSSProperties; rcbTypingIndicatorContainerStyle?: React.CSSProperties; rcbTypingIndicatorDotStyle?: React.CSSProperties; + toastPromptContainerStyle?: React.CSSProperties; + toastPromptStyle?: React.CSSProperties; + toastPromptHoveredStyle?: React.CSSProperties; } \ No newline at end of file diff --git a/src/types/internal/Toast.ts b/src/types/internal/Toast.ts new file mode 100644 index 00000000..e986e55f --- /dev/null +++ b/src/types/internal/Toast.ts @@ -0,0 +1,8 @@ +/** + * Defines the available attributes for a toast. + */ +export type Toast = { + id: string; + content: string | JSX.Element; + timeout?: number; +} \ No newline at end of file diff --git a/src/utils/buttonBuilder.tsx b/src/utils/buttonBuilder.tsx index 27babae0..45b8c4ed 100644 --- a/src/utils/buttonBuilder.tsx +++ b/src/utils/buttonBuilder.tsx @@ -139,6 +139,7 @@ export const createFileAttachmentButton = ( getPrevPath: () => keyof Flow | null, goToPath: (pathToGo: keyof Flow) => void, setTextAreaValue: (value: string) => void, + injectToast: (content: string | JSX.Element, timeout?: number) => void, handleActionInput: (path: keyof Flow, userInput: string, sendUserInput?: boolean) => Promise ) => { return ( @@ -146,6 +147,7 @@ export const createFileAttachmentButton = ( blockAllowsAttachment={blockAllowsAttachment} getCurrPath={getCurrPath} openChat={openChat} getPrevPath={getPrevPath} goToPath={goToPath} handleActionInput={handleActionInput} injectMessage={injectMessage} streamMessage={streamMessage} setTextAreaValue={setTextAreaValue} + injectToast={injectToast} /> ); };