Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
55fcf57
Add draft message feature
mmalykov Jul 10, 2021
73edf27
Add multi-user conversation feature
mmalykov Jul 10, 2021
685dcb1
Merge branch 'feature/multiusers-conversation' into feature/show-your…
mmalykov Jul 11, 2021
a129828
Added react-redux-simple-chat.drawio
mmalykov Jul 11, 2021
e495248
Add firebase and FirebaseContext
mmalykov Jul 11, 2021
00e15c4
Merge branch 'feature/show-your-skills' of https://github.com/mmaliko…
mmalykov Jul 11, 2021
fb41c7d
Add AppBar component
mmalykov Jul 11, 2021
4467fad
Add routes and Login component
mmalykov Jul 11, 2021
cda1980
Add login and registration flows, connect redux devtools
mmalykov Jul 11, 2021
91d496c
Replace useTypedSelector hook with selectors
mmalykov Jul 12, 2021
e98bd7c
Add loading conversations from firestore
mmalykov Jul 12, 2021
dc77dca
Add loading messages from firestore
mmalykov Jul 12, 2021
f48c256
Add realtime update of messages
mmalykov Jul 12, 2021
e3f1395
Fix scroll of chat component
mmalykov Jul 12, 2021
3b526fe
Move messages collection observer to hook
mmalykov Jul 12, 2021
83077df
Rename actions to more appropriate names
mmalykov Jul 12, 2021
207f4e7
Add create conversation feature
mmalykov Jul 12, 2021
3ed4a26
Optimize loading data from firestore
mmalykov Jul 12, 2021
83faec6
Move fetch conversation messages to useConversationMessagesSnapshot
mmalykov Jul 12, 2021
d024e96
Move all firestore dependencies to integrations/firebase in order to …
mmalykov Jul 12, 2021
842b66f
Move messages live update to integrations/firebase in order to remove…
mmalykov Jul 12, 2021
8334842
Move helpers to integrations/firebase
mmalykov Jul 12, 2021
e835e25
Refactor file names to keep consistency
mmalykov Jul 12, 2021
1546142
Move fetch logic from Chat to ConversationListContainer
mmalykov Jul 12, 2021
d7e7ac4
Add live load of conversations
mmalykov Jul 12, 2021
a13378b
Add Account form for updating user properties
mmalykov Jul 12, 2021
0736d06
Split chatReducer into conversationsReducer and messagesReducer
mmalykov Jul 12, 2021
bef5c82
Move FirebaseContext to hooks for reduce deeply coupling with firebas…
mmalykov Jul 12, 2021
d19698f
Move getting firebaseUser to hook, add useCurrentUser hook
mmalykov Jul 12, 2021
042c196
Refactor firebase folder structure
mmalykov Jul 12, 2021
51025e8
Wrap all firebase dependencies with api functions
mmalykov Jul 13, 2021
01e1f20
Add styles for MessageListItem
mmalykov Jul 13, 2021
204d9f5
Add styles for better looking
mmalykov Jul 13, 2021
3c7248d
Allow creating conversations with multiple users
mmalykov Jul 13, 2021
234ebf0
Fix loading of conversations and creating of conversations if convers…
mmalykov Jul 13, 2021
7a8ee94
Add edit and delete message actions
mmalykov Jul 13, 2021
828028b
Split AddMessage into Message and EditMessage for separate edit and c…
mmalykov Jul 13, 2021
b82ca70
Fix link styles
mmalykov Jul 13, 2021
6188fe5
Fix fetch conversations by user id
mmalykov Jul 13, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
REACT_APP_API_KEY = AIzaSyDqQWDcbw4q2nyUiwT2HMJbqt7prXYHL9k
REACT_APP_AUTH_DOMAIN = react-redux-simple-chat.firebaseapp.com
REACT_APP_PROJECT_ID = react-redux-simple-chat
REACT_APP_STORAGE_BUCKET = react-redux-simple-chat.appspot.com
REACT_APP_MESSAGING_SENDER_ID = 658215170381
REACT_APP_APP_ID = 1:658215170381:web:803c52e46dc93dcc1e8704
9 changes: 8 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"dependencies": {
"@material-ui/core": "^4.12.0",
"@material-ui/icons": "^4.11.2",
"@material-ui/lab": "^4.0.0-alpha.60",
"@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10",
Expand All @@ -13,14 +14,20 @@
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"@types/react-redux": "^7.1.16",
"@types/react-router-dom": "^5.1.8",
"firebase": "^8.7.1",
"formik": "^2.2.9",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-firebase-hooks": "^3.0.4",
"react-redux": "^7.2.4",
"react-router-dom": "^5.2.0",
"react-scripts": "4.0.3",
"redux": "^4.1.0",
"redux-thunk": "^2.3.0",
"typescript": "^4.1.2",
"web-vitals": "^1.0.1"
"web-vitals": "^1.0.1",
"yup": "^0.32.9"
},
"scripts": {
"start": "react-scripts start",
Expand Down
1 change: 1 addition & 0 deletions react-redux-simple-chat.drawio
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<mxfile host="app.diagrams.net" modified="2021-07-11T11:37:39.515Z" agent="5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36" version="14.8.5" etag="rk2JFiMAmZOXH5NQdel9" type="github"><diagram id="m5XzLHxQUExycl1XZxRQ">UzV2zq1wL0osyPDNT0nNUTV2VTV2LsrPL4GwciucU3NyVI0MMlNUjV1UjYwMgFjVyA2HrCFY1qAgsSg1rwSLBiADYTaQg2Y1AA==</diagram></mxfile>
3 changes: 0 additions & 3 deletions src/App.css

This file was deleted.

25 changes: 21 additions & 4 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,28 @@
import React from 'react';
import {Chat} from "./chat/components/Chat/Chat";
import './App.css';
import {AppBar} from "./components/AppBar/AppBar";
import {makeStyles} from "@material-ui/core";
import {BrowserRouter} from "react-router-dom";
import {AppRouter} from "./components/AppRouter/AppRouter";
import {useCurrentUser} from "./users/store/hooks/useCurrentUser";

const useStyles = makeStyles(() => ({
app: {
display: 'flex',
flexDirection: 'column',
height: '100vh',
}
}));

function App() {
const classes = useStyles();
const currentUser = useCurrentUser();

return (
<div className="App">
<Chat/>
<div className={classes.app}>
<BrowserRouter>
<AppBar/>
<AppRouter/>
</BrowserRouter>
</div>
);
}
Expand Down
83 changes: 83 additions & 0 deletions src/chat/api/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import {
addDocumentToCollection,
deleteDocumentFromCollection,
fetchDocumentsByFieldValue,
fetchDocumentsByIds, onCollectionByFieldValueSnapshot, onCollectionSnapshot,
updateDocumentInCollection
} from "../../integrations";
import {Message} from "../types/message";
import {Conversation} from "../types/conversation";
import {fetchUsersNotInIds} from "../../users/api";

// TODO: try to avoid cross module dependency
export const fetchUsersForNewConversation = async (conversations: Conversation[], userId: string = '') => {
try {
if (conversations.length === 0) {
return await fetchUsersNotInIds([userId]);
}

const existedParticipantsIds = extractParticipantsIdsFromConversations(conversations, userId);
const excludeParticipantsIds = [userId, ...existedParticipantsIds];

return await fetchUsersNotInIds(excludeParticipantsIds);
} catch (e) {
return [];
}
};

export const fetchMessagesByIds = async (conversationIds: string[]) => {
return await fetchDocumentsByIds<Message>('messages', conversationIds, 'conversationId');
};

export const fetchMessagesByConversationId = async (conversationId: string) => {
return await fetchDocumentsByFieldValue<Message>(
'messages',
'conversationId',
conversationId,
{fieldPath: 'createdAt', directionStr: 'desc'}
)
}

export const postNewMessage = async (message: Message) => {
return await addDocumentToCollection<Message>('messages', message);
};

export const putMessage = async (messageId: string, message: Partial<Message>) => {
await updateDocumentInCollection<Message>('messages', messageId, message);
}

export const deleteMessage = async (message: Message) => {
return await deleteDocumentFromCollection('messages', message.id as string);
};

export const postNewConversation = async (conversation: Partial<Conversation>, overrides: Partial<Conversation>) => {
return await addDocumentToCollection<Conversation>('conversations', conversation, overrides);
}

export const putConversation = async (conversationId: string, conversation: Partial<Conversation>) => {
await updateDocumentInCollection<Conversation>('conversations', conversationId, conversation);
}

export const subscribeOnConversationMessagesChanges = (conversationId: string, callback: (messages: Message[]) => any) => {
return onCollectionByFieldValueSnapshot<Message>(
'messages',
'conversationId', conversationId,
{fieldPath: 'createdAt', directionStr: 'desc'},
callback
);
};

export const subscribeOnConversationsChanges = (userId: string, callback: (conversations: Conversation[]) => void) => {
return onCollectionSnapshot<Conversation>('conversations', userId, callback);
};

function extractParticipantsIdsFromConversations(conversations: Conversation[], userId: string): string[] {
return conversations.reduce((participantsIds: string[], conversation) => {
if (conversation.user.id === userId) {
const participantsIdsSet = new Set([...participantsIds, ...conversation.participantsIds]);
return Array.from(participantsIdsSet.values());
}

return participantsIds;
}, []);
}
40 changes: 14 additions & 26 deletions src/chat/components/AddMessage/AddMessage.tsx
Original file line number Diff line number Diff line change
@@ -1,36 +1,24 @@
import React, {useState} from "react";
import {Fab, Grid, TextField} from "@material-ui/core";
import {Send} from '@material-ui/icons';
import React from "react";
import {Message} from "../Message/Message";

type Props = {
addMessage: (content: string) => void;
conversationId: string;
draftMessage: string;
sendMessage: (content: string) => void;
storeDraftMessage: (conversationId: string, content: string) => void;
}

export const AddMessage: React.FC<Props> = ({addMessage}) => {
const [message, setMessage] = useState('');
export const AddMessage: React.FC<Props> = ({conversationId, draftMessage, sendMessage, storeDraftMessage}) => {
const handleNewMessageChange = (content: string) => {
storeDraftMessage(conversationId, content);
};

const handleClick = () => {
addMessage(message);
setMessage('');
const handleAddNewMessageClick = (content: string) => {
sendMessage(content);
storeDraftMessage(conversationId, '');
};

return (
<Grid container>
<Grid item xs={11}>
<TextField
label="Write a message ..."
fullWidth
multiline
maxRows={5}
value={message}
onChange={e => setMessage(e.target.value)}
/>
</Grid>
<Grid item xs={1}>
<Fab color="primary" aria-label="add" onClick={handleClick}>
<Send/>
</Fab>
</Grid>
</Grid>
<Message initialContent={draftMessage} contentChange={handleNewMessageChange} buttonClick={handleAddNewMessageClick}/>
);
};
9 changes: 2 additions & 7 deletions src/chat/components/Chat/Chat.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,17 @@
import React, {useEffect} from "react";
import React from "react";
import {Grid, makeStyles, Paper} from "@material-ui/core";
import {ConversationContainer} from "../ConversationContainer/ConversationContainer";
import {ConversationListContainer} from "../ConversationListContainer/ConversationListContainer";
import {useChatActions} from "../../store/hooks/useChatActions";

const useStyles = makeStyles(() => ({
root: {
height: '100%',
maxHeight: `calc(100% - 64px)`, // TODO: refactor to responsive height (not depend on parent layout)
}
}));

export const Chat: React.FC = () => {
const classes = useStyles();
const {fetchConversations} = useChatActions();

useEffect(() => {
fetchConversations();
});

return (
<Grid container component={Paper} className={classes.root}>
Expand Down
52 changes: 42 additions & 10 deletions src/chat/components/ConversationContainer/ConversationContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,17 @@ import React from "react";
import {Grid, makeStyles} from "@material-ui/core";
import {MessagesList} from "../MessagesList/MessagesList";
import {AddMessage} from "../AddMessage/AddMessage";
import {useTypedSelector} from "../../../store/hooks/useTypedSelector";
import {useChatActions} from "../../store/hooks/useChatActions";
import {useSelector} from "react-redux";
import {
selectConversations,
selectDraftMessages,
selectEditingMessage,
selectMessagesLoading
} from "../../store/selectors";
import {useConversationMessages} from "../../hooks/useConversationMessages";
import {useConversationsActions, useMessagesActions} from "../../store/hooks";
import {Message} from "../../types/message";
import {EditMessage} from "../EditMessage/EditMessage";

const useConversationContainerStyles = makeStyles(() => ({
root: {
Expand All @@ -14,25 +23,48 @@ const useConversationContainerStyles = makeStyles(() => ({

export const ConversationContainer: React.FC = () => {
const containerClasses = useConversationContainerStyles();
const {selectedConversation} = useTypedSelector(state => state.chat);
const {addTextMessage} = useChatActions();
const {selectedConversation} = useSelector(selectConversations);
const {fetchMessagesError} = useSelector(selectMessagesLoading);
const {editingMessage} = useSelector(selectEditingMessage);
const {draftMessages} = useSelector(selectDraftMessages);
const {sendTextMessage} = useConversationsActions();
const {storeDraftTextMessage, editTextMessage} = useMessagesActions();
const [messages] = useConversationMessages(selectedConversation);
const messageContent = selectedConversation ?
(draftMessages[selectedConversation.id] ?? '') :
'';

if (!selectedConversation) {
return (
<Grid container alignItems="center" justifyContent="center" item xs={8}>
Please select the conversation
{fetchMessagesError ? fetchMessagesError : 'Please select the conversation'}
</Grid>
);
}

const addMessageHandler = (content: string) => {
addTextMessage(content, selectedConversation.id, selectedConversation.userId);
const handleSendMessage = (content: string) => {
sendTextMessage(content, selectedConversation.id, selectedConversation.userId);
};

const handleEditMessage = (message: Message) => {
editTextMessage(message);
};

return (
<Grid container direction="column" item xs={8} className={containerClasses.root}>
<MessagesList selectedConversation={selectedConversation}/>
<AddMessage addMessage={addMessageHandler}/>
<Grid container direction="column" item xs={9} className={containerClasses.root}>
<MessagesList selectedConversation={selectedConversation} messages={messages}/>
{editingMessage ? (
<EditMessage
editingMessage={editingMessage}
editMessage={handleEditMessage}/>
) : (
<AddMessage
conversationId={selectedConversation.id}
draftMessage={messageContent}
sendMessage={handleSendMessage}
storeDraftMessage={storeDraftTextMessage}/>
)}

</Grid>
);
};
Original file line number Diff line number Diff line change
@@ -1,34 +1,38 @@
import React from "react";
import {Grid} from "@material-ui/core";
import {alpha, Grid, makeStyles} from "@material-ui/core";
import {ConversationsList} from "../ConversationsList/ConversationsList";
import {useTypedSelector} from "../../../store/hooks/useTypedSelector";
import debounce from "@material-ui/core/utils/debounce";
import {useChatActions} from "../../store/hooks/useChatActions";
import {SearchTextField} from "../SearchTextField/SearchTextField";
import {useSelector} from "react-redux";
import {selectConversationsLoading} from "../../store/selectors";
import {ConversationListSearch} from "../ConversationsList/ConversationListSearch/ConversationListSearch";
import {useConversations} from "../../hooks/useConversations";

const useStyles = makeStyles((theme) => ({
root: {
border: `1px solid ${alpha(theme.palette.primary.dark, 0.1)}`
}
}));

export const ConversationListContainer: React.FC = () => {
const {conversations, filteredConversations, conversationsLoadingError, isConversationsLoading} = useTypedSelector(state => state.chat);
const {filterConversations} = useChatActions();
const filterConversationsDebounced = debounce(filterConversations, 500);
const classes = useStyles();
const [conversations, filteredConversations] = useConversations();
const {conversationsLoadingError, isConversationsLoading} = useSelector(selectConversationsLoading);

return (
<Grid item xs={4}>
<Grid className={classes.root} item xs={3}>
<Grid item xs={12} style={{padding: '10px'}}>
<SearchTextField queryChanged={filterConversationsDebounced}/>
<ConversationListSearch disabled={isConversationsLoading || !!conversationsLoadingError}/>
</Grid>
{isConversationsLoading && (
<Grid container alignItems="center" justifyContent="center">
Loading ...
</Grid>
)}
{conversationsLoadingError && (
<Grid container alignItems="center" justifyContent="center">
{conversationsLoadingError}
</Grid>
)}
{conversations?.length > 0 && (
<ConversationsList conversations={filteredConversations}/>
)}
{isConversationsLoading ?
(<Grid container alignItems="center" justifyContent="center">
Loading...
</Grid>) :
<ConversationsList allConversations={conversations} visibleConversations={filteredConversations}/>
}
</Grid>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import React from "react";
import {useConversationsActions} from "../../../store/hooks";
import debounce from "@material-ui/core/utils/debounce";
import {SearchTextField} from "../../SearchTextField/SearchTextField";

type Props = {
disabled: boolean;
};

export const ConversationListSearch: React.FC<Props> = ({disabled}) => {
const {filterConversations} = useConversationsActions();
const filterConversationsDebounced = debounce(filterConversations, 500);

return (
<SearchTextField disabled={disabled} queryChanged={filterConversationsDebounced}/>
);
};
Loading