Files
nirvana-v4/packages/desktop/src/components/Terminal.tsx
T

990 lines
28 KiB
TypeScript

import React, { useContext, useState, useCallback, useMemo, useEffect, useRef } from 'react';
import { Container } from '@mui/system';
import {
Avatar,
Box,
Fab,
Grid,
Paper,
Input,
CircularProgress,
IconButton,
Tooltip,
Stack,
Badge,
Divider,
List,
ListItem,
ListItemAvatar,
Button,
ListItemButton,
ListItemSecondaryAction,
ListItemText,
ListSubheader,
Typography,
AvatarGroup,
Menu,
MenuItem,
ListItemIcon,
} from '@mui/material';
import { blueGrey } from '@mui/material/colors';
import {
FiMoreVertical,
FiPlay,
FiSun,
FiActivity,
FiInbox,
FiSearch,
FiUsers,
FiCoffee,
FiCircle,
FiHeadphones,
FiLogOut,
FiMonitor,
FiWind,
} from 'react-icons/fi';
import { useSnackbar } from 'notistack';
import NirvanaLogo from './NirvanaLogo';
import Conversation from '@nirvana/core/src/models/conversation.model';
import useAuth from '../providers/AuthProvider';
import {
createOneOnOneConversation,
getConversationsQueryLIVE,
getUserById,
searchUsers,
} from '../firebase/firestore';
import { Link, AudioClip, Image } from '@nirvana/core/src/models/content.model';
import { useImmer } from 'use-immer';
import { User } from '@nirvana/core/src/models/user.model';
import { onSnapshot } from 'firebase/firestore';
import NirvanaAvatar from './NirvanaAvatar';
import { useDebounce, useEffectOnce, useKeyPressEvent } from 'react-use';
import KeyboardShortcutLabel from './KeyboardShortcutLabel';
import Channels from '../electron/constants';
type ConversationMap = {
[conversationId: string]: Conversation;
};
type UserMap = {
[userId: string]: User;
};
type ConversationContentMap = {
[conversationId: string]: { audio: AudioClip[]; links: Link[]; images: Image[] };
};
interface ITerminalContext {
conversationMap: ConversationMap;
selectedConversation?: Conversation;
selectConversation?: (conversationId: string) => void;
handleQuickDial?: (otherUserId: string) => void;
getUser?: (userId: string) => Promise<User | undefined>;
isUserSpeaking: boolean;
}
const TerminalContext = React.createContext<ITerminalContext>({
conversationMap: {},
isUserSpeaking: false,
});
// global audio data in memory
let audioChunks: Blob[] = [];
const handleMediaRecorderData = (e: BlobEvent) => {
audioChunks.push(e.data);
};
const handleOnStopRecording = (e: BlobEvent) => {
console.log(audioChunks);
const blob = new Blob(audioChunks);
const audioUrl = URL.createObjectURL(blob);
const player = new Audio(audioUrl);
player.autoplay = true;
};
const handleOnStartRecording = (e: BlobEvent) => {
audioChunks = [];
};
// TODOS
// fetch all conversations that I am part of
// create a convo
// 1. search
// 2. click on person
// 3. search database if there is already a convo for this
// - if yes: select this one
// - if no: create one
// 4. show the conversation details page show on right
// 5. connect live if other person there
// 6. send first clip
// 7. display all blocks/clips for convo simple and separate
// if no conversations, show stale state + create one with nirvana
// sort based on the different data sources: conversations, audio clips, etc.
// todo: extract each use effect to custom hook and will be clean
export function TerminalProvider({ children }: { children?: React.ReactNode }) {
const { enqueueSnackbar } = useSnackbar();
const { user, logout } = useAuth();
const [selectedConversationId, setSelectedConversationId] = useState<string>(undefined);
const [conversationMap, updateConversationMap] = useImmer<ConversationMap>({});
const [userMap, updateUserMap] = useImmer<UserMap>({});
const [contentMap, updatecontentMap] = useImmer<ConversationContentMap>({});
// fetch conversations
useEffect(() => {
const unsub = onSnapshot(getConversationsQueryLIVE(user.uid), (querySnapshot) => {
const conversations = querySnapshot.docs.map((doc) => doc.data());
console.log('got new or updated conversations', conversations);
querySnapshot.docChanges().forEach((docChange) => {
updateConversationMap((draft) => {
const currentConversation = docChange.doc.data();
if (docChange.type === 'added') {
console.log('New conversation: ', currentConversation);
draft[currentConversation.id] = currentConversation;
}
if (docChange.type === 'modified') {
console.log('Modified conversation: ', currentConversation);
draft[currentConversation.id] = currentConversation;
}
if (docChange.type === 'removed') {
console.log('Removed conversation: ', currentConversation);
delete draft[currentConversation.id];
}
});
});
enqueueSnackbar('got conversations...logging', { variant: 'success' });
});
return () => unsub();
}, [user, updateConversationMap]);
// cache of selected conversation
const selectedConversation: Conversation | undefined = useMemo(() => {
if (!selectedConversationId) return undefined;
// select if we do have it
const conversation = conversationMap[selectedConversationId];
if (!conversation) {
enqueueSnackbar('no conversation found', { variant: 'error' });
console.error('Should have found it... maybe retry once more');
}
return conversation;
// get conversation if not here
// const fetchedConversation = await getConversationById
// return undefined;
}, [selectedConversationId, conversationMap, enqueueSnackbar]);
// handle create or open existing conversation
// not 100% consistent to the second, but still works...don't need atomicity
const handleQuickDial = useCallback(
async (otherUserId: string) => {
setSearchVal('');
try {
// check all current conversations which should be live listened to
// if there is already a convo with exactly me and him, then select it
const findExistingConversation = Object.values(conversationMap).find((convo) => {
if (
convo.memberIdsList?.length === 2 &&
convo.memberIdsList.includes(user.uid) &&
convo.memberIdsList.includes(otherUserId)
) {
return true;
}
return false;
});
if (findExistingConversation) {
enqueueSnackbar('quick dialed someone!', { variant: 'success' });
setSelectedConversationId(findExistingConversation.id);
return;
}
// create conversation in this case
const newConversationId = await createOneOnOneConversation(otherUserId, user.uid);
enqueueSnackbar('started conversation!', { variant: 'success' });
setSelectedConversationId(newConversationId);
} catch (error) {
enqueueSnackbar('Something went wrong, please try again', { variant: 'error' });
}
},
[conversationMap, enqueueSnackbar, user],
);
const getUser = useCallback(
async (userId: string) => {
if (userMap[userId]) return userMap[userId];
const fetchedUser = await getUserById(userId);
updateUserMap((draft) => {
draft[userId] = fetchedUser;
});
return fetchedUser;
},
[userMap, updateUserMap],
);
const searchRef = useRef<HTMLInputElement>(null);
const [searchVal, setSearchVal] = useState<string>('');
const onSearchFocus = useCallback(() => {
enqueueSnackbar('search focused');
if (searchRef?.current) searchRef.current.focus();
}, [enqueueSnackbar, searchRef]);
const [searchUsersResults, setSearchUsersResults] = useState<User[]>([]);
const [searching, setSearching] = useState<boolean>(false);
useKeyPressEvent('Shift', onSearchFocus);
const [isUserSpeaking, setIsUserSpeaking] = useState<boolean>(false);
const [userAudioStream, setUserAudioStream] = useState<MediaStream>(null);
const [mediaRecorder, setMediaRecorder] = useState<MediaRecorder>(null);
const handleAskForMicrophonePermissions = useCallback(() => {
// fix this...has to do with mac configuration
// window.electronAPI.send(Channels.ASK_MICROPHONE_PERMISSIONS);
navigator.mediaDevices
.getUserMedia({ audio: true })
.then((localUserMedia: MediaStream) => {
setUserAudioStream(localUserMedia);
const userMediaRecoder = new MediaRecorder(localUserMedia);
setMediaRecorder(userMediaRecoder);
userMediaRecoder.ondataavailable = handleMediaRecorderData;
userMediaRecoder.onstop = handleOnStopRecording;
userMediaRecoder.onstart = handleOnStartRecording;
enqueueSnackbar('got mic');
console.log(localUserMedia);
})
.catch((error) => {
enqueueSnackbar('Check your audio mic permissions in preferences!!', { variant: 'error' });
console.error(error);
});
}, [setUserAudioStream, enqueueSnackbar, setMediaRecorder]);
// on load, try getting microphone access
useEffect(() => {
handleAskForMicrophonePermissions();
}, [handleAskForMicrophonePermissions]);
// const handleRecorderStopped = useCallback(async () => {
// await
// }, [])
// when user wants to talk,
// unmute them and send clip for everyone to hear in the distance
const handleBroadcast = useCallback(() => {
if (!userAudioStream) {
enqueueSnackbar('Check your audio mic permissions in preferences!!', { variant: 'error' });
handleAskForMicrophonePermissions();
return;
}
// prevent while delaying stopping
// ? better to just start after 200 ms as put below for the delay of stopping recording?
if (mediaRecorder.state === 'recording') return;
mediaRecorder.start();
setIsUserSpeaking(true);
enqueueSnackbar('started recording');
}, [setIsUserSpeaking, userAudioStream, mediaRecorder, handleAskForMicrophonePermissions]);
const handleStopBroadcast = useCallback(() => {
if (isUserSpeaking) {
setIsUserSpeaking(false);
enqueueSnackbar('stopped...', { variant: 'info' });
// actually stop talking after 2 seconds to capture everything
setTimeout(() => {
mediaRecorder.stop();
}, 200);
}
}, [setIsUserSpeaking, isUserSpeaking]);
useKeyPressEvent('`', handleBroadcast, handleStopBroadcast);
const [, cancel] = useDebounce(
async () => {
if (searchVal) {
enqueueSnackbar('searching...', { variant: 'info' });
let results = await searchUsers(searchVal);
results = results.filter((userResult) => userResult.id !== user.uid);
setSearchUsersResults(results);
console.warn('searched users', results);
}
setSearching(false);
},
1000,
[searchVal, enqueueSnackbar, setSearchUsersResults, user],
);
const handleChangeSearchInput = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setSearching(true);
setSearchVal(e.target.value);
},
[setSearching, setSearchVal],
);
const selectConversation = useCallback(
(conversationId: string) => setSelectedConversationId(conversationId),
[setSelectedConversationId],
);
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
return (
<TerminalContext.Provider
value={{
isUserSpeaking,
selectedConversation,
conversationMap,
getUser,
handleQuickDial,
selectConversation,
}}
>
<Grid container spacing={0}>
<Grid
item
xs={4}
sx={{
zIndex: 2,
backgroundColor: blueGrey[50],
boxShadow: 3,
borderRight: `1px solid ${blueGrey}`,
position: 'relative',
}}
>
<Stack
direction={'column'}
spacing={1}
sx={{
p: 2,
height: 'inherit',
}}
>
{/* navbar */}
<Stack
direction="row"
justifyContent={'flex-start'}
alignItems={'center'}
spacing={2}
sx={{
WebkitAppRegion: 'drag',
cursor: 'pointer',
pb: 1,
}}
>
<NirvanaLogo />
<Stack
direction={'row'}
sx={{
position: 'relative',
display: 'flex',
flexDirection: 'row',
bgcolor: blueGrey[100],
opacity: '50%',
borderRadius: 1,
px: 1,
py: 0.5,
flex: 1,
}}
alignItems={'center'}
spacing={1}
>
<FiSearch style={{ color: blueGrey[500] }} />
<Input
onChange={handleChangeSearchInput}
value={searchVal}
placeholder={'Find or start a conversation'}
inputRef={searchRef}
/>
{searching && <CircularProgress size={20} />}
<KeyboardShortcutLabel label="Shift" />
</Stack>
<Tooltip title={'Group conversation'}>
<IconButton color="default" size={'small'}>
<FiUsers />
</IconButton>
</Tooltip>
</Stack>
{searchVal ? (
<ListPeople people={searchUsersResults} />
) : (
<ListConversations
lookingForSomeone={selectedConversationId && !selectedConversation}
/>
)}
</Stack>
{/* footer controls */}
<Box
sx={{
top: 'auto',
bottom: 0,
position: 'absolute',
borderTop: '1px solid',
borderTopColor: blueGrey[100],
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
gap: 2,
width: '100%',
}}
>
<Stack
spacing={1}
direction={'row'}
alignItems={'center'}
justifyContent={'flex-start'}
sx={{
color: 'GrayText',
p: 1,
flex: 1,
}}
>
<IconButton
onClick={handleClick}
size="small"
sx={{ mr: 'auto', borderRadius: 1 }}
aria-controls={open ? 'account-menu' : undefined}
aria-haspopup="true"
aria-expanded={open ? 'true' : undefined}
>
<Avatar alt={user.displayName} src={user.photoURL} />
</IconButton>
<Box
sx={{
mr: 'auto',
}}
component="div"
>
<Button size={'small'} color={'secondary'} variant="text">
flow
</Button>
</Box>
<Tooltip title="Sound configuration">
<IconButton color="inherit" size="small">
<FiHeadphones />
</IconButton>
</Tooltip>
<Tooltip title="Desktop modes">
<IconButton color="inherit" size="small">
<FiMonitor />
</IconButton>
</Tooltip>
</Stack>
<Menu
anchorEl={anchorEl}
id="account-menu"
open={open}
onClose={handleClose}
onClick={handleClose}
PaperProps={{
elevation: 0,
sx: {
overflow: 'visible',
filter: 'drop-shadow(0px 2px 8px rgba(0,0,0,0.32))',
mt: 1.5,
'& .MuiAvatar-root': {
width: 32,
height: 32,
ml: -0.5,
mr: 1,
},
'&:before': {
content: '""',
display: 'block',
position: 'absolute',
top: 0,
right: 14,
width: 10,
height: 10,
bgcolor: 'background.paper',
transform: 'translateY(-50%) rotate(45deg)',
zIndex: 0,
},
},
}}
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
>
<MenuItem>
<Avatar /> Profile
</MenuItem>
<MenuItem>
<Avatar /> My account
</MenuItem>
<Divider />
<MenuItem onClick={logout}>
<ListItemIcon>
<FiLogOut />
</ListItemIcon>
<Typography color="warning">Logout</Typography>
</MenuItem>
</Menu>
</Box>
</Grid>
<MainPanel />
</Grid>
{children}
</TerminalContext.Provider>
);
}
export default function useTerminal() {
return useContext(TerminalContext);
}
function ListPeople({ people }: { people: User[] }) {
const { handleQuickDial } = useTerminal();
return (
<List
sx={{
pt: 2,
}}
subheader={
<ListSubheader
sx={{
display: 'flex',
justifyContent: 'center',
}}
>
<Typography align={'center'} variant="subtitle2">
Search Results
</Typography>
</ListSubheader>
}
>
<ListItem
sx={{
display: 'flex',
justifyContent: 'center',
}}
>
{people.length === 0 && (
<Typography align="center" variant="caption">
{`Can't find someone? Invite them!`}
</Typography>
)}
</ListItem>
{people.map((person) => (
<ListItem key={`${person.uid}-searchUsers`}>
<ListItemButton onClick={() => handleQuickDial(person.uid)}>
<ListItemAvatar>
<Avatar alt={person.displayName} src={person.photoUrl} />
</ListItemAvatar>
<ListItemText primary={person.displayName} />
<ListItemSecondaryAction sx={{ color: 'GrayText' }}>
<FiCoffee />
</ListItemSecondaryAction>
</ListItemButton>
</ListItem>
))}
</List>
);
}
function ListConversations({ lookingForSomeone = false }: { lookingForSomeone: boolean }) {
const { conversationMap } = useTerminal();
if (Object.keys(conversationMap).length === 0) {
return (
<Typography align="center" variant="caption">
Look for someone by name or email to start a conversation!
</Typography>
);
}
return (
<>
<List
sx={{
pt: 2,
}}
subheader={
<ListSubheader>
<FiActivity />
<Typography variant="subtitle2"> Priority</Typography>
</ListSubheader>
}
>
{lookingForSomeone && <CircularProgress />}
{Object.values(conversationMap).map((currentConversation) => (
<ConversationRow
key={`${currentConversation.id}-priorityConvoList`}
conversation={currentConversation}
/>
))}
</List>
<Divider />
{/* <List
sx={{
pt: 2,
}}
subheader={
<ListSubheader>
<FiInbox />
<Typography variant="subtitle2"> Inbox</Typography>
</ListSubheader>
}
></List> */}
</>
);
}
function ConversationRow({ conversation }: { conversation: Conversation }) {
const { user } = useAuth();
const { getUser, selectedConversation, selectConversation } = useTerminal();
const [conversationUsers, setConversationUsers] = useImmer<User[]>([]);
useEffect(() => {
(async () => {
const userPromises = conversation.memberIdsList
.filter((memberId) => memberId !== user.uid)
.map(async (memberId) => await getUser(memberId));
const userSettled = await Promise.all(userPromises);
setConversationUsers(userSettled);
})();
return;
}, [conversation.memberIdsList, getUser, user, setConversationUsers]);
return (
<ListItem key={`${conversation.id}-priorityConvoList`}>
<ListItemButton
selected={selectedConversation?.id === conversation.id}
onClick={() => selectConversation(conversation.id)}
>
<Box sx={{ color: 'GrayText', fontSize: 10, mr: 2 }}>
<FiCircle />
</Box>
<Typography sx={{ mr: 'auto', color: 'GrayText' }} variant={'overline'}>
{conversationUsers.map((conversationUser) => conversationUser.displayName).join(', ')}
</Typography>
<ListItemAvatar>
<AvatarGroup variant={'rounded'}>
{conversationUsers.map((conversationUser, index) => (
<Avatar
key={`${conversation.id}-${conversationUser.uid}-convoIcon`}
alt={conversationUser?.displayName}
src={conversationUser?.photoUrl}
sx={{ width: 30, height: 30 }}
/>
))}
</AvatarGroup>
</ListItemAvatar>
{/* <Typography variant={'caption'}>20 sec</Typography> */}
</ListItemButton>
</ListItem>
);
}
function MainPanel() {
const { user } = useAuth();
const { selectedConversation } = useTerminal();
// if selected conversation, show details
// do all fetching necessary to paint things here
// join the call so to speak
// show who is
return (
<Grid item xs={8} sx={{ backgroundColor: 'white', display: 'flex', flexDirection: 'column' }}>
{selectedConversation ? (
<ConversationDetails />
) : (
<Container
maxWidth={false}
sx={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
gap: 2,
flex: 1,
background: blueGrey[50],
}}
>
<Typography variant="h6">{`Hi ${
user.displayName?.split(' ')[0] ?? 'there'
}!`}</Typography>
<Typography variant="caption">Take a deep breath in.</Typography>
</Container>
)}
</Grid>
);
}
function ConversationDetails() {
const { user } = useAuth();
const { getUser, selectedConversation, selectConversation } = useTerminal();
const [conversationUsers, setConversationUsers] = useImmer<User[]>([]);
// join the room
useEffect(() => {
if (selectedConversation.membersInRoom?.length > 0) {
// join the audio room
}
}, [selectedConversation.membersInRoom]);
useEffect(() => {
(async () => {
const userPromises = selectedConversation.memberIdsList
.filter((memberId) => memberId !== user.uid)
.map(async (memberId) => await getUser(memberId));
const userSettled = await Promise.all(userPromises);
setConversationUsers(userSettled);
})();
return;
}, [selectedConversation.memberIdsList, getUser, user, setConversationUsers]);
return (
<>
<Stack
direction={'row'}
sx={{
py: 2,
px: 2,
borderBottom: '1px solid',
borderBottomColor: blueGrey[100],
WebkitAppRegion: 'drag',
cursor: 'pointer',
}}
alignItems={'center'}
justifyContent={'flex-start'}
>
<IconButton color="primary" size="small">
<FiSun />
</IconButton>
<Typography sx={{ color: 'GrayText' }} variant={'overline'}>
{selectedConversation.name ??
conversationUsers.map((conversationUser) => conversationUser.displayName).join(', ')}
</Typography>
<AvatarGroup variant={'rounded'} sx={{ ml: 'auto' }}>
{conversationUsers.map((conversationUser, index) => (
<Avatar
key={`${selectedConversation.id}-${conversationUser.uid}-convoIcon`}
alt={conversationUser?.displayName}
src={conversationUser?.photoUrl}
sx={{
width: 30,
height: 30,
opacity: selectedConversation.membersInRoom?.includes(conversationUser.uid)
? '100%'
: '20%',
}}
/>
))}
</AvatarGroup>
<Box>
<IconButton size="small">
<FiMoreVertical />
</IconButton>
</Box>
</Stack>
<Container maxWidth={false} sx={{ position: 'relative', flex: 1 }}>
{/* <ConversationHistory /> */}
<Box
sx={{
position: 'absolute',
zIndex: 10,
bottom: 0,
right: 0,
padding: 3,
}}
>
<Fab color="primary" aria-label="add" size="medium">
<FiSun />
</Fab>
</Box>
</Container>
</>
);
}
function ConversationHistory() {
return (
<Container maxWidth="xs">
<Stack
justifyContent={'flex-start'}
alignItems={'center'}
sx={{
pt: 2,
}}
>
<Typography variant="caption">yesterday</Typography>
<Paper elevation={1} sx={{ p: 1, width: '100%' }}>
<Stack direction={'row'} alignItems="center">
<Stack spacing={2} direction={'row'} alignItems={'center'}>
<Avatar alt={'Arjun Patel'} src="https://mui.com/static/images/avatar/2.jpg" />
<Typography color={'GrayText'} variant="overline">
{'Viet Phan'}
</Typography>
</Stack>
<Box
sx={{
ml: 'auto',
color: 'GrayText',
}}
>
<FiPlay />
</Box>
</Stack>
</Paper>
</Stack>
<Stack
justifyContent={'flex-start'}
alignItems={'center'}
sx={{
pt: 2,
}}
>
<Typography variant="caption">today</Typography>
<Paper elevation={8} sx={{ p: 1, width: '100%' }}>
<Stack direction={'row'} alignItems="center">
<Stack spacing={2} direction={'row'} alignItems={'center'}>
<Avatar alt={'Arjun Patel'} src="https://mui.com/static/images/avatar/2.jpg" />
<Typography color={'GrayText'} variant="overline">
{'Viet Phan'}
</Typography>
</Stack>
<Box
sx={{
ml: 'auto',
color: 'GrayText',
}}
>
<FiPlay />
</Box>
</Stack>
</Paper>
</Stack>
<Stack
justifyContent={'flex-start'}
alignItems={'center'}
sx={{
pt: 2,
}}
>
<Typography variant="caption">right now</Typography>
<Paper elevation={24} sx={{ p: 1, width: '100%' }}>
<Stack direction={'row'} alignItems="center">
<Stack spacing={2} direction={'row'} alignItems={'center'}>
<Avatar
alt={'Arjun Patel'}
src="https://lh3.googleusercontent.com/ogw/ADea4I6TRqnIptWNP25-iXdusoAHafj-cUPYkO53xKT2_H0=s64-c-mo"
/>
<Typography color={'GrayText'} variant="overline">
{'Arjun Patel'}
</Typography>
</Stack>
<Box
sx={{
ml: 'auto',
color: 'GrayText',
}}
>
<FiPlay />
</Box>
</Stack>
</Paper>
</Stack>
</Container>
);
}