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; isUserSpeaking: boolean; isCloudDoingMagic: boolean; } const TerminalContext = React.createContext({ 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(undefined); const [conversationMap, updateConversationMap] = useImmer({}); const [userMap, updateUserMap] = useImmer({}); const [conversationContentMap, updateContentMap] = useImmer({}); 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(false); const [userAudioStream, setUserAudioStream] = useState(null); const [mediaRecorder, setMediaRecorder] = useState(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(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(''); const [searchUsersResults, setSearchUsersResults] = useState([]); const [searching, setSearching] = useState(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) => { 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 ( {/* side panel */} {searchVal ? ( ) : ( )} {/* footer controls */} {/* todo: add a third mode which is when toggle broadcasting */} {selectedConversation ? ( ) : ( )} 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)}> ))} ); } const maxPriorityConvos = 5;