├── public ├── robots.txt ├── favicon.ico ├── logo192.png ├── logo512.png ├── manifest.json └── index.html ├── src ├── images │ ├── profiles │ │ ├── ben.png │ │ ├── daryl.png │ │ ├── jacob.png │ │ ├── john.jpeg │ │ ├── kim.jpeg │ │ ├── sarah.jpeg │ │ ├── stan.jpeg │ │ ├── douglas.png │ │ └── stacey.jpeg │ └── search │ │ └── search.svg ├── components │ ├── controls │ │ ├── icons │ │ │ ├── trash-icon │ │ │ │ ├── TrashIcon.scss │ │ │ │ └── TrashIcon.js │ │ │ └── attachment-icon │ │ │ │ ├── AttachmentIcon.scss │ │ │ │ └── AttachmentIcon.js │ │ └── buttons │ │ │ ├── Button.js │ │ │ ├── FormButton.js │ │ │ └── Button.scss │ ├── conversation │ │ ├── conversation-list │ │ │ ├── ConversationList.scss │ │ │ └── ConversationList.js │ │ ├── new-conversation │ │ │ ├── NewConversation.js │ │ │ └── NewConversation.scss │ │ ├── conversation-search │ │ │ ├── ConversationSearch.js │ │ │ └── ConversationSearch.scss │ │ ├── no-conversations │ │ │ ├── NoConversations.js │ │ │ └── NoConversations.scss │ │ └── conversation-item │ │ │ ├── ConversationItem.js │ │ │ └── ConversationItem.scss │ ├── chat-title │ │ ├── ChatTitle.scss │ │ ├── __snapshots__ │ │ │ └── ChatTitle.test.js.snap │ │ ├── ChatTitle.js │ │ └── ChatTitle.test.js │ ├── message │ │ ├── Message.js │ │ └── Message.scss │ └── chat-form │ │ ├── ChatForm.scss │ │ └── ChatForm.js ├── containers │ ├── message │ │ ├── MessageList.scss │ │ └── MessageList.js │ └── shell │ │ ├── ChatShell.scss │ │ └── ChatShell.js ├── App.js ├── store │ ├── reducers │ │ ├── index.js │ │ ├── messages.js │ │ └── conversations.js │ ├── sagas │ │ ├── index.js │ │ ├── messages.js │ │ └── conversations.js │ └── actions │ │ └── index.js ├── index.scss ├── App.test.js ├── styles │ └── _colors.scss ├── index.js ├── serviceWorker.js ├── logo.svg └── __snapshots__ │ └── App.test.js.snap ├── babel.config.js ├── package.json ├── .gitignore └── README.md /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lyraddigital/chat-app-react/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lyraddigital/chat-app-react/HEAD/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lyraddigital/chat-app-react/HEAD/public/logo512.png -------------------------------------------------------------------------------- /src/images/profiles/ben.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lyraddigital/chat-app-react/HEAD/src/images/profiles/ben.png -------------------------------------------------------------------------------- /src/images/profiles/daryl.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lyraddigital/chat-app-react/HEAD/src/images/profiles/daryl.png -------------------------------------------------------------------------------- /src/images/profiles/jacob.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lyraddigital/chat-app-react/HEAD/src/images/profiles/jacob.png -------------------------------------------------------------------------------- /src/images/profiles/john.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lyraddigital/chat-app-react/HEAD/src/images/profiles/john.jpeg -------------------------------------------------------------------------------- /src/images/profiles/kim.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lyraddigital/chat-app-react/HEAD/src/images/profiles/kim.jpeg -------------------------------------------------------------------------------- /src/images/profiles/sarah.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lyraddigital/chat-app-react/HEAD/src/images/profiles/sarah.jpeg -------------------------------------------------------------------------------- /src/images/profiles/stan.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lyraddigital/chat-app-react/HEAD/src/images/profiles/stan.jpeg -------------------------------------------------------------------------------- /src/images/profiles/douglas.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lyraddigital/chat-app-react/HEAD/src/images/profiles/douglas.png -------------------------------------------------------------------------------- /src/images/profiles/stacey.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lyraddigital/chat-app-react/HEAD/src/images/profiles/stacey.jpeg -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@babel/preset-env', 4 | '@babel/preset-react' 5 | ] 6 | }; -------------------------------------------------------------------------------- /src/components/controls/icons/trash-icon/TrashIcon.scss: -------------------------------------------------------------------------------- 1 | @import '../../../../styles/_colors'; 2 | 3 | .trash-logo { 4 | stroke: $primary-color; 5 | cursor: pointer; 6 | } -------------------------------------------------------------------------------- /src/containers/message/MessageList.scss: -------------------------------------------------------------------------------- 1 | #chat-message-list { 2 | grid-area: chat-message-list; 3 | display: flex; 4 | flex-direction: column-reverse; 5 | padding: 0 20px; 6 | overflow-y: scroll; 7 | } -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import ChatShell from './containers/shell/ChatShell'; 4 | 5 | const App = () => { 6 | return ( 7 | 8 | ); 9 | } 10 | 11 | export default App; 12 | -------------------------------------------------------------------------------- /src/components/conversation/conversation-list/ConversationList.scss: -------------------------------------------------------------------------------- 1 | @import '../../../styles/_colors'; 2 | 3 | #conversation-list { 4 | grid-area: conversation-list; 5 | background: $primary-color; 6 | overflow-y: scroll; 7 | } -------------------------------------------------------------------------------- /src/store/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | 3 | import conversationState from './conversations'; 4 | import messagesState from './messages'; 5 | 6 | export default combineReducers({ 7 | conversationState, 8 | messagesState 9 | }); -------------------------------------------------------------------------------- /src/components/controls/icons/attachment-icon/AttachmentIcon.scss: -------------------------------------------------------------------------------- 1 | @import '../../../../styles/_colors'; 2 | 3 | .attachment-logo { 4 | fill: $primary-color; 5 | cursor: pointer; 6 | width: 32px; 7 | height: 32px; 8 | enable-background: new 0 0 512.001 512.001; 9 | } -------------------------------------------------------------------------------- /src/components/controls/buttons/Button.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import './Button.scss'; 4 | 5 | const Button = ({children}) => { 6 | return ( 7 | <> 8 | 9 | 10 | ); 11 | } 12 | 13 | export default Button; -------------------------------------------------------------------------------- /src/components/conversation/new-conversation/NewConversation.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import './NewConversation.scss'; 4 | 5 | const NewConversation = () => { 6 | return ( 7 |
8 | 9 |
10 | ); 11 | } 12 | 13 | export default NewConversation; -------------------------------------------------------------------------------- /src/store/sagas/index.js: -------------------------------------------------------------------------------- 1 | import { all } from 'redux-saga/effects'; 2 | 3 | import { watchGetConversationsAsync } from './conversations'; 4 | import { watchGetMessagesAsync } from './messages'; 5 | 6 | export default function* rootSaga() { 7 | yield all([ 8 | watchGetConversationsAsync(), 9 | watchGetMessagesAsync() 10 | ]); 11 | } -------------------------------------------------------------------------------- /src/components/controls/buttons/FormButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import './Button.scss'; 4 | 5 | const FormButton = ({children, disabled}) => { 6 | return ( 7 | <> 8 | 12 | 13 | ); 14 | } 15 | 16 | export default FormButton; -------------------------------------------------------------------------------- /src/components/conversation/conversation-search/ConversationSearch.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import './ConversationSearch.scss'; 4 | 5 | const ConversationSearch = ({ conversations }) => { 6 | let searchInput = null; 7 | 8 | if (conversations && conversations.length > 0) { 9 | searchInput = ; 10 | } 11 | 12 | return ( 13 |
14 | { searchInput } 15 |
16 | ); 17 | } 18 | 19 | export default ConversationSearch; -------------------------------------------------------------------------------- /src/containers/shell/ChatShell.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles/_colors'; 2 | 3 | $chat-shell-background: lighten($accent-color, 65%); 4 | 5 | #chat-container { 6 | display: grid; 7 | grid: 8 | 'search-container chat-title' 71px 9 | 'conversation-list chat-message-list' 1fr 10 | 'new-message-container chat-form' 78px 11 | / 275px 1fr; 12 | min-width: 1000px; 13 | max-width: 1000px; 14 | max-height: 800px; 15 | width: 100%; 16 | height: 95vh; 17 | background: $chat-shell-background; 18 | border-radius: 10px; 19 | } -------------------------------------------------------------------------------- /src/components/chat-title/ChatTitle.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles/_colors'; 2 | 3 | $chat-title-shadow: darken($accent-color, 75.29); 4 | 5 | #chat-title { 6 | display: grid; 7 | grid: 36px / 1fr 36px; 8 | align-content: center; 9 | align-items: center; 10 | grid-area: chat-title; 11 | background: $accent-color; 12 | color: $primary-color; 13 | font-weight: bold; 14 | font-size: 2.0rem; 15 | border-radius: 0 10px 0 0; 16 | box-shadow: 0 1px 3px -1px $chat-title-shadow; 17 | padding: 0 20px; 18 | z-index: 1; 19 | } -------------------------------------------------------------------------------- /src/index.scss: -------------------------------------------------------------------------------- 1 | @import './styles/_colors'; 2 | 3 | * { 4 | margin: 0; 5 | padding: 0; 6 | box-sizing: border-box; 7 | } 8 | 9 | html, 10 | body { 11 | height: 100%; 12 | } 13 | 14 | html { 15 | font-family: Arial, Helvetica, sans-serif; 16 | background: linear-gradient(to right, $sky-blue 0%,$sky-dark-blue 100%); 17 | font-size: 10px; 18 | } 19 | 20 | body { 21 | display: flex; 22 | justify-content: center; 23 | } 24 | 25 | #root { 26 | display: grid; 27 | place-items: center center; 28 | } 29 | 30 | ::-webkit-scrollbar { 31 | display: none; 32 | } -------------------------------------------------------------------------------- /src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | import { Provider } from 'react-redux'; 4 | import { createStore } from 'redux'; 5 | 6 | import './index.css'; 7 | import rootReducer from './store/reducers'; 8 | import App from './App'; 9 | 10 | const store = createStore(rootReducer); 11 | 12 | test('App loads with default state', () => { 13 | const component = renderer.create( 14 | 15 | 16 | 17 | ); 18 | 19 | let tree = component.toJSON(); 20 | expect(tree).toMatchSnapshot(); 21 | }); 22 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /src/components/chat-title/__snapshots__/ChatTitle.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Chat title loads with correct title 1`] = ` 4 |
7 | 8 | My Conversation 9 | 10 | Delete Conversation 24 |
25 | `; 26 | -------------------------------------------------------------------------------- /src/components/conversation/no-conversations/NoConversations.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Button from '../../controls/buttons/Button'; 4 | 5 | import './NoConversations.scss'; 6 | 7 | const NoConversations = () => { 8 | return ( 9 |
10 |
11 |

No Conversations

12 |

Currently you have no conversations.

13 |

To start a new conversation click the button below.

14 | 15 |
16 |
17 | ); 18 | } 19 | 20 | export default NoConversations; -------------------------------------------------------------------------------- /src/components/conversation/no-conversations/NoConversations.scss: -------------------------------------------------------------------------------- 1 | @import '../../../styles/_colors'; 2 | 3 | #no-coversation-layout { 4 | flex: 1 0 0%; 5 | display: flex; 6 | flex-direction: column; 7 | 8 | #no-conversation-content { 9 | flex: 1 0 0%; 10 | display: flex; 11 | flex-direction: column; 12 | justify-content: center; 13 | align-items: center; 14 | border-radius: 0 0 10px 10px; 15 | } 16 | 17 | h2 { 18 | color: $primary-color; 19 | font-size: 3.5rem; 20 | margin-bottom: 2rem; 21 | } 22 | 23 | p { 24 | font-size: 2rem; 25 | } 26 | 27 | p:last-of-type { 28 | margin-bottom: 1.5rem; 29 | } 30 | } -------------------------------------------------------------------------------- /src/components/controls/buttons/Button.scss: -------------------------------------------------------------------------------- 1 | @import '../../../styles/_colors'; 2 | 3 | $button-text: lighten($accent-color, 65%); 4 | $button-disabled-background: lighten(desaturate(adjust-hue($primary-color, 1), 74.07), 24.31); 5 | $button-disabled-border: lighten(desaturate(adjust-hue($primary-color, 5), 89.24), 17.45); 6 | 7 | .primary-button { 8 | flex: 0 0 auto; 9 | background: $primary-color; 10 | border: 1px solid $primary-dark; 11 | color: $button-text; 12 | padding: 12px; 13 | border-radius: 5px; 14 | font-size: 1.4rem; 15 | cursor: pointer; 16 | outline: none; 17 | 18 | &:disabled { 19 | background: $button-disabled-background; 20 | border: 1px solid $button-disabled-border; 21 | } 22 | } -------------------------------------------------------------------------------- /src/components/chat-title/ChatTitle.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import TrashIcon from '../controls/icons/trash-icon/TrashIcon'; 4 | 5 | import './ChatTitle.scss'; 6 | 7 | const ChatTitle = ({ selectedConversation, onDeleteConversation }) => { 8 | let chatTitleContents = null; 9 | 10 | if (selectedConversation) { 11 | chatTitleContents = ( 12 | <> 13 | { selectedConversation.title } 14 |
{ onDeleteConversation(); } } title="Delete Conversation"> 15 | 16 |
17 | 18 | ); 19 | } 20 | 21 | return ( 22 |
23 | { chatTitleContents } 24 |
25 | ); 26 | } 27 | 28 | export default ChatTitle; -------------------------------------------------------------------------------- /src/components/conversation/new-conversation/NewConversation.scss: -------------------------------------------------------------------------------- 1 | @import '../../../styles/_colors'; 2 | 3 | $container-top-border: darken($primary-color, 27.84); 4 | 5 | #new-message-container { 6 | display: grid; 7 | grid: 40px / 40px; 8 | align-content: center; 9 | grid-area: new-message-container; 10 | background: $primary-color; 11 | border-top: 1px solid $container-top-border; 12 | border-radius: 0 0 0 10px; 13 | padding: 0 15px; 14 | 15 | button { 16 | display: flex; 17 | align-items: center; 18 | justify-content: center; 19 | background: $accent-color; 20 | border-radius: 100%; 21 | color: $primary-dark; 22 | text-decoration: none; 23 | font-size: 3.6rem; 24 | line-height: 3.6rem; 25 | outline: none; 26 | border: none; 27 | cursor: pointer; 28 | } 29 | } -------------------------------------------------------------------------------- /src/styles/_colors.scss: -------------------------------------------------------------------------------- 1 | @import '../../node_modules/compass-sass-mixins/lib/compass/utilities/color/_contrast.sass'; 2 | 3 | $primary-color: #0048AA; 4 | $accent-color: #EEE; 5 | 6 | $contrasted-primary-text: #FFF; // contrast-color($primary-color, lighten($accent-color, 10), darken($accent-color, 10)); 7 | $contrasted-accent-text: #111; // contrast-color($accent-color, lighten($primary-color, 10), darken($primary-color, 10)); 8 | 9 | $primary-dark: darken($primary-color, 8%); 10 | $contrasting-primary-text-color: black; // choose-contrast-color($primary-color); 11 | $contrasting-accent-text-color: white; // choose-contrast-color($accent-color); 12 | 13 | $shadow-box-color: #000000bf; 14 | $search-background-image-color: #ffffff4d; 15 | $light-grey-border: #DDD; 16 | $accent-text-color: darken($accent-color, 86.67); 17 | 18 | $sky-blue: #57c1eb; 19 | $sky-dark-blue: #246fa8; -------------------------------------------------------------------------------- /src/components/chat-title/ChatTitle.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | 4 | import ChatTitle from './ChatTitle'; 5 | 6 | test('Chat title loads with correct title for selected conversation', () => { 7 | const selectedConversation = { title: 'My Conversation' }; 8 | 9 | const component = renderer.create( 10 | <> 11 | 12 | 13 | ); 14 | 15 | const tree = component.toJSON(); 16 | 17 | expect(tree).toMatchSnapshot(); 18 | }); 19 | 20 | test('contents missing when there is no selected conversation', () => { 21 | const component = renderer.create( 22 | <> 23 | 24 | 25 | ); 26 | 27 | const tree = component.toJSON(); 28 | 29 | expect(tree).toMatchSnapshot(); 30 | }); 31 | -------------------------------------------------------------------------------- /src/components/message/Message.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classNames from 'classnames'; 3 | 4 | import './Message.scss'; 5 | 6 | const Message = ({ isMyMessage, message }) => { 7 | const messageClass = classNames('message-row', { 8 | 'you-message': isMyMessage, 9 | 'other-message': !isMyMessage 10 | }); 11 | 12 | const imageThumbnail = isMyMessage ? null : {message.imageAlt}; 13 | 14 | return ( 15 |
16 |
17 | {imageThumbnail} 18 |
19 | {message.messageText} 20 |
21 |
{message.createdAt}
22 |
23 |
24 | ); 25 | } 26 | 27 | export default Message; -------------------------------------------------------------------------------- /src/components/conversation/conversation-item/ConversationItem.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classNames from 'classnames'; 3 | 4 | import './ConversationItem.scss'; 5 | 6 | const ConversationItem = ({ conversation, isActive, onConversationItemSelected }) => { 7 | const className = classNames('conversation', { 8 | 'active': isActive 9 | }); 10 | 11 | return ( 12 |
onConversationItemSelected(conversation.id)}> 13 | {conversation.imageAlt} 14 |
{conversation.title}
15 |
{conversation.createdAt}
16 |
17 | {conversation.latestMessageText} 18 |
19 |
20 | ); 21 | } 22 | 23 | export default ConversationItem; -------------------------------------------------------------------------------- /src/components/chat-form/ChatForm.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles/_colors'; 2 | 3 | $input-border-color: darken($accent-color, 6.5%); 4 | $input-text-color: darken(adjust-hue($primary-color, -155), 23.33); 5 | $chat-form-top-border: darken($accent-color, 12.16); 6 | 7 | #chat-form { 8 | display: flex; 9 | align-items: center; 10 | grid-area: chat-form; 11 | background: $accent-color; 12 | border-radius: 0 0 10px 0; 13 | border-top: 1px solid $chat-form-top-border; 14 | padding-left: 42px; 15 | padding-right: 22px; 16 | 17 | input { 18 | flex: 1 0 0; 19 | outline: none; 20 | padding: 15px; 21 | border: 2px solid $input-border-color; 22 | border-right: none; 23 | color: $input-text-color; 24 | border-radius: 6px 0 0 6px; 25 | font-size: 1.4rem; 26 | margin-left: 15px; 27 | } 28 | 29 | button { 30 | height: 51px; 31 | flex: 0 0 90px; 32 | border-radius: 0 6px 6px 0; 33 | } 34 | } -------------------------------------------------------------------------------- /src/components/conversation/conversation-list/ConversationList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import ConversationItem from '../conversation-item/ConversationItem'; 4 | import './ConversationList.scss'; 5 | 6 | const ConversationList = ({ conversations, selectedConversation, onConversationItemSelected }) => { 7 | const conversationItems = conversations.map((conversation) => { 8 | const conversationIsActive = selectedConversation && conversation.id === selectedConversation.id; 9 | 10 | return ; 15 | }); 16 | 17 | return ( 18 |
19 | {conversationItems} 20 |
21 | ); 22 | } 23 | 24 | export default ConversationList; -------------------------------------------------------------------------------- /src/images/search/search.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/store/actions/index.js: -------------------------------------------------------------------------------- 1 | export const conversationChanged = conversationId => ({ 2 | type: 'SELECTED_CONVERSATION_CHANGED', 3 | conversationId 4 | }); 5 | 6 | export const conversationsRequested = () => ({ 7 | type: 'CONVERSATIONS_REQUESTED' 8 | }); 9 | 10 | export const conversationDeleted = () => ({ 11 | type: 'DELETE_CONVERSATION' 12 | }); 13 | 14 | export const newMessageAdded = textMessage => ({ 15 | type: 'NEW_MESSAGE_ADDED', 16 | textMessage 17 | }); 18 | 19 | export const messagesRequested = (conversationId, numberOfMessages, lastMessageId) => ({ 20 | type: 'MESSAGES_REQUESTED', 21 | payload: { 22 | conversationId, 23 | numberOfMessages, 24 | lastMessageId 25 | } 26 | }); 27 | 28 | export const messagesLoaded = (conversationId, messages, hasMoreMessages, lastMessageId) => ({ 29 | type: 'MESSAGES_LOADED', 30 | payload: { 31 | conversationId, 32 | messages, 33 | hasMoreMessages, 34 | lastMessageId 35 | } 36 | }); -------------------------------------------------------------------------------- /src/components/conversation/conversation-search/ConversationSearch.scss: -------------------------------------------------------------------------------- 1 | @import '../../../styles/_colors'; 2 | 3 | #search-container { 4 | display: flex; 5 | align-items: center; 6 | grid-area: search-container; 7 | background: $primary-color; 8 | padding: 0 20px; 9 | border-radius: 10px 0 0 0; 10 | box-shadow: 0 1px 3px -1px $shadow-box-color; 11 | z-index: 1; 12 | 13 | input { 14 | width: 0; 15 | flex: 1 0 0; 16 | color: $accent-color; 17 | outline: none; 18 | font-weight: bold; 19 | border-radius: 2px; 20 | height: 30px; 21 | border: 0; 22 | padding-left: 48px; 23 | padding-right: 20px; 24 | font-size: 1.4rem; 25 | background: url('../../../images/search/search.svg') no-repeat $search-background-image-color; 26 | background-position: 15px center; 27 | background-size: 20px 20px; 28 | } 29 | 30 | input::placeholder { 31 | color: $light-grey-border; 32 | font-weight: bold; 33 | } 34 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chat-app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "classnames": "^2.2.6", 7 | "compass-sass-mixins": "^0.12.7", 8 | "react": "^16.12.0", 9 | "react-dom": "^16.12.0", 10 | "react-redux": "^7.1.3", 11 | "react-scripts": "3.2.0", 12 | "redux": "^4.0.4", 13 | "redux-saga": "^1.1.3" 14 | }, 15 | "scripts": { 16 | "start": "react-scripts start", 17 | "build": "react-scripts build", 18 | "test": "react-scripts test", 19 | "eject": "react-scripts eject" 20 | }, 21 | "eslintConfig": { 22 | "extends": "react-app" 23 | }, 24 | "browserslist": { 25 | "production": [ 26 | ">0.2%", 27 | "not dead", 28 | "not op_mini all" 29 | ], 30 | "development": [ 31 | "last 1 chrome version", 32 | "last 1 firefox version", 33 | "last 1 safari version" 34 | ] 35 | }, 36 | "devDependencies": { 37 | "node-sass": "^4.13.0", 38 | "react-test-renderer": "^16.12.0", 39 | "redux-devtools": "^3.5.0" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { Provider } from 'react-redux'; 4 | import { createStore, applyMiddleware, compose } from 'redux'; 5 | import createSagaMiddleware from 'redux-saga'; 6 | 7 | import './index.scss'; 8 | import rootSaga from './store/sagas'; 9 | import rootReducer from './store/reducers'; 10 | import App from './App'; 11 | 12 | const sagaMiddleware = createSagaMiddleware(); 13 | 14 | const composeEnhancers = 15 | typeof window === 'object' && 16 | window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ ? 17 | window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({ 18 | // Specify extension’s options like name, actionsBlacklist, actionsCreators, serialize... 19 | }) : compose; 20 | 21 | const enhancer = composeEnhancers( 22 | applyMiddleware(sagaMiddleware) 23 | ); 24 | 25 | const store = createStore( 26 | rootReducer, 27 | enhancer 28 | ); 29 | 30 | sagaMiddleware.run(rootSaga); 31 | 32 | ReactDOM.render( 33 | 34 | 35 | , 36 | document.getElementById('root') 37 | ); 38 | -------------------------------------------------------------------------------- /src/store/reducers/messages.js: -------------------------------------------------------------------------------- 1 | const initialState = { 2 | messageDetails: {} 3 | } 4 | 5 | const messagesReducer = (state = initialState, action) => { 6 | switch(action.type) { 7 | case 'MESSAGES_LOADED': 8 | const { conversationId, messages, hasMoreMessages, lastMessageId } = action.payload; 9 | const currentConversationMapEntry = state.messageDetails[conversationId]; 10 | const newConversationMapEntry = { hasMoreMessages, lastMessageId, messages: [] }; 11 | 12 | if (currentConversationMapEntry) { 13 | newConversationMapEntry.messages = [...currentConversationMapEntry.messages]; 14 | } 15 | 16 | newConversationMapEntry.messages = [...newConversationMapEntry.messages, ...messages]; 17 | 18 | const newMessageDetails = { ...state.messageDetails }; 19 | newMessageDetails[conversationId] = newConversationMapEntry; 20 | 21 | return { messageDetails: newMessageDetails }; 22 | default: 23 | return state; 24 | } 25 | } 26 | 27 | export default messagesReducer; -------------------------------------------------------------------------------- /src/components/conversation/conversation-item/ConversationItem.scss: -------------------------------------------------------------------------------- 1 | @import '../../../styles/_colors'; 2 | 3 | .conversation { 4 | display: grid; 5 | grid-template-columns: 40px 1fr max-content; 6 | grid-gap: 10px; 7 | color: $contrasted-primary-text; // $contrasting-primary-text-color; 8 | font-size: 1.3rem; 9 | border-bottom: 1px solid $primary-dark; 10 | padding: 20px 20px 20px 15px; 11 | 12 | &.active, 13 | &:hover { 14 | background: $primary-dark; 15 | } 16 | 17 | &:hover { 18 | cursor: pointer; 19 | } 20 | 21 | > img { 22 | grid-row: span 2; 23 | height: 40px; 24 | width: 40px; 25 | border-radius: 100%; 26 | } 27 | 28 | .title-text { 29 | font-weight: bold; 30 | padding-left: 5px; 31 | white-space: nowrap; 32 | overflow-x: hidden; 33 | text-overflow: ellipsis; 34 | } 35 | 36 | .created-date { 37 | color: $light-grey-border; 38 | white-space: nowrap; 39 | font-size: 1rem; 40 | } 41 | 42 | .conversation-message { 43 | grid-column: span 2; 44 | padding-left: 5px; 45 | white-space: nowrap; 46 | overflow-x: hidden; 47 | text-overflow: ellipsis; 48 | } 49 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | # package-lock.json 64 | package-lock.json -------------------------------------------------------------------------------- /src/components/message/Message.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles/_colors'; 2 | 3 | $message-time-color: darken($accent-color, 46.5%); 4 | $speech-bubble-border-color: darken($accent-color, 13.18); 5 | 6 | .message-row { 7 | display: grid; 8 | grid-template-columns: 70%; 9 | margin-bottom: 20px; 10 | 11 | > .message-content { 12 | display: grid; 13 | 14 | > img { 15 | border-radius: 100%; 16 | grid-row: span 2; 17 | width: 48px; 18 | height: 48px; 19 | } 20 | 21 | > .message-time { 22 | font-size: 1.3rem; 23 | color: $message-time-color; 24 | } 25 | 26 | > .message-text { 27 | padding: 9px 14px; 28 | font-size: 1.6rem; 29 | margin-bottom: 5px; 30 | } 31 | } 32 | 33 | &.you-message { 34 | justify-content: end; 35 | 36 | > .message-content { 37 | justify-items: end; 38 | 39 | > .message-text { 40 | background: $primary-color; 41 | color: $contrasted-primary-text; 42 | border: 1px solid $primary-color; 43 | border-radius: 14px 14px 0 14px; 44 | } 45 | } 46 | } 47 | 48 | &.other-message { 49 | justify-items: start; 50 | 51 | > .message-content { 52 | grid-template-columns: 48px 1fr; 53 | grid-column-gap: 15px; 54 | 55 | > .message-text { 56 | background: $accent-color; 57 | color: $contrasted-accent-text; 58 | border: 1px solid $speech-bubble-border-color; 59 | border-radius: 14px 14px 14px 0; 60 | } 61 | } 62 | } 63 | } -------------------------------------------------------------------------------- /src/components/chat-form/ChatForm.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | import FormButton from '../controls/buttons/FormButton'; 4 | import AttachmentIcon from '../controls/icons/attachment-icon/AttachmentIcon'; 5 | 6 | import './ChatForm.scss'; 7 | 8 | const isMessageEmpty = (textMessage) => { 9 | return adjustTextMessage(textMessage).length === 0; 10 | } 11 | 12 | const adjustTextMessage = (textMessage) => { 13 | return textMessage.trim(); 14 | }; 15 | 16 | const ChatForm = ({ selectedConversation, onMessageSubmitted }) => { 17 | const [textMessage, setTextMessage] = useState(''); 18 | const disableButton = isMessageEmpty(textMessage); 19 | let formContents = null; 20 | let handleFormSubmit = null; 21 | 22 | if (selectedConversation) { 23 | formContents = ( 24 | <> 25 |
26 | 27 |
28 | { setTextMessage(e.target.value); } } /> 33 | Send 34 | 35 | ); 36 | 37 | handleFormSubmit = (e) => { 38 | e.preventDefault(); 39 | 40 | if (!isMessageEmpty(textMessage)) { 41 | onMessageSubmitted(textMessage); 42 | setTextMessage(''); 43 | } 44 | }; 45 | } 46 | 47 | return ( 48 |
49 | {formContents} 50 |
51 | ); 52 | } 53 | 54 | export default ChatForm; -------------------------------------------------------------------------------- /src/containers/message/MessageList.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import { messagesRequested } from '../../store/actions'; 5 | import Message from '../../components/message/Message'; 6 | import './MessageList.scss'; 7 | 8 | const MessageList = ({ conversationId, getMessagesForConversation, loadMessages }) => { 9 | const messageDetails = getMessagesForConversation(conversationId); 10 | const messages = messageDetails ? messageDetails.messages: null; 11 | let messageItems = null; 12 | 13 | useEffect(() => { 14 | if (!messageDetails) { 15 | loadMessages(conversationId, null); 16 | } 17 | }, [messageDetails, loadMessages, conversationId]) 18 | 19 | if (messages && messages.length > 0) { 20 | messageItems = messages.map((message, index) => { 21 | return ; 25 | }); 26 | } 27 | 28 | return ( 29 |
30 | {messageItems} 31 |
32 | ); 33 | } 34 | 35 | const mapStateToProps = state => { 36 | const getMessagesForConversation = conversationId => { 37 | return state.messagesState.messageDetails[conversationId]; 38 | } 39 | 40 | return { 41 | getMessagesForConversation 42 | } 43 | } 44 | 45 | const mapDispatchToProps = dispatch => { 46 | const loadMessages = (conversationId, lastMessageId) => { 47 | dispatch(messagesRequested(conversationId, 5, lastMessageId)); 48 | } 49 | 50 | return { loadMessages }; 51 | } 52 | 53 | export default connect( 54 | mapStateToProps, 55 | mapDispatchToProps 56 | )(MessageList); -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /src/store/reducers/conversations.js: -------------------------------------------------------------------------------- 1 | const initialState = { 2 | conversations: [], 3 | selectedConversation: {} 4 | }; 5 | 6 | initialState.selectedConversation = initialState.conversations[1]; 7 | 8 | const conversationsReducer = (state = initialState, action) => { 9 | switch (action.type) { 10 | case 'CONVERSATIONS_LOADED': { 11 | const newState = { ...state }; 12 | newState.conversations = action.payload.conversations ? action.payload.conversations : []; 13 | newState.selectedConversation = action.payload.selectedConversation; 14 | 15 | return newState; 16 | } 17 | case 'SELECTED_CONVERSATION_CHANGED': { 18 | const newState = { ...state }; 19 | newState.selectedConversation = 20 | newState.conversations.find( 21 | conversation => conversation.id === action.conversationId 22 | ); 23 | 24 | return newState; 25 | } 26 | case 'DELETE_CONVERSATION': { 27 | if (state.selectedConversation) { 28 | const newState = { ...state }; 29 | 30 | let selectedConversationIndex = 31 | newState.conversations.findIndex(c => c.id === newState.selectedConversation.id); 32 | newState.conversations.splice(selectedConversationIndex, 1); 33 | 34 | if (newState.conversations.length > 0) { 35 | if (selectedConversationIndex > 0) { 36 | --selectedConversationIndex; 37 | } 38 | 39 | newState.selectedConversation = newState.conversations[selectedConversationIndex]; 40 | } else { 41 | newState.selectedConversation = null; 42 | } 43 | 44 | return newState; 45 | } 46 | 47 | return state; 48 | } 49 | case 'NEW_MESSAGE_ADDED': { 50 | if (state.selectedConversation) { 51 | const newState = { ...state }; 52 | newState.selectedConversation = { ...newState.selectedConversation }; 53 | 54 | newState.selectedConversation.messages.unshift( 55 | { 56 | imageUrl: null, 57 | imageAlt: null, 58 | messageText: action.textMessage, 59 | createdAt: 'Apr 16', 60 | isMyMessage: true 61 | }, 62 | ) 63 | 64 | return newState; 65 | } 66 | 67 | return state; 68 | } 69 | default: 70 | return state; 71 | } 72 | } 73 | 74 | export default conversationsReducer; -------------------------------------------------------------------------------- /src/components/controls/icons/attachment-icon/AttachmentIcon.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import './AttachmentIcon.scss'; 4 | 5 | const AttachmentIcon = () => { 6 | return ( 7 | 9 | 16 | 17 | 20 | 23 | 26 | 29 | 30 | 31 | ); 32 | } 33 | 34 | export default AttachmentIcon; -------------------------------------------------------------------------------- /src/containers/shell/ChatShell.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import { conversationChanged, newMessageAdded, conversationDeleted, conversationsRequested } from '../../store/actions'; 5 | import ConversationSearch from '../../components/conversation/conversation-search/ConversationSearch'; 6 | import NoConversations from '../../components/conversation/no-conversations/NoConversations'; 7 | import ConversationList from '../../components/conversation/conversation-list/ConversationList'; 8 | import NewConversation from '../../components/conversation/new-conversation/NewConversation'; 9 | import ChatTitle from '../../components/chat-title/ChatTitle'; 10 | import MessageList from '../message/MessageList'; 11 | import ChatForm from '../../components/chat-form/ChatForm'; 12 | 13 | import './ChatShell.scss'; 14 | 15 | const ChatShell = ({ 16 | conversations, 17 | selectedConversation, 18 | conversationChanged, 19 | onMessageSubmitted, 20 | onDeleteConversation, 21 | loadConversations 22 | }) => { 23 | useEffect(() => { 24 | loadConversations(); 25 | }, [loadConversations]); 26 | 27 | let conversationContent = ( 28 | <> 29 | 30 | 31 | ); 32 | 33 | if (conversations.length > 0) { 34 | conversationContent = ( 35 | <> 36 | 37 | 38 | ); 39 | } 40 | 41 | return ( 42 |
43 | 44 | 48 | 49 | 52 | {conversationContent} 53 | 56 |
57 | ); 58 | } 59 | 60 | const mapStateToProps = state => { 61 | return { 62 | conversations: state.conversationState.conversations, 63 | selectedConversation: state.conversationState.selectedConversation 64 | }; 65 | }; 66 | 67 | const mapDispatchToProps = dispatch => ({ 68 | conversationChanged: conversationId => dispatch(conversationChanged(conversationId)), 69 | onMessageSubmitted: messageText => { dispatch(newMessageAdded(messageText)); }, 70 | onDeleteConversation: () => { dispatch(conversationDeleted()); }, 71 | loadConversations: () => { dispatch(conversationsRequested())} 72 | }); 73 | 74 | export default connect( 75 | mapStateToProps, 76 | mapDispatchToProps 77 | )(ChatShell); -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 2 | 3 | ## Available Scripts 4 | 5 | In the project directory, you can run: 6 | 7 | ### `npm start` 8 | 9 | Runs the app in the development mode.
10 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 11 | 12 | The page will reload if you make edits.
13 | You will also see any lint errors in the console. 14 | 15 | ### `npm test` 16 | 17 | Launches the test runner in the interactive watch mode.
18 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 19 | 20 | ### `npm run build` 21 | 22 | Builds the app for production to the `build` folder.
23 | It correctly bundles React in production mode and optimizes the build for the best performance. 24 | 25 | The build is minified and the filenames include the hashes.
26 | Your app is ready to be deployed! 27 | 28 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 29 | 30 | ### `npm run eject` 31 | 32 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 33 | 34 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 35 | 36 | Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 37 | 38 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 39 | 40 | ## Learn More 41 | 42 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 43 | 44 | To learn React, check out the [React documentation](https://reactjs.org/). 45 | 46 | ### Code Splitting 47 | 48 | This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting 49 | 50 | ### Analyzing the Bundle Size 51 | 52 | This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size 53 | 54 | ### Making a Progressive Web App 55 | 56 | This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app 57 | 58 | ### Advanced Configuration 59 | 60 | This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration 61 | 62 | ### Deployment 63 | 64 | This section has moved here: https://facebook.github.io/create-react-app/docs/deployment 65 | 66 | ### `npm run build` fails to minify 67 | 68 | This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify 69 | -------------------------------------------------------------------------------- /src/components/controls/icons/trash-icon/TrashIcon.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import './TrashIcon.scss'; 4 | 5 | const TrashIcon = () => { 6 | return ( 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | ); 34 | } 35 | 36 | export default TrashIcon; -------------------------------------------------------------------------------- /src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.1/8 is considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | 'New content is available and will be used when all ' + 74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log('Content is cached for offline use.'); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch(error => { 97 | console.error('Error during service worker registration:', error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl) 104 | .then(response => { 105 | // Ensure service worker exists, and that we really are getting a JS file. 106 | const contentType = response.headers.get('content-type'); 107 | if ( 108 | response.status === 404 || 109 | (contentType != null && contentType.indexOf('javascript') === -1) 110 | ) { 111 | // No service worker found. Probably a different app. Reload the page. 112 | navigator.serviceWorker.ready.then(registration => { 113 | registration.unregister().then(() => { 114 | window.location.reload(); 115 | }); 116 | }); 117 | } else { 118 | // Service worker found. Proceed as normal. 119 | registerValidSW(swUrl, config); 120 | } 121 | }) 122 | .catch(() => { 123 | console.log( 124 | 'No internet connection found. App is running in offline mode.' 125 | ); 126 | }); 127 | } 128 | 129 | export function unregister() { 130 | if ('serviceWorker' in navigator) { 131 | navigator.serviceWorker.ready.then(registration => { 132 | registration.unregister(); 133 | }); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/store/sagas/messages.js: -------------------------------------------------------------------------------- 1 | import { put, takeLatest } from 'redux-saga/effects'; 2 | 3 | import { messagesLoaded } from '../actions'; 4 | 5 | const messageDetails = { 6 | '2': [ 7 | { 8 | id: '1', 9 | imageUrl: null, 10 | imageAlt: null, 11 | messageText: 'Ok fair enough. Well good talking to you.', 12 | createdAt: 'Oct 20', 13 | isMyMessage: true 14 | }, 15 | { 16 | id: '2', 17 | imageUrl: require('../../images/profiles/kim.jpeg'), 18 | imageAlt: 'Kim O\'Neil', 19 | messageText: ` 20 | Not sure exactly yet. It will be next year sometime. Probably late. 21 | `, 22 | createdAt: 'Oct 20', 23 | isMyMessage: false 24 | }, 25 | { 26 | id: '3', 27 | imageUrl: null, 28 | imageAlt: null, 29 | messageText: 'Yeah I know. But oh well. So when is the big date?', 30 | createdAt: 'Oct 19', 31 | isMyMessage: true 32 | }, 33 | { 34 | id: '4', 35 | imageUrl: require('../../images/profiles/kim.jpeg'), 36 | imageAlt: 'Kim O\'Neil', 37 | messageText: ` 38 | Well I know you like doing that stuff. But honestly I think 39 | you are already really talented. It's a shame you haven't found 40 | what you are looking for yet. 41 | `, 42 | createdAt: 'Oct 19', 43 | isMyMessage: false 44 | }, 45 | { 46 | id: '5', 47 | imageUrl: null, 48 | imageAlt: null, 49 | messageText: ` 50 | I'm doing ok. Just working on building some applications to 51 | bulk up my resume, so I can get a better job. 52 | `, 53 | createdAt: 'Oct 19', 54 | isMyMessage: true 55 | }, 56 | { 57 | id: '6', 58 | imageUrl: require('../../images/profiles/kim.jpeg'), 59 | imageAlt: 'Kim O\'Neil', 60 | messageText: ` 61 | I've just been really busy at work myself, looking to get 62 | married sometime next year too. How are you going? 63 | `, 64 | createdAt: 'Oct 19', 65 | isMyMessage: false 66 | }, 67 | { 68 | id: '7', 69 | imageUrl: null, 70 | imageAlt: null, 71 | messageText: 'Yes it has been a little while', 72 | createdAt: 'Oct 19', 73 | isMyMessage: true 74 | }, 75 | { 76 | id: '8', 77 | imageUrl: require('../../images/profiles/kim.jpeg'), 78 | imageAlt: 'Kim O\'Neil', 79 | messageText: 'Hey!!!! Have not spoken to you for a while', 80 | createdAt: 'Oct 19', 81 | isMyMessage: false 82 | }, 83 | { 84 | id: '9', 85 | imageUrl: null, 86 | imageAlt: null, 87 | messageText: 'Hi Kim?', 88 | createdAt: 'Oct 19', 89 | isMyMessage: true 90 | } 91 | ], 92 | '3': [ 93 | { 94 | id: '1', 95 | imageUrl: null, 96 | imageAlt: null, 97 | messageText: 'Hi', 98 | createdAt: '1 week ago', 99 | isMyMessage: true 100 | } 101 | ], 102 | '4': [ 103 | { 104 | id: '1', 105 | imageUrl: null, 106 | imageAlt: null, 107 | messageText: 'Hi', 108 | createdAt: '1 week ago', 109 | isMyMessage: true 110 | } 111 | ], 112 | '5': [ 113 | { 114 | id: '1', 115 | imageUrl: null, 116 | imageAlt: null, 117 | messageText: 'Hi', 118 | createdAt: '1 week ago', 119 | isMyMessage: true 120 | } 121 | ], 122 | '6': [ 123 | { 124 | id: '1', 125 | imageUrl: null, 126 | imageAlt: null, 127 | messageText: 'Hi', 128 | createdAt: '1 week ago', 129 | isMyMessage: true 130 | } 131 | ], 132 | '7': [ 133 | { 134 | id: '1', 135 | imageUrl: null, 136 | imageAlt: null, 137 | messageText: 'Hi', 138 | createdAt: '1 week ago', 139 | isMyMessage: true 140 | } 141 | ], 142 | '8': [ 143 | { 144 | id: '1', 145 | imageUrl: null, 146 | imageAlt: null, 147 | messageText: 'Hi', 148 | createdAt: '1 week ago', 149 | isMyMessage: true 150 | } 151 | ], 152 | '9': [ 153 | { 154 | id: '1', 155 | imageUrl: null, 156 | imageAlt: null, 157 | messageText: 'Hi', 158 | createdAt: '1 week ago', 159 | isMyMessage: true 160 | } 161 | ] 162 | }; 163 | 164 | const delay = (ms) => new Promise(res => setTimeout(res, ms)); 165 | 166 | const messagesSaga = function*(action) { 167 | const { conversationId, numberOfMessages, lastMessageId } = action.payload; 168 | const messages = messageDetails[conversationId]; 169 | const startIndex = lastMessageId ? messages.findIndex(message => message.id === lastMessageId) + 1: 0; 170 | const endIndex = startIndex + numberOfMessages; 171 | const pageGroup = messages.slice(startIndex, endIndex); 172 | const newLastMessageId = pageGroup.length > 0 ? pageGroup[pageGroup.length - 1].id: null; 173 | const hasMoreMessages = newLastMessageId && endIndex < (messages.length - 1); 174 | 175 | yield delay(1000); 176 | 177 | yield put(messagesLoaded( 178 | conversationId, 179 | pageGroup, 180 | hasMoreMessages, 181 | newLastMessageId 182 | )); 183 | 184 | if (hasMoreMessages) { 185 | yield delay(1000); 186 | yield put({ 187 | type: 'MESSAGES_REQUESTED', 188 | payload: { 189 | conversationId, 190 | numberOfMessages, 191 | lastMessageId: newLastMessageId 192 | } 193 | }) 194 | } 195 | } 196 | 197 | export const watchGetMessagesAsync = function*() { 198 | yield takeLatest('MESSAGES_REQUESTED', messagesSaga); 199 | } -------------------------------------------------------------------------------- /src/store/sagas/conversations.js: -------------------------------------------------------------------------------- 1 | import { put, takeEvery } from 'redux-saga/effects'; 2 | 3 | import { messagesLoaded } from '../actions'; 4 | 5 | const delay = (ms) => new Promise(res => setTimeout(res, ms)); 6 | 7 | const conversations = [ 8 | { 9 | id: '1', 10 | imageUrl: require('../../images/profiles/daryl.png'), 11 | imageAlt: 'Daryl Duckmanton', 12 | title: 'Daryl Duckmanton', 13 | createdAt: 'Apr 16', 14 | latestMessageText: 'This is a message', 15 | messages: [ 16 | { 17 | imageUrl: null, 18 | imageAlt: null, 19 | messageText: 'Ok then', 20 | createdAt: 'Apr 16', 21 | isMyMessage: true 22 | }, 23 | { 24 | imageUrl: require('../../images/profiles/daryl.png'), 25 | imageAlt: 'Daryl Duckmanton', 26 | messageText: ` 27 | Yeah I think it's best we do that. Otherwise things won't work well at all. 28 | I'm adding more text here to test the sizing of the speech bubble and the 29 | wrapping of it too. 30 | `, 31 | createdAt: 'Apr 16', 32 | isMyMessage: false 33 | }, 34 | { 35 | imageUrl: null, 36 | imageAlt: null, 37 | messageText: 'Maybe we can use Jim\'s studio.', 38 | createdAt: 'Apr 15', 39 | isMyMessage: true 40 | }, 41 | { 42 | imageUrl: require('../../images/profiles/daryl.png'), 43 | imageAlt: 'Daryl Duckmanton', 44 | messageText: ` 45 | All I know is where I live it's too hard 46 | to record because of all the street noise. 47 | `, 48 | createdAt: 'Apr 15', 49 | isMyMessage: false 50 | }, 51 | { 52 | imageUrl: null, 53 | imageAlt: null, 54 | messageText: ` 55 | Well we need to work out sometime soon where 56 | we really want to record our video course. 57 | `, 58 | createdAt: 'Apr 15', 59 | isMyMessage: true 60 | }, 61 | { 62 | imageUrl: require('../../images/profiles/daryl.png'), 63 | imageAlt: 'Daryl Duckmanton', 64 | messageText: ` 65 | I'm just in the process of finishing off the 66 | last pieces of material for the course. 67 | `, 68 | createdAt: 'Apr 15', 69 | isMyMessage: false 70 | }, 71 | { 72 | imageUrl: null, 73 | imageAlt: null, 74 | messageText: 'How\'s it going?', 75 | createdAt: 'Apr 13', 76 | isMyMessage: true 77 | }, 78 | { 79 | imageUrl: require('../../images/profiles/daryl.png'), 80 | imageAlt: 'Daryl Duckmanton', 81 | messageText: ' Hey mate what\'s up?', 82 | createdAt: 'Apr 13', 83 | isMyMessage: false 84 | }, 85 | { 86 | imageUrl: null, 87 | imageAlt: null, 88 | messageText: 'Hey Daryl?', 89 | createdAt: 'Apr 13', 90 | isMyMessage: true 91 | } 92 | ] 93 | }, 94 | { 95 | id: '2', 96 | imageUrl: require('../../images/profiles/kim.jpeg'), 97 | imageAlt: 'Kim O\'Neil', 98 | title: 'Kim O\'Neil', 99 | createdAt: 'Oct 20', 100 | latestMessageText: 'Ok fair enough. Well good talking to you.', 101 | messages: [] 102 | }, 103 | { 104 | id: '3', 105 | imageUrl: require('../../images/profiles/john.jpeg'), 106 | imageAlt: 'John Anderson', 107 | title: 'John Anderson', 108 | createdAt: '1 week ago', 109 | latestMessageText: 'Yes I love how Python does that', 110 | messages: [] 111 | }, 112 | { 113 | id: '4', 114 | imageUrl: require('../../images/profiles/ben.png'), 115 | imageAlt: 'Ben Smith', 116 | title: 'Ben Smith', 117 | createdAt: '2:49 PM', 118 | latestMessageText: 'Yeah Miami Heat are done', 119 | messages: [] 120 | }, 121 | { 122 | id: '5', 123 | imageUrl: require('../../images/profiles/douglas.png'), 124 | imageAlt: 'Douglas Johannasen', 125 | title: 'Douglas Johannasen', 126 | createdAt: '6:14 PM', 127 | latestMessageText: 'No it does not', 128 | messages: [] 129 | }, 130 | { 131 | id: '6', 132 | imageUrl: require('../../images/profiles/jacob.png'), 133 | imageAlt: 'Jacob Manly', 134 | title: 'Jacob Manly', 135 | createdAt: '3 secs ago', 136 | latestMessageText: 'Just be very careful doing that', 137 | messages: [] 138 | }, 139 | { 140 | id: '7', 141 | imageUrl: require('../../images/profiles/stacey.jpeg'), 142 | imageAlt: 'Stacey Wilson', 143 | title: 'Stacey Wilson', 144 | createdAt: '30 mins ago', 145 | latestMessageText: 'Awesome!!! Congratulations!!!!', 146 | messages: [] 147 | }, 148 | { 149 | id: '8', 150 | imageUrl: require('../../images/profiles/stan.jpeg'), 151 | imageAlt: 'Stan George', 152 | title: 'Stan George', 153 | createdAt: '1 week ago', 154 | latestMessageText: 'Good job', 155 | messages: [] 156 | }, 157 | { 158 | id: '9', 159 | imageUrl: require('../../images/profiles/sarah.jpeg'), 160 | imageAlt: 'Sarah Momes', 161 | title: 'Sarah Momes', 162 | createdAt: '1 year ago', 163 | latestMessageText: 'Thank you. I appreciate that.', 164 | messages: [] 165 | } 166 | ]; 167 | 168 | export const conversationsSaga = function*() { 169 | yield delay(1000); 170 | yield put(messagesLoaded(conversations[0].id, conversations[0].messages, false, null)); 171 | 172 | yield put({ 173 | type: 'CONVERSATIONS_LOADED', 174 | payload: { 175 | conversations, 176 | selectedConversation: conversations[0] 177 | } 178 | }); 179 | } 180 | 181 | export function* watchGetConversationsAsync() { 182 | yield takeEvery('CONVERSATIONS_REQUESTED', conversationsSaga); 183 | } -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/__snapshots__/App.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`App loads with default state 1`] = ` 4 |
7 |
10 | 14 |
15 |
18 |
22 | Daryl Duckmanton 26 |
29 | Daryl Duckmanton 30 |
31 |
34 | Apr 16 35 |
36 |
39 | This is a message 40 |
41 |
42 |
46 | Kim O'Neil 50 |
53 | Kim O'Neil 54 |
55 |
58 | Oct 20 59 |
60 |
63 | Ok fair enough. Well good talking to you. 64 |
65 |
66 |
70 | John Anderson 74 |
77 | John Anderson 78 |
79 |
82 | 1 week ago 83 |
84 |
87 | Yes I love how Python does that 88 |
89 |
90 |
94 | Ben Smith 98 |
101 | Ben Smith 102 |
103 |
106 | 2:49 PM 107 |
108 |
111 | Yeah Miami Heat are done 112 |
113 |
114 |
118 | Douglas Johannasen 122 |
125 | Douglas Johannasen 126 |
127 |
130 | 6:14 PM 131 |
132 |
135 | No it does not 136 |
137 |
138 |
142 | Jacob Manly 146 |
149 | Jacob Manly 150 |
151 |
154 | 3 secs ago 155 |
156 |
159 | Just be very careful doing that 160 |
161 |
162 |
166 | Stacey Wilson 170 |
173 | Stacey Wilson 174 |
175 |
178 | 30 mins ago 179 |
180 |
183 | Awesome!!! Congratulations!!!! 184 |
185 |
186 |
190 | Stan George 194 |
197 | Stan George 198 |
199 |
202 | 1 week ago 203 |
204 |
207 | Good job 208 |
209 |
210 |
214 | Sarah Momes 218 |
221 | Sarah Momes 222 |
223 |
226 | 1 year ago 227 |
228 |
231 | Thank you. I appreciate that. 232 |
233 |
234 |
235 |
238 | 241 |
242 |
245 | 246 | Kim O'Neil 247 | 248 | Delete Conversation 262 |
263 |
266 |
269 |
272 |
275 | Ok fair enough. Well good talking to you. 276 |
277 |
280 | Oct 20 281 |
282 |
283 |
284 |
287 |
290 | Kim O'Neil 294 |
297 | 298 | Not sure exactly yet. It will be next year sometime. Probably late. 299 | 300 |
301 |
304 | Oct 20 305 |
306 |
307 |
308 |
311 |
314 |
317 | Yeah I know. But oh well. So when is the big date? 318 |
319 |
322 | Oct 19 323 |
324 |
325 |
326 |
329 |
332 | Kim O'Neil 336 |
339 | 340 | Well I know you like doing that stuff. But honestly I think 341 | you are already really talented. It's a shame you haven't found 342 | what you are looking for yet. 343 | 344 |
345 |
348 | Oct 19 349 |
350 |
351 |
352 |
355 |
358 |
361 | 362 | I'm doing ok. Just working on building some applications to 363 | bulk up my resume, so I can get a better job. 364 | 365 |
366 |
369 | Oct 19 370 |
371 |
372 |
373 |
376 |
379 | Kim O'Neil 383 |
386 | 387 | I've just been really busy at work myself, looking to get 388 | married sometime next year too. How are you going? 389 | 390 |
391 |
394 | Oct 19 395 |
396 |
397 |
398 |
401 |
404 |
407 | Yes it has been a little while 408 |
409 |
412 | Oct 19 413 |
414 |
415 |
416 |
419 |
422 | Kim O'Neil 426 |
429 | Hey!!!! Have not spoken to you for a while 430 |
431 |
434 | Oct 19 435 |
436 |
437 |
438 |
441 |
444 |
447 | Hi Kim? 448 |
449 |
452 | Oct 19 453 |
454 |
455 |
456 |
457 |
461 | Add Attachment 474 | 481 | 487 |
488 |
489 | `; 490 | --------------------------------------------------------------------------------