Repository: tjtanjin/react-chatbotify (ts)

Issue:
[Task] Add unit test cases for StylesContext
**Task Description:**
Currently, we run unit tests using jest which can be found [**here**](https://github.com/tjtanjin/react-chatbotify/tree/main/__tests__). 

However, the test cases are not yet comprehensive enough and work can be done to cover more grounds. Specifically for this issue, we hope to add test cases for the `StylesContext`.

**Deliverable(s):**
New unit test cases that cover the testing of the `StylesContext`.

**Additional Context:**
- The `StylesContext` can be found [**here**](https://github.com/tjtanjin/react-chatbotify/tree/main/src/context/StylesContext.tsx)
- An example for `BotRefsContext` can be found [**here**](https://github.com/tjtanjin/react-chatbotify/blob/main/__tests__/context/BotRefsContext.test.tsx) which may serve as a good source for reference

Pull request:
Added test cases for StylesContext
#### Description

Please include a brief summary of the change and include the relevant issue(s).

Closes #232 

#### What change does this PR introduce?
I have added a StylesContext.test.tsx file which contains test code for StylesContext.

Please select the relevant option(s).

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] Documentation update (changes to docs/code comments)

#### What is the proposed approach?

Please give a short overview/explanation on the approach taken to resolve the issue(s).

#### Checklist:

- [x] The commit message follows our adopted [guidelines](https://www.conventionalcommits.org/en/v1.0.0/)
- [x] Testing has been done for the change(s) added (for bug fixes/features)
- [ ] Relevant comments/docs have been added/updated (for bug fixes/features)

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index aa66119..3c75ed6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,18 @@
 # CHANGELOG.md
 
+## v2.0.0-beta.21 (22-10-2024)
+
+**Minor Breaking Change:**
+- The `desktopEnabled` and `mobileEnabled` properties previously found under `settings.general` have been moved to a new `settings.device` section
+
+**Fixed:**
+- Fixed an issue with notification sound spams when streaming messages
+
+**Added:**
+- Added a new `sendIconHoveredStyle` to allow users to define send icon styles on hover
+- Added a new `device` section (which the `desktopEnabled` and `mobileEnabled` properties are moved to)
+- Added a new `applyMobileOptimizations` property to the newly added `settings.device` section
+
 ## v2.0.0-beta.20 (21-10-2024)
 
 **Fixed:**
diff --git a/package-lock.json b/package-lock.json
index f4217ca..7bc6276 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,20 +1,21 @@
 {
   "name": "react-chatbotify",
-  "version": "2.0.0-beta.20",
+  "version": "2.0.0-beta.21",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "react-chatbotify",
-      "version": "2.0.0-beta.20",
+      "version": "2.0.0-beta.21",
       "license": "MIT",
       "devDependencies": {
-        "@testing-library/jest-dom": "^6.5.0",
+        "@testing-library/jest-dom": "^6.6.2",
         "@testing-library/react": "^16.0.1",
         "@types/dom-speech-recognition": "^0.0.4",
         "@types/jest": "^29.5.13",
         "@types/react": "^18.3.6",
         "@types/react-dom": "^18.3.0",
+        "@types/testing-library__react-hooks": "^3.4.1",
         "@typescript-eslint/eslint-plugin": "^5.60.1",
         "@vitejs/plugin-react": "^4.0.4",
         "cypress": "^13.14.2",
@@ -2095,9 +2096,9 @@
       }
     },
     "node_modules/@testing-library/jest-dom": {
-      "version": "6.5.0",
-      "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.5.0.tgz",
-      "integrity": "sha512-xGGHpBXYSHUUr6XsKBfs85TWlYKpTc37cSBBVrXcib2MkHLboWlkClhWF37JKlDb9KEq3dHs+f2xR7XJEWGBxA==",
+      "version": "6.6.2",
+      "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.6.2.tgz",
+      "integrity": "sha512-P6GJD4yqc9jZLbe98j/EkyQDTPgqftohZF5FBkHY5BUERZmcf4HeO2k0XaefEg329ux2p21i1A1DmyQ1kKw2Jw==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
@@ -2339,6 +2340,7 @@
       "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.13.tgz",
       "integrity": "sha512-wd+MVEZCHt23V0/L642O5APvspWply/rGY5BcW4SUETo2UzPU3Z26qr8jC2qxpimI2jjx9h7+2cj2FwIr01bXg==",
       "dev": true,
+      "license": "MIT",
       "dependencies": {
         "expect": "^29.0.0",
         "pretty-format": "^29.0.0"
@@ -2397,6 +2399,16 @@
         "@types/react": "*"
       }
     },
+    "node_modules/@types/react-test-renderer": {
+      "version": "18.3.0",
+      "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-18.3.0.tgz",
+      "integrity": "sha512-HW4MuEYxfDbOHQsVlY/XtOvNHftCVEPhJF2pQXXwcUiUF+Oyb0usgp48HSgpK5rt8m9KZb22yqOeZm+rrVG8gw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/react": "*"
+      }
+    },
     "node_modules/@types/semver": {
       "version": "7.5.0",
       "dev": true,
@@ -2423,6 +2435,16 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/@types/testing-library__react-hooks": {
+      "version": "3.4.1",
+      "resolved": "https://registry.npmjs.org/@types/testing-library__react-hooks/-/testing-library__react-hooks-3.4.1.tgz",
+      "integrity": "sha512-G4JdzEcq61fUyV6wVW9ebHWEiLK2iQvaBuCHHn9eMSbZzVh4Z4wHnUGIvQOYCCYeu5DnUtFyNYuAAgbSaO/43Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/react-test-renderer": "*"
+      }
+    },
     "node_modules/@types/tough-cookie": {
       "version": "4.0.5",
       "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
@@ -2491,6 +2513,8 @@
     },
     "node_modules/@typescript-eslint/parser": {
       "version": "5.62.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz",
+      "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==",
       "dev": true,
       "license": "BSD-2-Clause",
       "peer": true,
diff --git a/package.json b/package.json
index 6e00c09..bb1b130 100644
--- a/package.json
+++ b/package.json
@@ -12,7 +12,7 @@
   "files": [
     "./dist"
   ],
-  "version": "2.0.0-beta.20",
+  "version": "2.0.0-beta.21",
   "description": "A modern React library for creating flexible and extensible chatbots.",
   "type": "module",
   "main": "./dist/index.cjs",
@@ -50,7 +50,7 @@
     "react-dom": ">=16.14.0 <20.0.0 || ^19.0.0-0"
   },
   "devDependencies": {
-    "@testing-library/jest-dom": "^6.5.0",
+    "@testing-library/jest-dom": "^6.6.2",
     "@testing-library/react": "^16.0.1",
     "@types/dom-speech-recognition": "^0.0.4",
     "@types/jest": "^29.5.13",
diff --git a/src/components/Buttons/SendButton/SendButton.tsx b/src/components/Buttons/SendButton/SendButton.tsx
index 70a5c37..4d4a093 100644
--- a/src/components/Buttons/SendButton/SendButton.tsx
+++ b/src/components/Buttons/SendButton/SendButton.tsx
@@ -62,6 +62,14 @@ const SendButton = () => {
 		...styles.sendIconDisabledStyle
 	};
 
+	// styles for hovered send icon
+	const sendIconHoveredStyle: React.CSSProperties = {
+		backgroundImage: `url(${settings.chatInput?.sendButtonIcon})`,
+		fill: "#fff",
+		...styles.sendIconStyle, // by default inherit the base style
+		...styles.sendIconHoveredStyle
+	};
+
 	/**
 	 * Handles mouse enter event on send button.
 	 */
@@ -86,14 +94,22 @@ const SendButton = () => {
 				<span
 					className="rcb-send-icon"
 					data-testid="rcb-send-icon"
-					style={textAreaDisabled ? sendIconDisabledStyle : sendIconStyle}
+					style={textAreaDisabled ?
+						sendIconDisabledStyle :
+						(isHovered ? sendIconHoveredStyle : sendIconStyle)
+					}
 				/>
 			)
 		}
 		return (
 			IconComponent &&
 			<span className="rcb-send-icon" data-testid="rcb-send-icon">
-				<IconComponent style={textAreaDisabled ? sendIconDisabledStyle : sendIconStyle}/>
+				<IconComponent
+					style={textAreaDisabled ?
+						sendIconDisabledStyle :
+						(isHovered ? sendIconHoveredStyle : sendIconStyle)
+					}
+				/>
 			</span>
 		)
 	}
diff --git a/src/components/ChatBotBody/ChatBotBody.tsx b/src/components/ChatBotBody/ChatBotBody.tsx
index 40ff9bf..7a95b7c 100644
--- a/src/components/ChatBotBody/ChatBotBody.tsx
+++ b/src/components/ChatBotBody/ChatBotBody.tsx
@@ -1,4 +1,4 @@
-import { Dispatch, SetStateAction, CSSProperties, MouseEvent } from "react";
+import { Dispatch, SetStateAction, CSSProperties, MouseEvent, useEffect } from "react";
 
 import ChatMessagePrompt from "./ChatMessagePrompt/ChatMessagePrompt";
 import ToastPrompt from "./ToastPrompt/ToastPrompt";
@@ -41,6 +41,7 @@ const ChatBotBody = ({
 	// handles bot states
 	const {
 		isBotTyping,
+		isScrolling,
 		setIsScrolling,
 		setUnreadCount,
 	} = useBotStatesContext();
@@ -81,6 +82,20 @@ const ChatBotBody = ({
 		...styles.toastPromptContainerStyle
 	};
 
+	// shifts scroll position when scroll height changes and determines if a user is scrolling in chat window.
+	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;
+		}
+	}, [chatBodyRef.current?.scrollHeight]);
+
 	/**
 	 * Checks and updates whether a user is scrolling in chat window.
 	 */
diff --git a/src/components/ChatBotContainer.tsx b/src/components/ChatBotContainer.tsx
index eaa1f9e..52c6c55 100644
--- a/src/components/ChatBotContainer.tsx
+++ b/src/components/ChatBotContainer.tsx
@@ -97,8 +97,8 @@ const ChatBotContainer = ({
 	 * Checks if chatbot should be shown depending on platform.
 	 */
 	const shouldShowChatBot = () => {
-		return (isDesktop && settings.general?.desktopEnabled)
-			|| (!isDesktop && settings.general?.mobileEnabled);
+		return (isDesktop && settings.device?.desktopEnabled)
+			|| (!isDesktop && settings.device?.mobileEnabled);
 	}
 
 	return (
diff --git a/src/constants/internal/DefaultSettings.tsx b/src/constants/internal/DefaultSettings.tsx
index fd79059..9c839f0 100644
--- a/src/constants/internal/DefaultSettings.tsx
+++ b/src/constants/internal/DefaultSettings.tsx
@@ -30,8 +30,6 @@ export const DefaultSettings: Settings = {
 		showInputRow: true,
 		actionDisabledIcon: actionDisabledIcon,
 		embedded: false,
-		desktopEnabled: true,
-		mobileEnabled: true,
 		flowStartTrigger: "ON_LOAD",
 	},
 	tooltip: {
@@ -212,5 +210,10 @@ export const DefaultSettings: Settings = {
 		sendButton: "send message",
 		voiceButton: "toggle voice",
 		inputTextArea: "input text area",
+	},
+	device: {
+		desktopEnabled: true,
+		mobileEnabled: true,
+		applyMobileOptimizations: true,
 	}
 }
diff --git a/src/constants/internal/DefaultStyles.tsx b/src/constants/internal/DefaultStyles.tsx
index b9e6979..71918e1 100644
--- a/src/constants/internal/DefaultStyles.tsx
+++ b/src/constants/internal/DefaultStyles.tsx
@@ -58,6 +58,7 @@ export const DefaultStyles: Styles = {
 	voiceIconDisabledStyle: {},
 	sendIconStyle: {},
 	sendIconDisabledStyle: {},
+	sendIconHoveredStyle: {},
 	rcbTypingIndicatorContainerStyle: {},
 	rcbTypingIndicatorDotStyle: {},
 	toastPromptContainerStyle: {},
diff --git a/src/hooks/internal/useIsDesktopInternal.ts b/src/hooks/internal/useIsDesktopInternal.ts
index cd1fe2a..250bc62 100644
--- a/src/hooks/internal/useIsDesktopInternal.ts
+++ b/src/hooks/internal/useIsDesktopInternal.ts
@@ -1,17 +1,27 @@
 import { useMemo } from 'react';
 
+import { useSettingsContext } from '../../context/SettingsContext';
+
 export const useIsDesktopInternal = () => {
+	const { settings } = useSettingsContext();
+
 	const isDesktop = useMemo(() => {
 		if (typeof window === 'undefined' || !window.navigator) {
 			return false; // Default to false if running on server-side
 		}
+
+		// if not applying mobile optimizations, can just use desktop behavior
+		if (!settings.device?.applyMobileOptimizations) {
+			return true;
+		}
+
 		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;
-	}, []);
+	}, [settings.device?.applyMobileOptimizations]);
 
 	// boolean indicating if user is on desktop (otherwise treated as on mobile)
 	return isDesktop;
diff --git a/src/hooks/internal/useMessagesInternal.ts b/src/hooks/internal/useMessagesInternal.ts
index 986c9da..92d5883 100644
--- a/src/hooks/internal/useMessagesInternal.ts
+++ b/src/hooks/internal/useMessagesInternal.ts
@@ -94,7 +94,6 @@ export const useMessagesInternal = () => {
 							break;
 						}
 					}
-					handlePostMessagesUpdate(updatedMessages);
 					return updatedMessages;
 				});
 			}, streamSpeed);
@@ -138,6 +137,12 @@ export const useMessagesInternal = () => {
 		const isUserStream = typeof message.content === "string"
 			&& message.sender === "user" && settings?.userBubble?.simStream;
 
+		// handles post-message inject event
+		setUnreadCount(prev => prev + 1);
+		if (settings.event?.rcbPostInjectMessage) {
+			callRcbEvent(RcbEvent.POST_INJECT_MESSAGE, {message});
+		}
+
 		if (isBotStream) {
 			const streamSpeed = settings.botBubble?.streamSpeed as number;
 			await simulateStream(message, streamSpeed, useMarkup);
@@ -152,12 +157,6 @@ export const useMessagesInternal = () => {
 			});
 		}
 
-		// handles post-message inject event
-		setUnreadCount(prev => prev + 1);
-		if (settings.event?.rcbPostInjectMessage) {
-			callRcbEvent(RcbEvent.POST_INJECT_MESSAGE, {message});
-		}
-
 		return message.id;
 	}, [settings, audioToggledOn, isChatWindowOpen, callRcbEvent, simulateStream]);
 
diff --git a/src/index.tsx b/src/index.tsx
index bbc4022..34808bf 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -1,8 +1,14 @@
+// chatbot import
 import ChatBot from "./components/ChatBot";
+
+// provider import
 import ChatBotProvider from "./context/ChatBotContext";
 
+// util imports
 import { getDefaultSettings } from "./utils/configParser";
 import { getDefaultStyles } from "./utils/configParser";
+
+// type imports
 import { Block } from "./types/Block";
 import { Params } from "./types/Params";
 import { Flow } from "./types/Flow";
diff --git a/src/types/Plugin.ts b/src/types/Plugin.ts
index 602a21a..d20d069 100644
--- a/src/types/Plugin.ts
+++ b/src/types/Plugin.ts
@@ -1,4 +1,4 @@
 /**
  * Defines a plugin type.
  */
-export type Plugin = (...args: unknown[]) => (...hookArgs: unknown[]) => string;
\ No newline at end of file
+export type Plugin = (...args: unknown[]) => unknown;
\ No newline at end of file
diff --git a/src/types/Settings.ts b/src/types/Settings.ts
index bb01529..b69453a 100644
--- a/src/types/Settings.ts
+++ b/src/types/Settings.ts
@@ -12,8 +12,6 @@ export type Settings = {
 		showInputRow?: boolean;
 		actionDisabledIcon?: string;
 		embedded?: boolean;
-		desktopEnabled?: boolean;
-		mobileEnabled?: boolean;
 		flowStartTrigger?: string;
 	},
 	tooltip?: {
@@ -167,5 +165,10 @@ export type Settings = {
 		sendButton?: string;
 		voiceButton?: string;
 		inputTextArea?: string;
+	},
+	device?: {
+		desktopEnabled?: boolean;
+		mobileEnabled?: boolean;
+		applyMobileOptimizations?: boolean;
 	}
 }
diff --git a/src/types/Styles.ts b/src/types/Styles.ts
index 9e8f286..46118a0 100644
--- a/src/types/Styles.ts
+++ b/src/types/Styles.ts
@@ -59,6 +59,7 @@ export type Styles = {
 	voiceIconDisabledStyle?: React.CSSProperties;
 	sendIconStyle?: React.CSSProperties;
 	sendIconDisabledStyle?: React.CSSProperties;
+	sendIconHoveredStyle?: React.CSSProperties;
 	rcbTypingIndicatorContainerStyle?: React.CSSProperties;
 	rcbTypingIndicatorDotStyle?: React.CSSProperties;
 	toastPromptContainerStyle?: React.CSSProperties;


Test patch:
diff --git a/__tests__/context/MessagesContext.test.tsx b/__tests__/context/MessagesContext.test.tsx
index 37b6ad7..1c6d7a3 100644
--- a/__tests__/context/MessagesContext.test.tsx
+++ b/__tests__/context/MessagesContext.test.tsx
@@ -1,4 +1,5 @@
 import React from 'react'
+
 import { expect } from '@jest/globals'
 import { act, render, screen } from '@testing-library/react'
 import '@testing-library/jest-dom/jest-globals'
diff --git a/__tests__/context/PathsContext.test.tsx b/__tests__/context/PathsContext.test.tsx
index 269603a..2000ba2 100644
--- a/__tests__/context/PathsContext.test.tsx
+++ b/__tests__/context/PathsContext.test.tsx
@@ -1,4 +1,5 @@
 import React from 'react'
+
 import { expect } from '@jest/globals'
 import { act, render, screen } from '@testing-library/react'
 import '@testing-library/jest-dom/jest-globals'
diff --git a/__tests__/context/StylesContext.test.tsx b/__tests__/context/StylesContext.test.tsx
new file mode 100644
index 0000000..e58909d
--- /dev/null
+++ b/__tests__/context/StylesContext.test.tsx
@@ -0,0 +1,61 @@
+import React from 'react';
+
+import { render, screen, act } from '@testing-library/react';
+import { StylesProvider, useStylesContext } from '../../src/context/StylesContext';
+import { DefaultStyles } from "../../src/constants/internal/DefaultStyles";
+import '@testing-library/jest-dom';
+
+
+// Mocking a child component to test context
+const MockChild = () => {
+	const { styles, setStyles } = useStylesContext();
+    
+	return (
+		<div>
+			<p>Current Styles: {JSON.stringify(styles)}</p>
+			<button onClick={() => setStyles({ ...styles, chatWindowStyle: { backgroundColor: 'blue' } })}>
+				Change Chat Window Style
+			</button>
+		</div>
+	);
+};
+
+// Test suite for StylesProvider
+describe('StylesProvider', () => {
+	beforeEach(() => {
+		jest.clearAllMocks();
+	});
+
+	test('provides default styles', () => {
+		render(
+			<StylesProvider styles={DefaultStyles} setStyles={jest.fn()}>
+				<MockChild />
+			</StylesProvider>
+		);
+
+		// Check if the default styles are provided
+		const stylesElement = screen.getByText(`Current Styles: ${JSON.stringify(DefaultStyles)}`);
+		expect(stylesElement).toBeInTheDocument();
+	});
+
+	test('allows updating styles through setStyles', () => {
+		const setStylesMock = jest.fn();
+		render(
+			<StylesProvider styles={DefaultStyles} setStyles={setStylesMock}>
+				<MockChild />
+			</StylesProvider>
+		);
+
+		// Click the button to change styles
+		const button = screen.getByText('Change Chat Window Style');
+		act(() => {
+			button.click();
+		});
+
+		// Verify that setStyles was called with the updated styles
+		expect(setStylesMock).toHaveBeenCalledWith({
+			...DefaultStyles,
+			chatWindowStyle: { backgroundColor: 'blue' },
+		});
+	});
+});
diff --git a/__tests__/hooks/internal/useIsDesktopInternal.test.ts b/__tests__/hooks/internal/useIsDesktopInternal.test.ts
index 9f9b0e0..b1ef42b 100644
--- a/__tests__/hooks/internal/useIsDesktopInternal.test.ts
+++ b/__tests__/hooks/internal/useIsDesktopInternal.test.ts
@@ -1,9 +1,14 @@
 import { renderHook } from '@testing-library/react';
+
+import { useSettingsContext } from "../../../src/context/SettingsContext";
 import { useIsDesktopInternal} from "../../../src/hooks/internal/useIsDesktopInternal";
 
 const originalNavigator = window.navigator;
 const originalInnerWidth = window.innerWidth;
 
+// Mock the contexts
+jest.mock("../../../src/context/SettingsContext");
+
 const mockWindowProperty = (property: string, value: any) => {
     Object.defineProperty(window, property, {
         configurable: true,
@@ -13,6 +18,13 @@ const mockWindowProperty = (property: string, value: any) => {
 };
 
 describe('useIsDesktopInternal', () => {
+    beforeEach(() => {
+		// default mock values
+		(useSettingsContext as jest.Mock).mockReturnValue({
+			settings: { device: { applyMobileOptimizations: true } },
+		});
+	});
+
     afterEach(() => {
         Object.defineProperty(window, 'navigator', {
             configurable: true,
@@ -73,4 +85,18 @@ describe('useIsDesktopInternal', () => {
         const { result } = renderHook(() => useIsDesktopInternal());
         expect(result.current).toBe(true);
     });
+
+    it('should return true if mobile optimizations are not applied even if user is on mobile', () => {
+        (useSettingsContext as jest.Mock).mockReturnValue({
+			settings: { device: { applyMobileOptimizations: false } },
+		});
+
+        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(true);
+    });
 });