nice flow and quick working without creating a new conversation
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user