From 1d6a7a54d2b121e8e27740db8787070e11d49860 Mon Sep 17 00:00:00 2001 From: Lorenzo Date: Wed, 25 Dec 2024 12:14:22 +0100 Subject: [PATCH] Initial commit --- .github/FUNDING.yml | 4 + .github/workflows/format.yml | 37 + .github/workflows/tests.yml | 33 + .gitignore | 25 + .prettierignore | 3 + .prettierrc | 6 + README.md | 50 + eslint.config.js | 21 + index.html | 13 + lib/components/chat/chat.tsx | 27 + lib/components/chat/hooks/useChat.ts | 45 + lib/components/chat/styles/chat.styles.ts | 24 + .../content/hooks/useMarkdownContent.ts | 34 + lib/components/content/markdown-content.tsx | 63 + .../content/styles/markdown-content.styles.ts | 38 + lib/components/icons/assistant-icon.tsx | 23 + lib/components/icons/copied-icon.tsx | 30 + lib/components/icons/copy-icon.tsx | 29 + lib/components/icons/index.ts | 5 + lib/components/icons/menu-icon.tsx | 26 + lib/components/icons/send-icon.tsx | 26 + .../icons/styles/icon-container.styles.ts | 8 + lib/components/message/assistant-loading.tsx | 27 + lib/components/message/assistant-message.tsx | 30 + .../message/hooks/useMessageInput.ts | 79 + .../message/hooks/useScrollToBottom.ts | 11 + lib/components/message/message-input.tsx | 47 + lib/components/message/messages.tsx | 36 + .../message/styles/message-input.styles.ts | 74 + .../message/styles/messages.styles.ts | 112 + lib/components/message/user-message.tsx | 23 + lib/components/sidebar/hooks/useSidebar.ts | 56 + lib/components/sidebar/sidebar-item.tsx | 22 + lib/components/sidebar/sidebar.tsx | 47 + .../sidebar/styles/sidebar-item.styles.ts | 23 + .../sidebar/styles/sidebar.styles.ts | 85 + lib/main.ts | 9 + lib/provider/chat-provider.tsx | 51 + lib/provider/index.ts | 2 + lib/provider/reducer.tsx | 44 + lib/provider/types.ts | 18 + lib/types.ts | 79 + lib/types/index.ts | 5 + lib/utils/index.ts | 10 + lib/vite-env.d.ts | 1 + package-lock.json | 9433 +++++++++++++++++ package.json | 60 + public/vite.svg | 1 + src/App.tsx | 115 + src/dummy/data.ts | 851 ++ src/index.css | 0 src/main.tsx | 10 + src/styles/Container.styles.ts | 15 + src/vite-env.d.ts | 1 + tests/components/chat/chat.test.tsx | 153 + tests/components/chat/hooks/useChat.test.tsx | 144 + .../content/markdown-content.test.tsx | 132 + .../message/assistant-loading.test.tsx | 69 + .../message/assistant-message.test.tsx | 90 + .../message/hooks/useMessageInput.test.tsx | 150 + .../message/hooks/useScrollToBottom.test.ts | 34 + .../components/message/message-input.test.tsx | 141 + tests/components/message/messages.test.tsx | 104 + .../components/message/user-message.test.tsx | 69 + .../components/sidebar/sidebar-item.test.tsx | 88 + tests/components/sidebar/sidebar.test.tsx | 170 + tests/provider/chat-provider.test.tsx | 49 + tests/setup.d.ts | 7 + tests/setup.ts | 12 + tsconfig.app.json | 26 + tsconfig.json | 7 + tsconfig.lib.json | 4 + tsconfig.node.json | 24 + vite.config.ts | 32 + vitest.config.ts | 11 + 75 files changed, 13463 insertions(+) create mode 100644 .github/FUNDING.yml create mode 100644 .github/workflows/format.yml create mode 100644 .github/workflows/tests.yml create mode 100644 .gitignore create mode 100644 .prettierignore create mode 100644 .prettierrc create mode 100644 README.md create mode 100644 eslint.config.js create mode 100644 index.html create mode 100644 lib/components/chat/chat.tsx create mode 100644 lib/components/chat/hooks/useChat.ts create mode 100644 lib/components/chat/styles/chat.styles.ts create mode 100644 lib/components/content/hooks/useMarkdownContent.ts create mode 100644 lib/components/content/markdown-content.tsx create mode 100644 lib/components/content/styles/markdown-content.styles.ts create mode 100644 lib/components/icons/assistant-icon.tsx create mode 100644 lib/components/icons/copied-icon.tsx create mode 100644 lib/components/icons/copy-icon.tsx create mode 100644 lib/components/icons/index.ts create mode 100644 lib/components/icons/menu-icon.tsx create mode 100644 lib/components/icons/send-icon.tsx create mode 100644 lib/components/icons/styles/icon-container.styles.ts create mode 100644 lib/components/message/assistant-loading.tsx create mode 100644 lib/components/message/assistant-message.tsx create mode 100644 lib/components/message/hooks/useMessageInput.ts create mode 100644 lib/components/message/hooks/useScrollToBottom.ts create mode 100644 lib/components/message/message-input.tsx create mode 100644 lib/components/message/messages.tsx create mode 100644 lib/components/message/styles/message-input.styles.ts create mode 100644 lib/components/message/styles/messages.styles.ts create mode 100644 lib/components/message/user-message.tsx create mode 100644 lib/components/sidebar/hooks/useSidebar.ts create mode 100644 lib/components/sidebar/sidebar-item.tsx create mode 100644 lib/components/sidebar/sidebar.tsx create mode 100644 lib/components/sidebar/styles/sidebar-item.styles.ts create mode 100644 lib/components/sidebar/styles/sidebar.styles.ts create mode 100644 lib/main.ts create mode 100644 lib/provider/chat-provider.tsx create mode 100644 lib/provider/index.ts create mode 100644 lib/provider/reducer.tsx create mode 100644 lib/provider/types.ts create mode 100644 lib/types.ts create mode 100644 lib/types/index.ts create mode 100644 lib/utils/index.ts create mode 100644 lib/vite-env.d.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/vite.svg create mode 100644 src/App.tsx create mode 100644 src/dummy/data.ts create mode 100644 src/index.css create mode 100644 src/main.tsx create mode 100644 src/styles/Container.styles.ts create mode 100644 src/vite-env.d.ts create mode 100644 tests/components/chat/chat.test.tsx create mode 100644 tests/components/chat/hooks/useChat.test.tsx create mode 100644 tests/components/content/markdown-content.test.tsx create mode 100644 tests/components/message/assistant-loading.test.tsx create mode 100644 tests/components/message/assistant-message.test.tsx create mode 100644 tests/components/message/hooks/useMessageInput.test.tsx create mode 100644 tests/components/message/hooks/useScrollToBottom.test.ts create mode 100644 tests/components/message/message-input.test.tsx create mode 100644 tests/components/message/messages.test.tsx create mode 100644 tests/components/message/user-message.test.tsx create mode 100644 tests/components/sidebar/sidebar-item.test.tsx create mode 100644 tests/components/sidebar/sidebar.test.tsx create mode 100644 tests/provider/chat-provider.test.tsx create mode 100644 tests/setup.d.ts create mode 100644 tests/setup.ts create mode 100644 tsconfig.app.json create mode 100644 tsconfig.json create mode 100644 tsconfig.lib.json create mode 100644 tsconfig.node.json create mode 100644 vite.config.ts create mode 100644 vitest.config.ts diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..29e8fce --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,4 @@ +# These are supported funding model platforms + +github: FunkyOz +buy_me_a_coffee: funkyoz diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml new file mode 100644 index 0000000..0f7550e --- /dev/null +++ b/.github/workflows/format.yml @@ -0,0 +1,37 @@ +name: Format Check + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + format: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [18, 19, 20, 21, 22, 23] + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + + - name: Install dependencies + run: | + npm install + + - name: Run analyse + run: | + npm run analyse + + - name: Run lint + run: | + npm run lint \ No newline at end of file diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..93be384 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,33 @@ +name: Test Suite + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [18, 19, 20, 21, 22, 23] + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + + - name: Install dependencies + run: | + npm install + + - name: Run tests + run: | + npm run tests \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fa432fb --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +.cursorrules diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..4b7e16f --- /dev/null +++ b/.prettierignore @@ -0,0 +1,3 @@ +# Ignore artifacts: +dist +coverage diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..0b81e39 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "trailingComma": "es5", + "tabWidth": 4, + "semi": true, + "singleQuote": false +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..44f2acc --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: + +- Configure the top-level `parserOptions` property like this: + +```js +export default tseslint.config({ + languageOptions: { + // other options... + parserOptions: { + project: ["./tsconfig.node.json", "./tsconfig.app.json"], + tsconfigRootDir: import.meta.dirname, + }, + }, +}); +``` + +- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked` +- Optionally add `...tseslint.configs.stylisticTypeChecked` +- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config: + +```js +// eslint.config.js +import react from "eslint-plugin-react"; + +export default tseslint.config({ + // Set the react version + settings: { react: { version: "18.3" } }, + plugins: { + // Add the react plugin + react, + }, + rules: { + // other rules... + // Enable its recommended rules + ...react.configs.recommended.rules, + ...react.configs["jsx-runtime"].rules, + }, +}); +``` diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..4d03ec7 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,21 @@ +import globals from "globals"; +import pluginJs from "@eslint/js"; +import tseslint from "typescript-eslint"; +import pluginReact from "eslint-plugin-react"; + +/** @type {import('eslint').Linter.Config[]} */ +export default [ + { files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"] }, + { languageOptions: { globals: globals.browser } }, + pluginJs.configs.recommended, + ...tseslint.configs.recommended, + pluginReact.configs.flat.recommended, + { + settings: { react: { version: "detect" } }, + rules: { + "@typescript-eslint/no-explicit-any": "off", + "react-hooks/exhaustive-deps": "off", + "react/react-in-jsx-scope": "off", + }, + }, +]; diff --git a/index.html b/index.html new file mode 100644 index 0000000..e0ef3be --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + TS + + +
+ + + diff --git a/lib/components/chat/chat.tsx b/lib/components/chat/chat.tsx new file mode 100644 index 0000000..91a82bc --- /dev/null +++ b/lib/components/chat/chat.tsx @@ -0,0 +1,27 @@ +import { ChatProps } from "../../types"; +import { ChatWrapper, ChatContent, ChatContainer } from "./styles/chat.styles"; +import { useChat } from "./hooks/useChat"; +import { ChatProvider } from "../../provider"; + +export const Chat = ({ withAutoFocus, children, assistantIcon }: ChatProps) => { + const { sidebar, messageInput, messages } = useChat(children); + + return ( + + + + {sidebar} + + {messages} + {messageInput} + + + + + ); +}; + +export default Chat; diff --git a/lib/components/chat/hooks/useChat.ts b/lib/components/chat/hooks/useChat.ts new file mode 100644 index 0000000..044cfae --- /dev/null +++ b/lib/components/chat/hooks/useChat.ts @@ -0,0 +1,45 @@ +import React, { ReactNode, ReactElement, useMemo } from "react"; +import { Sidebar } from "../../../components/sidebar/sidebar"; +import { MessageInput } from "../../../components/message/message-input"; +import { Messages } from "../../../components/message/messages"; +type UseChatReturn = { + sidebar: ReactNode; + messageInput: ReactNode; + messages: ReactNode; +}; + +const isSidebarElement = (child: ReactNode): child is ReactElement => { + return React.isValidElement(child) && child.type === Sidebar; +}; + +const isMessageInputElement = (child: ReactNode): child is ReactElement => { + return React.isValidElement(child) && child.type === MessageInput; +}; + +const isMessagesElement = (child: ReactNode): child is ReactElement => { + return React.isValidElement(child) && child.type === Messages; +}; + +export const useChat = (children: ReactNode): UseChatReturn => { + const childrenAsArray = useMemo(() => { + return Array.isArray(children) ? children : [children]; + }, [children]); + + const sidebar = useMemo(() => { + return childrenAsArray.find(isSidebarElement); + }, [childrenAsArray]); + + const messageInput = useMemo(() => { + return childrenAsArray.find(isMessageInputElement); + }, [childrenAsArray]); + + const messages = useMemo(() => { + return childrenAsArray.find(isMessagesElement); + }, [childrenAsArray]); + + return { + sidebar, + messageInput, + messages, + }; +}; diff --git a/lib/components/chat/styles/chat.styles.ts b/lib/components/chat/styles/chat.styles.ts new file mode 100644 index 0000000..1842ef7 --- /dev/null +++ b/lib/components/chat/styles/chat.styles.ts @@ -0,0 +1,24 @@ +import styled from "styled-components"; + +export const ChatWrapper = styled.div` + display: flex; + flex-direction: column; + height: 100%; + width: 100%; +`; + +export const ChatContent = styled.div` + display: flex; + flex: 1; + overflow: hidden; +`; + +export const ChatContainer = styled.div` + display: flex; + flex-direction: column; + flex: 1; + position: relative; + height: 100%; + overflow-y: auto; + background-color: #ffffff; +`; diff --git a/lib/components/content/hooks/useMarkdownContent.ts b/lib/components/content/hooks/useMarkdownContent.ts new file mode 100644 index 0000000..fff0403 --- /dev/null +++ b/lib/components/content/hooks/useMarkdownContent.ts @@ -0,0 +1,34 @@ +import { useCallback, useEffect, useState } from "react"; + +const useMarkdownContent = (onCodeCopied?: (code: string) => void) => { + const [isCopied, setIsCopied] = useState(false); + + const handleCopy = useCallback( + async (code: string) => { + if (isCopied) { + return; + } + try { + await navigator.clipboard.writeText(code); + if (onCodeCopied) { + onCodeCopied(code); + } + setIsCopied(true); + } catch (err) { + console.error("Failed to copy: ", err); + } + }, + [onCodeCopied, isCopied] + ); + + useEffect(() => { + if (isCopied) { + const timeout = setTimeout(() => setIsCopied(false), 5000); + return () => clearTimeout(timeout); + } + }, [isCopied]); + + return { handleCopy, isCopied }; +}; + +export default useMarkdownContent; diff --git a/lib/components/content/markdown-content.tsx b/lib/components/content/markdown-content.tsx new file mode 100644 index 0000000..759330a --- /dev/null +++ b/lib/components/content/markdown-content.tsx @@ -0,0 +1,63 @@ +import { MarkdownContentProps } from "../../types"; +import Markdown from "react-markdown"; +import SyntaxHighlighter from "react-syntax-highlighter"; +import { + CodeContainer, + CodeCopyButton, + CodeLanguage, + CodeTitle, +} from "./styles/markdown-content.styles"; +import { CopyIcon, CopiedIcon } from "../icons"; +import useMarkdownContent from "./hooks/useMarkdownContent"; + +/** + * A component that renders markdown content with proper styling + */ +export const MarkdownContent = ({ + content, + className, + onCodeCopied, +}: MarkdownContentProps) => { + const { handleCopy, isCopied } = useMarkdownContent(onCodeCopied); + + return ( + + + + {match ? match[1] : "plain"} + + handleCopy(codeString)} + > + {isCopied ? : } + + + {match ? ( + + {codeString} + + ) : ( + {children} + )} + + ); + }, + }} + > + {content} + + ); +}; diff --git a/lib/components/content/styles/markdown-content.styles.ts b/lib/components/content/styles/markdown-content.styles.ts new file mode 100644 index 0000000..73caf91 --- /dev/null +++ b/lib/components/content/styles/markdown-content.styles.ts @@ -0,0 +1,38 @@ +import styled from "styled-components"; + +export const CodeContainer = styled.div` + background-color: #f9f9f9; + border-radius: 0.5rem; + border: 1px solid #e0e0e0; + + & > .code-block { + padding: 1rem !important; + border-radius: 0.5rem; + background-color: transparent !important; + } +`; + +export const CodeTitle = styled.div` + padding: 0.5rem 1rem; + background-color: transparent; + display: flex; + justify-content: space-between; + align-items: center; +`; + +export const CodeLanguage = styled.div` + line-height: 1; + background-color: transparent; + color: #5d5d5d; + font-size: 0.75rem; + user-select: none; +`; + +export const CodeCopyButton = styled.button<{ $isCopied?: boolean }>` + background-color: transparent; + color: #5d5d5d; + border: none; + cursor: ${({ $isCopied }) => ($isCopied ? "default" : "pointer")}; + display: flex; + align-items: center; +`; diff --git a/lib/components/icons/assistant-icon.tsx b/lib/components/icons/assistant-icon.tsx new file mode 100644 index 0000000..0169ad4 --- /dev/null +++ b/lib/components/icons/assistant-icon.tsx @@ -0,0 +1,23 @@ +import React from "react"; + +interface AssistantIconProps { + className?: string; +} + +export const AssistantIcon: React.FC = ({ className }) => ( + + + + + + + +); + +AssistantIcon.displayName = "AssistantIcon"; diff --git a/lib/components/icons/copied-icon.tsx b/lib/components/icons/copied-icon.tsx new file mode 100644 index 0000000..3063685 --- /dev/null +++ b/lib/components/icons/copied-icon.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import { IconContainer } from "./styles/icon-container.styles"; +interface CopiedIconProps { + className?: string; +} + +export const CopiedIcon: React.FC = ({ className }) => ( + + + Copied! + +); + +CopiedIcon.displayName = "CopiedIcon"; diff --git a/lib/components/icons/copy-icon.tsx b/lib/components/icons/copy-icon.tsx new file mode 100644 index 0000000..ac747d6 --- /dev/null +++ b/lib/components/icons/copy-icon.tsx @@ -0,0 +1,29 @@ +import React from "react"; +import { IconContainer } from "./styles/icon-container.styles"; + +interface CopyIconProps { + className?: string; +} + +export const CopyIcon: React.FC = ({ className }) => ( + + + + + Copy code + +); + +CopyIcon.displayName = "CopyIcon"; diff --git a/lib/components/icons/index.ts b/lib/components/icons/index.ts new file mode 100644 index 0000000..3437f25 --- /dev/null +++ b/lib/components/icons/index.ts @@ -0,0 +1,5 @@ +export * from "./menu-icon"; +export * from "./send-icon"; +export * from "./assistant-icon"; +export * from "./copy-icon"; +export * from "./copied-icon"; diff --git a/lib/components/icons/menu-icon.tsx b/lib/components/icons/menu-icon.tsx new file mode 100644 index 0000000..77655ef --- /dev/null +++ b/lib/components/icons/menu-icon.tsx @@ -0,0 +1,26 @@ +import React from "react"; + +interface MenuIconProps { + className?: string; +} + +export const MenuIcon: React.FC = ({ className }) => ( + + + +); + +MenuIcon.displayName = "MenuIcon"; diff --git a/lib/components/icons/send-icon.tsx b/lib/components/icons/send-icon.tsx new file mode 100644 index 0000000..26a7658 --- /dev/null +++ b/lib/components/icons/send-icon.tsx @@ -0,0 +1,26 @@ +import React from "react"; + +interface SendIconProps { + className?: string; +} + +export const SendIcon: React.FC = ({ className }) => ( + + + +); + +SendIcon.displayName = "SendIcon"; diff --git a/lib/components/icons/styles/icon-container.styles.ts b/lib/components/icons/styles/icon-container.styles.ts new file mode 100644 index 0000000..65ba82b --- /dev/null +++ b/lib/components/icons/styles/icon-container.styles.ts @@ -0,0 +1,8 @@ +import styled from "styled-components"; + +export const IconContainer = styled.div` + display: flex; + align-items: center; + gap: 0.25rem; + width: fit-content; +`; diff --git a/lib/components/message/assistant-loading.tsx b/lib/components/message/assistant-loading.tsx new file mode 100644 index 0000000..042d922 --- /dev/null +++ b/lib/components/message/assistant-loading.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import { + AssistantMessageWrapper, + AssistantMessageContent, + LoadingCircle, + AssistantIconWrapper, +} from "./styles/messages.styles"; +import { useChatProvider } from "../../provider"; +import { AssistantLoadingProps } from "../../types"; + +export const AssistantLoading: React.FC = ({ + className, +}) => { + const { + state: { assistantIcon }, + } = useChatProvider(); + return ( + + {assistantIcon} + + + + + ); +}; + +AssistantLoading.displayName = "AssistantLoading"; diff --git a/lib/components/message/assistant-message.tsx b/lib/components/message/assistant-message.tsx new file mode 100644 index 0000000..e472cdd --- /dev/null +++ b/lib/components/message/assistant-message.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import { AssistantMessageProps } from "../../types"; +import { + AssistantMessageContent, + AssistantMessageWrapper, + AssistantIconWrapper, +} from "./styles/messages.styles"; +import { useChatProvider } from "../../provider"; + +export const AssistantMessage: React.FC = ({ + children, + endContent, + className, +}) => { + const { + state: { assistantIcon }, + } = useChatProvider(); + + return ( + + {assistantIcon} + + {children} + {endContent} + + + ); +}; + +AssistantMessage.displayName = "AssistantMessage"; diff --git a/lib/components/message/hooks/useMessageInput.ts b/lib/components/message/hooks/useMessageInput.ts new file mode 100644 index 0000000..9f9520a --- /dev/null +++ b/lib/components/message/hooks/useMessageInput.ts @@ -0,0 +1,79 @@ +import { useState, useRef, ChangeEvent, KeyboardEvent, useEffect } from "react"; +import { useChatProvider } from "../../../provider"; + +type UseMessageInputReturn = { + message: string; + textareaRef: React.RefObject; + handleSend: () => void; + handleKeyDown: (e: KeyboardEvent) => void; + handleInput: (e: ChangeEvent) => void; +}; + +type UseMessageInputProps = { + onSend?: (message: string) => void; + value?: string; +}; + +export const useMessageInput = ({ + onSend, + value = "", +}: UseMessageInputProps): UseMessageInputReturn => { + const [message, setMessage] = useState(value); + const textareaRef = useRef(null); + const { + state: { withAutoFocus }, + } = useChatProvider(); + const initialAutoFocus = useRef(withAutoFocus); + + useEffect(() => { + setMessage(value); + }, [value]); + + // Handle initial focus only + useEffect(() => { + if (initialAutoFocus.current && textareaRef.current) { + textareaRef.current.focus(); + } + }, []); + + const handleSend = () => { + if (message.trim() && onSend) { + onSend(message.trim()); + setMessage(""); + } + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter") { + if (e.shiftKey) { + // Allow new line with Shift+Enter + return; + } + + e.preventDefault(); + if (message.trim()) { + handleSend(); + } + + if (textareaRef.current) { + textareaRef.current.style.height = "1.5rem"; + } + } + }; + + const handleInput = (e: ChangeEvent) => { + setMessage(e.target.value); + if (textareaRef.current) { + textareaRef.current.style.height = "1.5rem"; + textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`; + } + }; + + return { + message, + textareaRef, + handleSend, + handleKeyDown, + handleInput, + }; +}; diff --git a/lib/components/message/hooks/useScrollToBottom.ts b/lib/components/message/hooks/useScrollToBottom.ts new file mode 100644 index 0000000..b85c46d --- /dev/null +++ b/lib/components/message/hooks/useScrollToBottom.ts @@ -0,0 +1,11 @@ +import { useEffect, useRef } from "react"; + +export const useScrollToBottom = (dependency: any) => { + const bottomRef = useRef(null); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [dependency]); + + return bottomRef; +}; diff --git a/lib/components/message/message-input.tsx b/lib/components/message/message-input.tsx new file mode 100644 index 0000000..929ab01 --- /dev/null +++ b/lib/components/message/message-input.tsx @@ -0,0 +1,47 @@ +import React from "react"; +import { + MessageInputContainer, + MessageInputWrapper, + SendButton, + Textarea, +} from "./styles/message-input.styles"; +import { useMessageInput } from "./hooks/useMessageInput"; +import { SendIcon } from "../icons"; +import { MessageInputProps } from "../../types"; +import { useChatProvider } from "../../provider"; + +export const MessageInput: React.FC = ({ + placeholder = "Type a message...", + onSend, + sendIcon, + value = "", + className, +}) => { + const { message, textareaRef, handleInput, handleKeyDown, handleSend } = + useMessageInput({ + onSend: onSend || (() => {}), + value, + }); + const { + state: { isSidebarOpen }, + } = useChatProvider(); + + return ( + + +