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; isUserSpeaking: boolean; } const TerminalContext = React.createContext({ 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(undefined); const [conversationMap, updateConversationMap] = useImmer({}); const [userMap, updateUserMap] = useImmer({}); const [contentMap, updatecontentMap] = useImmer({}); // 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(null); const [searchVal, setSearchVal] = useState(''); const onSearchFocus = useCallback(() => { enqueueSnackbar('search focused'); if (searchRef?.current) searchRef.current.focus(); }, [enqueueSnackbar, searchRef]); const [searchUsersResults, setSearchUsersResults] = useState([]); const [searching, setSearching] = useState(false); useKeyPressEvent('Shift', onSearchFocus); const [isUserSpeaking, setIsUserSpeaking] = useState(false); const [userAudioStream, setUserAudioStream] = useState(null); const [mediaRecorder, setMediaRecorder] = useState(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) => { setSearching(true); setSearchVal(e.target.value); }, [setSearching, setSearchVal], ); const selectConversation = useCallback( (conversationId: string) => setSelectedConversationId(conversationId), [setSelectedConversationId], ); const [anchorEl, setAnchorEl] = React.useState(null); const open = Boolean(anchorEl); const handleClick = (event: React.MouseEvent) => { setAnchorEl(event.currentTarget); }; const handleClose = () => { setAnchorEl(null); }; return ( {/* navbar */} {searching && } {searchVal ? ( ) : ( )} {/* footer controls */} Profile My account Logout {children} ); } export default function useTerminal() { return useContext(TerminalContext); } function ListPeople({ people }: { people: User[] }) { const { handleQuickDial } = useTerminal(); return ( Search Results } > {people.length === 0 && ( {`Can't find someone? Invite them!`} )} {people.map((person) => ( handleQuickDial(person.uid)}> ))} ); } function ListConversations({ lookingForSomeone = false }: { lookingForSomeone: boolean }) { const { conversationMap } = useTerminal(); if (Object.keys(conversationMap).length === 0) { return ( Look for someone by name or email to start a conversation! ); } return ( <> Priority } > {lookingForSomeone && } {Object.values(conversationMap).map((currentConversation) => ( ))} {/* Inbox } > */} ); } function ConversationRow({ conversation }: { conversation: Conversation }) { const { user } = useAuth(); const { getUser, selectedConversation, selectConversation } = useTerminal(); const [conversationUsers, setConversationUsers] = useImmer([]); 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 ( selectConversation(conversation.id)} > {conversationUsers.map((conversationUser) => conversationUser.displayName).join(', ')} {conversationUsers.map((conversationUser, index) => ( ))} {/* 20 sec */} ); } 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 ( {selectedConversation ? ( ) : ( {`Hi ${ user.displayName?.split(' ')[0] ?? 'there' }!`} Take a deep breath in. )} ); } function ConversationDetails() { const { user } = useAuth(); const { getUser, selectedConversation, selectConversation } = useTerminal(); const [conversationUsers, setConversationUsers] = useImmer([]); // 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 ( <> {selectedConversation.name ?? conversationUsers.map((conversationUser) => conversationUser.displayName).join(', ')} {conversationUsers.map((conversationUser, index) => ( ))} {/* */} ); } function ConversationHistory() { return ( yesterday {'Viet Phan'} today {'Viet Phan'} right now {'Arjun Patel'} ); }