diff --git a/CHANGELOG.md b/CHANGELOG.md index 098d8f1b..aa661192 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # CHANGELOG.md +## v2.0.0-beta.20 (21-10-2024) + +**Fixed:** +- Fixed an issue with the default chatbot footer icon +- Improved checks for desktop/mobile devices +- Reduced unnecessary re-renders (minor optimizations) +- Properly fixed chatbot svg icon on mobile + +**Added:** +- Added new `replaceSettings`, `replaceStyles`, `replaceMessages`, `replacePaths` and `replaceToasts` utility functions to their respective hooks (replaces their respective state setters) + +**Note:** +Hooks no longer directly expose state setters (not a great practice, and hinders optimizations that can be done within the library itself). The new functions serve as a drop-in replacement for the state setters. + ## v2.0.0-beta.19 (18-10-2024) **Fixed:** diff --git a/__tests__/context/MessagesContext.test.tsx b/__tests__/context/MessagesContext.test.tsx new file mode 100644 index 00000000..37b6ad7e --- /dev/null +++ b/__tests__/context/MessagesContext.test.tsx @@ -0,0 +1,114 @@ +import React from 'react' +import { expect } from '@jest/globals' +import { act, render, screen } from '@testing-library/react' +import '@testing-library/jest-dom/jest-globals' + +import { + useMessagesContext, + MessagesProvider, +} from '../../src/context/MessagesContext' + +const TestComponent = () => { + const { messages, setMessages } = useMessagesContext() + + const handleAddMessage = () => { + setMessages([ + ...messages, + { + id: '1', + content: 'Hello World!', + sender: 'user1', + type: 'message', + timestamp: new Date().toUTCString(), + }, + ]) + } + + const handleClearMessage = () => { + setMessages([]) + } + + return ( +
+

+ Messages: {messages.map((message) => message.content).join(', ')} +

+

Messages Count: {messages.length}

+ + + +
+ ) +} + +describe('MessagesContext', () => { + it('provides the correct default values', () => { + render( + + + + ) + + expect(screen.getByTestId('messages')).toHaveTextContent(`Messages:`) + expect(screen.getByTestId('messagesCount')).toHaveTextContent( + `Messages Count: 0` + ) + }) + + it('allows adding messages in the context', () => { + render( + + + + ) + + const addMessageBtn = screen.getByText('Add Message') + + act(() => { + addMessageBtn.click() + }) + + expect(screen.getByTestId('messages')).toHaveTextContent( + `Messages: Hello World!` + ) + expect(screen.getByTestId('messagesCount')).toHaveTextContent( + `Messages Count: 1` + ) + + act(() => { + addMessageBtn.click() + }) + + expect(screen.getByTestId('messagesCount')).toHaveTextContent( + `Messages Count: 2` + ) + }) + + it('allows updating messages in the context', () => { + render( + + + + ) + + const clearMessageBtn = screen.getByText('Clear Message') + const addMessageBtn = screen.getByText('Add Message') + + act(() => { + clearMessageBtn.click() + }) + + expect(screen.getByTestId('messages')).toHaveTextContent(`Messages:`) + expect(screen.getByTestId('messagesCount')).toHaveTextContent( + `Messages Count: 0` + ) + + act(() => { + addMessageBtn.click() + }) + + expect(screen.getByTestId('messagesCount')).toHaveTextContent( + `Messages Count: 1` + ) + }) +}) diff --git a/__tests__/context/PathsContext.test.tsx b/__tests__/context/PathsContext.test.tsx new file mode 100644 index 00000000..269603a1 --- /dev/null +++ b/__tests__/context/PathsContext.test.tsx @@ -0,0 +1,84 @@ +import React from 'react' +import { expect } from '@jest/globals' +import { act, render, screen } from '@testing-library/react' +import '@testing-library/jest-dom/jest-globals' + +import { usePathsContext, PathsProvider } from '../../src/context/PathsContext' + +const TestComponent = () => { + const { paths, setPaths } = usePathsContext() + + const handleAddPath = () => { + setPaths([...paths, '/path/to/1']) + } + + const handleClearPaths = () => { + setPaths([]) + } + + return ( +
+

Paths Count: {paths.length}

+ + + +
+ ) +} + +describe('PathsContext', () => { + it('provides the correct default values', () => { + render( + + + + ) + + expect(screen.getByTestId('pathsCount')).toHaveTextContent(`Paths Count: 0`) + }) + + it('allows adding paths in the context', () => { + render( + + + + ) + + const addPathBtn = screen.getByText('Add Path') + + act(() => { + addPathBtn.click() + }) + + expect(screen.getByTestId('pathsCount')).toHaveTextContent(`Paths Count: 1`) + + act(() => { + addPathBtn.click() + }) + + expect(screen.getByTestId('pathsCount')).toHaveTextContent(`Paths Count: 2`) + }) + + it('allows updating paths in the context', () => { + render( + + + + ) + + const clearPathsBtn = screen.getByText('Clear Paths') + const addPathBtn = screen.getByText('Add Path') + + act(() => { + clearPathsBtn.click() + }) + + expect(screen.getByTestId('pathsCount')).toHaveTextContent(`Paths Count: 0`) + + act(() => { + addPathBtn.click() + }) + + expect(screen.getByTestId('pathsCount')).toHaveTextContent(`Paths Count: 1`) + }) +}) diff --git a/__tests__/hooks/internal/useChatHistoryInternal.test.ts b/__tests__/hooks/internal/useChatHistoryInternal.test.ts index 3bace45c..de7beff0 100644 --- a/__tests__/hooks/internal/useChatHistoryInternal.test.ts +++ b/__tests__/hooks/internal/useChatHistoryInternal.test.ts @@ -90,6 +90,9 @@ describe("useChatHistoryInternal Hook", () => { expect.any(Object), initialChatHistory, expect.any(Function), + expect.any(Object), + expect.any(Number), + expect.any(Function), ); // checks if history is being loaded diff --git a/__tests__/hooks/internal/useIsDesktopInternal.test.ts b/__tests__/hooks/internal/useIsDesktopInternal.test.ts new file mode 100644 index 00000000..9f9b0e0c --- /dev/null +++ b/__tests__/hooks/internal/useIsDesktopInternal.test.ts @@ -0,0 +1,76 @@ +import { renderHook } from '@testing-library/react'; +import { useIsDesktopInternal} from "../../../src/hooks/internal/useIsDesktopInternal"; + +const originalNavigator = window.navigator; +const originalInnerWidth = window.innerWidth; + +const mockWindowProperty = (property: string, value: any) => { + Object.defineProperty(window, property, { + configurable: true, + writable: true, + value, + }); +}; + +describe('useIsDesktopInternal', () => { + afterEach(() => { + Object.defineProperty(window, 'navigator', { + configurable: true, + writable: true, + value: originalNavigator, + }); + Object.defineProperty(window, 'innerWidth', { + configurable: true, + writable: true, + value: originalInnerWidth, + }); + }); + + it('should return false when running on server-side (no window object)', () => { + mockWindowProperty('navigator', undefined); + mockWindowProperty('innerWidth', 0); + + const { result } = renderHook(() => useIsDesktopInternal()); + expect(result.current).toBe(false); + }); + + it('should return false for a mobile user agent', () => { + mockWindowProperty('navigator', { + userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15A372 Safari/604.1', + }); + mockWindowProperty('innerWidth', 800); + + const { result } = renderHook(() => useIsDesktopInternal()); + expect(result.current).toBe(false); + }); + + it('should return true for a desktop user agent and wide screen', () => { + mockWindowProperty('navigator', { + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + }); + mockWindowProperty('innerWidth', 1024); + + const { result } = renderHook(() => useIsDesktopInternal()); + expect(result.current).toBe(true); + }); + + it('should return false for a narrow screen, even on a desktop user agent', () => { + mockWindowProperty('navigator', { + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + }); + mockWindowProperty('innerWidth', 500); + + const { result } = renderHook(() => useIsDesktopInternal()); + expect(result.current).toBe(false); + }); + + it('should return true if user agent is not mobile and window width is exactly 768', () => { + mockWindowProperty('navigator', { + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + }); + mockWindowProperty('innerWidth', 768); + + const { result } = renderHook(() => useIsDesktopInternal()); + expect(result.current).toBe(true); + }); +}); diff --git a/__tests__/hooks/internal/useMessagesInternal.test.ts b/__tests__/hooks/internal/useMessagesInternal.test.ts new file mode 100644 index 00000000..c233b25b --- /dev/null +++ b/__tests__/hooks/internal/useMessagesInternal.test.ts @@ -0,0 +1,122 @@ +import { renderHook } from "@testing-library/react"; +import { act } from "react"; +import { useMessagesInternal } from "../../../src/hooks/internal/useMessagesInternal"; +import { useSettingsContext } from "../../../src/context/SettingsContext"; +import { useMessagesContext } from "../../../src/context/MessagesContext"; +import { useBotStatesContext } from "../../../src/context/BotStatesContext"; +import { useBotRefsContext } from "../../../src/context/BotRefsContext"; +import { useRcbEventInternal } from "../../../src/hooks/internal/useRcbEventInternal"; +import { Message } from "../../../src/types/Message"; + +jest.mock("../../../src/context/SettingsContext"); +jest.mock("../../../src/context/MessagesContext"); +jest.mock("../../../src/context/BotStatesContext"); +jest.mock("../../../src/context/BotRefsContext"); +jest.mock("../../../src/hooks/internal/useRcbEventInternal"); +jest.mock("../../../src/services/AudioService"); +jest.mock("../../../src/services/ChatHistoryService"); + +describe("useMessagesInternal", () => { + const mockSetMessages = jest.fn(); + const mockSetIsBotTyping = jest.fn(); + const mockSetUnreadCount = jest.fn(); + const mockCallRcbEvent = jest.fn(); + const mockStreamMessageMap = { current: new Map() }; + const mockMessages: Message[] = []; + + beforeEach(() => { + jest.clearAllMocks(); + (useSettingsContext as jest.Mock).mockReturnValue({ + settings: { + botBubble: { dangerouslySetInnerHtml: false, simStream: false }, + userBubble: { dangerouslySetInnerHtml: false, simStream: false }, + event: {}, + }, + }); + (useMessagesContext as jest.Mock).mockReturnValue({ + messages: mockMessages, + setMessages: mockSetMessages, + }); + (useBotStatesContext as jest.Mock).mockReturnValue({ + audioToggledOn: false, + isChatWindowOpen: true, + setIsBotTyping: mockSetIsBotTyping, + setUnreadCount: mockSetUnreadCount, + }); + (useBotRefsContext as jest.Mock).mockReturnValue({ + streamMessageMap: mockStreamMessageMap, + }); + (useRcbEventInternal as jest.Mock).mockReturnValue({ + callRcbEvent: mockCallRcbEvent, + }); + }); + + it("should return expected functions and values", () => { + const { result } = renderHook(() => useMessagesInternal()); + + expect(result.current).toHaveProperty("endStreamMessage"); + expect(result.current).toHaveProperty("injectMessage"); + expect(result.current).toHaveProperty("removeMessage"); + expect(result.current).toHaveProperty("streamMessage"); + expect(result.current).toHaveProperty("messages"); + expect(result.current).toHaveProperty("replaceMessages"); + }); + + it("should inject a message correctly", async () => { + const { result } = renderHook(() => useMessagesInternal()); + + await act(async () => { + const messageId = await result.current.injectMessage("Test message", "bot"); + expect(messageId).toBeTruthy(); + }); + + expect(mockSetMessages).toHaveBeenCalled(); + expect(mockSetUnreadCount).toHaveBeenCalledWith(expect.any(Function)); + }); + + it("should remove a message correctly", async () => { + const mockMessageId = "test-id"; + const mockMessage: Message = { id: mockMessageId, content: "Test", sender: "bot", type: "text", + timestamp: String(Date.now()) }; + (useMessagesContext as jest.Mock).mockReturnValue({ + messages: [mockMessage], + setMessages: mockSetMessages, + }); + + const { result } = renderHook(() => useMessagesInternal()); + + await act(async () => { + const removedId = await result.current.removeMessage(mockMessageId); + expect(removedId).toBe(mockMessageId); + }); + + expect(mockSetMessages).toHaveBeenCalled(); + expect(mockSetUnreadCount).toHaveBeenCalledWith(expect.any(Function)); + }); + + it("should stream a message correctly", async () => { + const { result } = renderHook(() => useMessagesInternal()); + + await act(async () => { + const messageId = await result.current.streamMessage("Test stream", "bot"); + expect(messageId).toBeTruthy(); + }); + + expect(mockSetMessages).toHaveBeenCalled(); + expect(mockSetUnreadCount).toHaveBeenCalledWith(expect.any(Function)); + expect(mockStreamMessageMap.current.has("bot")).toBeTruthy(); + }); + + it("should end stream message correctly", async () => { + mockStreamMessageMap.current.set("bot", "test-id"); + const { result } = renderHook(() => useMessagesInternal()); + + await act(async () => { + const success = await result.current.endStreamMessage("bot"); + expect(success).toBeTruthy(); + }); + + expect(mockStreamMessageMap.current.has("bot")).toBeFalsy(); + }); + +}); \ No newline at end of file diff --git a/__tests__/hooks/internal/useSettingsInternal.test.ts b/__tests__/hooks/internal/useSettingsInternal.test.ts index 79860b5c..84c247e5 100644 --- a/__tests__/hooks/internal/useSettingsInternal.test.ts +++ b/__tests__/hooks/internal/useSettingsInternal.test.ts @@ -17,6 +17,7 @@ jest.mock("../../../src/utils/configParser", () => ({ describe("useSettingsInternal", () => { const mockSetSettings = jest.fn(); + const mockReplaceSettings = jest.fn(); const mockSettings: Settings = { general: { primaryColor: "red" } }; beforeEach(() => { @@ -24,15 +25,17 @@ describe("useSettingsInternal", () => { (useSettingsContext as jest.Mock).mockReturnValue({ settings: mockSettings, setSettings: mockSetSettings, + replaceSettings: mockReplaceSettings, }); }); - it("should return settings, setSettings and updateSettings method", () => { + it("should return settings, replaceSettings and updateSettings method", () => { const { result } = renderHook(() => useSettingsInternal()); expect(result.current.settings).toEqual(mockSettings); - expect(result.current.setSettings).toBe(mockSetSettings); + expect(result.current.replaceSettings).toBeDefined(); expect(result.current.updateSettings).toBeDefined(); + expect(typeof result.current.replaceSettings).toBe("function"); expect(typeof result.current.updateSettings).toBe("function"); }); diff --git a/__tests__/services/BlockService/CheckboxProcessor.test.tsx b/__tests__/services/BlockService/CheckboxProcessor.test.tsx index db52feec..96a9dfca 100644 --- a/__tests__/services/BlockService/CheckboxProcessor.test.tsx +++ b/__tests__/services/BlockService/CheckboxProcessor.test.tsx @@ -74,8 +74,6 @@ describe("processCheckboxes", () => { const expectedCheckboxes = { items: ["Item 1", "Item 2"], min: 2, max: 2 }; expect(mockParams.injectMessage).toHaveBeenCalled(); const [content] = (mockParams.injectMessage as jest.Mock).mock.calls[0]; - console.log("content.props.checkboxes:", content.props.checkboxes); - console.log("expectedCheckboxes:", expectedCheckboxes); expect(content.props.checkboxes).toMatchObject(expectedCheckboxes); }); diff --git a/package-lock.json b/package-lock.json index 5cf7a55a..f4217ca4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "react-chatbotify", - "version": "2.0.0-beta.19", + "version": "2.0.0-beta.20", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "react-chatbotify", - "version": "2.0.0-beta.19", + "version": "2.0.0-beta.20", "license": "MIT", "devDependencies": { "@testing-library/jest-dom": "^6.5.0", diff --git a/package.json b/package.json index 78df543e..6e00c095 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "files": [ "./dist" ], - "version": "2.0.0-beta.19", + "version": "2.0.0-beta.20", "description": "A modern React library for creating flexible and extensible chatbots.", "type": "module", "main": "./dist/index.cjs", diff --git a/src/components/Buttons/VoiceButton/VoiceButton.tsx b/src/components/Buttons/VoiceButton/VoiceButton.tsx index e611e012..376ebd21 100644 --- a/src/components/Buttons/VoiceButton/VoiceButton.tsx +++ b/src/components/Buttons/VoiceButton/VoiceButton.tsx @@ -1,4 +1,4 @@ -import { useEffect, MouseEvent, useState, useRef } from "react"; +import { useEffect, MouseEvent, useRef, useState } from "react"; import MediaDisplay from "../../ChatBotBody/MediaDisplay/MediaDisplay"; import { startVoiceRecording, stopVoiceRecording } from "../../../services/VoiceService"; @@ -52,7 +52,7 @@ const VoiceButton = () => { handleSubmitText(); } }, [voiceInputTrigger]) - + // handles starting and stopping of voice recording on toggle useEffect(() => { if (voiceToggledOn) { diff --git a/src/components/ChatBotBody/ChatBotBody.tsx b/src/components/ChatBotBody/ChatBotBody.tsx index 5102243e..40ff9bf7 100644 --- a/src/components/ChatBotBody/ChatBotBody.tsx +++ b/src/components/ChatBotBody/ChatBotBody.tsx @@ -1,4 +1,4 @@ -import { Dispatch, SetStateAction, useEffect, CSSProperties, MouseEvent } from "react"; +import { Dispatch, SetStateAction, CSSProperties, MouseEvent } from "react"; import ChatMessagePrompt from "./ChatMessagePrompt/ChatMessagePrompt"; import ToastPrompt from "./ToastPrompt/ToastPrompt"; @@ -16,17 +16,13 @@ import "./ChatBotBody.css"; /** * Contains chat messages between the user and bot. * - * @param chatScrollHeight number representing chat window scroll height * @param setChatScrollHeight setter for tracking chat window scroll height */ const ChatBotBody = ({ - chatScrollHeight, setChatScrollHeight, }: { - chatScrollHeight: number; setChatScrollHeight: Dispatch>; }) => { - // handles settings const { settings } = useSettingsContext(); @@ -45,11 +41,8 @@ const ChatBotBody = ({ // handles bot states const { isBotTyping, - isLoadingChatHistory, - setIsLoadingChatHistory, - isScrolling, setIsScrolling, - setUnreadCount + setUnreadCount, } = useBotStatesContext(); // handles bot refs @@ -88,60 +81,6 @@ const ChatBotBody = ({ ...styles.toastPromptContainerStyle }; - // shifts scroll position when messages are updated and when bot is typing - useEffect(() => { - if (isLoadingChatHistory) { - if (!chatBodyRef.current) { - return; - } - - const { scrollHeight } = chatBodyRef.current; - const scrollDifference = scrollHeight - chatScrollHeight; - chatBodyRef.current.scrollTop = chatBodyRef.current.scrollTop + scrollDifference; - setIsLoadingChatHistory(false); - return; - } - - if (settings.chatWindow?.autoJumpToBottom || !isScrolling) { - // defer update to next event loop, handles edge case where messages are sent too fast - // and the scrolling does not properly reach the bottom - setTimeout(() => { - if (!chatBodyRef.current) { - return; - } - - chatBodyRef.current.scrollTop = chatBodyRef.current.scrollHeight; - if (isChatWindowOpen) { - setUnreadCount(0); - } - }) - } - }, [messages.length, isBotTyping]); - - // shifts scroll position when scroll height changes - useEffect(() => { - if (!chatBodyRef.current) { - return; - } - - // used to return chat history to correct height - setChatScrollHeight(chatBodyRef.current.scrollHeight); - - if (!isScrolling) { - chatBodyRef.current.scrollTop = chatBodyRef.current.scrollHeight; - if (isChatWindowOpen) { - setUnreadCount(0); - } - } - }, [chatBodyRef.current?.scrollHeight]); - - // sets unread count to 0 if not scrolling - useEffect(() => { - if (!isScrolling) { - setUnreadCount(0); - } - }, [isScrolling]); - /** * Checks and updates whether a user is scrolling in chat window. */ @@ -149,15 +88,24 @@ const ChatBotBody = ({ if (!chatBodyRef.current) { return; } + + // used to return chat history to correct height + setChatScrollHeight(chatBodyRef.current.scrollHeight); + const { scrollTop, clientHeight, scrollHeight } = chatBodyRef.current; - setIsScrolling( - scrollTop + clientHeight < scrollHeight - (settings.chatWindow?.messagePromptOffset ?? 30) - ); + const isScrolling = scrollTop + clientHeight < scrollHeight - (settings.chatWindow?.messagePromptOffset ?? 30); + setIsScrolling(isScrolling); // workaround to ensure user never truly scrolls to bottom by introducing a 1 pixel offset // this is necessary to prevent unexpected scroll behaviors of the chat window when user reaches the bottom - if (!isScrolling && scrollTop + clientHeight >= scrollHeight - 1) { - chatBodyRef.current.scrollTop = scrollHeight - clientHeight - 1; + if (!isScrolling) { + if (scrollTop + clientHeight >= scrollHeight - 1) { + chatBodyRef.current.scrollTop = scrollHeight - clientHeight - 1; + } + + if (isChatWindowOpen) { + setUnreadCount(0); + } } }; diff --git a/src/components/ChatBotContainer.tsx b/src/components/ChatBotContainer.tsx index b193172b..eaa1f9e7 100644 --- a/src/components/ChatBotContainer.tsx +++ b/src/components/ChatBotContainer.tsx @@ -8,7 +8,7 @@ import ChatBotButton from "./ChatBotButton/ChatBotButton"; import ChatBotTooltip from "./ChatBotTooltip/ChatBotTooltip"; import { useButtonInternal } from "../hooks/internal/useButtonsInternal"; import { useChatWindowInternal } from "../hooks/internal/useChatWindowInternal"; -import { useBotEffectInternal } from "../hooks/internal/useBotEffectsInternal"; +import { useBotEffectsInternal } from "../hooks/internal/useBotEffectsInternal"; import { useIsDesktopInternal } from "../hooks/internal/useIsDesktopInternal"; import { useBotRefsContext } from "../context/BotRefsContext"; import { useBotStatesContext } from "../context/BotStatesContext"; @@ -48,7 +48,7 @@ const ChatBotContainer = ({ const { inputRef } = useBotRefsContext(); // handles chat window - const { chatScrollHeight, + const { setChatScrollHeight, viewportHeight, viewportWidth, @@ -59,7 +59,7 @@ const ChatBotContainer = ({ const { headerButtons, chatInputButtons, footerButtons } = useButtonInternal(); // loads all use effects - useBotEffectInternal(); + useBotEffectsInternal(); /** * Retrieves class name for window state. @@ -146,7 +146,7 @@ const ChatBotContainer = ({ }
{settings.general?.showHeader && } - + {settings.general?.showInputRow && } {settings.general?.showFooter && }
diff --git a/src/constants/internal/DefaultSettings.tsx b/src/constants/internal/DefaultSettings.tsx index 22f963e1..fd790597 100644 --- a/src/constants/internal/DefaultSettings.tsx +++ b/src/constants/internal/DefaultSettings.tsx @@ -154,7 +154,7 @@ export const DefaultSettings: Settings = { background: "linear-gradient(to right, #42b0c5, #491d8d)", }} > - + React ChatBotify diff --git a/src/hooks/internal/useBotEffectsInternal.tsx b/src/hooks/internal/useBotEffectsInternal.tsx index 7e440e83..c743c911 100644 --- a/src/hooks/internal/useBotEffectsInternal.tsx +++ b/src/hooks/internal/useBotEffectsInternal.tsx @@ -4,7 +4,6 @@ import ChatHistoryButton from "../../components/ChatHistoryButton/ChatHistoryBut import { preProcessBlock } from "../../services/BlockService/BlockService"; import { saveChatHistory, setHistoryStorageValues } from "../../services/ChatHistoryService"; import { createMessage } from "../../utils/messageBuilder"; -import { isChatBotVisible } from "../../utils/displayChecker"; import { useIsDesktopInternal } from "./useIsDesktopInternal"; import { useChatWindowInternal } from "./useChatWindowInternal"; import { useNotificationInternal } from "./useNotificationsInternal"; @@ -25,7 +24,7 @@ import { Params } from "../../types/Params"; /** * Internal custom hook for common use effects. */ -export const useBotEffectInternal = () => { +export const useBotEffectsInternal = () => { // handles platform const isDesktop = useIsDesktopInternal(); @@ -39,7 +38,7 @@ export const useBotEffectInternal = () => { removeMessage, streamMessage, messages, - setMessages + replaceMessages, } = useMessagesInternal(); // handles paths @@ -50,10 +49,9 @@ export const useBotEffectInternal = () => { // handles bot states const { - isChatWindowOpen, isBotTyping, + isChatWindowOpen, isScrolling, - timeoutId, hasFlowStarted, setIsChatWindowOpen, setTextAreaDisabled, @@ -66,14 +64,14 @@ export const useBotEffectInternal = () => { } = useBotStatesContext(); // handles bot refs - const { flowRef, chatBodyRef, streamMessageMap, paramsInputRef, keepVoiceOnRef } = useBotRefsContext(); + const { chatBodyRef, flowRef, streamMessageMap, paramsInputRef, keepVoiceOnRef } = useBotRefsContext(); const flow = flowRef.current as Flow; // handles chat window const { viewportHeight, setViewportHeight, setViewportWidth, openChat } = useChatWindowInternal(); // handles notifications - const { playNotificationSound, setUnreadCount, setUpNotifications } = useNotificationInternal(); + const { setUpNotifications } = useNotificationInternal(); // handles user first interaction const { handleFirstInteraction } = useFirstInteractionInternal(); @@ -103,13 +101,9 @@ export const useBotEffectInternal = () => { }; }, []); - // default setup for notifications + // default setup for notifications, text area, chat window, audio and voice useEffect(() => { setUpNotifications(); - }, []) - - // default setup for text area, chat window, audio and voice - useEffect(() => { setTextAreaDisabled(settings.chatInput?.disabled as boolean); setIsChatWindowOpen(settings.chatWindow?.defaultOpen as boolean); setAudioToggledOn(settings.audio?.defaultToggledOn as boolean); @@ -120,6 +114,13 @@ export const useBotEffectInternal = () => { }, 1) }, []) + // scrolls to bottom if bot is typing and user is not scrolling + useEffect(() => { + if (!isScrolling && chatBodyRef?.current) { + chatBodyRef.current.scrollTop = chatBodyRef.current.scrollHeight; + } + }, [isBotTyping]) + // renders chat history button if enabled and triggers update if chat history configurations change useEffect(() => { if (settings.chatHistory?.disabled) { @@ -130,7 +131,7 @@ export const useBotEffectInternal = () => { if (chatHistory != null) { // note: must always render this button even if autoload (chat history logic relies on system message) const messageContent = createMessage(, "system"); - setMessages([messageContent]); + replaceMessages([messageContent]); if (settings.chatHistory?.autoLoad) { showChatHistory(); } @@ -173,36 +174,6 @@ export const useBotEffectInternal = () => { }); }, [isDesktop]); - // triggers saving of chat history and checks for notifications - useEffect(() => { - saveChatHistory(messages); - - // if messages are empty or chatbot is open and user is not scrolling, no need to notify - if (messages.length === 0 || isChatWindowOpen && !isScrolling) { - return; - } - - // if chatbot is embedded and visible, no need to notify - if (settings.general?.embedded && isChatBotVisible(chatBodyRef.current as HTMLDivElement) || isBotTyping) { - return; - } - - const lastMessage = messages[messages.length - 1]; - // if message is sent by user or is bot typing or bot is embedded, return - if (!lastMessage || lastMessage.sender === "user") { - return; - } - - playNotificationSound(); - }, [messages.length]); - - // resets unread count on opening chat - useEffect(() => { - if (isChatWindowOpen) { - setUnreadCount(0); - } - }, [isChatWindowOpen]); - // handles scrolling/resizing window on mobile devices useEffect(() => { if (isDesktop) { @@ -293,6 +264,4 @@ export const useBotEffectInternal = () => { goToPath("start"); } }, [hasFlowStarted, settings.general?.flowStartTrigger]); - - return { timeoutId } }; diff --git a/src/hooks/internal/useButtonsInternal.ts b/src/hooks/internal/useButtonsInternal.ts index 02ac31ee..3d2ed817 100644 --- a/src/hooks/internal/useButtonsInternal.ts +++ b/src/hooks/internal/useButtonsInternal.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useMemo } from "react"; import { createAudioButton, @@ -20,11 +20,6 @@ export const useButtonInternal = () => { // handles settings const { settings } = useSettingsContext(); - // buttons to show in header, chat input and footer - const [headerButtons, setHeaderButtons] = useState>([]); - const [chatInputButtons, setChatInputButtons] = useState>([]); - const [footerButtons, setFooterButtons] = useState>([]); - // handles the rendering of buttons const staticButtonComponentMap = useMemo(() => ({ [Button.CLOSE_CHAT_BUTTON]: () => createCloseChatButton(), @@ -36,13 +31,15 @@ export const useButtonInternal = () => { [Button.VOICE_MESSAGE_BUTTON]: () => createVoiceButton() }), []); - // sets buttons to be shown - useEffect(() => { - const buttonConfig = getButtonConfig(settings, staticButtonComponentMap); - setHeaderButtons(buttonConfig.header); - setChatInputButtons(buttonConfig.chatInput); - setFooterButtons(buttonConfig.footer); + // computes button configurations whenever settings or map changes + const { header, chatInput, footer } = useMemo(() => { + return getButtonConfig(settings, staticButtonComponentMap); }, [settings, staticButtonComponentMap]); - return {headerButtons, chatInputButtons, footerButtons}; + // memoizes creation of jsx elements + const headerButtons = useMemo(() => header, [header]); + const chatInputButtons = useMemo(() => chatInput, [chatInput]); + const footerButtons = useMemo(() => footer, [footer]); + + return { headerButtons, chatInputButtons, footerButtons }; }; diff --git a/src/hooks/internal/useChatHistoryInternal.ts b/src/hooks/internal/useChatHistoryInternal.ts index da3022b9..bdebf225 100644 --- a/src/hooks/internal/useChatHistoryInternal.ts +++ b/src/hooks/internal/useChatHistoryInternal.ts @@ -1,7 +1,9 @@ import { useCallback } from "react"; -import { useRcbEventInternal } from "./useRcbEventInternal"; import { getHistoryMessages, loadChatHistory } from "../../services/ChatHistoryService"; +import { useRcbEventInternal } from "./useRcbEventInternal"; +import { useChatWindowInternal } from "./useChatWindowInternal"; +import { useBotRefsContext } from "../../context/BotRefsContext"; import { useMessagesContext } from "../../context/MessagesContext"; import { useSettingsContext } from "../../context/SettingsContext"; import { useStylesContext } from "../../context/StylesContext"; @@ -27,9 +29,15 @@ export const useChatHistoryInternal = () => { setIsLoadingChatHistory, } = useBotStatesContext(); + // handles bot refs + const { chatBodyRef } = useBotRefsContext(); + // handles rcb events const { callRcbEvent } = useRcbEventInternal(); + // handles chat window + const { chatScrollHeight } = useChatWindowInternal(); + /** * Loads and shows chat history in the chat window. * @@ -49,7 +57,9 @@ export const useChatHistoryInternal = () => { } } setIsLoadingChatHistory(true); - loadChatHistory(settings, styles, chatHistory, setMessages); + loadChatHistory(settings, styles, chatHistory, setMessages, + chatBodyRef, chatScrollHeight, setIsLoadingChatHistory + ); }, [settings, styles, setMessages]); return { isLoadingChatHistory, setIsLoadingChatHistory, showChatHistory }; diff --git a/src/hooks/internal/useChatWindowInternal.ts b/src/hooks/internal/useChatWindowInternal.ts index 51d6be0f..c88f1f8a 100644 --- a/src/hooks/internal/useChatWindowInternal.ts +++ b/src/hooks/internal/useChatWindowInternal.ts @@ -19,7 +19,8 @@ export const useChatWindowInternal = () => { viewportHeight, setViewportHeight, viewportWidth, - setViewportWidth + setViewportWidth, + setUnreadCount } = useBotStatesContext(); // handles rcb events @@ -42,7 +43,13 @@ export const useChatWindowInternal = () => { return; } } - setIsChatWindowOpen(prev => !prev); + setIsChatWindowOpen(prev => { + // if currently false means opening so set unread count to 0 + if (!prev) { + setUnreadCount(0); + } + return !prev; + }); }, []); /** diff --git a/src/hooks/internal/useFlowInternal.ts b/src/hooks/internal/useFlowInternal.ts index ae7f6876..645df543 100644 --- a/src/hooks/internal/useFlowInternal.ts +++ b/src/hooks/internal/useFlowInternal.ts @@ -1,21 +1,21 @@ +import { useMessagesInternal } from "./useMessagesInternal"; +import { usePathsInternal } from "./usePathsInternal"; +import { useToastsInternal } from "./useToastsInternal"; import { useBotRefsContext } from "../../context/BotRefsContext"; import { useBotStatesContext } from "../../context/BotStatesContext"; -import { useMessagesContext } from "../../context/MessagesContext"; -import { usePathsContext } from "../../context/PathsContext"; -import { useToastsContext } from "../../context/ToastsContext"; /** * Internal custom hook for managing flow. */ export const useFlowInternal = () => { // handles messages - const { setMessages } = useMessagesContext(); + const { replaceMessages } = useMessagesInternal(); // handles paths - const { setPaths } = usePathsContext(); + const { replacePaths } = usePathsInternal(); // handles toasts - const { setToasts } = useToastsContext(); + const { replaceToasts } = useToastsInternal(); // handles bot states const { hasFlowStarted } = useBotStatesContext(); @@ -27,9 +27,9 @@ export const useFlowInternal = () => { * Restarts the conversation flow for the chatbot. */ const restartFlow = () => { - setMessages([]); - setToasts([]); - setPaths(["start"]); + replaceMessages([]); + replaceToasts([]); + replacePaths(["start"]); } /** diff --git a/src/hooks/internal/useIsDesktopInternal.ts b/src/hooks/internal/useIsDesktopInternal.ts index 9d1279df..cd1fe2ac 100644 --- a/src/hooks/internal/useIsDesktopInternal.ts +++ b/src/hooks/internal/useIsDesktopInternal.ts @@ -5,7 +5,12 @@ export const useIsDesktopInternal = () => { if (typeof window === 'undefined' || !window.navigator) { return false; // Default to false if running on server-side } - return !(/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)); + const userAgent = navigator.userAgent; + const isNotMobileUA = !(/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(userAgent)); + const isWideEnough = window.innerWidth >= 768; + + // device is desktop if it is not a mobile agent and if the width is wide enough + return isNotMobileUA && isWideEnough; }, []); // boolean indicating if user is on desktop (otherwise treated as on mobile) diff --git a/src/hooks/internal/useMessagesInternal.ts b/src/hooks/internal/useMessagesInternal.ts index e9bd5357..986c9da4 100644 --- a/src/hooks/internal/useMessagesInternal.ts +++ b/src/hooks/internal/useMessagesInternal.ts @@ -4,6 +4,8 @@ import { processAudio } from "../../services/AudioService"; import { saveChatHistory } from "../../services/ChatHistoryService"; import { createMessage } from "../../utils/messageBuilder"; import { parseMarkupMessage } from "../../utils/markupParser"; +import { isChatBotVisible } from "../../utils/displayChecker"; +import { useNotificationInternal } from "./useNotificationsInternal"; import { useRcbEventInternal } from "./useRcbEventInternal"; import { useSettingsContext } from "../../context/SettingsContext"; import { useMessagesContext } from "../../context/MessagesContext"; @@ -23,13 +25,22 @@ export const useMessagesInternal = () => { const { messages, setMessages } = useMessagesContext(); // handles bot states - const { audioToggledOn, isChatWindowOpen, setIsBotTyping, setUnreadCount } = useBotStatesContext(); + const { + audioToggledOn, + isChatWindowOpen, + isScrolling, + setIsBotTyping, + setUnreadCount + } = useBotStatesContext(); // handles bot refs - const { streamMessageMap } = useBotRefsContext(); + const { streamMessageMap, chatBodyRef } = useBotRefsContext(); // handles rcb events const { callRcbEvent } = useRcbEventInternal(); + + // handles notification + const { playNotificationSound } = useNotificationInternal(); /** * Simulates the streaming of a message from the bot. @@ -43,7 +54,11 @@ export const useMessagesInternal = () => { setIsBotTyping(false); // set an initial empty message to be used for streaming - setMessages(prevMessages => [...prevMessages, message]); + setMessages(prevMessages => { + const updatedMessages = [...prevMessages, message]; + handlePostMessagesUpdate(updatedMessages); + return updatedMessages; + }); streamMessageMap.current.set("bot", message.id); // initialize default message to empty with stream index position 0 @@ -79,6 +94,7 @@ export const useMessagesInternal = () => { break; } } + handlePostMessagesUpdate(updatedMessages); return updatedMessages; }); }, streamSpeed); @@ -129,7 +145,11 @@ export const useMessagesInternal = () => { const streamSpeed = settings.userBubble?.streamSpeed as number; await simulateStream(message, streamSpeed, useMarkup); } else { - setMessages((prevMessages) => [...prevMessages, message]); + setMessages((prevMessages) => { + const updatedMessages = [...prevMessages, message]; + handlePostMessagesUpdate(updatedMessages); + return updatedMessages; + }); } // handles post-message inject event @@ -160,7 +180,11 @@ export const useMessagesInternal = () => { } } - setMessages((prevMessages) => prevMessages.filter(message => message.id !== messageId)); + setMessages((prevMessages) => { + const updatedMessages = prevMessages.filter(message => message.id !== messageId); + handlePostMessagesUpdate(updatedMessages); + return updatedMessages; + }); setUnreadCount((prevCount) => Math.max(prevCount - 1, 0)); return messageId; }, [callRcbEvent, messages, settings.event?.rcbRemoveMessage]); @@ -185,7 +209,11 @@ export const useMessagesInternal = () => { } setIsBotTyping(false); - setMessages((prevMessages) => [...prevMessages, message]); + setMessages((prevMessages) => { + const updatedMessages = [...prevMessages, message]; + handlePostMessagesUpdate(updatedMessages); + return [...prevMessages, message]; + }); setUnreadCount(prev => prev + 1); streamMessageMap.current.set(sender, message.id); return message.id; @@ -212,7 +240,7 @@ export const useMessagesInternal = () => { break; } } - + handlePostMessagesUpdate(updatedMessages) return updatedMessages; }); return streamMessageMap.current.get(sender) ?? null; @@ -255,12 +283,64 @@ export const useMessagesInternal = () => { return true; }, [callRcbEvent, messages, settings.event?.rcbStopStreamMessage, streamMessageMap]) + /** + * Replaces (overwrites entirely) the current messages with the new messages. + */ + const replaceMessages = (newMessages: Array) => { + handlePostMessagesUpdate(newMessages); + setMessages(newMessages); + } + + /** + * Handles post messages updates such as saving chat history, scrolling to bottom + * and playing notification sound. + */ + const handlePostMessagesUpdate = (updatedMessages: Array) => { + saveChatHistory(updatedMessages); + + // tracks if notification should be played + let shouldNotify = true; + + // if messages are empty or chatbot is open and user is not scrolling, no need to notify + if (updatedMessages.length === 0 || isChatWindowOpen && !isScrolling) { + shouldNotify = false; + } + + // if chatbot is embedded and visible, no need to notify + if (settings.general?.embedded && isChatBotVisible(chatBodyRef.current as HTMLDivElement)) { + shouldNotify = false; + } + + const lastMessage = updatedMessages[updatedMessages.length - 1]; + // if message is sent by user or is bot typing or bot is embedded, return + if (!lastMessage || lastMessage.sender === "user") { + shouldNotify = false; + } + + if (shouldNotify) { + playNotificationSound(); + } + + // if auto scroll enabled or is not scrolling, then scroll to bottom + if (settings.chatWindow?.autoJumpToBottom || !isScrolling) { + // defer update to next event loop, handles edge case where messages are sent too fast + // and the scrolling does not properly reach the bottom + setTimeout(() => { + if (!chatBodyRef.current) { + return; + } + + chatBodyRef.current.scrollTop = chatBodyRef.current.scrollHeight; + }, 1) + } + } + return { endStreamMessage, injectMessage, removeMessage, streamMessage, messages, - setMessages + replaceMessages }; }; diff --git a/src/hooks/internal/usePathsInternal.ts b/src/hooks/internal/usePathsInternal.ts index c2291a0e..6f34efc7 100644 --- a/src/hooks/internal/usePathsInternal.ts +++ b/src/hooks/internal/usePathsInternal.ts @@ -74,6 +74,13 @@ export const usePathsInternal = () => { return true; }, [paths, setPaths, settings]); + /** + * Replaces (overwrites entirely) the current paths with the new paths. + */ + const replacePaths = (newPaths: Array) => { + setPaths(newPaths); + } + return { getCurrPath, getPrevPath, @@ -81,6 +88,6 @@ export const usePathsInternal = () => { blockAllowsAttachment, setBlockAllowsAttachment, paths, - setPaths + replacePaths }; }; diff --git a/src/hooks/internal/useSettingsInternal.ts b/src/hooks/internal/useSettingsInternal.ts index c1231f80..d5a74582 100644 --- a/src/hooks/internal/useSettingsInternal.ts +++ b/src/hooks/internal/useSettingsInternal.ts @@ -9,18 +9,25 @@ export const useSettingsInternal = () => { // handles settings const { settings, setSettings } = useSettingsContext(); - /** - * Updates the settings for the chatbot. - * - * @param fields fields to update - */ - const updateSettings = (fields: object) => { - setSettings(deepClone(getCombinedConfig(fields, settings) as Settings)); - } + /** + * Updates the settings for the chatbot. + * + * @param fields fields to update + */ + const updateSettings = (fields: object) => { + setSettings(deepClone(getCombinedConfig(fields, settings) as Settings)); + } + + /** + * Replaces (overwrites entirely) the current settings with the new settings. + */ + const replaceSettings = (newSettings: Settings) => { + setSettings(newSettings); + } return { settings, - setSettings, - updateSettings + replaceSettings, + updateSettings }; }; diff --git a/src/hooks/internal/useStylesInternal.ts b/src/hooks/internal/useStylesInternal.ts index 88a0d45f..78d5a0c0 100644 --- a/src/hooks/internal/useStylesInternal.ts +++ b/src/hooks/internal/useStylesInternal.ts @@ -9,18 +9,25 @@ export const useStylesInternal = () => { // handles styles const { styles, setStyles } = useStylesContext(); - /** - * Updates the styles for the chatbot. - * - * @param fields fields to update - */ - const updateStyles = (fields: object) => { - setStyles(deepClone(getCombinedConfig(fields, styles) as Styles)); - } + /** + * Updates the styles for the chatbot. + * + * @param fields fields to update + */ + const updateStyles = (fields: object) => { + setStyles(deepClone(getCombinedConfig(fields, styles) as Styles)); + } + + /** + * Replaces (overwrites entirely) the current styles with the new styles. + */ + const replaceStyles = (newStyles: Styles) => { + setStyles(newStyles); + } return { styles, - setStyles, - updateStyles + replaceStyles, + updateStyles }; }; diff --git a/src/hooks/internal/useToastsInternal.ts b/src/hooks/internal/useToastsInternal.ts index cb6aa466..49819ca2 100644 --- a/src/hooks/internal/useToastsInternal.ts +++ b/src/hooks/internal/useToastsInternal.ts @@ -98,10 +98,17 @@ export const useToastsInternal = () => { return id; }, [callRcbEvent, setToasts]); + /** + * Replaces (overwrites entirely) the current toasts with the new toasts. + */ + const replaceToasts = (newToasts: Array) => { + setToasts(newToasts); + } + return { showToast, dismissToast, toasts, - setToasts + replaceToasts }; }; diff --git a/src/hooks/useMessages.ts b/src/hooks/useMessages.ts index 02f453bd..733cb6bc 100644 --- a/src/hooks/useMessages.ts +++ b/src/hooks/useMessages.ts @@ -11,7 +11,7 @@ export const useMessages = () => { removeMessage, streamMessage, messages, - setMessages + replaceMessages, } = useMessagesInternal(); return { @@ -20,6 +20,6 @@ export const useMessages = () => { removeMessage, streamMessage, messages, - setMessages, + replaceMessages, }; }; diff --git a/src/hooks/usePaths.ts b/src/hooks/usePaths.ts index 3867e448..ac49bc7a 100644 --- a/src/hooks/usePaths.ts +++ b/src/hooks/usePaths.ts @@ -10,7 +10,7 @@ export const usePaths = () => { getPrevPath, goToPath, paths, - setPaths + replacePaths, } = usePathsInternal(); return { @@ -18,6 +18,6 @@ export const usePaths = () => { getPrevPath, goToPath, paths, - setPaths + replacePaths, }; }; diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 317f4b96..e3a86263 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -5,11 +5,11 @@ import { useSettingsInternal } from "./internal/useSettingsInternal"; */ export const useSettings = () => { // handles settings - const { settings, setSettings, updateSettings } = useSettingsInternal(); + const { settings, replaceSettings, updateSettings } = useSettingsInternal(); return { settings, - setSettings, + replaceSettings, updateSettings }; }; diff --git a/src/hooks/useStyles.ts b/src/hooks/useStyles.ts index 5b5943f1..d61dd96e 100644 --- a/src/hooks/useStyles.ts +++ b/src/hooks/useStyles.ts @@ -5,11 +5,11 @@ import { useStylesInternal } from "./internal/useStylesInternal"; */ export const useStyles = () => { // handles styles - const { styles, setStyles, updateStyles } = useStylesInternal(); + const { styles, replaceStyles, updateStyles } = useStylesInternal(); return { styles, - setStyles, + replaceStyles, updateStyles }; }; diff --git a/src/hooks/useToasts.ts b/src/hooks/useToasts.ts index 08205086..f54502ec 100644 --- a/src/hooks/useToasts.ts +++ b/src/hooks/useToasts.ts @@ -5,12 +5,12 @@ import { useToastsInternal } from "./internal/useToastsInternal"; */ export const useToasts = () => { // handles toasts - const { showToast, dismissToast, toasts, setToasts } = useToastsInternal(); + const { showToast, dismissToast, toasts, replaceToasts } = useToastsInternal(); return { showToast, dismissToast, toasts, - setToasts + replaceToasts }; }; diff --git a/src/services/ChatHistoryService.tsx b/src/services/ChatHistoryService.tsx index 6c39834e..454ac59b 100644 --- a/src/services/ChatHistoryService.tsx +++ b/src/services/ChatHistoryService.tsx @@ -124,11 +124,13 @@ const parseMessageToString = (message: Message) => { * @param styles styles provided to the bot * @param chatHistory chat history to show * @param setMessages setter for updating messages - * @param prevTextAreaDisabled boolean indicating if text area was previously disabled - * @param setTextAreaDisabled setter for enabling/disabling user text area + * @param chatBodyRef reference to the chat body + * @param chatScrollHeight current chat scroll height + * @param setIsLoadingChatHistory setter for whether chat history is loading */ const loadChatHistory = (settings: Settings, styles: Styles, chatHistory: Message[], - setMessages: Dispatch>) => { + setMessages: Dispatch>, chatBodyRef: React.RefObject, + chatScrollHeight: number, setIsLoadingChatHistory: Dispatch>) => { historyLoaded = true; if (chatHistory != null) { @@ -160,6 +162,17 @@ const loadChatHistory = (settings: Settings, styles: Styles, chatHistory: Messag return [...parsedMessages, lineBreakMessage, ...prevMessages]; }); }, 500) + + // slight delay afterwards to maintain scroll position + setTimeout(() => { + if (!chatBodyRef.current) { + return; + } + const { scrollHeight } = chatBodyRef.current; + const scrollDifference = scrollHeight - chatScrollHeight; + chatBodyRef.current.scrollTop = chatBodyRef.current.scrollTop + scrollDifference; + setIsLoadingChatHistory(false); + }, 510) } catch { // remove chat history on error (to address corrupted storage values) localStorage.removeItem(settings.chatHistory?.storageKey as string); diff --git a/src/types/Plugin.ts b/src/types/Plugin.ts index d20d0695..602a21a3 100644 --- a/src/types/Plugin.ts +++ b/src/types/Plugin.ts @@ -1,4 +1,4 @@ /** * Defines a plugin type. */ -export type Plugin = (...args: unknown[]) => unknown; \ No newline at end of file +export type Plugin = (...args: unknown[]) => (...hookArgs: unknown[]) => string; \ No newline at end of file