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

728 lines
22 KiB
TypeScript

import React, { useContext, useState, useCallback, useMemo, useEffect } from 'react';
import {
Avatar,
Box,
Grid,
IconButton,
Tooltip,
Stack,
Divider,
List,
ListItem,
ListItemAvatar,
Button,
ListItemButton,
ListItemSecondaryAction,
ListItemText,
ListSubheader,
Typography,
Menu,
MenuItem,
ListItemIcon,
Fab,
} from '@mui/material';
import { blueGrey } from '@mui/material/colors';
import { FiZap, FiHeadphones, FiLogOut, FiMonitor, FiSun } from 'react-icons/fi';
import { useSnackbar } from 'notistack';
import Conversation from '@nirvana/core/src/models/conversation.model';
import useAuth from '../providers/AuthProvider';
import {
createOneOnOneConversation,
getConversationContentQueryLIVE,
getConversationsQueryLIVE,
getUserById,
searchUsers,
sendContentBlockToConversation,
} from '../firebase/firestore';
import { useImmer } from 'use-immer';
import { User } from '@nirvana/core/src/models/user.model';
import { onSnapshot, Unsubscribe } from 'firebase/firestore';
import { useDebounce, useKeyPressEvent, useUnmount } from 'react-use';
import { uploadAudioClip } from '../firebase/firebaseStorage';
import { ContentBlock, ContentType } from '@nirvana/core/src/models/content.model';
import Navbar from './Navbar';
import MainPanel from './MainPanel';
import { ConversationList } from './ConversationList';
type ConversationMap = {
[conversationId: string]: Conversation;
};
type UserMap = {
[userId: string]: User;
};
type ConversationContentMap = {
[conversationId: string]: ContentBlock[];
};
interface ITerminalContext {
conversationMap: ConversationMap;
conversationContentMap: ConversationContentMap;
selectedConversation?: Conversation;
selectConversation?: (conversationId: string) => void;
handleQuickDial?: (otherUserId: string) => void;
getUser?: (userId: string) => Promise<User | undefined>;
isUserSpeaking: boolean;
isCloudDoingMagic: boolean;
}
const TerminalContext = React.createContext<ITerminalContext>({
conversationMap: {},
conversationContentMap: {},
isUserSpeaking: false,
isCloudDoingMagic: false,
});
// global audio data in memory
let audioChunks: Blob[] = [];
const handleMediaRecorderData = (e: BlobEvent) => {
audioChunks.push(e.data);
};
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
// 1. create group conversation page
// you know what to do...simple row of selected users...dropdown options should show searched user list
// use material selects for this dropdown to avoid all the work...and hide select-like things
// 2. auto select that conversation and have it show up
// 3. allow adding name when creating convo
// 4.
// 5. convo settings: add people (simple add method with firestore), change name | max 8 people
/**
* Streaming stuff:
* - join a stream room if 2 or more in the list
* - leave on unmount
* - timer to unselect a conversation after 30 minutes...no fancy checking if user is there/active
* - show little control to see if
*/
/**
*
* media stuff:
* - paste image + view in history + with modal nice and big
* - paste a link + see in a content block
* - paste link of image and have it save as image
*/
/**
*
* tech debt: now go back and make convo line and side panel name and icons and all legit
*/
/**
* nice convo history
* - chunk has max of 1 minute
* - put current clip in next chunk if previous clip was more than 4 hours ago
* - put current clip in next chunk if it's after my last active date here
*/
// 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 [conversationContentMap, updateContentMap] = useImmer<ConversationContentMap>({});
const [contentListeners, setContentListeners] = useImmer<{
[conversationId: string]: Unsubscribe;
}>({});
// TODO: put in a custom hook to separate out logic
const addConversationContentListener = useCallback(
(conversationId: string) => {
// ?optimization
// ? only start listening to conversation content where it is in my priority box?
// ? all other conversations, just fetch periodically?
// TODO: future work/optimization
// if we removed a conversation, unsubscribe from content listener as well
setContentListeners((draftListeners) => {
// if we have a listener for conversation already, then just move on
if (draftListeners[conversationId]) return;
// if we don't, then create one for this conversation
const contentListener = onSnapshot(
getConversationContentQueryLIVE(conversationId),
(querySnapshot) => {
querySnapshot.docChanges().forEach((docChange) => {
const currentContentBlock = docChange.doc.data();
if (docChange.type === 'added') {
// TODO: add to audio queue from here if there was an addition?
updateContentMap((draftContent) => {
if (draftContent[conversationId]) {
draftContent[conversationId].push(currentContentBlock);
} else {
draftContent[conversationId] = [currentContentBlock];
}
});
}
if (docChange.type === 'modified') {
//
}
if (docChange.type === 'removed') {
//
}
});
},
);
//add to map of listeners
draftListeners[conversationId] = contentListener;
});
},
[setContentListeners, updateContentMap],
);
// fetch conversations
// TODO: put members map in another collection to avoid all of the re-renders for all folks...
// duplicate data for better reads
useEffect(() => {
const unsub = onSnapshot(getConversationsQueryLIVE(user.uid), (querySnapshot) => {
const conversations = querySnapshot.docs.map((doc) => doc.data());
console.log('got new or updated conversations', conversations);
updateConversationMap((draft) => {
querySnapshot.docChanges().forEach((docChange) => {
const currentConversation = docChange.doc.data();
if (docChange.type === 'added') {
console.log('New conversation: ', currentConversation);
draft[currentConversation.id] = currentConversation;
addConversationContentListener(currentConversation.id);
}
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];
}
});
});
});
return () => unsub();
}, [user, updateConversationMap, enqueueSnackbar, addConversationContentListener]);
// on unmount, get rid of all content listeners
useUnmount(() => {
Object.values(contentListeners).forEach((unsub) => unsub());
});
// 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 handleEscape = useCallback(() => {
setSelectedConversationId(undefined);
}, [setSelectedConversationId]);
useKeyPressEvent('Escape', handleEscape);
const [isUserSpeaking, setIsUserSpeaking] = useState<boolean>(false);
const [userAudioStream, setUserAudioStream] = useState<MediaStream>(null);
const [mediaRecorder, setMediaRecorder] = useState<MediaRecorder>(null);
// from the time of starting to speak to finally sent to everyone out there
// feeling to user that everyone is listening right now even though it's only recording
const [isCloudDoingMagic, setIsCloudDoingMagic] = useState<boolean>(false);
const handleOnStopRecording = useCallback(async () => {
console.log(audioChunks[0]);
const blob = new Blob(audioChunks);
const audioUrl = URL.createObjectURL(blob);
const player = new Audio(audioUrl);
player.autoplay = true;
try {
// ?should I send blob or first audio chunk in array?
const downloadUrl = await uploadAudioClip(
`${user.uid}-${new Date().valueOf()}`,
audioChunks[0],
);
const contentBlock = new ContentBlock(
user.uid,
downloadUrl,
ContentType.audio,
audioChunks[0].type,
);
await sendContentBlockToConversation(contentBlock, selectedConversation.id);
enqueueSnackbar('clip sent!', { variant: 'success' });
} catch (error) {
console.error(error);
enqueueSnackbar('Problem in recording clip', { variant: 'error' });
}
setIsCloudDoingMagic(false);
}, [user.uid, setIsCloudDoingMagic, enqueueSnackbar, selectedConversation?.id]);
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, handleOnStopRecording]);
// on load, try getting microphone access
useEffect(() => {
handleAskForMicrophonePermissions();
}, [handleAskForMicrophonePermissions]);
// when user wants to talk,
// unmute them and send clip for everyone to hear in the distance
const handleBroadcast = useCallback(() => {
if (!selectedConversation) {
enqueueSnackbar('You must select a conversation first!!!', { variant: 'warning' });
return;
}
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;
enqueueSnackbar('started recording');
setIsCloudDoingMagic(true);
mediaRecorder.start();
setIsUserSpeaking(true);
}, [
selectedConversation,
setIsUserSpeaking,
userAudioStream,
mediaRecorder,
handleAskForMicrophonePermissions,
setIsCloudDoingMagic,
enqueueSnackbar,
]);
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, mediaRecorder, enqueueSnackbar]);
useKeyPressEvent('`', handleBroadcast, handleStopBroadcast);
const [searchVal, setSearchVal] = useState<string>('');
const [searchUsersResults, setSearchUsersResults] = useState<User[]>([]);
const [searching, setSearching] = useState<boolean>(false);
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,
isCloudDoingMagic,
conversationContentMap,
}}
>
<Grid container spacing={0}>
{/* side panel */}
<Grid
item
xs={4}
sx={{
zIndex: 2,
backgroundColor: blueGrey[50],
boxShadow: 3,
borderRight: `1px solid ${blueGrey}`,
display: 'flex',
flexDirection: 'column',
}}
>
<Navbar
searchVal={searchVal}
isSearching={searching}
handleChangeSearchInput={handleChangeSearchInput}
/>
<Box sx={{ p: 2 }}>
{searchVal ? (
<ListPeople people={searchUsersResults} />
) : (
<ConversationList
lookingForSomeone={selectedConversationId && !selectedConversation}
/>
)}
</Box>
{/* footer controls */}
<Box
sx={{
mt: 'auto',
borderTop: '1px solid',
borderTopColor: blueGrey[100],
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
gap: 2,
width: '100%',
zIndex: 10,
boxShadow: 10,
bgcolor: blueGrey[200],
}}
>
<Stack
spacing={1}
direction={'row'}
alignItems={'center'}
justifyContent={'flex-start'}
sx={{
color: 'GrayText',
p: 1,
flex: 1,
}}
>
<IconButton
onClick={handleClick}
size="small"
sx={{ borderRadius: 1 }}
aria-controls={open ? 'account-menu' : undefined}
aria-haspopup="true"
aria-expanded={open ? 'true' : undefined}
>
<Avatar alt={user.displayName} src={user.photoURL} />
</IconButton>
<Stack
direction={'row'}
alignItems={'center'}
component="div"
sx={{ mr: 'auto', flex: 1 }}
>
<Tooltip title="Sound configuration">
<IconButton color="inherit" size="small">
<FiHeadphones />
</IconButton>
</Tooltip>
<Tooltip title="Desktop modes">
<IconButton color="inherit" size="small">
<FiMonitor />
</IconButton>
</Tooltip>
<Button size={'small'} color={'secondary'} variant="text">
flow
</Button>
</Stack>
{/* todo: add a third mode which is when toggle broadcasting */}
{selectedConversation ? (
<Tooltip title="Speak or toggle by clicking here!">
<Fab color="primary" aria-label="add" size="medium">
<FiSun />
</Fab>
</Tooltip>
) : (
<Tooltip title="No conversation selected!">
<IconButton color="inherit" size="small">
<FiSun />
</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 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) => (
<Tooltip key={`${person.uid}-searchUsers`} title={'quick dial'}>
<ListItem>
<ListItemButton onClick={() => handleQuickDial(person.uid)}>
<ListItemAvatar>
<Avatar alt={person.displayName} src={person.photoUrl} />
</ListItemAvatar>
<ListItemText primary={person.displayName} secondary={person.email} />
<ListItemSecondaryAction sx={{ color: 'GrayText' }}>
<FiZap />
</ListItemSecondaryAction>
</ListItemButton>
</ListItem>
</Tooltip>
))}
</List>
);
}
const maxPriorityConvos = 5;