├── .gitignore ├── .storybook ├── main.js └── preview.js ├── LICENSE ├── README.md ├── example ├── .npmignore ├── index.html ├── index.tsx ├── package.json └── tsconfig.json ├── package.json ├── pnpm-lock.yaml ├── src ├── components │ ├── conversation-header │ │ └── index.tsx │ ├── conversation-list │ │ └── index.tsx │ ├── conversation │ │ ├── index.tsx │ │ ├── profile.png │ │ └── profile.webp │ ├── loading │ │ ├── index.css │ │ └── index.tsx │ ├── main-container │ │ └── index.tsx │ ├── message-container │ │ └── index.tsx │ ├── message-header │ │ └── index.tsx │ ├── message-input │ │ └── index.tsx │ ├── message-list-background │ │ └── index.tsx │ ├── message-list │ │ └── index.tsx │ ├── message │ │ ├── borderController.tsx │ │ ├── incoming-message │ │ │ ├── index.tsx │ │ │ ├── profile.png │ │ │ └── profile.webp │ │ ├── index.tsx │ │ ├── media-content │ │ │ └── index.tsx │ │ ├── outgoing-message │ │ │ └── index.tsx │ │ ├── text-content │ │ │ └── index.tsx │ │ └── timestamp │ │ │ ├── index.tsx │ │ │ └── loading │ │ │ ├── index.css │ │ │ └── index.tsx │ ├── sidebar │ │ └── index.tsx │ └── typing-indicator │ │ ├── index.css │ │ └── index.tsx ├── contexts │ └── MinChatUIContext.tsx ├── hooks │ ├── useCheckIsMobile.tsx │ ├── useColorSet.tsx │ ├── useDetectScrollPosition.tsx │ └── useTypingListener.tsx ├── index.css ├── index.tsx ├── providers │ └── MinChatUiProvider.tsx ├── react-app-env.d.ts ├── types │ ├── ConversationType.tsx │ ├── MessageType.tsx │ └── UserType.tsx └── utils │ └── date-utils │ └── index.ts ├── stories ├── Conversation.stories.tsx ├── ConversationHeader.stories.tsx ├── ConversationList.stories.tsx ├── MainContainer.stories.tsx ├── Message.stories.tsx ├── MessageContainer.stories.tsx ├── MessageHeader.stories.tsx ├── MessageInput.stories.tsx ├── MessageList.stories.tsx ├── Sidebar.stories.tsx ├── TypingIndicator.stories.tsx └── data.tsx ├── test └── blah.test.tsx ├── tsconfig.json ├── tsdx.config.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .cache 5 | dist 6 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | stories: ['../stories/**/*.stories.@(ts|tsx|js|jsx)'], 3 | addons: ['@storybook/addon-links', '@storybook/addon-essentials'], 4 | // https://storybook.js.org/docs/react/configure/typescript#mainjs-configuration 5 | typescript: { 6 | check: true, // type-check stories during Storybook build 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | // https://storybook.js.org/docs/react/writing-stories/parameters#global-parameters 2 | export const parameters = { 3 | // https://storybook.js.org/docs/react/essentials/actions#automatically-matching-args 4 | actions: { argTypesRegex: '^on.*' }, 5 | }; 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Minchat 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | 5 |

6 | 7 | ## Overview 8 | 9 | Build your own chat UI with React components in just a few minutes using the **React Chat UI Kit from MinChat**. Our open-source toolkit accelerates the development of web chat applications with a flexible and powerful set of components. 10 | 11 | **React Chat UI makes chat UI development faster** 12 | 13 | ### Why Choose MinChat's React Chat UI? 14 | 15 | - **Speed up development**: Quickly integrate chat functionality into your app. 16 | - **Customizable components**: Tailor the UI to fit your needs. 17 | - **Open Source**: Benefit from community-driven improvements. 18 | 19 | # Demo 20 | 21 | [View Live Demo](https://minchat.io/demo) 22 | If you would like to see the React chat UI in action, you can visit the [live demo](https://minchat.io/demo). 23 | This demo allows you to test out the various features of the react chat components and see how it can be 24 | integrated into a real-world application. We encourage you to give it a try and see for yourself the power 25 | and flexibility of our chat UI. 26 | 27 | # Documentation 28 | 29 | You can view detailed documentation [here](https://react.minchat.io) 30 | 31 | # Install 32 | 33 | Install the component library using your preferred package manager: 34 | 35 | **Using npm.** 36 | 37 | ```bash 38 | npm install @minchat/react-chat-ui 39 | ``` 40 | 41 | **Using yarn.** 42 | 43 | ```bash 44 | yarn add @minchat/react-chat-ui 45 | ``` 46 | 47 | # Usage 48 | 49 | Here's a quick example to get you started: 50 | 51 | ```jsx 52 | import { 53 | MinChatUiProvider, 54 | MainContainer, 55 | MessageInput, 56 | MessageContainer, 57 | MessageList, 58 | MessageHeader 59 | } from "@minchat/react-chat-ui"; 60 | function App() { 61 | return ( 62 | 63 | 64 | 65 | 66 | 76 | 77 | 78 | 79 | 80 | ) 81 | } 82 | 83 | export default App 84 | 85 | 86 | ``` 87 | 88 | # Modify Component Colors 89 | 90 | You can modify the colors of each and every component, by passing a colorSet prop to the `MinChatUiProvider` which defines the colors to use. ommited colors will use the default theme. 91 | 92 | ```jsx 93 | const myColorSet = { 94 | // input 95 | "--input-background-color": "#FF0000", 96 | "--input-text-color": "#fff", 97 | "--input-element-color": "rgb(0, 0, 255)", 98 | "--input-attach-color": "#fff", 99 | "--input-send-color": "#fff", 100 | "--input-placeholder-color": "rgb(255, 255, 255)", 101 | 102 | // message header 103 | "--message-header-background-color": "#FF0000", 104 | "--message-header-text-color": "#fff", 105 | "--message-header-last-active-color": "rgb(0, 0, 255)", 106 | "--message-header-back-color": "rgb(255, 255, 255)", 107 | 108 | // chat list header 109 | "--chatlist-header-background-color": "#FF0000", 110 | "--chatlist-header-text-color": "rgb(255, 255, 255)", 111 | "--chatlist-header-divider-color": "rgb(0, 128, 0)", 112 | 113 | //chatlist 114 | "--chatlist-background-color": "rgb(255, 192, 203)", 115 | "--no-conversation-text-color": "rgb(255, 255, 255)", 116 | 117 | //chat item 118 | "--chatitem-background-color": "rgb(0, 0, 255)", 119 | "--chatitem-selected-background-color": "rgb(255, 255, 0)", 120 | "--chatitem-title-text-color": "#FF0000", 121 | "--chatitem-content-text-color": "#FF0000", 122 | "--chatitem-hover-color": "#FF0000", 123 | 124 | //main container 125 | "--container-background-color": "rgb(255, 192, 203)", 126 | 127 | //loader 128 | "--loader-color": "rgb(0, 128, 0)", 129 | 130 | //message list 131 | "--messagelist-background-color": "rgb(0, 0, 255)", 132 | "--no-message-text-color": "rgb(255, 255, 255)", 133 | 134 | // incoming message 135 | "--incoming-message-text-color": "rgb(255, 255, 255)", 136 | "--incoming-message-name-text-color": "rgb(255, 255, 255)", 137 | "--incoming-message-background-color": "rgb(0, 128, 0)", 138 | "--incoming-message-timestamp-color": "rgb(255, 255, 255)", 139 | "--incoming-message-link-color": "#FF0000", 140 | 141 | //outgoing message 142 | "--outgoing-message-text-color": "#FF0000", 143 | "--outgoing-message-background-color": "rgb(255, 255, 0)", 144 | "--outgoing-message-timestamp-color": "#FF0000", 145 | "--outgoing-message-checkmark-color": "#FF0000", 146 | "--outgoing-message-loader-color": "#FF0000", 147 | "--outgoing-message-link-color": "rgb(0, 128, 0)", 148 | } 149 | 150 | function App() { 151 | return ( 152 | 155 | 156 | {/** rest of your code*/} 157 | 158 | 159 | ) 160 | } 161 | ``` 162 | 163 | # Typescript 164 | 165 | Our library is written in TypeScript, offering type safety and easy integration into both TypeScript and JavaScript projects. 166 | 167 | # Show your support 168 | If you love our library, consider starring ⭐ our GitHub repository! 169 | 170 | # Community and support 171 | For articles, tutorials, and a full guide, visit our [website](https://minchat.io/blog). Connect with other developers, share ideas, and get help. 172 | 173 | # Website 174 | 175 | [https://minchat.io](https://minchat.io) 176 | 177 | Unleash the power of seamless chat functionality with MinChat's [React Chat API!](https://minchat.io) Say goodbye to backend worries and hello to effortless integration. Get started today and save months of development time. Build a full-fledged React chat application in just minutes, not months! 178 | 179 | # License 180 | 181 | React Chat UI Kit is licensed under [MIT](https://github.com/MinChatHQ/react-chat-ui/blob/master/LICENSE). Feel free to use it in your projects. 182 | 183 | --- 184 | 185 | Ready to build an amazing chat experience? Visit MinChat.io to get started and unleash the power of seamless chat functionality in your application today! 186 | -------------------------------------------------------------------------------- /example/.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cache 3 | dist -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Playground 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /example/index.tsx: -------------------------------------------------------------------------------- 1 | import 'react-app-polyfill/ie11'; 2 | import * as React from 'react'; 3 | import * as ReactDOM from 'react-dom'; 4 | import { Thing } from '../.'; 5 | 6 | const App = () => { 7 | return ( 8 |
9 | 10 |
11 | ); 12 | }; 13 | 14 | ReactDOM.render(, document.getElementById('root')); 15 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "start": "parcel index.html", 8 | "build": "parcel build index.html" 9 | }, 10 | "dependencies": { 11 | "react-app-polyfill": "^1.0.0" 12 | }, 13 | "alias": { 14 | "react": "../node_modules/react", 15 | "react-dom": "../node_modules/react-dom/profiling", 16 | "scheduler/tracing": "../node_modules/scheduler/tracing-profiling" 17 | }, 18 | "devDependencies": { 19 | "@types/react": "^16.9.11", 20 | "@types/react-dom": "^16.8.4", 21 | "parcel": "^1.12.3", 22 | "typescript": "^3.4.5" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": false, 4 | "target": "es5", 5 | "module": "commonjs", 6 | "jsx": "react", 7 | "moduleResolution": "node", 8 | "noImplicitAny": false, 9 | "noUnusedLocals": false, 10 | "noUnusedParameters": false, 11 | "removeComments": true, 12 | "strictNullChecks": true, 13 | "preserveConstEnums": true, 14 | "sourceMap": true, 15 | "lib": ["es2015", "es2016", "dom"], 16 | "types": ["node"] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.16.2", 3 | "license": "MIT", 4 | "main": "dist/index.js", 5 | "typings": "dist/index.d.ts", 6 | "files": [ 7 | "dist", 8 | "src" 9 | ], 10 | "engines": { 11 | "node": ">=10" 12 | }, 13 | "scripts": { 14 | "start": "tsdx watch", 15 | "build": "tsdx build", 16 | "test": "tsdx test --passWithNoTests", 17 | "lint": "tsdx lint", 18 | "prepare": "tsdx build", 19 | "size": "size-limit", 20 | "analyze": "size-limit --why", 21 | "storybook": "start-storybook -p 6006", 22 | "build-storybook": "build-storybook" 23 | }, 24 | "peerDependencies": { 25 | "react": ">=16" 26 | }, 27 | "husky": { 28 | "hooks": { 29 | "pre-commit": "tsdx lint" 30 | } 31 | }, 32 | "prettier": { 33 | "printWidth": 80, 34 | "semi": true, 35 | "singleQuote": true, 36 | "trailingComma": "es5" 37 | }, 38 | "name": "@minchat/react-chat-ui", 39 | "author": "MinChat", 40 | "module": "dist/react-chat-ui.esm.js", 41 | "size-limit": [ 42 | { 43 | "path": "dist/react-chat-ui.cjs.production.min.js", 44 | "limit": "10 KB" 45 | }, 46 | { 47 | "path": "dist/react-chat-ui.esm.js", 48 | "limit": "10 KB" 49 | } 50 | ], 51 | "devDependencies": { 52 | "@babel/core": "^7.23.0", 53 | "@rollup/plugin-image": "^3.0.3", 54 | "@size-limit/preset-small-lib": "^8.2.6", 55 | "@storybook/addon-essentials": "^6.5.16", 56 | "@storybook/addon-info": "^5.3.21", 57 | "@storybook/addon-links": "^6.5.16", 58 | "@storybook/addons": "^6.5.16", 59 | "@storybook/react": "^6.5.16", 60 | "@types/react": "^18.2.25", 61 | "@types/react-dom": "^18.2.11", 62 | "@types/resize-observer-browser": "^0.1.8", 63 | "babel-loader": "^9.1.3", 64 | "husky": "^8.0.3", 65 | "react": "^18.2.0", 66 | "react-dom": "^18.2.0", 67 | "react-is": "^18.2.0", 68 | "size-limit": "^8.2.6", 69 | "tsdx": "^0.14.1", 70 | "tslib": "^2.6.2", 71 | "typescript": "^4.9.5" 72 | }, 73 | "dependencies": { 74 | "@types/styled-components": "^5.1.28", 75 | "postcss": "^8.4.31", 76 | "rollup-plugin-import-css": "^3.3.4", 77 | "rollup-plugin-postcss": "^4.0.2", 78 | "rollup-plugin-scss": "^4.0.0", 79 | "styled-components": "^5.3.11" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/components/conversation-header/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import useColorSet from '../../hooks/useColorSet' 4 | 5 | export type Props = { 6 | showHeader?: boolean 7 | loading?: boolean 8 | } 9 | 10 | const Container = styled.div<{ 11 | backgroundColor?: string, 12 | dividerColor?: string 13 | 14 | }>` 15 | height:56px; 16 | padding:0px; 17 | background-color:${({ backgroundColor }) => backgroundColor || '#ffffff'}; 18 | 19 | ${({ dividerColor }) => dividerColor ? 20 | `border-bottom: 1px solid ${dividerColor};` 21 | : 22 | 'box-shadow:0px 1px 0px rgba(0, 0, 0, 0.07999999821186066);' 23 | 24 | } 25 | 26 | position:absolute; 27 | top: 0px; 28 | left: 0px; 29 | right: 0px; 30 | z-index: 2; 31 | display: flex; 32 | align-items: center; 33 | 34 | ` 35 | 36 | const ChatTitle = styled.div<{ 37 | color?: string 38 | }>` 39 | text-align:center; 40 | vertical-align:text-top; 41 | font-size:16px; 42 | line-height:auto; 43 | color:${({ color }) => color || '#000000'}; 44 | position:absolute; 45 | width: 100%; 46 | font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 47 | user-select: none; 48 | 49 | ` 50 | const HeaderPlaceholder = styled.div` 51 | background-color: transparent; 52 | height: 56px; 53 | position: absolute; 54 | top: 0px; 55 | left: 0px; 56 | right: 0px; 57 | z-index: 1; 58 | box-sizing: border-box; 59 | ` 60 | 61 | export default function ConversationHeader({ loading, showHeader = true }: Props) { 62 | 63 | const backgroundColor = useColorSet("--chatlist-header-background-color") 64 | const textColor = useColorSet("--chatlist-header-text-color") 65 | const dividerColor = useColorSet("--chatlist-header-divider-color") 66 | 67 | return ( 68 | <> 69 | { 70 | loading ? 71 |
72 | : 73 | (!showHeader ? 74 | 75 | : 76 | 79 | 80 | Messages 83 | 84 | 85 | ) 86 | } 87 | 88 | ) 89 | } -------------------------------------------------------------------------------- /src/components/conversation-list/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useRef } from 'react'; 2 | import styled from 'styled-components'; 3 | import Loading from '../loading'; 4 | import ConversationType from '../../types/ConversationType'; 5 | import Conversation from '../conversation'; 6 | import useColorSet from '../../hooks/useColorSet'; 7 | import MinChatUIContext from '../../contexts/MinChatUIContext'; 8 | 9 | export interface Props { 10 | onConversationClick?: (index: number) => void; 11 | conversations?: ConversationType[]; 12 | loading?: boolean; 13 | selectedConversationId?: string; 14 | onScrollToBottom?: () => void; 15 | themeColor?: string; 16 | mobileView?: boolean; 17 | /** 18 | * the current user on the chat ui 19 | */ 20 | currentUserId?: string; 21 | renderCustomConversationitem?: (conversation: ConversationType, index: number) => React.ReactNode 22 | customLoaderComponent?: React.ReactNode 23 | customEmptyConversationsComponent?: React.ReactNode 24 | 25 | } 26 | 27 | const ScrollContainer = styled.div<{ 28 | loading?: boolean, 29 | backgroundColor?: string 30 | }>` 31 | position: relative; 32 | height: 100%; 33 | width: 100%; 34 | padding-top: ${({ loading }) => loading ? '0px' : '56px'}; 35 | box-sizing: border-box; 36 | overflow-y: auto; 37 | max-height: 100vh; 38 | overflow-x: hidden; 39 | background-color: ${({ backgroundColor }) => backgroundColor || '#ffffff'}; 40 | scrollbar-width: none; /* Firefox */ 41 | -ms-overflow-style: none; /* Internet Explorer 10+ */ 42 | ::-webkit-scrollbar { /* WebKit */ 43 | width: 0; 44 | height: 0; 45 | } 46 | `; 47 | 48 | const Container = styled.div` 49 | height: 100%; 50 | position: relative; 51 | max-height: 100vh; 52 | overflow: hidden; 53 | `; 54 | 55 | // const SearchElement = styled.input` 56 | // width:100%; 57 | // height:40px; 58 | // padding:0px; 59 | // position:relative; 60 | // background-color:#e5e7eb; 61 | // border-radius:20px; 62 | // border:1px solid #ecebeb; 63 | // font-size:14px; 64 | // font-family:SF Pro Text; 65 | // line-height:auto; 66 | // padding-left: 16px; 67 | // text-align:left; 68 | // vertical-align:text-top; 69 | // margin-right: 56px; 70 | // &:focus{ 71 | // outline: none; 72 | 73 | // } 74 | // ` 75 | 76 | const NoChatsTextContainer = styled.div<{ 77 | color?: string 78 | }>` 79 | color: ${({ color }) => color || 'rgba(0, 0, 0, 0.36)'}; 80 | font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 81 | 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 82 | 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; 83 | font-size: 14px; 84 | display: flex; 85 | justify-content: center; 86 | align-items: center; 87 | height: 100px; 88 | `; 89 | 90 | 91 | const LoadingContainer = styled.div` 92 | width: 100%; 93 | height: 100%; 94 | display: flex; 95 | justify-content: center; 96 | align-items: center; 97 | z-index: 1; 98 | position: relative; 99 | ` 100 | 101 | export default function ConversationList({ 102 | conversations, 103 | loading, 104 | onConversationClick, 105 | selectedConversationId, 106 | onScrollToBottom, 107 | currentUserId, 108 | renderCustomConversationitem, 109 | customLoaderComponent, 110 | customEmptyConversationsComponent 111 | }: Props) { 112 | const scrollContainerRef = useRef(); 113 | 114 | const backgroundColor = useColorSet("--chatlist-background-color") 115 | const noConversation = useColorSet("--no-conversation-text-color") 116 | 117 | const { themeColor } = useContext(MinChatUIContext) 118 | 119 | 120 | return ( 121 | 122 | { 126 | //detect when scrolled to bottom 127 | const bottom = 128 | scrollContainerRef.current.scrollHeight - 129 | scrollContainerRef.current.scrollTop === 130 | scrollContainerRef.current.clientHeight; 131 | if (bottom) { 132 | onScrollToBottom && onScrollToBottom(); 133 | } 134 | }} 135 | ref={scrollContainerRef} 136 | > 137 | {loading ? 138 | 139 | {customLoaderComponent ? 140 | customLoaderComponent : 141 | } 142 | : ( 143 | <> 144 | {conversations && conversations.length <= 0 && ( 145 | customEmptyConversationsComponent ? 146 | customEmptyConversationsComponent : 147 | 148 |

No conversation started...

149 |
150 | )} 151 | 152 | {conversations && 153 | conversations.map((conversation, index) => ( 154 | (renderCustomConversationitem && renderCustomConversationitem(conversation, index)) ? 155 | renderCustomConversationitem(conversation, index) 156 | : 157 | onConversationClick && onConversationClick(index)} 159 | key={index} 160 | title={conversation.title} 161 | lastMessage={conversation.lastMessage} 162 | avatar={conversation.avatar} 163 | selected={selectedConversationId === conversation.id} 164 | currentUserId={currentUserId} 165 | unread={conversation.unread} 166 | /> 167 | ))} 168 | 169 | )} 170 |
171 |
172 | ); 173 | } 174 | -------------------------------------------------------------------------------- /src/components/conversation/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect, useRef, useState } from 'react'; 2 | import styled from 'styled-components'; 3 | import MessageType from '../../types/MessageType'; 4 | import placeholderProfilePNG from './profile.png'; 5 | import { calculateTimeAgo } from '../../utils/date-utils'; 6 | import useColorSet from '../../hooks/useColorSet'; 7 | import MinChatUIContext from '../../contexts/MinChatUIContext'; 8 | 9 | export type Props = { 10 | title: string; 11 | lastMessage?: MessageType; 12 | unread?: boolean, 13 | avatar?: string; 14 | onClick: () => void; 15 | selected?: boolean; 16 | /** 17 | * the current user on the chat ui 18 | */ 19 | currentUserId?: string; 20 | }; 21 | const Container = styled.div` 22 | width: 100%; 23 | height: 88px; 24 | position: relative; 25 | margin-top: 1px; 26 | cursor: pointer; 27 | display: flex; 28 | align-items: center; 29 | box-sizing: border-box; 30 | user-select: none; 31 | 32 | `; 33 | const ContentContainer = styled.div` 34 | display: flex; 35 | position: relative; 36 | flex-direction: row; 37 | align-items: center; 38 | padding-left: 8px; 39 | width: 100%; 40 | height: 100%; 41 | box-sizing: border-box; 42 | `; 43 | 44 | const Background = styled.div<{ 45 | themeColor: string 46 | selected?: boolean 47 | hoverColor?: string 48 | backgroundColor?: string 49 | selectedBackgroundColor?: string 50 | }>` 51 | position: absolute; 52 | width: 100%; 53 | height: 100%; 54 | background-color: ${({ themeColor, selected, backgroundColor, selectedBackgroundColor }) => 55 | selected ? (selectedBackgroundColor || themeColor) : (backgroundColor || '#ffffff')}; 56 | opacity: 0.2; 57 | z-index: 1; 58 | transition: all 0.3s ease-in-out; 59 | 60 | &:hover{ 61 | ${({ selected }) => (!selected ? 'opacity: 0.09;' : '')} 62 | background-color: ${({ themeColor, hoverColor }) => hoverColor || themeColor}; 63 | 64 | } 65 | `; 66 | 67 | const Name = styled.div<{ 68 | unread?: boolean, 69 | titleTextColor?: string 70 | }>` 71 | font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 72 | 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 73 | 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; 74 | text-align: left; 75 | vertical-align: text-top; 76 | font-size: 14px; 77 | line-height: auto; 78 | position: relative; 79 | z-index: 1; 80 | color: ${({ titleTextColor }) => titleTextColor || '#000000'}; 81 | 82 | ${({ unread }) => 83 | unread 84 | ? ` 85 | font-weight: 700; 86 | ` 87 | : ''} 88 | `; 89 | 90 | const NameContainer = styled.div` 91 | display: flex; 92 | width: 100%; 93 | justify-content: space-between; 94 | ` 95 | 96 | const Timestamp = styled.div<{ 97 | color?: string, 98 | unread?: boolean 99 | }>` 100 | text-align:right; 101 | vertical-align:text-top; 102 | font-size:12px; 103 | margin-left: 6px; 104 | margin-top:2px; 105 | margin-right:2px; 106 | align-self:flex-start; 107 | line-height:auto; 108 | font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 109 | 110 | ${({ unread, color }) => 111 | unread 112 | ? ` 113 | color: ${color || 'black'} ; 114 | font-weight: 600; 115 | ` : ` 116 | color: ${color || 'rgb(75 85 99)'}; 117 | `} 118 | ` 119 | 120 | // const LastMessageUser = styled.div<{ seen?: boolean }>` 121 | // font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 122 | // text-align:left; 123 | // vertical-align:text-top; 124 | // font-size:12px; 125 | // align-self:flex-start; 126 | // position:relative; 127 | // color:#7a7a7a; 128 | // white-space: nowrap; 129 | // text-overflow: ellipsis; 130 | 131 | // ${({ seen }) => !seen ? ` 132 | // color: black; 133 | // font-weight: 600; 134 | // ` : ''} 135 | 136 | // ` 137 | 138 | const MessageComponent = styled.div<{ 139 | unread?: boolean; 140 | width: number; 141 | media?: boolean; 142 | color?: string 143 | }>` 144 | font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 145 | 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 146 | 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; 147 | text-align: left; 148 | vertical-align: text-top; 149 | font-size: 12px; 150 | align-self: flex-start; 151 | position: relative; 152 | color: ${({ color }) => color || '#7a7a7a'}; 153 | 154 | overflow: hidden; 155 | white-space: nowrap; 156 | text-overflow: ellipsis; 157 | box-sizing: border-box; 158 | max-width: ${({ width }) => width}px; 159 | display: flex; 160 | margin-top: 4px; 161 | 162 | ${({ unread, color }) => 163 | unread 164 | ? ` 165 | color: ${color || 'black'} ; 166 | font-weight: 600; 167 | ` : ''} 168 | 169 | `; 170 | 171 | 172 | const TextContainer = styled.div` 173 | position: relative; 174 | height: 100%; 175 | width: 100%; 176 | padding-right: 20px; 177 | border-bottom: 1px solid rgba(0, 0, 0, 0.04); 178 | display: flex; 179 | justify-content: center; 180 | flex-direction: column; 181 | `; 182 | 183 | const DisplayPictureContainer = styled.div` 184 | width: 58px; 185 | height: 58px; 186 | margin-right: 12px; 187 | box-sizing: border-box; 188 | `; 189 | 190 | const DisplayPicture = styled.img` 191 | width: 58px; 192 | height: 58px; 193 | border-radius: 9999px; 194 | box-sizing: border-box; 195 | border-width: 2px; 196 | border-color: rgb(255 255 255); 197 | object-fit: cover; 198 | z-index: 1; 199 | position: relative; 200 | `; 201 | 202 | const MediaIconContainer = styled.div` 203 | width: 16px; 204 | height: 16px; 205 | margin-left: 3px; 206 | `; 207 | 208 | const MediaContainer = styled.div` 209 | display: flex; 210 | align-items: center; 211 | flex-direction: row; 212 | justify-content: center; 213 | gap: 4px; 214 | margin-left: 4px; 215 | ` 216 | 217 | export default function Conversation({ 218 | title, 219 | lastMessage, 220 | onClick, 221 | avatar, 222 | selected = false, 223 | currentUserId, 224 | unread 225 | }: Props) { 226 | const [containerWidth, setContainerWidth] = useState(0); 227 | 228 | const [usedAvatar, setUsedAvatar] = React.useState( 229 | placeholderProfilePNG 230 | ); 231 | 232 | const [dateSent, setDateSent] = useState() 233 | const [intervalId, setIntervalId] = useState() 234 | 235 | const { themeColor } = useContext(MinChatUIContext) 236 | 237 | useEffect(() => { 238 | function updateDateSent() { 239 | if (lastMessage?.createdAt) { 240 | setDateSent(calculateTimeAgo(new Date(lastMessage.createdAt))) 241 | } 242 | } 243 | 244 | updateDateSent() 245 | clearInterval(intervalId) 246 | 247 | const id = setInterval(() => updateDateSent(), 60_000) 248 | setIntervalId(id) 249 | 250 | return () => { 251 | if (intervalId) { 252 | clearInterval(intervalId); 253 | setIntervalId(null); // Reset intervalId after clearing 254 | } 255 | 256 | }; 257 | }, [lastMessage]) 258 | 259 | 260 | useEffect(() => { 261 | window.addEventListener('resize', () => { 262 | calculateContainerWidth(); 263 | }); 264 | }, []); 265 | 266 | useEffect(() => { 267 | if (avatar && avatar.trim().length > 0) { 268 | setUsedAvatar(avatar); 269 | } 270 | }, [avatar]); 271 | 272 | const containerRef = useRef(null); 273 | 274 | useEffect(() => { 275 | calculateContainerWidth(); 276 | }, [containerRef]); 277 | 278 | 279 | const backgroundColor = useColorSet("--chatitem-background-color") 280 | const titleTextColor = useColorSet("--chatitem-title-text-color") 281 | const contentTextColor = useColorSet("--chatitem-content-text-color") 282 | const hoverColor = useColorSet("--chatitem-hover-color") 283 | const selectedBackgroundColor = useColorSet("--chatitem-selected-background-color") 284 | 285 | 286 | /** 287 | * 288 | */ 289 | const calculateContainerWidth = () => { 290 | if (containerRef && containerRef.current) { 291 | setContainerWidth(containerRef.current.clientWidth); 292 | } 293 | }; 294 | 295 | 296 | const getMediaIcon = () => { 297 | 298 | switch (lastMessage?.media?.type) { 299 | case "image": 300 | return 308 | 309 | 310 | 311 | case "video": 312 | return 321 | 322 | 323 | case "gif": 324 | return 332 | 333 | 334 | 335 | 336 | default: 337 | return 344 | 345 | 346 | 347 | } 348 | } 349 | 350 | const getMediaText = () => { 351 | 352 | switch (lastMessage?.media?.type) { 353 | case "image": 354 | return "Image" 355 | case "video": 356 | return lastMessage?.media?.name ? lastMessage?.media?.name : "Video" 357 | case "gif": 358 | return lastMessage?.media?.name ? lastMessage?.media?.name : "Gif" 359 | default: 360 | return lastMessage?.media?.name ? lastMessage?.media?.name : "File" 361 | } 362 | } 363 | 364 | return ( 365 | 366 | 371 | 372 | 373 |
374 | 375 | { 377 | setUsedAvatar(placeholderProfilePNG); 378 | }} 379 | src={usedAvatar} 380 | /> 381 | 382 |
383 | 384 | 385 | 386 | 387 | {title} 390 | 391 | {dateSent} 394 | 395 | 396 | 401 | {lastMessage?.user.id === currentUserId 402 | ? 'You' 403 | : lastMessage?.user.name} 404 | :{' '} 405 | {lastMessage?.media ? ( 406 | 407 | 408 | {getMediaIcon()} 409 | 410 | {getMediaText()} 411 | 412 | ) : ( 413 |
415 | )} 416 |
417 |
418 |
419 |
420 | ); 421 | } 422 | -------------------------------------------------------------------------------- /src/components/conversation/profile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MinChatHQ/react-chat-ui/498328841aa94938f1cfb2eaeb6ab200a2fb158f/src/components/conversation/profile.png -------------------------------------------------------------------------------- /src/components/conversation/profile.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MinChatHQ/react-chat-ui/498328841aa94938f1cfb2eaeb6ab200a2fb158f/src/components/conversation/profile.webp -------------------------------------------------------------------------------- /src/components/loading/index.css: -------------------------------------------------------------------------------- 1 | .lds-ring { 2 | display: inline-block; 3 | width: 48px; 4 | height: 48px; 5 | position: absolute; 6 | top: 50%; 7 | left: 50%; 8 | transform: translate(-50%, -50%); 9 | } 10 | 11 | .lds-ring div:nth-child(1) { 12 | animation-delay: -0.45s; 13 | } 14 | 15 | .lds-ring div:nth-child(2) { 16 | animation-delay: -0.3s; 17 | } 18 | 19 | .lds-ring div:nth-child(3) { 20 | animation-delay: -0.15s; 21 | } 22 | 23 | @keyframes lds-ring { 24 | 0% { 25 | transform: rotate(0deg); 26 | } 27 | 28 | 100% { 29 | transform: rotate(360deg); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/components/loading/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import './index.css'; 4 | import useColorSet from '../../hooks/useColorSet'; 5 | 6 | type Props = { 7 | themeColor?: string; 8 | }; 9 | 10 | const Container = styled.div` 11 | height: 100%; 12 | width: 100%; 13 | display: flex; 14 | justify-content: center; 15 | align-items: center; 16 | box-sizing: border-box; 17 | position: relative; 18 | `; 19 | 20 | const InternalDiv = styled.div<{ themeColor?: string }>` 21 | box-sizing: border-box; 22 | width: 42px; 23 | height: 42px; 24 | margin: 8px; 25 | position: absolute; 26 | border: 6px solid #fff; 27 | border-radius: 50%; 28 | animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite; 29 | border-color: ${({ themeColor }) => themeColor} transparent transparent 30 | transparent; 31 | box-sizing: border-box; 32 | `; 33 | 34 | export default function Loading({ themeColor }: Props) { 35 | 36 | const color = useColorSet("--loader-color") 37 | 38 | return ( 39 | 40 |
41 | 42 | 43 | 44 | 45 |
46 |
47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /src/components/main-container/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { } from 'react' 2 | import styled from 'styled-components' 3 | import "../../index.css" 4 | import useColorSet from '../../hooks/useColorSet' 5 | 6 | 7 | const Container = styled.div<{ 8 | backgroundColor?: string 9 | }>` 10 | height: 100%; 11 | position: relative; 12 | display: flex; 13 | width: 100%; 14 | flex-direction: row; 15 | ${({ backgroundColor }) => backgroundColor ? `background-color: ${backgroundColor};` : ""} 16 | ` 17 | 18 | 19 | export interface Props { 20 | style?: React.CSSProperties | undefined 21 | children?: React.ReactNode 22 | } 23 | 24 | 25 | export default function MainContainer({ 26 | children, 27 | style 28 | }: Props) { 29 | 30 | const backgroundColor = useColorSet("--container-background-color") 31 | 32 | return ( 33 | 36 | {children} 37 | 38 | ) 39 | } -------------------------------------------------------------------------------- /src/components/message-container/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | 4 | export type Props = { 5 | children: React.ReactNode 6 | } 7 | 8 | const Container = styled.div` 9 | position: relative; 10 | width: 100%; 11 | display: flex; 12 | flex-direction: column; 13 | height: 100%; 14 | box-sizing: border-box; 15 | ` 16 | 17 | 18 | export default function MessageContainer({ 19 | children 20 | }: Props) { 21 | return ( 22 | 23 | {children} 24 | 25 | ) 26 | } -------------------------------------------------------------------------------- /src/components/message-header/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | import styled from "styled-components" 3 | import { calculateLastSeen } from '../../utils/date-utils' 4 | import useColorSet from '../../hooks/useColorSet' 5 | 6 | export type Props = { 7 | onBack?: () => void 8 | children?: React.ReactNode 9 | showBack?: boolean 10 | mobileView?: boolean 11 | lastActive?: Date 12 | } 13 | 14 | 15 | const Container = styled.div<{ mobile?: boolean }>` 16 | position: relative; 17 | width: 100%; 18 | height: 64px; 19 | display: flex; 20 | box-sizing: border-box; 21 | 22 | ${({ mobile }) => !mobile ? ` 23 | padding-right: 12px; 24 | ` : 25 | ` 26 | `} 27 | ` 28 | const InnerContainer = styled.div<{ 29 | backgroundColor?: string 30 | }>` 31 | background-color:${({ backgroundColor }) => backgroundColor || '#f3f4f6'}; 32 | border-top-left-radius: 16px; 33 | border-top-right-radius: 16px; 34 | height:100%; 35 | padding:0px; 36 | box-shadow:0px 1px 0px rgba(0, 0, 0, 0.07999999821186066); 37 | position:relative; 38 | width:100%; 39 | z-index: 1; 40 | display: flex; 41 | align-items: center; 42 | box-sizing: border-box; 43 | ` 44 | 45 | const HeadingContainer = styled.div` 46 | position:absolute; 47 | width: 100%; 48 | 49 | ` 50 | 51 | const ChatTitle = styled.div<{ 52 | color?: string 53 | }>` 54 | text-align:center; 55 | vertical-align:text-top; 56 | font-size:16px; 57 | line-height:auto; 58 | color:${({ color }) => color || '#000000'}; 59 | user-select: none; 60 | position: relative; 61 | width: 100%; 62 | font-weight: 500; 63 | font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 64 | 65 | ` 66 | 67 | const LastSeenText = styled.div<{ 68 | color?: string 69 | }>` 70 | text-align:center; 71 | vertical-align:text-top; 72 | font-size:10px; 73 | line-height:auto; 74 | color:${({ color }) => color || 'rgb(107 114 128)'}; 75 | user-select: none; 76 | position: relative; 77 | width: 100%; 78 | font-weight: 100; 79 | font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 80 | 81 | ` 82 | 83 | const BackContainer = styled.div` 84 | cursor: pointer; 85 | height: 100%; 86 | padding-left: 8px; 87 | padding-right: 8px; 88 | display: flex; 89 | align-items: center; 90 | justify-content: center; 91 | width: 38px; 92 | z-index: 1; 93 | box-sizing: border-box; 94 | 95 | ` 96 | 97 | const BackIcon = styled.svg<{ 98 | color?: string 99 | }>` 100 | padding:0px; 101 | cursor: pointer; 102 | box-sizing: border-box; 103 | 104 | color: ${({ color }) => color ? ` ${color}` : 'black'}; 105 | ` 106 | export default function MessageHeader({ 107 | onBack, 108 | children, 109 | showBack = true, 110 | mobileView, 111 | lastActive 112 | }: Props) { 113 | 114 | const [lastSeen, setLastSeen] = useState() 115 | const [intervalId, setIntervalId] = useState() 116 | 117 | useEffect(() => { 118 | /** 119 | * 120 | */ 121 | function updateLastSeen() { 122 | if (lastActive) { 123 | setLastSeen(calculateLastSeen(lastActive)) 124 | } else { 125 | setLastSeen(undefined) 126 | } 127 | } 128 | 129 | 130 | updateLastSeen() 131 | 132 | clearInterval(intervalId) 133 | if (lastActive) { 134 | const id = setInterval(() => updateLastSeen(), 5_000) 135 | setIntervalId(id) 136 | } 137 | 138 | 139 | return () => { 140 | if (intervalId) { 141 | clearInterval(intervalId); 142 | setIntervalId(null); // Reset intervalId after clearing 143 | } 144 | }; 145 | }, [lastActive]) 146 | 147 | const backgroundColor = useColorSet("--message-header-background-color") 148 | const textColor = useColorSet("--message-header-text-color") 149 | const lastActiveColor = useColorSet("--message-header-last-active-color") 150 | const backColor = useColorSet("--message-header-back-color") 151 | 152 | return ( 153 | 155 | 156 | 158 | 159 | {showBack && 162 | 166 | 171 | 172 | 173 | } 174 | 175 | 176 | {children} 179 | 180 | {lastSeen && 181 | {lastSeen} 184 | } 185 | 186 | 187 | 188 | {/*
189 | 190 |
*/} 191 |
192 |
193 | 194 | ) 195 | } -------------------------------------------------------------------------------- /src/components/message-input/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useRef, useState } from 'react' 2 | import styled from 'styled-components' 3 | import useTypingListener from '../../hooks/useTypingListener' 4 | import useColorSet from '../../hooks/useColorSet' 5 | import MinChatUIContext from '../../contexts/MinChatUIContext' 6 | 7 | export type Props = { 8 | onSendMessage?: (text: string) => void 9 | mobileView?: boolean 10 | onStartTyping?: () => void 11 | onEndTyping?: () => void 12 | showAttachButton?: boolean 13 | onAttachClick?: () => void 14 | placeholder?: string 15 | disabled?: boolean 16 | showSendButton: boolean 17 | 18 | onKeyDown?: React.KeyboardEventHandler | undefined 19 | onKeyUp?: React.KeyboardEventHandler | undefined 20 | 21 | 22 | } 23 | 24 | const Container = styled.div<{ 25 | mobile?: boolean, 26 | }>` 27 | box-sizing: border-box; 28 | position: relative; 29 | width: 100%; 30 | display: flex; 31 | 32 | 33 | ${({ mobile }) => mobile ? ` 34 | padding-right: 0px; 35 | 36 | `: 37 | ` 38 | padding-right: 12px; 39 | `} 40 | ` 41 | const Form = styled.form<{ 42 | backgroundColor?: string, 43 | borderColor?: string, 44 | }>` 45 | background-color:${({ backgroundColor }) => backgroundColor || "#f3f4f6"}; 46 | padding-top: 8px; 47 | padding-bottom: 8px; 48 | border-bottom-right-radius: 16px; 49 | border-bottom-left-radius: 16px; 50 | box-shadow:0px -1px 0px rgba(0, 0, 0, 0.07999999821186066); 51 | position: relative; 52 | width: 100%; 53 | display: flex; 54 | align-items: end; 55 | box-sizing: border-box; 56 | ` 57 | 58 | const InputContainer = styled.div` 59 | width: 100%; 60 | display: flex; 61 | align-items: center; 62 | justify-content: center; 63 | position: relative; 64 | box-sizing: border-box; 65 | ` 66 | 67 | const InputBackground = styled.div<{ 68 | showOpacity: boolean, 69 | bgColor?: string 70 | }>` 71 | ${({ showOpacity }) => showOpacity ? `opacity: 0.4;` : ''} 72 | height: 100%; 73 | width: 100%; 74 | border-radius:0.7rem; 75 | position: absolute; 76 | background-color: ${({ bgColor }) => bgColor}; 77 | border:1px solid #ecebeb; 78 | 79 | ` 80 | 81 | 82 | const InputElementContainer = styled.div` 83 | padding: 8px; 84 | padding-left: 16px; 85 | padding-right: 16px; 86 | width:100%; 87 | ` 88 | 89 | const InputElement = styled.div<{ 90 | color?: string 91 | }>` 92 | width:100%; 93 | border: none; 94 | max-height: 6.4em; 95 | /* Adjust this value to control the maximum number of lines */ 96 | position:relative; 97 | font-size:14px; 98 | overflow: scroll; 99 | 100 | color: ${({ color }) => color || "rgba(0,0,0,.87)"}; 101 | font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 102 | background-color: transparent; 103 | text-align:left; 104 | opacity: 1; 105 | 106 | min-height: 1.6em; 107 | line-height:1.6em; 108 | word-wrap: break-word; 109 | overflow-wrap: anywhere; 110 | 111 | &:focus{ 112 | outline: none; 113 | } 114 | 115 | ::-webkit-scrollbar { 116 | display: none; 117 | } 118 | 119 | -ms-overflow-style: none; /* IE and Edge */ 120 | scrollbar-width: none; /* Firefox */ 121 | ` 122 | 123 | 124 | const ArrowContainer = styled.div<{ showCursor: boolean, disabled: boolean }>` 125 | position: relative; 126 | padding-left:16px; 127 | padding-right:16px; 128 | cursor: ${({ showCursor, disabled }) => showCursor && !disabled ? 'pointer' : 'default'}; 129 | display: flex; 130 | align-items: end; 131 | opacity: ${({ showCursor, disabled }) => showCursor && !disabled ? '1' : '0.4'}; 132 | height: 100%; 133 | padding-top: 8px; 134 | padding-bottom: 8px; 135 | 136 | 137 | ` 138 | 139 | const AttachmentContainer = styled.div<{ disabled: boolean }>` 140 | position: relative; 141 | padding-left:16px; 142 | padding-right:16px; 143 | display: flex; 144 | 145 | align-items: end; 146 | height: 100%; 147 | padding-top: 8px; 148 | padding-bottom: 8px; 149 | 150 | ${({ disabled }) => !disabled ? ` 151 | cursor: pointer; 152 | opacity: 1; 153 | ` : ` 154 | opacity: 0.6; 155 | `} 156 | 157 | ` 158 | 159 | const AttachPlaceholder = styled.div` 160 | position: relative; 161 | padding:12px; 162 | ` 163 | const SendPlaceholder = styled.div` 164 | position: relative; 165 | padding:12px; 166 | ` 167 | 168 | const PlaceHolder = styled.span<{ 169 | color?: string 170 | }>` 171 | color: ${({ color }) => color || '#9ca3af'}; 172 | position: absolute; 173 | left: 0; 174 | top: 0; 175 | bottom: 0; 176 | display: flex; 177 | align-items: center; 178 | padding-left: 16px; 179 | font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 180 | font-size:12px; 181 | pointer-events: none; 182 | ` 183 | 184 | 185 | export default function MessageInput({ 186 | onSendMessage, 187 | mobileView, 188 | onStartTyping, 189 | onEndTyping, 190 | showAttachButton = true, 191 | showSendButton = true, 192 | disabled = false, 193 | onAttachClick, 194 | placeholder = 'Send a message...', 195 | onKeyDown, 196 | onKeyUp 197 | }: Props) { 198 | 199 | const { themeColor } = useContext(MinChatUIContext) 200 | 201 | const [text, setText] = useState("") 202 | const inputRef = useRef(null); 203 | 204 | const { setTyping, ...inputProps } = useTypingListener({ onStartTyping, onEndTyping }) 205 | 206 | const handleSubmit = () => { 207 | if (!disabled && text.trim().length > 0) { 208 | inputRef.current.innerText = '' 209 | setTyping(false) 210 | onSendMessage && onSendMessage(text.trim()) 211 | setText("") 212 | 213 | } 214 | } 215 | 216 | // colorSets 217 | const backgroundColor = useColorSet("--input-background-color") 218 | const inputTextColor = useColorSet("--input-text-color") 219 | const inputAttachColor = useColorSet("--input-attach-color") 220 | const inputSendColor = useColorSet("--input-send-color") 221 | const inputElementColor = useColorSet("--input-element-color") 222 | const inputPlaceholderColor = useColorSet("--input-placeholder-color") 223 | 224 | return ( 225 | 228 |
{ 233 | e.preventDefault() 234 | handleSubmit() 235 | }} 236 | > 237 | 238 | {showAttachButton ? ( 239 | 243 | 244 | 250 | 251 | 252 | 253 | 254 | 255 | paperclip 256 | 257 | 258 | 259 | 260 | ) 261 | : 262 | } 263 | 264 | 266 | 270 | 271 | 272 | 273 | setText(event.target.innerText)} 278 | contentEditable={!disabled} 279 | suppressContentEditableWarning={true} 280 | onKeyDown={(event: any) => { 281 | if (event.key === 'Enter') { 282 | event.preventDefault(); // Prevents adding a new line 283 | handleSubmit(); 284 | return; 285 | } 286 | 287 | inputProps.onKeyDown() 288 | onKeyDown && onKeyDown(event) 289 | }} 290 | onKeyUp={(event: any) => { 291 | inputProps.onKeyUp() 292 | onKeyUp && onKeyUp(event) 293 | }} 294 | /> 295 | {text === '' && {placeholder}} 298 | 299 | 300 | 301 | 302 | {showSendButton ? ( 303 | 304 | 0} 307 | onClick={handleSubmit} 308 | > 309 | 310 | 316 | 317 | 318 | 319 | 320 | ) 321 | : 322 | 323 | } 324 | 325 |
326 | 327 | ) 328 | } -------------------------------------------------------------------------------- /src/components/message-list-background/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import useColorSet from '../../hooks/useColorSet' 4 | 5 | const ScrollBackground = styled.div<{ 6 | roundedCorners?: boolean 7 | backgroundColor?: string 8 | }>` 9 | background-color:${({ backgroundColor }) => backgroundColor || '#f3f4f6'}; 10 | position: relative; 11 | width: 100%; 12 | height: 100%; 13 | border-radius: ${({ roundedCorners }) => roundedCorners ? '16px' : '0px'}; 14 | 15 | ` 16 | 17 | const ScrollBackgroundContainer = styled.div<{ 18 | mobile?: boolean, 19 | }>` 20 | position: absolute; 21 | width: 100%; 22 | height: 100%; 23 | z-index: 0; 24 | box-sizing: border-box; 25 | ${({ mobile }) => !mobile ? ` 26 | padding-right: 12px; 27 | ` : ""} 28 | 29 | ` 30 | 31 | type Props = { 32 | mobileView?: boolean, 33 | roundedCorners?: boolean 34 | } 35 | 36 | export default function MessageListBackground({ 37 | mobileView, 38 | roundedCorners = true 39 | }: Props) { 40 | 41 | const backgroundColor = useColorSet("--messagelist-background-color") 42 | 43 | return ( 44 | 45 | 48 | 49 | ) 50 | } -------------------------------------------------------------------------------- /src/components/message-list/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from 'react' 2 | import Message from '../message' 3 | import styled from 'styled-components' 4 | import Loading from '../loading' 5 | import useDetectScrollPosition from '../../hooks/useDetectScrollPosition' 6 | import MessageType from '../../types/MessageType' 7 | import TypingIndicator from '../typing-indicator' 8 | import MessageListBackground from '../message-list-background' 9 | import useColorSet from '../../hooks/useColorSet' 10 | 11 | export type MessageListProps = { 12 | themeColor?: string 13 | messages?: MessageType[] 14 | currentUserId?: string 15 | loading?: boolean 16 | onScrollToTop?: () => void 17 | mobileView?: boolean 18 | showTypingIndicator?: boolean 19 | typingIndicatorContent?: string 20 | customTypingIndicatorComponent?: React.ReactNode 21 | customEmptyMessagesComponent?: React.ReactNode 22 | customLoaderComponent?: React.ReactNode 23 | } 24 | 25 | 26 | 27 | const Container = styled.div` 28 | height: 100%; 29 | /* display: flex; 30 | flex-direction: column; */ 31 | position: relative; 32 | max-height: 100vh; 33 | overflow-y: hidden; 34 | /* background-color: #ffffff; */ 35 | padding-left: 0px; 36 | padding-right: 12px; 37 | ` 38 | 39 | const InnerContainer = styled.div` 40 | height: 100%; 41 | ` 42 | 43 | 44 | const ScrollContainer = styled.div` 45 | overflow-y: auto; 46 | position: relative; 47 | height: 100%; 48 | width: 100%; 49 | max-height: 100vh; 50 | box-sizing: border-box; 51 | display: flex; 52 | flex-direction: column; 53 | scrollbar-width: none; /* Firefox */ 54 | -ms-overflow-style: none; /* Internet Explorer 10+ */ 55 | ::-webkit-scrollbar { /* WebKit */ 56 | width: 0; 57 | height: 0; 58 | } 59 | ` 60 | 61 | const Buffer = styled.div` 62 | height: 2px; 63 | width: 100%; 64 | position: relative; 65 | ` 66 | 67 | const NoMessagesTextContainer = styled.div<{ 68 | color?: string 69 | }>` 70 | color:${({ color }) => color || 'rgba(0,0,0,.36)'}; 71 | font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 72 | font-size:14px; 73 | display: flex; 74 | justify-content: center; 75 | align-items: center; 76 | height: 100%; 77 | user-select: none; 78 | 79 | ` 80 | 81 | const LoadingContainer = styled.div` 82 | width: 100%; 83 | height: 100%; 84 | display: flex; 85 | justify-content: center; 86 | align-items: center; 87 | z-index: 1; 88 | position: relative; 89 | ` 90 | 91 | export default function MessageList({ 92 | messages, 93 | currentUserId, 94 | loading = false, 95 | onScrollToTop, 96 | themeColor = '#6ea9d7', 97 | mobileView, 98 | typingIndicatorContent, 99 | showTypingIndicator, 100 | customTypingIndicatorComponent, 101 | customLoaderComponent, 102 | customEmptyMessagesComponent 103 | }: MessageListProps) { 104 | 105 | /** keeps track of whether messages was previously empty or whether it has already scrolled */ 106 | const [messagesWasEmpty, setMessagesWasEmpty] = useState(true) 107 | const containerRef = useRef() 108 | 109 | const bottomBufferRef = useRef() 110 | const scrollContainerRef = useRef() 111 | 112 | const { detectBottom, detectTop } = useDetectScrollPosition(scrollContainerRef) 113 | 114 | 115 | useEffect(() => { 116 | //detecting when the scroll view is first rendered and messages have rendered then you can scroll to the bottom 117 | if (bottomBufferRef.current && scrollContainerRef.current && !messagesWasEmpty) { 118 | scrollToBottom() 119 | } 120 | 121 | }, [messagesWasEmpty, bottomBufferRef.current, scrollContainerRef.current]) 122 | 123 | 124 | useEffect(() => { 125 | if (!messages) { 126 | setMessagesWasEmpty(true) 127 | } 128 | 129 | if (messages) { 130 | if (messagesWasEmpty) { 131 | //if the messages object was previously empty then scroll to bottom 132 | // this is for when the first page of messages arrives 133 | //if a user has instead scrolled to the top and the next page of messages arrives then don't scroll to bottom 134 | 135 | setMessagesWasEmpty(false) 136 | scrollToBottom() 137 | } 138 | 139 | // when closer to the bottom of the scroll bar and a new message arrives then scroll to bottom 140 | if (detectBottom()) { 141 | scrollToBottom() 142 | } 143 | 144 | } 145 | }, [messages]) 146 | 147 | 148 | useEffect(() => { 149 | //TODO when closer to the bottom of the scroll bar and a new message arrives then scroll to bottom 150 | if (detectBottom()) { 151 | scrollToBottom() 152 | } 153 | }, [showTypingIndicator]) 154 | 155 | 156 | const noMessageTextColor = useColorSet("--no-message-text-color") 157 | 158 | const scrollToBottom = async () => { 159 | if (bottomBufferRef.current && scrollContainerRef.current) { 160 | const container = scrollContainerRef.current 161 | const scrollPoint = bottomBufferRef.current 162 | 163 | const parentRect = container.getBoundingClientRect() 164 | const childRect = scrollPoint.getBoundingClientRect() 165 | 166 | // Scroll by offset relative to parent 167 | const scrollOffset = childRect.top + container.scrollTop - parentRect.top; 168 | 169 | if (container.scrollBy) { 170 | container.scrollBy({ top: scrollOffset, behavior: "auto" }); 171 | } else { 172 | container.scrollTop = scrollOffset; 173 | } 174 | } 175 | } 176 | 177 | 178 | 179 | return ( 180 | 183 | 184 | 187 | 188 | 189 | 190 | 191 | {loading ? 192 | 193 | {customLoaderComponent ? 194 | customLoaderComponent : 195 | } 196 | 197 | : 198 | <> 199 | 200 | { 202 | //detect when scrolled to top 203 | if (detectTop()) { 204 | onScrollToTop && onScrollToTop() 205 | } 206 | }} 207 | ref={scrollContainerRef}> 208 | 209 | {(messages && messages.length <= 0) && 210 | (customEmptyMessagesComponent ? 211 | customEmptyMessagesComponent 212 | : 213 | 215 |

No messages yet...

216 |
) 217 | } 218 | {messages && scrollContainerRef.current && bottomBufferRef.current && messages.map(({ user, text, media, loading: messageLoading, seen, createdAt }, index) => { 219 | //determining the type of message to render 220 | let lastClusterMessage, firstClusterMessage, last, single 221 | 222 | //if it is the first message in the messages array then show the header 223 | if (index === 0) { firstClusterMessage = true } 224 | //if the previous message from a different user then show the header 225 | if (index > 0 && messages[index - 1].user.id !== user.id) { firstClusterMessage = true } 226 | //if it is the last message in the messages array then show the avatar and is the last incoming 227 | if (index === messages.length - 1) { lastClusterMessage = true; last = true } 228 | //if the next message from a different user then show the avatar and is last message incoming 229 | if (index < messages.length - 1 && messages[index + 1].user.id !== user.id) { lastClusterMessage = true; last = true } 230 | //if the next message and the previous message are not from the same user then single incoming is true 231 | if (index < messages.length - 1 && index > 0 && messages[index + 1].user.id !== user.id && messages[index - 1].user.id !== user.id) { single = true } 232 | //if it is the first message in the messages array and the next message is from a different user then single incoming is true 233 | if (index === 0 && index < messages.length - 1 && messages[index + 1].user.id !== user.id) { single = true } 234 | //if it is the last message in the messages array and the previous message is from a different user then single incoming is true 235 | if (index === messages.length - 1 && index > 0 && messages[index - 1].user.id !== user.id) { single = true } 236 | //if the messages array contains only 1 message then single incoming is true 237 | if (messages.length === 1) { single = true } 238 | 239 | 240 | if (user.id == (currentUserId && currentUserId.toLowerCase())) { 241 | 242 | // my message 243 | return 256 | 257 | } else { 258 | 259 | // other message 260 | return 273 | } 274 | })} 275 | 276 | {showTypingIndicator && ( 277 | customTypingIndicatorComponent ? 278 | customTypingIndicatorComponent 279 | : 282 | )} 283 | 284 | {/* bottom buffer */} 285 |
286 | 287 |
288 |
289 | 290 | 291 | } 292 |
293 | 294 |
295 | ) 296 | } -------------------------------------------------------------------------------- /src/components/message/borderController.tsx: -------------------------------------------------------------------------------- 1 | 2 | 3 | type Props = { 4 | type: "incoming" | "outgoing", 5 | last?: boolean, 6 | single?: boolean, 7 | 8 | } 9 | export const getBorderCss = ({ 10 | type, 11 | last, 12 | single 13 | }: Props) => { 14 | 15 | let borderTopLeft, borderTopRight, borderBottomLeft, borderBottomRight 16 | 17 | if (type === "outgoing") { 18 | borderTopLeft = true 19 | borderBottomLeft = true 20 | borderBottomRight = last ? true : false 21 | borderTopRight = !last && single ? true : false 22 | } else { 23 | borderTopRight = true 24 | borderBottomRight = true 25 | borderBottomLeft = single || last ? true : false 26 | borderTopLeft = last ? true : false 27 | } 28 | 29 | return ` 30 | border-top-left-radius: ${borderTopLeft ? "8px" : "2px"}; 31 | border-top-right-radius: ${borderTopRight ? "8px" : "2px"}; 32 | border-bottom-left-radius: ${borderBottomLeft ? "8px" : "2px"}; 33 | border-bottom-right-radius: ${borderBottomRight ? "8px" : "2px"}; 34 | ` 35 | 36 | } -------------------------------------------------------------------------------- /src/components/message/incoming-message/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect } from 'react' 2 | import styled from 'styled-components' 3 | import { Container as MyMessageContainer, Wrapper as MyMessageWrapper, Background } from '../outgoing-message' 4 | import placeholderProfilePNG from './profile.webp' 5 | import MediaContent from '../media-content' 6 | import { getBorderCss } from '../borderController' 7 | import TextContent from '../text-content' 8 | import { Props } from '..' 9 | import Timestamp from '../timestamp' 10 | import useColorSet from '../../../hooks/useColorSet' 11 | import MinChatUIContext from '../../../contexts/MinChatUIContext' 12 | 13 | 14 | const MessageContainer = styled(MyMessageContainer)` 15 | margin-left: 0px; 16 | box-sizing: border-box; 17 | margin-bottom: 0px; 18 | ` 19 | 20 | 21 | const Wrapper = styled(MyMessageWrapper)` 22 | justify-content: start; 23 | align-items: flex-end; 24 | ` 25 | 26 | const DPContainer = styled.div` 27 | width: 32px; 28 | height: 32px; 29 | margin-left: 10px; 30 | box-sizing: border-box; 31 | user-select: none; 32 | 33 | ` 34 | const DisplayPicture = styled.img` 35 | width: 32px; 36 | height: 32px; 37 | border-radius: 9999px; 38 | box-sizing: border-box; 39 | border-width: 2px; 40 | border-color: rgb(255 255 255); 41 | object-fit: cover; 42 | position: relative; 43 | z-index: 1; 44 | ` 45 | 46 | 47 | 48 | const Name = styled.div<{ 49 | color?: string 50 | }>` 51 | text-align:left; 52 | vertical-align:text-top; 53 | font-size:14px; 54 | align-self:flex-start; 55 | line-height:auto; 56 | color:${({ color }) => color || "#4b5563"}; 57 | font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 58 | font-weight: 500; 59 | user-select: none; 60 | 61 | ` 62 | 63 | const TextWrapper = styled.div` 64 | margin-left:8px; 65 | box-sizing: border-box; 66 | ` 67 | 68 | 69 | const IncomingMessageBackground = styled(Background) <{ 70 | backgroundColor?: string 71 | }>` 72 | ${({ backgroundColor }) => !backgroundColor ? "opacity: 0.5;" : ""} 73 | ` 74 | 75 | const HeaderContainer = styled.div` 76 | display: flex; 77 | align-items: "center"; 78 | margin-top: 16px; 79 | margin-bottom: 6px; 80 | ` 81 | 82 | 83 | 84 | export default function IncomingMessage({ 85 | text, 86 | media, 87 | user, 88 | showAvatar, 89 | showHeader, 90 | last, 91 | single, 92 | created_at, 93 | }: Omit) { 94 | 95 | const { themeColor } = useContext(MinChatUIContext) 96 | 97 | const [avatar, setAvatar] = React.useState(placeholderProfilePNG) 98 | 99 | useEffect(() => { 100 | if (user?.avatar && user.avatar.trim().length > 0) { 101 | setAvatar(user.avatar) 102 | } 103 | }, [user]) 104 | 105 | 106 | const textColor = useColorSet("--incoming-message-text-color") 107 | const nameTextColor = useColorSet("--incoming-message-name-text-color") 108 | const linkColor = useColorSet("--incoming-message-link-color") 109 | 110 | const backgroundColor = useColorSet("--incoming-message-background-color") 111 | const timestampColor = useColorSet("--incoming-message-timestamp-color") 112 | 113 | return ( 114 | 118 | 119 | {showAvatar && 120 | 121 | { 123 | setAvatar(placeholderProfilePNG) 124 | }} 125 | src={avatar} 126 | />} 127 | 128 | 129 | 130 | {showHeader && 131 | 132 | {user?.name} 133 | 134 | } 135 | 136 |
137 | 138 | getBorderCss({ 140 | type: "incoming", 141 | last, 142 | single 143 | }))()} 144 | backgroundColor={backgroundColor} 145 | bgColor={backgroundColor || themeColor} /> 146 | 147 | {media ? 152 | : 153 | {text}} 156 | 157 | 161 | 162 |
163 | 164 |
165 |
166 | ) 167 | } 168 | 169 | -------------------------------------------------------------------------------- /src/components/message/incoming-message/profile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MinChatHQ/react-chat-ui/498328841aa94938f1cfb2eaeb6ab200a2fb158f/src/components/message/incoming-message/profile.png -------------------------------------------------------------------------------- /src/components/message/incoming-message/profile.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MinChatHQ/react-chat-ui/498328841aa94938f1cfb2eaeb6ab200a2fb158f/src/components/message/incoming-message/profile.webp -------------------------------------------------------------------------------- /src/components/message/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import User from '../../types/UserType'; 3 | import OutgoingMessage from './outgoing-message' 4 | import IncomingMessage from './incoming-message' 5 | import { MediaType } from '../../types/MessageType'; 6 | 7 | 8 | export type Props = { 9 | created_at?: Date 10 | seen?: boolean 11 | text?: string, 12 | media?: MediaType, 13 | loading?: boolean 14 | type?: "incoming" | "outgoing" 15 | user?: User 16 | showAvatar?: boolean 17 | showHeader?: boolean 18 | // determines whether its the last message in the group of outgoing or incoming 19 | last?: boolean 20 | //determines whether its the only message in the group of outgoing or incoming 21 | single?: boolean 22 | clusterFirstMessage?: boolean 23 | clusterLastMessage?: boolean 24 | 25 | }; 26 | 27 | 28 | export default function Message({ 29 | text, 30 | media, 31 | created_at, 32 | seen, 33 | loading, 34 | type = "outgoing", 35 | user, 36 | showAvatar, 37 | showHeader, 38 | last, 39 | single, 40 | clusterFirstMessage, 41 | clusterLastMessage 42 | }: Props) { 43 | 44 | return ( 45 | type === "outgoing" ? 46 | 57 | 58 | : 59 | 60 | 70 | 71 | ) 72 | } 73 | 74 | -------------------------------------------------------------------------------- /src/components/message/media-content/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import { MediaType } from '../../../types/MessageType' 4 | import { getBorderCss } from '../borderController' 5 | 6 | 7 | interface Props extends MediaType { 8 | last?: boolean 9 | single?: boolean 10 | messageType: "incoming" | "outgoing" 11 | } 12 | 13 | const ImageContainer = styled.div` 14 | width: 99%; 15 | padding: 1px; 16 | position: relative; 17 | user-select: none; 18 | 19 | ` 20 | 21 | const Image = styled.img<{ 22 | borderCss: string, 23 | }>` 24 | width: 100%; 25 | margin: 0px; 26 | position: relative; 27 | 28 | ${({ borderCss }) => borderCss}; 29 | 30 | ` 31 | 32 | const FileContainer = styled.a` 33 | text-align:left; 34 | vertical-align:text-top; 35 | font-size:14px; 36 | align-self:flex-start; 37 | line-height:auto; 38 | color:#000000; 39 | font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 40 | padding-left:16px; 41 | padding-right:16px; 42 | padding-top:8px; 43 | padding-bottom:8px; 44 | position: relative; 45 | box-sizing: border-box; 46 | word-wrap: break-word; 47 | width: 100%; 48 | text-decoration: none; 49 | user-select: none; 50 | ` 51 | 52 | const SizeText = styled.span` 53 | margin-left: 6px; 54 | font-size: 11px; 55 | 56 | ` 57 | 58 | const DownloadIcon = 67 | 68 | 69 | 70 | const Video = styled.video<{ 71 | borderCss: string, 72 | }>` 73 | width: 100%; 74 | height: 240px; 75 | 76 | ${({ borderCss }) => borderCss}; 77 | 78 | ` 79 | 80 | 81 | export default function MediaContent({ 82 | type, 83 | url, 84 | size, 85 | last, 86 | single, 87 | messageType 88 | }: Props) { 89 | 90 | return ( 91 | <> 92 | {(type === 'image' || type === 'gif') && 93 | 94 | getBorderCss({ 96 | type: messageType, 97 | last, 98 | single 99 | }))()} 100 | src={url} 101 | alt={url} /> 102 | 103 | } 104 | 105 | 106 | 107 | {(type === 'file' || type === 'video') && 108 |
109 | {type === 'video' && 110 | 122 | } 123 |
124 | {DownloadIcon}    {url}{size && ({size})} 127 |
128 |
129 | 130 | } 131 | 132 | ) 133 | } -------------------------------------------------------------------------------- /src/components/message/outgoing-message/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react' 2 | import styled from 'styled-components' 3 | import MediaContent from '../media-content' 4 | import { getBorderCss } from '../borderController' 5 | import TextContent from '../text-content' 6 | import Timestamp from '../timestamp' 7 | import { Props } from '..' 8 | import useColorSet from '../../../hooks/useColorSet' 9 | import MinChatUIContext from '../../../contexts/MinChatUIContext' 10 | 11 | 12 | export const Wrapper = styled.div<{ firstMessage?: boolean, lastMessage?: boolean }>` 13 | display:flex; 14 | justify-content: end; 15 | margin-right: 10px; 16 | margin-top: ${({ firstMessage }) => firstMessage ? "16px" : "2px"}; 17 | position: relative; 18 | box-sizing: border-box; 19 | margin-bottom: ${({ lastMessage }) => lastMessage ? "16px" : "2px"}; 20 | z-index: 1; 21 | ` 22 | 23 | 24 | 25 | export const Container = styled.div` 26 | max-width:272px; 27 | min-width:80px; 28 | margin-left: 10px; 29 | justify-content:flex-end; 30 | align-items:flex-end; 31 | gap:10px; 32 | position:relative; 33 | box-sizing: border-box; 34 | ` 35 | export const Background = styled.div<{ 36 | bgColor: string, 37 | borderCss: string, 38 | }>` 39 | position: absolute; 40 | width: 100%; 41 | height: 100%; 42 | background-color:${({ bgColor }) => bgColor}; 43 | 44 | ${({ borderCss }) => borderCss}; 45 | ` 46 | 47 | 48 | 49 | 50 | 51 | 52 | export default function MyMessage({ 53 | text, 54 | media, 55 | loading, 56 | last, 57 | single, 58 | clusterFirstMessage, 59 | clusterLastMessage, 60 | created_at, 61 | seen 62 | }: Omit) { 63 | 64 | const { themeColor } = useContext(MinChatUIContext) 65 | 66 | const textColor = useColorSet("--outgoing-message-text-color") 67 | const backgroundColor = useColorSet("--outgoing-message-background-color") 68 | const timestampColor = useColorSet("--outgoing-message-timestamp-color") 69 | const checkmarkColor = useColorSet("--outgoing-message-checkmark-color") 70 | const loaderColor = useColorSet("--outgoing-message-loader-color") 71 | const linkColor = useColorSet("--outgoing-message-link-color") 72 | 73 | 74 | 75 | 76 | 77 | 78 | return ( 79 | 85 |
86 | 87 | 88 | 89 | getBorderCss({ 91 | type: "outgoing", 92 | last, 93 | single 94 | }))()} 95 | bgColor={backgroundColor || themeColor} /> 96 | 97 | {media ? 102 | : 103 | {text}} 107 | 108 | 116 | 117 | 118 | 119 |
120 |
121 | ) 122 | } 123 | 124 | -------------------------------------------------------------------------------- /src/components/message/text-content/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | 4 | type Props = { 5 | children?: string 6 | color?: string 7 | linkColor?: string 8 | } 9 | 10 | export const Content = styled.div<{ 11 | color?: string 12 | linkColor?: string 13 | }>` 14 | text-align:left; 15 | vertical-align:text-top; 16 | font-size:14px; 17 | align-self:flex-start; 18 | line-height:auto; 19 | color:${({ color }) => color || '#000000'}; 20 | font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 21 | padding-left:16px; 22 | padding-right:16px; 23 | padding-top:8px; 24 | padding-bottom:8px; 25 | position: relative; 26 | box-sizing: border-box; 27 | word-wrap: break-word; 28 | width: 100%; 29 | 30 | user-select: none; 31 | 32 | a { 33 | color: ${({ linkColor }) => linkColor || 'blue'}; 34 | } 35 | 36 | ` 37 | 38 | 39 | export default function TextContent({ 40 | linkColor, 41 | color, 42 | children = "" 43 | }: Props) { 44 | 45 | // Regular expression to match URLs 46 | const urlRegex = /(https?:\/\/[^\s]+)/g; 47 | 48 | return ($&') }} /> 52 | ) 53 | } -------------------------------------------------------------------------------- /src/components/message/timestamp/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | import Loading from './loading' 3 | import styled from 'styled-components' 4 | import { calculateTimeAgo } from '../../../utils/date-utils' 5 | 6 | type Props = { 7 | loading?: boolean 8 | date?: Date 9 | seen?: boolean 10 | showSeen?: boolean 11 | color?: string 12 | loaderColor?: string 13 | checkmarkColor?: string 14 | } 15 | 16 | 17 | const LoadingContainer = styled.div<{ 18 | color?: string 19 | }>` 20 | position: relative; 21 | height: 100%; 22 | display: flex; 23 | align-items: center; 24 | margin-right:4px; 25 | margin-left:2px; 26 | 27 | 28 | ${({ color }) => color ? `color: ${color};` : ''} 29 | ` 30 | 31 | export const Content = styled.div<{ 32 | color?: string 33 | }>` 34 | text-align:right; 35 | vertical-align:text-top; 36 | font-size:12px; 37 | 38 | margin-right:2px; 39 | align-self:flex-start; 40 | line-height:auto; 41 | color: ${({ color }) => color || 'rgb(75 85 99)'}; 42 | font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 43 | ` 44 | 45 | const Check = styled.div<{ 46 | color?: string 47 | }>` 48 | position:relative; 49 | width:16px; 50 | height:16px; 51 | padding-bottom:4px; 52 | padding-right:4px; 53 | color: ${({ color }) => color || 'rgb(75 85 99)'}; 54 | ` 55 | 56 | const PlaceholderCheck = styled(Check)` 57 | width:8px; 58 | ` 59 | 60 | const Container = styled.div` 61 | display:flex; 62 | width: 100%; 63 | position: relative; 64 | justify-content: end; 65 | align-items: center; 66 | margin-top: -8px; 67 | user-select: none; 68 | 69 | ` 70 | 71 | export default function Timestamp({ 72 | loading, 73 | date, 74 | showSeen, 75 | seen, 76 | color, 77 | loaderColor, 78 | checkmarkColor 79 | }: Props) { 80 | 81 | const [dateSent, setDateSent] = useState() 82 | 83 | useEffect(() => { 84 | function updateDateSent() { 85 | if (date) { 86 | setDateSent(calculateTimeAgo(date)) 87 | } 88 | } 89 | 90 | updateDateSent() 91 | 92 | const intervalId = setInterval(() => updateDateSent(), 60_000) 93 | 94 | return () => clearInterval(intervalId); 95 | 96 | }, []) 97 | 98 | return ( 99 | 100 | 101 | {dateSent} 102 | 103 | {loading ? 104 | 105 | : 106 | (showSeen ? 107 | 108 | 109 | 111 | {seen ? 112 | 113 | 114 | 124 | : 125 | 126 | 130 | 133 | 134 | 135 | } 136 | 137 | : 138 | 139 | ) 140 | } 141 | 142 | ) 143 | } -------------------------------------------------------------------------------- /src/components/message/timestamp/loading/index.css: -------------------------------------------------------------------------------- 1 | .message-lds-ring { 2 | display: inline-block; 3 | position: relative; 4 | width: 12px; 5 | height: 12px; 6 | } 7 | 8 | .message-lds-ring div:nth-child(1) { 9 | animation-delay: -0.45s; 10 | } 11 | 12 | .message-lds-ring div:nth-child(2) { 13 | animation-delay: -0.3s; 14 | } 15 | 16 | .message-lds-ring div:nth-child(3) { 17 | animation-delay: -0.15s; 18 | } 19 | 20 | @keyframes lds-ring { 21 | 0% { 22 | transform: rotate(0deg); 23 | } 24 | 25 | 100% { 26 | transform: rotate(360deg); 27 | } 28 | } -------------------------------------------------------------------------------- /src/components/message/timestamp/loading/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import "./index.css" 4 | 5 | type Props = { 6 | color?: string 7 | } 8 | 9 | 10 | const Container = styled.div` 11 | display: flex; 12 | justify-content: center; 13 | align-items: center 14 | ; 15 | ` 16 | 17 | const InnerContainer = styled.div<{ 18 | color?: string 19 | }>` 20 | box-sizing: border-box; 21 | display: block; 22 | position: absolute; 23 | width: 8px; 24 | height: 8px; 25 | 26 | position: absolute; 27 | left: 0; 28 | right: 0; 29 | margin-left: auto; 30 | margin-right: auto; 31 | margin-top: auto; 32 | margin-bottom: auto; 33 | top: 0; 34 | bottom: 0; 35 | 36 | /* margin: 6px; */ 37 | border: 2px solid ${({ color }) => color || '#fff'}; 38 | border-radius: 50%; 39 | animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite; 40 | border-color: ${({ color }) => color || '#fff'} transparent transparent transparent; 41 | ` 42 | 43 | export default function Loading({ color }: Props) { 44 | 45 | return ( 46 | 47 |
48 | 49 |
50 |
51 |
52 |
53 | 54 | 55 | ) 56 | } -------------------------------------------------------------------------------- /src/components/sidebar/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | 4 | export type Props = { 5 | children: any 6 | } 7 | 8 | const Container = styled.div` 9 | /* max-width: 384px; */ 10 | padding-top: 16px; 11 | padding-bottom: 16px; 12 | width: 35%; 13 | height: 100%; 14 | position: relative; 15 | box-sizing: border-box; 16 | 17 | ` 18 | 19 | export default function Sidebar({ children }: Props) { 20 | return ( 21 | {children} 22 | ) 23 | } -------------------------------------------------------------------------------- /src/components/typing-indicator/index.css: -------------------------------------------------------------------------------- 1 | .loading-animation { 2 | display: flex; 3 | align-items: center; 4 | } 5 | 6 | @keyframes loading-animation-move { 7 | 0% { 8 | transform: translateY(0); 9 | } 10 | 50% { 11 | transform: translateY(-5px); 12 | } 13 | 100% { 14 | transform: translateY(0); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/components/typing-indicator/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import './index.css' 4 | 5 | export type Props = { 6 | content?: string 7 | themeColor?: string 8 | } 9 | 10 | const Container = styled.div` 11 | display: flex; 12 | align-items: center; 13 | flex-direction: row; 14 | /* this is to compensate for the width of the other message dp and its margin left */ 15 | margin-left: 42px; 16 | margin-bottom: 16px; 17 | margin-top: 16px; 18 | ` 19 | 20 | const Text = styled.div<{ themeColor: string }>` 21 | font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 22 | font-size:12px; 23 | line-height:auto; 24 | font-weight: 600; 25 | margin-left: 8px; 26 | 27 | color: ${({ themeColor }) => themeColor}; 28 | ` 29 | 30 | const Dot1 = styled.div<{ themeColor: string }>` 31 | width: 5px; 32 | height: 5px; 33 | border-radius: 50%; 34 | background-color: ${({ themeColor }) => themeColor}; 35 | animation: loading-animation-move 0.7s ease-in-out infinite; 36 | margin-right: 4px; 37 | 38 | animation-delay: 0ms; 39 | 40 | ` 41 | const Dot2 = styled(Dot1)` 42 | animation-delay: 0.2s; 43 | ` 44 | const Dot3 = styled(Dot1)` 45 | animation-delay: 0.4s; 46 | margin-right: 0; 47 | ` 48 | 49 | export default function TypingIndicator({ 50 | content, 51 | themeColor = '#6ea9d7' 52 | }: Props) { 53 | 54 | return ( 55 | 56 |
57 | 58 | 59 | 60 |
61 | 62 | {content} 63 |
64 | ) 65 | } -------------------------------------------------------------------------------- /src/contexts/MinChatUIContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | 3 | export default createContext<{ 4 | colorSet?: {} 5 | themeColor: string 6 | }>({ 7 | colorSet: {}, 8 | themeColor: '#6ea9d7' 9 | }) -------------------------------------------------------------------------------- /src/hooks/useCheckIsMobile.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | 4 | const useCheckIsMobile = (containerRef: React.RefObject) => { 5 | 6 | 7 | const [isMobile, setIsMobile] = useState(false); 8 | 9 | useEffect(() => { 10 | if (containerRef?.current) { 11 | const resizeObserver = new ResizeObserver(entries => { 12 | if (!Array.isArray(entries) || !entries.length) { 13 | return; 14 | } 15 | 16 | const width = entries[0].contentRect.width 17 | setIsMobile(width < 768); 18 | }); 19 | 20 | resizeObserver.observe(containerRef.current); 21 | 22 | 23 | return () => { 24 | if (containerRef.current) { 25 | resizeObserver.unobserve(containerRef.current); 26 | } 27 | } 28 | } 29 | 30 | return () =>{} 31 | 32 | }, [containerRef?.current]); 33 | 34 | return isMobile 35 | } 36 | 37 | export default useCheckIsMobile -------------------------------------------------------------------------------- /src/hooks/useColorSet.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import MinChatUIContext from "../contexts/MinChatUIContext"; 3 | 4 | 5 | 6 | export default function useColorSet(label: string): string | undefined { 7 | 8 | const { colorSet } = useContext(MinChatUIContext) 9 | 10 | return colorSet ? (colorSet as any)[label] : undefined 11 | } -------------------------------------------------------------------------------- /src/hooks/useDetectScrollPosition.tsx: -------------------------------------------------------------------------------- 1 | import { RefObject } from "react" 2 | 3 | 4 | const useDetectScrollPosition = (ref: RefObject) => { 5 | 6 | const detectTop = () => { 7 | if (ref.current) { 8 | return ref.current.scrollTop < 50 9 | } 10 | return false 11 | } 12 | 13 | // const detectBottom = () => { 14 | // if (ref.current) { 15 | // return ref.current.scrollHeight - ref.current.scrollTop === ref.current.clientHeight 16 | // } 17 | // return false 18 | // } 19 | 20 | 21 | const detectBottom = () => { 22 | if (ref.current) { 23 | const threshold = 100; 24 | return ref.current.scrollHeight - ref.current.scrollTop <= ref.current.clientHeight + threshold; 25 | } 26 | return false; 27 | } 28 | 29 | 30 | 31 | return { detectTop, detectBottom } 32 | 33 | 34 | } 35 | 36 | export default useDetectScrollPosition 37 | -------------------------------------------------------------------------------- /src/hooks/useTypingListener.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | 4 | 5 | 6 | export type Props = { 7 | onStartTyping?: () => void, 8 | onEndTyping?: () => void, 9 | } 10 | 11 | const useTypingListener = (props: Props) => { 12 | const [typing, setTyping] = useState(false); 13 | 14 | let timeout: any; 15 | 16 | useEffect(() => { 17 | //call the function when typing starts or ends but should not call it on every render and should only be called when the value of typing changes 18 | if (typing) { 19 | props.onStartTyping && props.onStartTyping() 20 | } else { 21 | props.onEndTyping && props.onEndTyping() 22 | } 23 | 24 | }, [typing]) 25 | 26 | const onKeyDown = () => { 27 | clearTimeout(timeout); 28 | setTyping(true); 29 | } 30 | 31 | const onKeyUp = () => { 32 | timeout = setTimeout(() => { 33 | setTyping(false); 34 | }, 2_000); 35 | } 36 | return { setTyping, onKeyUp, onKeyDown } 37 | 38 | } 39 | 40 | export default useTypingListener 41 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | .fade-animation { 2 | animation: fadeIn 0.2s ease-in-out; 3 | } 4 | 5 | .fade-animation-slow { 6 | animation: fadeIn 0.4s ease-in-out; 7 | } 8 | 9 | @keyframes fadeIn { 10 | from { 11 | opacity: 0; 12 | } 13 | 14 | to { 15 | opacity: 1; 16 | } 17 | } -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import MessageList from "./components/message-list"; 2 | import MessageListBackground from "./components/message-list-background"; 3 | import ConversationList from "./components/conversation-list"; 4 | import MainContainer from "./components/main-container"; 5 | import Loading from "./components/loading"; 6 | import Message from "./components/message" 7 | import MessageInput from "./components/message-input"; 8 | import MessageHeader from "./components/message-header"; 9 | import MessageContainer from "./components/message-container"; 10 | import ConversationHeader from "./components/conversation-header"; 11 | import TypingIndicator from "./components/typing-indicator"; 12 | import useCheckIsMobile from "./hooks/useCheckIsMobile"; 13 | import useTypingListener from "./hooks/useTypingListener"; 14 | 15 | import Sidebar from "./components/sidebar" 16 | import MinChatUiProvider from "./providers/MinChatUiProvider"; 17 | 18 | 19 | 20 | 21 | export { 22 | Loading, 23 | Message, 24 | MessageInput, 25 | MessageHeader, 26 | MessageList, 27 | ConversationList, 28 | MainContainer, 29 | ConversationHeader, 30 | TypingIndicator, 31 | useCheckIsMobile, 32 | useTypingListener, 33 | Sidebar, 34 | MessageListBackground, 35 | MessageContainer, 36 | MinChatUiProvider 37 | } -------------------------------------------------------------------------------- /src/providers/MinChatUiProvider.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import MinChatUIContext from '../contexts/MinChatUIContext' 3 | 4 | type Props = { 5 | colorSet?: {}, 6 | theme?: string 7 | children: any 8 | } 9 | 10 | export default function MinChatUiProvider({ 11 | colorSet, 12 | children, 13 | theme 14 | }: Props) { 15 | 16 | return ( 17 | 18 | {children} 19 | 20 | ) 21 | } -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.png"; 2 | declare module "*.svg"; 3 | declare module "*.jpeg"; 4 | declare module "*.jpg"; 5 | declare module "*.webp"; 6 | -------------------------------------------------------------------------------- /src/types/ConversationType.tsx: -------------------------------------------------------------------------------- 1 | import MessageType from "./MessageType" 2 | 3 | type ConversationType = { 4 | title: string, 5 | lastMessage?: MessageType 6 | unread?:boolean 7 | avatar?: string 8 | id?: string 9 | } 10 | 11 | export default ConversationType -------------------------------------------------------------------------------- /src/types/MessageType.tsx: -------------------------------------------------------------------------------- 1 | import UserType from "./UserType" 2 | 3 | export interface MediaType { 4 | type: "image" | "video" | "file" | "gif" 5 | url: string 6 | size?: string 7 | name?: string 8 | } 9 | 10 | type MessageType = { 11 | user: UserType 12 | id?: string 13 | text?: string 14 | media?: MediaType 15 | createdAt?: Date 16 | seen?: boolean 17 | loading?: boolean 18 | } 19 | 20 | export default MessageType -------------------------------------------------------------------------------- /src/types/UserType.tsx: -------------------------------------------------------------------------------- 1 | type UserType = { 2 | id?: string, 3 | name?: string, 4 | avatar?: string 5 | } 6 | 7 | export default UserType -------------------------------------------------------------------------------- /src/utils/date-utils/index.ts: -------------------------------------------------------------------------------- 1 | function calculateDifferences(date: Date) { 2 | try { 3 | const currentDate = new Date() 4 | const timeDifference = (new Date(currentDate.toUTCString())).getTime() - (new Date(date ? date.toUTCString() : "")).getTime(); 5 | const minutesAgo = Math.floor(timeDifference / (1000 * 60)); 6 | const hoursAgo = Math.floor(minutesAgo / 60); 7 | const daysAgo = Math.floor(hoursAgo / 24); 8 | 9 | return { 10 | minutesAgo, 11 | hoursAgo, 12 | daysAgo 13 | } 14 | } catch (e) { 15 | return { 16 | minutesAgo: 0, 17 | hoursAgo: 0, 18 | daysAgo: 0 19 | } 20 | } 21 | } 22 | 23 | 24 | export function calculateLastSeen(date: Date): string { 25 | const diff = calculateDifferences(date) 26 | 27 | if (diff.minutesAgo < 1) { 28 | return 'Active now' 29 | } else if (diff.minutesAgo === 1) { 30 | return 'Seen 1 minute ago' 31 | } else if (diff.minutesAgo < 60) { 32 | return `Seen ${diff.minutesAgo} minutes ago` 33 | } else if (diff.hoursAgo === 1) { 34 | return 'Seen 1 hour ago' 35 | } else if (diff.hoursAgo < 24) { 36 | return `Seen ${diff.hoursAgo} hours ago` 37 | } else if (diff.daysAgo === 1) { 38 | return 'Seen 1 day ago' 39 | } else { 40 | return `Seen ${diff.daysAgo} days ago` 41 | } 42 | } 43 | 44 | /** 45 | * 46 | * @param date 47 | * @returns 48 | */ 49 | export function calculateTimeAgo(date: Date): string { 50 | 51 | const diff = calculateDifferences(date) 52 | 53 | if (diff.minutesAgo < 1) { 54 | return 'just now'; 55 | } 56 | else if (diff.minutesAgo < 60) { 57 | return `${diff.minutesAgo}m` 58 | } else if (diff.hoursAgo < 24) { 59 | return `${diff.hoursAgo}h` 60 | } else { 61 | return `${diff.daysAgo}d` 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /stories/Conversation.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Meta, Story } from '@storybook/react'; 3 | import styled from 'styled-components'; 4 | import Conversation, { Props } from "../src/components/conversation" 5 | 6 | const meta: Meta = { 7 | title: 'Conversation', 8 | component: Conversation, 9 | parameters: { 10 | controls: { expanded: true }, 11 | }, 12 | }; 13 | 14 | export default meta; 15 | 16 | const Template: Story = args => { }} /> 30 | 31 | const UnseenTemplate: Story = args => { }} /> 45 | 46 | 47 | const HtmlTemplate: Story = args => Hello everbody" 53 | , 54 | user: { 55 | id: "martha_stewart", 56 | name: "Daniel", 57 | avatar: "https://media.sproutsocial.com/uploads/2022/06/profile-picture.jpeg" 58 | }, 59 | }} 60 | onClick={() => { }} /> 61 | 62 | const UnseenFileTemplate: Story = args => { }} /> 81 | 82 | 83 | 84 | const NoAvatarTemplate: Story = args => { }} /> 97 | 98 | const SelectedTemplate: Story = args => { }} /> 112 | 113 | const ImageMessageTemplate: Story = args => { }} /> 131 | 132 | const FileMessageTemplate: Story = args => { }} /> 149 | 150 | const VideoMessageTemplate: Story = args => { }} /> 167 | 168 | const GifMessageTemplate: Story = args => { }} /> 185 | 186 | 187 | 188 | // By passing using the Args format for exported stories, you can control the props for a component for reuse in a test 189 | // https://storybook.js.org/docs/react/workflows/unit-testing 190 | export const Default = Template.bind({}); 191 | export const NewMessage = UnseenTemplate.bind({}); 192 | export const NewFileMessage = UnseenFileTemplate.bind({}); 193 | export const WithPlaceholderAvatar = NoAvatarTemplate.bind({}); 194 | export const Selected = SelectedTemplate.bind({}); 195 | export const ImageMessage = ImageMessageTemplate.bind({}); 196 | export const VideoMessage = VideoMessageTemplate.bind({}); 197 | export const FileMessage = FileMessageTemplate.bind({}); 198 | export const GifMessage = GifMessageTemplate.bind({}); 199 | export const Html = HtmlTemplate.bind({}); 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | -------------------------------------------------------------------------------- /stories/ConversationHeader.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Meta, Story } from '@storybook/react'; 3 | import styled from 'styled-components'; 4 | import ConversationHeader, { Props } from "../src/components/conversation-header" 5 | 6 | const meta: Meta = { 7 | title: 'ConversationHeader', 8 | component: ConversationHeader, 9 | parameters: { 10 | controls: { expanded: true }, 11 | }, 12 | }; 13 | 14 | export default meta; 15 | 16 | const Template: Story = args => 17 | 18 | // By passing using the Args format for exported stories, you can control the props for a component for reuse in a test 19 | // https://storybook.js.org/docs/react/workflows/unit-testing 20 | export const Default = Template.bind({}); 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /stories/ConversationList.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Meta, Story } from '@storybook/react'; 3 | import ConversationList, { Props } from '../src/components/conversation-list'; 4 | import styled from 'styled-components'; 5 | import { chats, fewChats } from './data'; 6 | 7 | 8 | const meta: Meta = { 9 | title: 'ConversationList', 10 | component: ConversationList, 11 | parameters: { 12 | controls: { expanded: true }, 13 | }, 14 | }; 15 | 16 | export default meta 17 | 18 | 19 | const Template: Story = args => undefined} 25 | /> 26 | 27 | const TemplateCustomConversationItem: Story = args =>
{conversation.lastMessage?.text}
} 33 | /> 34 | 35 | const NoChatsTemplate: Story = args => 42 | 43 | const CustomNoChatsTemplate: Story = args => Custom No Chats View...
} 49 | 50 | /> 51 | 52 | const LoadingTemplate: Story = args =>
65 |
66 | 67 | const CustomLoadingTemplate: Story = args =>
Custom Loading...
} 79 | 80 | /> 81 |
82 | 83 | const FewChatsTemplate: Story = args =>
; 89 | 90 | const WithPaddingContainer = styled.div` 91 | height: 500px; 92 | padding: 20px; 93 | background-color: red; 94 | position: relative; 95 | width: 500px; 96 | ` 97 | const TemplateWithPadding: Story = args => 98 | ; 105 | 106 | 107 | // By passing using the Args format for exported stories, you can control the props for a component for reuse in a test 108 | // https://storybook.js.org/docs/react/workflows/unit-testing 109 | export const Default = Template.bind({}); 110 | 111 | export const WithPadding = TemplateWithPadding.bind({}); 112 | export const FewConversation = FewChatsTemplate.bind({}); 113 | export const LoadingConversation = LoadingTemplate.bind({}); 114 | export const CustomLoadingConversation = CustomLoadingTemplate.bind({}); 115 | export const NoConversation = NoChatsTemplate.bind({}); 116 | export const CustomNoConversation = CustomNoChatsTemplate.bind({}); 117 | export const CustomConversationItem = TemplateCustomConversationItem.bind({}); 118 | 119 | 120 | 121 | 122 | 123 | Default.args = {}; 124 | -------------------------------------------------------------------------------- /stories/MainContainer.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Meta, Story } from '@storybook/react'; 3 | import MainContainer, { Props } from '../src/components/main-container'; 4 | import styled from 'styled-components'; 5 | import { chats, messages, fewMessages } from './data'; 6 | import Sidebar from '../src/components/sidebar'; 7 | import ConversationList from '../src/components/conversation-list'; 8 | import ConversationHeader from '../src/components/conversation-header'; 9 | 10 | import MessageHeader from '../src/components/message-header'; 11 | import MessageInput from '../src/components/message-input'; 12 | import MessageList from '../src/components/message-list'; 13 | import MessageContainer from '../src/components/message-container'; 14 | import MessageListBackground from '../src/components/message-list-background'; 15 | import MinChatUIProvider from '../src/providers/MinChatUiProvider' 16 | import MinChatUiProvider from '../src/providers/MinChatUiProvider'; 17 | 18 | 19 | 20 | 21 | const meta: Meta = { 22 | title: 'MainContainer', 23 | component: MainContainer, 24 | argTypes: { 25 | selectedConversation: { 26 | onSendMessage: { 27 | action: "sendMessage" 28 | } 29 | }, 30 | inbox: { 31 | onConversationClick: { 32 | action: "select COnversation" 33 | } 34 | } 35 | }, 36 | parameters: { 37 | controls: { expanded: true }, 38 | }, 39 | }; 40 | 41 | export default meta 42 | 43 | 44 | 45 | const Provider = ({ children }: any) => {children} 102 | 103 | 104 | const CustomColorsTemplate: Story = args => { 105 | 106 | 107 | return 108 | 111 | 112 | 113 | 114 | 117 | 118 | 119 | 120 | Welcome 123 | 127 | 128 | 129 | 130 | 131 | } 132 | 133 | 134 | const LoadingCustomColorsTemplate: Story = args => { 135 | 136 | 137 | return 138 | 141 | 142 | 145 | 146 | 147 | 148 | 149 | 153 | 154 | 155 | 156 | } 157 | 158 | const MobileChatListCustomColorsTemplate: Story = args => { 159 | return 160 | 163 | 164 | 165 | 169 | 170 | 171 | } 172 | 173 | const MobileMessageCustomColorsTemplate: Story = args => { 174 | return 175 | 178 | 179 | Welcome 182 | 186 | 187 | 188 | 189 | 190 | } 191 | 192 | 193 | const NoMessageCustomColorsTemplate: Story = args => { 194 | return 195 | 198 | 199 | Welcome 202 | 206 | 207 | 208 | 209 | 210 | } 211 | 212 | const NoConversationCustomColorsTemplate: Story = args => { 213 | return 214 | 217 | 218 | 219 | 223 | 224 | 225 | 226 | 227 | } 228 | 229 | 230 | 231 | const Template: Story = args => { 232 | 233 | 234 | return 237 | 238 | 239 | 240 | 243 | 244 | 245 | 246 | Welcome 247 | 251 | 252 | 253 | 254 | 255 | } 256 | 257 | const NoSelectedChatTemplate: Story = args => { 258 | 259 | 260 | return 261 | 262 | 263 | 264 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | } 274 | 275 | const MobileTemplate: Story = args => { 276 | return
277 |
278 | 279 | 280 | 281 | 284 | 285 | 286 | 287 |
288 | 289 |
290 | } 291 | 292 | // const LoadingTemplate: Story = args => { 293 | 294 | 295 | // return 298 | // 299 | // 300 | 301 | // 305 | // 306 | 307 | // 308 | // Welcome 309 | // 313 | // 314 | // 315 | // 316 | // } 317 | 318 | 319 | // const MobileNoSelectedChatTemplate: Story = args => 320 | //
321 | //
333 | 334 | // const NoChatsTemplate: Story = args => ; 344 | 345 | // const WithPaddingContainer = styled.div` 346 | // height: 500px; 347 | // padding: 20px; 348 | // border: #FF0000 1px solid; 349 | // position: relative; 350 | // width: 800px; 351 | // ` 352 | // const TemplateWithPadding: Story = args =>
356 | // console.log("onSendMessage"), 370 | // onBack: () => { } 371 | 372 | // }} 373 | // /> 374 | // 375 | //
376 | 377 | // By passing using the Args format for exported stories, you can control the props for a component for reuse in a test 378 | // https://storybook.js.org/docs/react/workflows/unit-testing 379 | export const Default = Template.bind({}); 380 | export const Mobile = MobileTemplate.bind({}); 381 | // export const MobileNoSelectedConversation = MobileNoSelectedChatTemplate.bind({}); 382 | export const NoSelectedConversation = NoSelectedChatTemplate.bind({}); 383 | // export const NoConversation = NoChatsTemplate.bind({}); 384 | // export const WithPadding = TemplateWithPadding.bind({}); 385 | // export const LoadingConversation = LoadingTemplate.bind({}); 386 | export const CustomColors = CustomColorsTemplate.bind({}); 387 | 388 | export const MobileChatListCustomColors = MobileChatListCustomColorsTemplate.bind({}); 389 | export const MobileMessageCustomColors = MobileMessageCustomColorsTemplate.bind({}); 390 | export const LoadingCustomColors = LoadingCustomColorsTemplate.bind({}); 391 | export const NoMessageCustomColors = NoMessageCustomColorsTemplate.bind({}); 392 | export const NoConversationCustomColors = NoConversationCustomColorsTemplate.bind({}); 393 | 394 | 395 | 396 | 397 | Default.args = {}; 398 | -------------------------------------------------------------------------------- /stories/Message.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Meta, Story } from '@storybook/react'; 3 | import styled from 'styled-components'; 4 | import Message, { Props } from "../src/components/message" 5 | 6 | const meta: Meta = { 7 | title: 'Message', 8 | component: Message, 9 | parameters: { 10 | controls: { expanded: true }, 11 | }, 12 | }; 13 | 14 | export default meta; 15 | 16 | const date = new Date() 17 | const minutesAgoDate = new Date(date) 18 | const hoursAgoDate = new Date(date) 19 | const daysAgoDate = new Date(date) 20 | const monthsAgoDate = new Date(date) 21 | 22 | 23 | 24 | minutesAgoDate.setMinutes(minutesAgoDate.getMinutes() - 10) 25 | hoursAgoDate.setHours(hoursAgoDate.getHours() - 3) 26 | daysAgoDate.setDate(daysAgoDate.getDate() - 10) 27 | monthsAgoDate.setMonth(monthsAgoDate.getMonth() - 2) 28 | 29 | const LeftTemplate: Story = args => 40 | 41 | const IncomingWithLinkTemplate: Story = args => 54 | 55 | const IncomingWithHTMLTemplate: Story = args => 68 | 69 | const IncomingWithLinkAndHTMLTemplate: Story = args => 82 | 83 | const OutgoingWithLinkTemplate: Story = args => 96 | 97 | 98 | 99 | const RightTemplate: Story = args => 112 | 113 | 114 | const LoadingTemplate: Story = args => 128 | 129 | const WithAvatarTemplate: Story = args => 142 | 143 | const ImageContentTemplate: Story = args => 159 | 160 | const FileContentTemplate: Story = args => 178 | 179 | const VideoContentTemplate: Story = args => 196 | 197 | const GifContentTemplate: Story = args => 214 | 215 | const WithHeaderTemplate: Story = args => 229 | 230 | const LastIncomingTemplate: Story = args => 244 | 245 | 246 | const SingleIncomingTemplate: Story = args => 260 | 261 | 262 | 263 | const SingleOutgoingTemplate: Story = args => 276 | 277 | const LastOutgoingTemplate: Story = args => 290 | 291 | const OutgoingImageContentTemplate: Story = args => 306 | 307 | const OutgoingFileContentTemplate: Story = args => 323 | 324 | const OutgoingVideoContentTemplate: Story = args => 341 | 342 | 343 | const OutgoingGifContentTemplate: Story = args => 360 | 361 | 362 | // By passing using the Args format for exported stories, you can control the props for a component for reuse in a test 363 | // https://storybook.js.org/docs/react/workflows/unit-testing 364 | 365 | export const Incoming = LeftTemplate.bind({}); 366 | export const IncomingWithLink = IncomingWithLinkTemplate.bind({}); 367 | export const IncomingWithHTML = IncomingWithHTMLTemplate.bind({}); 368 | export const IncomingWithLinkAndHTML = IncomingWithLinkAndHTMLTemplate.bind({}); 369 | 370 | export const IncomingWithAvatar = WithAvatarTemplate.bind({}); 371 | export const IncomingWithHeader = WithHeaderTemplate.bind({}); 372 | export const SingleIncoming = SingleIncomingTemplate.bind({}); 373 | export const LastIncoming = LastIncomingTemplate.bind({}); 374 | export const IncomingImageContent = ImageContentTemplate.bind({}); 375 | export const IncomingFileContent = FileContentTemplate.bind({}); 376 | export const IncomingVideoContent = VideoContentTemplate.bind({}); 377 | export const IncomingGifContent = GifContentTemplate.bind({}); 378 | 379 | 380 | 381 | export const Outgoing = RightTemplate.bind({}); 382 | export const SingleOutgoing = SingleOutgoingTemplate.bind({}); 383 | export const LastOutgoing = LastOutgoingTemplate.bind({}); 384 | export const SendMessageLoading = LoadingTemplate.bind({}); 385 | export const OutgoingWithLink = OutgoingWithLinkTemplate.bind({}); 386 | export const OutgoingImageContent = OutgoingImageContentTemplate.bind({}); 387 | export const OutgoingFileContent = OutgoingFileContentTemplate.bind({}); 388 | export const OutgoingVideoContent = OutgoingVideoContentTemplate.bind({}); 389 | export const OutgoingGifContent = OutgoingGifContentTemplate.bind({}); 390 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | 398 | -------------------------------------------------------------------------------- /stories/MessageContainer.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Meta, Story } from '@storybook/react'; 3 | import styled from 'styled-components'; 4 | import { chats, messages, fewMessages } from './data'; 5 | import MessageHeader from '../src/components/message-header'; 6 | import MessageInput from '../src/components/message-input'; 7 | import MessageList from '../src/components/message-list'; 8 | import MessageContainer, { Props } from '../src/components/message-container'; 9 | 10 | 11 | 12 | 13 | 14 | const meta: Meta = { 15 | title: 'MessagesContainer', 16 | component: MessageContainer, 17 | argTypes: { 18 | }, 19 | parameters: { 20 | controls: { expanded: true }, 21 | }, 22 | }; 23 | 24 | export default meta 25 | 26 | 27 | 28 | const Template: Story = args => { 29 | 30 | return 31 | Welcome 32 | 36 | 37 | 38 | } 39 | 40 | 41 | const FewMessagesTemplate: Story = args => { 42 | 43 | return
44 | Welcome 45 | 49 | 50 | 51 |
52 | } 53 | 54 | const NoMessagesTemplate: Story = args => { 55 | 56 | return
57 | Welcome 58 | 62 | 63 | 64 |
65 | } 66 | 67 | const MobileTemplate: Story = args => { 68 | return
69 | Welcome 70 | 75 | 76 | 77 |
78 | } 79 | 80 | 81 | // By passing using the Args format for exported stories, you can control the props for a component for reuse in a test 82 | // https://storybook.js.org/docs/react/workflows/unit-testing 83 | export const Default = Template.bind({}); 84 | export const Mobile = MobileTemplate.bind({}); 85 | export const FewMessages = FewMessagesTemplate.bind({}); 86 | export const NoMessages = NoMessagesTemplate.bind({}); 87 | 88 | 89 | 90 | Default.args = {}; 91 | -------------------------------------------------------------------------------- /stories/MessageHeader.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Meta, Story } from '@storybook/react'; 3 | import styled from 'styled-components'; 4 | import MessageHeader, { Props } from "../src/components/message-header" 5 | 6 | const meta: Meta = { 7 | title: 'MessageHeader', 8 | component: MessageHeader, 9 | parameters: { 10 | controls: { expanded: true }, 11 | }, 12 | }; 13 | 14 | export default meta; 15 | 16 | const date = new Date() 17 | const minutesAgoDate = new Date(date) 18 | const hoursAgoDate = new Date(date) 19 | const daysAgoDate = new Date(date) 20 | const monthsAgoDate = new Date(date) 21 | 22 | 23 | 24 | minutesAgoDate.setMinutes(minutesAgoDate.getMinutes() - 10) 25 | hoursAgoDate.setHours(hoursAgoDate.getHours() - 3) 26 | daysAgoDate.setDate(daysAgoDate.getDate() - 10) 27 | monthsAgoDate.setMonth(monthsAgoDate.getMonth() - 2) 28 | 29 | 30 | 31 | 32 | 33 | const Template: Story = args => Daniel 34 | const NoBackTemplate: Story = args => Daniel 35 | const ActiveNowTemplate: Story = args => Daniel 36 | const MinutesAgoTemplate: Story = args => Daniel 37 | const HoursAgoTemplate: Story = args => Daniel 38 | const DaysAgoTemplate: Story = args => Daniel 39 | const MonthsAgoTemplate: Story = args => Daniel 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | // By passing using the Args format for exported stories, you can control the props for a component for reuse in a test 51 | // https://storybook.js.org/docs/react/workflows/unit-testing 52 | export const Default = Template.bind({}); 53 | export const WithoutBackIcon = NoBackTemplate.bind({}); 54 | export const ActiveNow = ActiveNowTemplate.bind({}); 55 | export const MinutesAgo = MinutesAgoTemplate.bind({}); 56 | export const HoursAgo = HoursAgoTemplate.bind({}); 57 | export const DaysAgo = DaysAgoTemplate.bind({}); 58 | export const MonthsAgo = MonthsAgoTemplate.bind({}); 59 | 60 | 61 | 62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /stories/MessageInput.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Meta, Story } from '@storybook/react'; 3 | import styled from 'styled-components'; 4 | import MessageInput, { Props } from "../src/components/message-input" 5 | import MinChatUIProvider from "../src/providers/MinChatUiProvider" 6 | const meta: Meta = { 7 | title: 'MessageInput', 8 | component: MessageInput, 9 | parameters: { 10 | controls: { expanded: true }, 11 | }, 12 | }; 13 | 14 | export default meta; 15 | 16 | 17 | const Template: Story = args => 18 | 21 | console.log("onAttachClick")} 24 | > 25 | 26 | 27 | const TemplateWithoutAttach: Story = args => 28 | 31 | console.log("onAttachClick")} 34 | showAttachButton={false} 35 | > 36 | 37 | 38 | const TemplateWithoutSend: Story = args => 39 | 42 | console.log("onAttachClick")} 45 | showAttachButton={false} 46 | showSendButton={false} 47 | > 48 | 49 | 50 | const TemplateDisabled: Story = args => 51 | 54 | console.log("onAttachClick")} 57 | disabled={true} 58 | > 59 | 60 | 61 | 62 | 63 | // By passing using the Args format for exported stories, you can control the props for a component for reuse in a test 64 | // https://storybook.js.org/docs/react/workflows/unit-testing 65 | export const Default = Template.bind({}); 66 | export const WithoutAttach = TemplateWithoutAttach.bind({}); 67 | 68 | export const WithoutSend = TemplateWithoutSend.bind({}); 69 | export const Disabled = TemplateDisabled.bind({}); 70 | 71 | 72 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /stories/MessageList.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Meta, Story } from '@storybook/react'; 3 | import MessageList, { MessageListProps } from '../src/components/message-list'; 4 | import styled from 'styled-components'; 5 | import { chats, messages, fewMessages } from './data'; 6 | 7 | const meta: Meta = { 8 | title: 'MessageList', 9 | component: MessageList, 10 | parameters: { 11 | controls: { expanded: true }, 12 | }, 13 | }; 14 | 15 | export default meta; 16 | 17 | 18 | const Template: Story = args =>
23 |
; 24 | 25 | const LoadingTemplate: Story = args =>
33 |
; 34 | 35 | const CustomLoadingTemplate: Story = args =>
Custom Loading...
} 42 | /> 43 |
; 44 | 45 | const SendMessageLoadingTemplate: Story = args =>
64 |
; 65 | 66 | 67 | const FewEntriesTemplate: Story = args =>
74 |
; 75 | 76 | const NoEntriesTemplate: Story = args =>
83 |
; 84 | 85 | const CustomNoEntriesTemplate: Story = args =>
Custom No Messages View...
} 91 | /> 92 |
; 93 | 94 | 95 | const WithPaddingContainer = styled.div` 96 | height: 400px; 97 | padding: 20px; 98 | position: relative; 99 | width: 500px; 100 | background-color: red; 101 | border: 3px solid black; 102 | ` 103 | 104 | const TemplateWithPadding: Story = args =>
; 111 | 112 | const TypingIndicatorTemplate: Story = args =>
132 |
; 133 | 134 | const CustomTypingIndicatorTemplate: Story = args =>
Custom typing indicator working!
} 139 | themeColor='#6ea9d7' 140 | showTypingIndicator={true} 141 | typingIndicatorContent="Mark is typing" 142 | messages={[ 143 | { 144 | "user": { 145 | "id": "danny_1", 146 | "name": "Daniel Georgetown", 147 | avatar: "https://media.sproutsocial.com/uploads/2022/06/profile-picture.jpeg" 148 | 149 | }, 150 | "text": "this message should have loading" 151 | }, 152 | ...messages 153 | ]} 154 | /> 155 | ; 156 | 157 | // By passing using the Args format for exported stories, you can control the props for a component for reuse in a test 158 | // https://storybook.js.org/docs/react/workflows/unit-testing 159 | export const Default = Template.bind({}); 160 | export const WithPadding = TemplateWithPadding.bind({}); 161 | export const FewMessages = FewEntriesTemplate.bind({}); 162 | export const NoMessages = NoEntriesTemplate.bind({}); 163 | export const CustomNoMessages = CustomNoEntriesTemplate.bind({}); 164 | export const MessagesLoading = LoadingTemplate.bind({}); 165 | export const CustomMessagesLoading = CustomLoadingTemplate.bind({}); 166 | 167 | export const SendMessageLoading = SendMessageLoadingTemplate.bind({}); 168 | export const TypingIndicator = TypingIndicatorTemplate.bind({}); 169 | export const CustomTypingIndicator = CustomTypingIndicatorTemplate.bind({}); 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | Default.args = {}; 179 | -------------------------------------------------------------------------------- /stories/Sidebar.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Meta, Story } from '@storybook/react'; 3 | import Sidebar, { Props } from "../src/components/sidebar" 4 | import { chats } from './data'; 5 | import ConversationList from '../src/components/conversation-list'; 6 | 7 | const meta: Meta = { 8 | title: 'Sidebar', 9 | component: Sidebar, 10 | parameters: { 11 | controls: { expanded: true }, 12 | }, 13 | }; 14 | 15 | export default meta; 16 | 17 | const Template: Story = args => 23 | 24 | 25 | 26 | // By passing using the Args format for exported stories, you can control the props for a component for reuse in a test 27 | // https://storybook.js.org/docs/react/workflows/unit-testing 28 | export const Default = Template.bind({}); 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /stories/TypingIndicator.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Meta, Story } from '@storybook/react'; 3 | import styled from 'styled-components'; 4 | import TypingIndicator, { Props } from "../src/components/typing-indicator" 5 | 6 | const meta: Meta = { 7 | title: 'TypingIndicator', 8 | component: TypingIndicator, 9 | parameters: { 10 | controls: { expanded: true }, 11 | }, 12 | }; 13 | 14 | export default meta; 15 | 16 | 17 | const Template: Story = args => 18 | const WithoutContentTemplate: Story = args => 19 | 20 | 21 | 22 | 23 | // By passing using the Args format for exported stories, you can control the props for a component for reuse in a test 24 | // https://storybook.js.org/docs/react/workflows/unit-testing 25 | export const Default = Template.bind({}); 26 | export const WithoutContent = WithoutContentTemplate.bind({}); 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /stories/data.tsx: -------------------------------------------------------------------------------- 1 | import ConversationType from "../src/types/ConversationType"; 2 | import MessageType from "../src/types/MessageType"; 3 | 4 | const date = new Date() 5 | 6 | export const chats: ConversationType[] = [ 7 | { 8 | id: '1', 9 | title: 'Epic gamers', 10 | avatar: 11 | 'https://images.pexels.com/photos/1704488/pexels-photo-1704488.jpeg?cs=srgb&dl=pexels-suliman-sallehi-1704488.jpg&fm=jpg', 12 | unread: true, 13 | lastMessage: { 14 | seen: false, 15 | createdAt: date, 16 | text: 'Hello everbody', 17 | user: { 18 | id: 'martha_stewart', 19 | name: 'Daniel', 20 | avatar: 'https://media.sproutsocial.com/uploads/2022/06/profile-picture.jpeg', 21 | }, 22 | }, 23 | }, 24 | { 25 | id: '2', 26 | 27 | title: 'Devops', 28 | lastMessage: { 29 | createdAt: date, 30 | seen: true, 31 | text: 'How do you enable an actuator on a servo motor of a hardware and design laboratory experiment in the city,an actuator on a servo motor of a hardware and design laboratory experiment in the city', 32 | user: { 33 | avatar: 'https://fsdfsdfsdfs', 34 | id: 'daniel', 35 | name: 'Stanley Herbert Lee', 36 | }, 37 | }, 38 | }, 39 | { 40 | // id: "3", 41 | title: 'This is a group with a long title heading that is multilines long', 42 | avatar: 43 | 'https://media.sproutsocial.com/uploads/2022/06/profile-picture.jpeg', 44 | lastMessage: { 45 | createdAt: date, 46 | seen: true, 47 | text: 'Hello everbody', 48 | media: { 49 | type: "image", 50 | url: 'https://media.sproutsocial.com/uploads/2022/06/profile-picture.jpeg', 51 | }, 52 | user: { 53 | id: 'me', 54 | name: 'Martha Stewart', 55 | }, 56 | }, 57 | }, 58 | { 59 | id: '4', 60 | title: 'Epic gamers', 61 | lastMessage: { 62 | createdAt: date, 63 | seen: true, 64 | text: 'Hello everbody', 65 | user: { 66 | id: 'daniel', 67 | name: 'Daniel', 68 | avatar: 69 | 'https://cdn.pixabay.com/photo/2015/03/03/08/55/portrait-657116_1280.jpg', 70 | }, 71 | }, 72 | }, 73 | { 74 | id: '5', 75 | title: 'Devops', 76 | lastMessage: { 77 | createdAt: date, 78 | seen: true, 79 | text: 'How do you enable an actuator', 80 | 81 | user: { 82 | id: 'daniel', 83 | name: 'Daniel', 84 | }, 85 | }, 86 | }, 87 | { 88 | id: '6', 89 | title: 'Pigments', 90 | lastMessage: { 91 | createdAt: date, 92 | seen: true, 93 | text: 'Hello everbody', 94 | user: { 95 | id: 'daniel', 96 | name: 'Daniel', 97 | }, 98 | }, 99 | }, 100 | { 101 | id: '7', 102 | title: 'Epic gamers', 103 | lastMessage: { 104 | createdAt: date, 105 | seen: true, 106 | text: 'Hello everbody', 107 | user: { 108 | id: 'daniel', 109 | name: 'Daniel', 110 | }, 111 | }, 112 | }, 113 | { 114 | id: '8', 115 | title: 'Devops', 116 | lastMessage: { 117 | createdAt: date, 118 | seen: true, 119 | text: 'How do you enable an actuator', 120 | 121 | user: { 122 | id: 'daniel', 123 | name: 'Daniel', 124 | }, 125 | }, 126 | }, 127 | { 128 | id: '9', 129 | title: 'Pigments', 130 | lastMessage: { 131 | createdAt: date, 132 | seen: true, 133 | text: 'Hello everbody', 134 | user: { 135 | id: 'daniel', 136 | name: 'Daniel', 137 | }, 138 | }, 139 | }, 140 | ]; 141 | 142 | export const fewChats = [ 143 | { 144 | title: 'Epic gamers', 145 | lastMessage: { 146 | createdAt: date, 147 | seen: false, 148 | text: 'Hello everbody', 149 | user: { 150 | id: 'daniel', 151 | name: 'Daniel', 152 | }, 153 | }, 154 | }, 155 | { 156 | id: '2', 157 | title: 'Devops', 158 | lastMessage: { 159 | seen: true, 160 | text: 'How do you enable an actuator', 161 | 162 | user: { 163 | id: 'daniel', 164 | name: 'Daniel', 165 | }, 166 | }, 167 | }, 168 | { 169 | id: '3', 170 | title: 'Pigments', 171 | lastMessage: { 172 | createdAt: date, 173 | seen: true, 174 | text: 'Hello everbody', 175 | user: { 176 | id: 'daniel', 177 | name: 'Daniel', 178 | }, 179 | }, 180 | }, 181 | ]; 182 | 183 | 184 | export const messages: MessageType[] = [ 185 | { 186 | user: { 187 | id: 'danny_1', 188 | name: 'Daniel Georgetown', 189 | avatar: 190 | 'https://media.sproutsocial.com/uploads/2022/06/profile-picture.jpeg', 191 | }, 192 | createdAt: date, 193 | text: 'first message', 194 | }, 195 | { 196 | user: { 197 | id: 'mark', 198 | name: 'Markus', 199 | }, 200 | createdAt: date, 201 | text: 'hello', 202 | }, 203 | { 204 | user: { 205 | id: 'danny_1', 206 | name: 'Daniel Georgetown', 207 | }, 208 | createdAt: date, 209 | text: 'last message 2', 210 | }, 211 | { 212 | user: { 213 | id: 'danny_1', 214 | name: 'Daniel Georgetown', 215 | }, 216 | createdAt: date, 217 | text: 'how do you think we should aproach this', 218 | }, 219 | { 220 | user: { 221 | id: 'danny_1', 222 | name: 'Daniel Georgetown', 223 | }, 224 | createdAt: date, 225 | text: 'sdfsdf', 226 | }, 227 | { 228 | user: { 229 | id: 'danny_1', 230 | name: 'Daniel Georgetown', 231 | }, 232 | media: { 233 | type: "image", 234 | url: 'https://media.sproutsocial.com/uploads/2022/06/profile-picture.jpeg', 235 | }, 236 | createdAt: date, 237 | 238 | }, 239 | { 240 | user: { 241 | id: 'danny_1', 242 | name: 'Daniel Georgetown', 243 | }, 244 | createdAt: date, 245 | text: 'how do you think we should aproach this https://google.com', 246 | }, 247 | { 248 | user: { 249 | id: 'mark', 250 | name: 'Markus', 251 | }, 252 | createdAt: date, 253 | media: { 254 | type: "image", 255 | url: 'https://media.sproutsocial.com/uploads/2022/06/profile-picture.jpeg', 256 | } 257 | }, 258 | { 259 | user: { 260 | id: 'danny_1', 261 | name: 'Daniel Georgetown', 262 | }, 263 | createdAt: date, 264 | text: 'determine', 265 | }, 266 | { 267 | user: { 268 | id: 'danny_1', 269 | name: 'Daniel Georgetown', 270 | }, 271 | createdAt: date, 272 | text: 'resolve', 273 | }, 274 | { 275 | user: { 276 | id: 'danny_1', 277 | name: 'Daniel Georgetown', 278 | }, 279 | createdAt: date, 280 | text: 'will', 281 | }, 282 | { 283 | user: { 284 | id: 'danny_1', 285 | name: 'Daniel Georgetown', 286 | }, 287 | createdAt: date, 288 | text: 'this', 289 | }, 290 | { 291 | user: { 292 | id: 'danny_1', 293 | name: 'Daniel Georgetown', 294 | }, 295 | createdAt: date, 296 | text: 'how', 297 | }, 298 | { 299 | user: { 300 | id: 'danny_1', 301 | name: 'Daniel Georgetown', 302 | }, 303 | createdAt: date, 304 | text: 'we ', 305 | }, 306 | { 307 | user: { 308 | id: 'danny_1', 309 | name: 'Daniel Georgetown', 310 | avatar: 311 | 'https://media.sproutsocial.com/uploads/2022/06/profile-picture.jpeg', 312 | }, 313 | createdAt: date, 314 | text: 'foks', 315 | }, 316 | { 317 | user: { 318 | id: 'danny_1', 319 | name: 'Daniel Georgetown', 320 | }, 321 | createdAt: date, 322 | text: 'ipsum', 323 | }, 324 | { 325 | user: { 326 | id: 'danny_1', 327 | name: 'Daniel Georgetown', 328 | }, 329 | createdAt: date, 330 | text: 'lorem', 331 | }, 332 | { 333 | user: { 334 | id: 'danny_1', 335 | name: 'Daniel Georgetown', 336 | avatar: 337 | 'https://media.sproutsocial.com/uploads/2022/06/profile-picture.jpeg', 338 | }, 339 | createdAt: date, 340 | text: 'totally justifiable', 341 | }, 342 | { 343 | user: { 344 | id: 'danny_2', 345 | name: 'Dan', 346 | avatar: 347 | 'https://media.sproutsocial.com/uploads/2022/06/profile-picture.jpeg', 348 | }, 349 | createdAt: date, 350 | text: 'it ', 351 | }, 352 | { 353 | user: { 354 | id: 'danny_2', 355 | name: 'Dan', 356 | avatar: 357 | 'https://media.sproutsocial.com/uploads/2022/06/profile-picture.jpeg', 358 | }, 359 | createdAt: date, 360 | text: 'is https://google.com ', 361 | }, 362 | { 363 | createdAt: date, 364 | user: { 365 | id: 'danny_2', 366 | name: 'Dan', 367 | avatar: 368 | 'https://media.sproutsocial.com/uploads/2022/06/profile-picture.jpeg', 369 | }, 370 | media: { 371 | type: "image", 372 | url: 'https://media.sproutsocial.com/uploads/2022/06/profile-picture.jpeg', 373 | }, 374 | }, 375 | { 376 | user: { 377 | id: 'danny_1', 378 | name: 'Daniel Georgetown', 379 | }, 380 | seen: true, 381 | createdAt: date, 382 | text: 'this ', 383 | }, 384 | { 385 | user: { 386 | id: 'danny_1', 387 | name: 'Daniel Georgetown', 388 | }, 389 | seen: true, 390 | createdAt: date, 391 | text: 'the ', 392 | }, 393 | { 394 | user: { 395 | id: 'danny_1', 396 | name: 'Daniel Georgetown', 397 | }, 398 | seen: false, 399 | createdAt: date, 400 | text: 'only message you will send today', 401 | }, 402 | { 403 | user: { 404 | id: 'danny_1', 405 | name: 'Daniel Georgetown', 406 | }, 407 | text: 'come on man https://google.com', 408 | seen: true, 409 | createdAt: date, 410 | loading: true 411 | }, 412 | { 413 | user: { 414 | id: 'danny_1', 415 | name: 'Daniel Georgetown', 416 | }, 417 | text: 'this is the last message', 418 | createdAt: date, 419 | seen: true, 420 | loading: true 421 | }, 422 | ]; 423 | 424 | export const fewMessages = [ 425 | { 426 | user: { 427 | id: 'danny_2', 428 | name: 'Daniel Georgetown', 429 | avatar: 430 | 'https://media.sproutsocial.com/uploads/2022/06/profile-picture.jpeg', 431 | }, 432 | createdAt: date, 433 | text: 'first message', 434 | }, 435 | { 436 | user: { 437 | id: 'danny_1', 438 | name: 'Daniel Georgetown', 439 | avatar: 440 | 'https://media.sproutsocial.com/uploads/2022/06/profile-picture.jpeg', 441 | }, 442 | createdAt: date, 443 | text: 'come on man', 444 | }, 445 | { 446 | user: { 447 | id: 'danny_1', 448 | name: 'Daniel Georgetown', 449 | avatar: 450 | 'https://media.sproutsocial.com/uploads/2022/06/profile-picture.jpeg', 451 | }, 452 | createdAt: date, 453 | text: 'this is the last message', 454 | }, 455 | ]; 456 | -------------------------------------------------------------------------------- /test/blah.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import * as ReactDOM from 'react-dom'; 3 | import { Default as Thing } from '../stories/Thing.stories'; 4 | 5 | describe('Thing', () => { 6 | it('renders without crashing', () => { 7 | const div = document.createElement('div'); 8 | ReactDOM.render(, div); 9 | ReactDOM.unmountComponentAtNode(div); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs 3 | "include": ["src", "types"], 4 | "compilerOptions": { 5 | "module": "esnext", 6 | "lib": ["dom", "esnext"], 7 | "importHelpers": true, 8 | // output .d.ts declaration files for consumers 9 | "declaration": true, 10 | // output .js.map sourcemap files for consumers 11 | "sourceMap": true, 12 | // match output dir to input dir. e.g. dist/index instead of dist/src/index 13 | "rootDir": "./src", 14 | // stricter type-checking for stronger correctness. Recommended by TS 15 | "strict": true, 16 | // linter checks for common issues 17 | "noImplicitReturns": true, 18 | "noFallthroughCasesInSwitch": true, 19 | // noUnused* overlap with @typescript-eslint/no-unused-vars, can disable if duplicative 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | // use Node's module resolution algorithm, instead of the legacy TS one 23 | "moduleResolution": "node", 24 | // transpile JSX to React.createElement 25 | "jsx": "react", 26 | // interop between ESM and CJS modules. Recommended by TS 27 | "esModuleInterop": true, 28 | // significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS 29 | "skipLibCheck": true, 30 | // error out if import and file system have a casing mismatch. Recommended by TS 31 | "forceConsistentCasingInFileNames": true, 32 | // `tsdx build` ignores this option, but it is commonly used when type-checking separately with `tsc` 33 | "noEmit": true, 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tsdx.config.js: -------------------------------------------------------------------------------- 1 | const images = require('@rollup/plugin-image') 2 | const postcss = require("rollup-plugin-postcss"); 3 | 4 | // Not transpiled with TypeScript or Babel, so use plain Es6/Node.js! 5 | module.exports = { 6 | // This function will run for each entry/format/env combination 7 | rollup(config, options) { 8 | // config.plugins.push(image()) 9 | 10 | config.plugins = [ 11 | images({ incude: ['**/*.png', '**/*.jpg', '**/*.webp'] }), 12 | postcss(), 13 | ...config.plugins, 14 | ] 15 | 16 | return config 17 | }, 18 | }; --------------------------------------------------------------------------------