nice flow and quick working without creating a new conversation

This commit is contained in:
talksik
2022-05-30 08:08:36 -05:00
parent 1e3d26f664
commit cf5e16eeb9
4 changed files with 208 additions and 103 deletions
@@ -1,4 +1,5 @@
import { FieldValue, serverTimestamp, Timestamp } from 'firebase/firestore'; import { FieldValue, serverTimestamp, Timestamp } from 'firebase/firestore';
import { User } from './user.model';
export default class Conversation { export default class Conversation {
id: string; id: string;
@@ -9,6 +10,8 @@ export default class Conversation {
public members: MemberMap, public members: MemberMap,
public userCache: User[],
public name: string | null = null, public name: string | null = null,
public lastUpdatedDate = Timestamp.now(), public lastUpdatedDate = Timestamp.now(),
@@ -20,8 +23,8 @@ export default class Conversation {
} }
export type MemberMap = { export type MemberMap = {
[memberId: string]: ConversationMember [memberId: string]: ConversationMember;
} };
export class ConversationMember { export class ConversationMember {
constructor( constructor(
@@ -44,4 +47,4 @@ export enum MemberState {
priority = 'priority', priority = 'priority',
inbox = 'inbox', inbox = 'inbox',
// deleted = "deleted" // deleted = "deleted"
} }
@@ -1,4 +1,4 @@
import React, { HTMLAttributes, useCallback, useState } from 'react'; import React, { HTMLAttributes, useCallback, useState, useEffect } from 'react';
import { import {
Autocomplete, Autocomplete,
AutocompleteChangeReason, AutocompleteChangeReason,
@@ -20,18 +20,21 @@ import {
import { FiX } from 'react-icons/fi'; import { FiX } from 'react-icons/fi';
import { blueGrey } from '@mui/material/colors'; import { blueGrey } from '@mui/material/colors';
import { useSnackbar } from 'notistack'; import { useSnackbar } from 'notistack';
import { useDebounce } from 'react-use'; import { useDebounce, useKeyPressEvent, useToggle } from 'react-use';
import { searchUsers } from '../firebase/firestore'; import { searchUsers } from '../firebase/firestore';
import { User } from '@nirvana/core/src/models/user.model'; import { User } from '@nirvana/core/src/models/user.model';
import useAuth from '../providers/AuthProvider'; import useAuth from '../providers/AuthProvider';
import UserDetailRow from '../subcomponents/UserDetailRow'; import UserDetailRow from '../subcomponents/UserDetailRow';
import CircularProgress from '@mui/material/CircularProgress';
export default function NewConversationDialog({ export default function NewConversationDialog({
open, open,
handleClose, handleClose,
handleSubmit,
}: { }: {
open: boolean; open: boolean;
handleClose: () => void; handleClose: () => void;
handleSubmit: (selectedUsers: User[], conversationName?: string) => void;
}) { }) {
const { enqueueSnackbar } = useSnackbar(); const { enqueueSnackbar } = useSnackbar();
const { user } = useAuth(); const { user } = useAuth();
@@ -75,16 +78,6 @@ export default function NewConversationDialog({
[setSearching, setSearchVal], [setSearching, setSearchVal],
); );
const handleSubmit = useCallback(() => {
try {
//
} catch (error) {
//
}
handleClose();
}, [handleClose]);
const renderOption = useCallback( const renderOption = useCallback(
(props: HTMLAttributes<HTMLLIElement>, option: User, state: AutocompleteRenderOptionState) => ( (props: HTMLAttributes<HTMLLIElement>, option: User, state: AutocompleteRenderOptionState) => (
<ListItem {...props}> <ListItem {...props}>
@@ -94,6 +87,48 @@ export default function NewConversationDialog({
[], [],
); );
const [conversationName, setConversationName] = useState<string>('');
useEffect(() => {
if (selectedUsers.length < 2) {
setConversationName('');
}
}, [selectedUsers, setConversationName]);
const handleChangeName = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setConversationName(e.target.value);
},
[setConversationName],
);
const [isSubmitting, toggleIsSubmitting] = useToggle(false);
const handleSubmitLocal = useCallback(async () => {
if (selectedUsers.length === 0) {
enqueueSnackbar('Must a person!', { variant: 'error' });
return;
}
toggleIsSubmitting();
handleSubmit(selectedUsers, conversationName);
// clear form for next time
setSelectedUsers([]);
setConversationName('');
toggleIsSubmitting();
}, [
selectedUsers,
setConversationName,
conversationName,
enqueueSnackbar,
handleSubmit,
setSelectedUsers,
toggleIsSubmitting,
]);
return ( return (
<Dialog fullScreen open={open} onClose={handleClose}> <Dialog fullScreen open={open} onClose={handleClose}>
<Container <Container
@@ -113,53 +148,67 @@ export default function NewConversationDialog({
<FiX /> <FiX />
</IconButton> </IconButton>
<Container {isSubmitting ? (
maxWidth="sm" <CircularProgress />
sx={{ ) : (
display: 'flex', <Container
flexDirection: 'column', maxWidth="sm"
gap: 2, sx={{
}} display: 'flex',
> flexDirection: 'column',
<Autocomplete gap: 5,
multiple }}
loading={searching} >
includeInputInList <Typography variant="h4">Start a Conversation</Typography>
id="tags-outlined"
autoHighlight <Autocomplete
onInputChange={handleChangeSearchInput} multiple
options={searchUsersResults} loading={searching}
renderOption={renderOption} includeInputInList
getOptionLabel={(option) => (typeof option === 'string' ? option : option.displayName)} id="tags-outlined"
value={selectedUsers} autoHighlight
onChange={handleChangeSelections} onInputChange={handleChangeSearchInput}
filterSelectedOptions options={searchUsersResults}
isOptionEqualToValue={(optionUser, valueUser) => optionUser.id === valueUser.id} renderOption={renderOption}
filterOptions={(options) => options} getOptionLabel={(option) =>
inputValue={searchVal} typeof option === 'string' ? option : option.displayName
renderInput={(params) => ( }
value={selectedUsers}
onChange={handleChangeSelections}
filterSelectedOptions
isOptionEqualToValue={(optionUser, valueUser) => optionUser.id === valueUser.id}
filterOptions={(options) => options}
inputValue={searchVal}
renderInput={(params) => (
<TextField
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
fullWidth
label="People"
{...params}
placeholder="Search by name or email"
/>
)}
/>
{selectedUsers.length > 1 && (
<TextField <TextField
value={conversationName}
onChange={handleChangeName}
fullWidth fullWidth
label="People" label="Name (optional)"
{...params} placeholder="Channel, subject line, whatever..."
placeholder="Search by name or email"
/> />
)} )}
/>
<TextField <Stack justifyContent={'flex-end'} direction={'row'} spacing={2}>
fullWidth <Button variant={'text'}>Cancel</Button>
label="Name (optional)" <Button disabled={selectedUsers.length === 0} variant={'contained'} color="primary">
placeholder="Channel, subject line, whatever..." Connect
/> </Button>
</Stack>
<Stack justifyContent={'flex-end'} direction={'row'} spacing={2}> </Container>
<Button variant={'text'}>Cancel</Button> )}
<Button variant={'contained'} color="primary">
Connect
</Button>
</Stack>
</Container>
</Container> </Container>
</Dialog> </Dialog>
); );
+48 -13
View File
@@ -50,6 +50,7 @@ import Navbar from './Navbar';
import MainPanel from './MainPanel'; import MainPanel from './MainPanel';
import { ConversationList } from './ConversationList'; import { ConversationList } from './ConversationList';
import NewConversationDialog from './NewConversationDialog'; import NewConversationDialog from './NewConversationDialog';
import { createGroupConversation } from '../firebase/firestore';
type ConversationMap = { type ConversationMap = {
[conversationId: string]: Conversation; [conversationId: string]: Conversation;
}; };
@@ -69,7 +70,7 @@ interface ITerminalContext {
selectedConversation?: Conversation; selectedConversation?: Conversation;
selectConversation?: (conversationId: string) => void; selectConversation?: (conversationId: string) => void;
handleQuickDial?: (otherUserId: string) => void; handleQuickDial?: (otherUser: User) => void;
getUser?: (userId: string) => Promise<User | undefined>; getUser?: (userId: string) => Promise<User | undefined>;
@@ -159,7 +160,7 @@ const handleOnStartRecording = (e: BlobEvent) => {
// todo: extract each use effect to custom hook and will be clean // todo: extract each use effect to custom hook and will be clean
export function TerminalProvider({ children }: { children?: React.ReactNode }) { export function TerminalProvider({ children }: { children?: React.ReactNode }) {
const { enqueueSnackbar } = useSnackbar(); const { enqueueSnackbar } = useSnackbar();
const { user, logout } = useAuth(); const { user, logout, nirvanaUser } = useAuth();
const [selectedConversationId, setSelectedConversationId] = useState<string>(undefined); const [selectedConversationId, setSelectedConversationId] = useState<string>(undefined);
@@ -282,7 +283,7 @@ export function TerminalProvider({ children }: { children?: React.ReactNode }) {
// handle create or open existing conversation // handle create or open existing conversation
// not 100% consistent to the second, but still works...don't need atomicity // not 100% consistent to the second, but still works...don't need atomicity
const handleQuickDial = useCallback( const handleQuickDial = useCallback(
async (otherUserId: string) => { async (otherUser: User) => {
setSearchVal(''); setSearchVal('');
try { try {
@@ -292,8 +293,8 @@ export function TerminalProvider({ children }: { children?: React.ReactNode }) {
const findExistingConversation = Object.values(conversationMap).find((convo) => { const findExistingConversation = Object.values(conversationMap).find((convo) => {
if ( if (
convo.memberIdsList?.length === 2 && convo.memberIdsList?.length === 2 &&
convo.memberIdsList.includes(user.uid) && convo.memberIdsList.includes(nirvanaUser.id) &&
convo.memberIdsList.includes(otherUserId) convo.memberIdsList.includes(otherUser.id)
) { ) {
return true; return true;
} }
@@ -307,14 +308,14 @@ export function TerminalProvider({ children }: { children?: React.ReactNode }) {
} }
// create conversation in this case // create conversation in this case
const newConversationId = await createOneOnOneConversation(otherUserId, user.uid); const newConversationId = await createOneOnOneConversation(otherUser, nirvanaUser);
enqueueSnackbar('started conversation!', { variant: 'success' }); enqueueSnackbar('started conversation!', { variant: 'success' });
setSelectedConversationId(newConversationId); setSelectedConversationId(newConversationId);
} catch (error) { } catch (error) {
enqueueSnackbar('Something went wrong, please try again', { variant: 'error' }); enqueueSnackbar('Something went wrong, please try again', { variant: 'error' });
} }
}, },
[conversationMap, enqueueSnackbar, user], [conversationMap, enqueueSnackbar, nirvanaUser],
); );
const getUser = useCallback( const getUser = useCallback(
@@ -496,12 +497,42 @@ export function TerminalProvider({ children }: { children?: React.ReactNode }) {
setAnchorEl(null); setAnchorEl(null);
}; };
// const handleCreateConversation = useCallback(() => {
// }, [])
const [createConversationMode, setCreateConversationMode] = useState<boolean>(true); const [createConversationMode, setCreateConversationMode] = useState<boolean>(true);
const handleStartConversation = useCallback(
async (selectedUsers: User[], conversationName?: string) => {
if (selectedUsers.length === 0) {
enqueueSnackbar('Must select more than one person');
return;
}
try {
// one on one, handle quick dial to prevent another conversation
if (selectedUsers.length === 1) {
await handleQuickDial(selectedUsers[0]);
setCreateConversationMode(false);
return;
}
// create group chat
// create conversation in this case
const newConversationId = await createGroupConversation(
selectedUsers,
nirvanaUser,
conversationName ?? null,
);
enqueueSnackbar('started group conversation!', { variant: 'success' });
setSelectedConversationId(newConversationId);
setCreateConversationMode(false);
} catch (error) {
enqueueSnackbar('Something went wrong, please try again', { variant: 'error' });
}
},
[nirvanaUser, handleQuickDial, enqueueSnackbar, setCreateConversationMode],
);
const handleShowCreateConvoForm = useCallback(() => { const handleShowCreateConvoForm = useCallback(() => {
setSelectedConversationId(undefined); setSelectedConversationId(undefined);
setCreateConversationMode(true); setCreateConversationMode(true);
@@ -693,7 +724,11 @@ export function TerminalProvider({ children }: { children?: React.ReactNode }) {
{children} {children}
{/* create chat dialog */} {/* create chat dialog */}
<NewConversationDialog open={createConversationMode} handleClose={handleEscape} /> <NewConversationDialog
open={createConversationMode}
handleSubmit={handleStartConversation}
handleClose={handleEscape}
/>
</TerminalContext.Provider> </TerminalContext.Provider>
); );
} }
@@ -739,7 +774,7 @@ function ListPeople({ people }: { people: User[] }) {
{people.map((person) => ( {people.map((person) => (
<Tooltip key={`${person.uid}-searchUsers`} title={'quick dial'}> <Tooltip key={`${person.uid}-searchUsers`} title={'quick dial'}>
<ListItem> <ListItem>
<ListItemButton onClick={() => handleQuickDial(person.uid)}> <ListItemButton onClick={() => handleQuickDial(person)}>
<ListItemAvatar> <ListItemAvatar>
<Avatar alt={person.displayName} src={person.photoUrl} /> <Avatar alt={person.displayName} src={person.photoUrl} />
</ListItemAvatar> </ListItemAvatar>
+51 -33
View File
@@ -160,19 +160,23 @@ export const getConversationContentQueryLIVE = (conversationId: string) =>
* @returns id of new conversation * @returns id of new conversation
*/ */
export const createOneOnOneConversation = async ( export const createOneOnOneConversation = async (
otherUserId: string, otherUser: User,
myUserId: string, currentUser: User,
): Promise<string | undefined> => { ): Promise<string> => {
const myMember = new ConversationMember(myUserId, MemberRole.admin, MemberState.inbox);
const otherMember = new ConversationMember(otherUserId, MemberRole.regular, MemberState.inbox);
const newMemberMap: MemberMap = {
[myUserId]: { ...myMember },
[otherUserId]: { ...otherMember },
};
const newConversation = new Conversation(myUserId, [myUserId, otherUserId], newMemberMap);
try { try {
const myMember = new ConversationMember(currentUser.id, MemberRole.admin, MemberState.inbox);
const otherMember = new ConversationMember(otherUser.id, MemberRole.regular, MemberState.inbox);
const newMemberMap: MemberMap = {
[currentUser.id]: { ...myMember },
[otherUser.id]: { ...otherMember },
};
const userCache: User[] = [{ ...currentUser }, { ...otherUser }];
const membersList = [currentUser.id, otherUser.id];
const newConversation = new Conversation(currentUser.id, membersList, newMemberMap, userCache);
const newDoc = await addDoc(db.conversations, newConversation); const newDoc = await addDoc(db.conversations, newConversation);
return newDoc.id; return newDoc.id;
} catch (e) { } catch (e) {
@@ -183,28 +187,42 @@ export const createOneOnOneConversation = async (
}; };
export const createGroupConversation = async ( export const createGroupConversation = async (
otherUserIds: string[], otherUsers: User[],
myUserId: string, adminUser: User,
): Promise<void> => { conversationName: string | null,
const myMember = new ConversationMember(myUserId, MemberRole.admin, MemberState.inbox); ): Promise<string> => {
const newMemberMap: MemberMap = {
[myUserId]: { ...myMember },
};
otherUserIds.forEach((otherMemberId) => {
const otherMember = new ConversationMember(
otherMemberId,
MemberRole.regular,
MemberState.inbox,
);
newMemberMap[otherMemberId] = { ...otherMember };
});
const newConversation = new Conversation(myUserId, [myUserId, ...otherUserIds], newMemberMap);
try { try {
await addDoc(db.conversations, newConversation); const userCache = [{ ...adminUser }];
const myMember = new ConversationMember(adminUser.id, MemberRole.admin, MemberState.inbox);
const newMemberMap: MemberMap = {
[adminUser.id]: { ...myMember },
};
otherUsers.forEach((otherUser) => {
const otherMember = new ConversationMember(
otherUser.id,
MemberRole.regular,
MemberState.inbox,
);
newMemberMap[otherUser.id] = { ...otherMember };
userCache.push({ ...otherUser });
});
const membersList = [adminUser.id, ...otherUsers.map((otherUser) => otherUser.id)];
const newConversation = new Conversation(
adminUser.id,
membersList,
newMemberMap,
userCache,
conversationName,
);
const newConversationDoc = await addDoc(db.conversations, newConversation);
return newConversationDoc.id;
} catch (e) { } catch (e) {
console.error('Error : ', e); console.error('Error : ', e);