diff --git a/packages/desktop/src/app.tsx b/packages/desktop/src/app.tsx index a9b14d8..670cf93 100644 --- a/packages/desktop/src/app.tsx +++ b/packages/desktop/src/app.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { AuthProvider } from './providers/AuthProvider'; +import { ConversationProvider } from './providers/ConversationProvider'; import { ElectronProvider } from './providers/ElectronProvider'; import ErrorParent from './providers/ErrorBoundary'; import { NirvanaTheme } from './mui/NirvanaTheme'; @@ -26,11 +27,13 @@ root.render( - - - <> - - + + + + <> + + + diff --git a/packages/desktop/src/components/ConversationDetails.tsx b/packages/desktop/src/components/ConversationDetails.tsx index 5b47cdf..79a59e8 100644 --- a/packages/desktop/src/components/ConversationDetails.tsx +++ b/packages/desktop/src/components/ConversationDetails.tsx @@ -19,6 +19,7 @@ import { User } from '@nirvana/core/src/models/user.model'; /* eslint-disable jsx-a11y/media-has-caption */ import { blueGrey } from '@mui/material/colors'; import useAuth from '../providers/AuthProvider'; +import useConversations from '../providers/ConversationProvider'; import { useImmer } from 'use-immer'; import { useRendersCount } from 'react-use'; import useTerminal from './Terminal'; @@ -32,13 +33,8 @@ import useTerminal from './Terminal'; export default function ConversationDetails() { const { user } = useAuth(); - const { - getUser, - selectedConversation, - conversationContentMap, - selectConversation, - isCloudDoingMagic, - } = useTerminal(); + const { isCloudDoingMagic } = useTerminal(); + const { selectedConversation, conversationContentMap, selectConversation } = useConversations(); const rendersCount = useRendersCount(); console.warn('RENDER COUNT | CONVERSATION DETAILS | ', rendersCount); diff --git a/packages/desktop/src/components/ConversationList.tsx b/packages/desktop/src/components/ConversationList.tsx index c6c66bc..ca35e1f 100644 --- a/packages/desktop/src/components/ConversationList.tsx +++ b/packages/desktop/src/components/ConversationList.tsx @@ -24,6 +24,7 @@ import KeyboardShortcutLabel from './KeyboardShortcutLabel'; import { NirvanaRules } from '../util/rules'; import { SUPPORT_DISPLAY_NAME } from '../util/support'; import useAuth from '../providers/AuthProvider'; +import useConversations from '../providers/ConversationProvider'; import useTerminal from './Terminal'; // sort conversations based on the different data sources: type, conversations, audio clips, etc. @@ -33,8 +34,9 @@ import useTerminal from './Terminal'; * @param lookingForSomeone: if a conversation id has been selected and we are looking for it * @returns */ -export function ConversationList({ lookingForSomeone = false }: { lookingForSomeone: boolean }) { - const { conversationMap, handleOmniSearch } = useTerminal(); +export function ConversationList() { + const { handleOmniSearch } = useTerminal(); + const { conversationMap } = useConversations(); const rendersCount = useRendersCount(); console.warn('RENDER COUNT | CONVERSATION LIST | ', rendersCount); @@ -69,8 +71,6 @@ export function ConversationList({ lookingForSomeone = false }: { lookingForSome } > - {lookingForSomeone && } - {Object.values(conversationMap).map((currentConversation, index) => ( (null); diff --git a/packages/desktop/src/components/MainPanel.tsx b/packages/desktop/src/components/MainPanel.tsx index 933ccc2..8901a9b 100644 --- a/packages/desktop/src/components/MainPanel.tsx +++ b/packages/desktop/src/components/MainPanel.tsx @@ -1,15 +1,17 @@ -import { Grid, Container, Typography } from '@mui/material'; -import { blueGrey } from '@mui/material/colors'; +import { Container, Grid, Typography } from '@mui/material'; import React, { useMemo } from 'react'; -import useAuth from '../providers/AuthProvider'; -import ConversationDetails from './ConversationDetails'; -import useTerminal from './Terminal'; +import ConversationDetails from './ConversationDetails'; +import { blueGrey } from '@mui/material/colors'; +import useAuth from '../providers/AuthProvider'; +import useConversations from '../providers/ConversationProvider'; import { useRendersCount } from 'react-use'; +import useTerminal from './Terminal'; export default function MainPanel() { const { user } = useAuth(); - const { selectedConversation } = useTerminal(); + + const { selectedConversation } = useConversations(); // if selected conversation, show details // do all fetching necessary to paint things here diff --git a/packages/desktop/src/components/OmniSearchResults.tsx b/packages/desktop/src/components/OmniSearchResults.tsx index 55b592a..617401b 100644 --- a/packages/desktop/src/components/OmniSearchResults.tsx +++ b/packages/desktop/src/components/OmniSearchResults.tsx @@ -16,6 +16,7 @@ import { ConversationRow } from './ConversationList'; import { FiZap } from 'react-icons/fi'; import React from 'react'; import { User } from '@nirvana/core/src/models/user.model'; +import useConversations from '../providers/ConversationProvider'; import useTerminal from './Terminal'; export default function OmniSearchResults({ @@ -25,7 +26,8 @@ export default function OmniSearchResults({ people: User[]; conversations: Conversation[]; }) { - const { handleQuickDial, selectConversation } = useTerminal(); + const { handleQuickDial } = useTerminal(); + const { selectConversation } = useConversations(); return ( <> diff --git a/packages/desktop/src/components/Terminal.tsx b/packages/desktop/src/components/Terminal.tsx index cd0b2e3..ae0df62 100644 --- a/packages/desktop/src/components/Terminal.tsx +++ b/packages/desktop/src/components/Terminal.tsx @@ -26,22 +26,15 @@ import { blueGrey } from '@mui/material/colors'; import { createGroupConversation } from '../firebase/firestore'; import { uploadAudioClip } from '../firebase/firebaseStorage'; import useAuth from '../providers/AuthProvider'; +import useConversations from '../providers/ConversationProvider'; import { useImmer } from 'use-immer'; import { useSearchConversations } from '../util/clientSearch'; import { useSnackbar } from 'notistack'; import useStreamHandler from '../hooks/useStreamHandler'; interface ITerminalContext { - conversationMap: ConversationMap; - conversationContentMap: ConversationContentMap; - - selectedConversation?: Conversation; - selectConversation?: (conversationId: string) => void; - handleQuickDial?: (otherUser: User) => void; - getUser?: (userId: string) => Promise; - isUserSpeaking: boolean; isCloudDoingMagic: boolean; @@ -54,9 +47,6 @@ interface ITerminalContext { } const TerminalContext = React.createContext({ - conversationMap: {}, - conversationContentMap: {}, - isUserSpeaking: false, isCloudDoingMagic: false, @@ -78,125 +68,11 @@ export function TerminalProvider({ children }: { children?: React.ReactNode }) { const { enqueueSnackbar } = useSnackbar(); const { user, logout, nirvanaUser } = useAuth(); - const [selectedConversationId, setSelectedConversationId] = useState(undefined); - - const [conversationMap, updateConversationMap] = useImmer({}); - const [userMap, updateUserMap] = useImmer({}); - const [conversationContentMap, updateContentMap] = useImmer({}); + const { conversationMap, conversationContentMap, selectConversation, selectedConversation } = + useConversations(); const { searchRelevantConversations } = useSearchConversations(conversationMap); - 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) => { - 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]); - - // handles stream and device stuff - useStreamHandler(selectedConversation); - // handle create or open existing conversation // not 100% consistent to the second, but still works...don't need atomicity const handleQuickDial = useCallback( @@ -219,33 +95,18 @@ export function TerminalProvider({ children }: { children?: React.ReactNode }) { }); if (findExistingConversation) { - setSelectedConversationId(findExistingConversation.id); + selectConversation(findExistingConversation.id); return; } // create conversation in this case const newConversationId = await createOneOnOneConversation(otherUser, nirvanaUser); - setSelectedConversationId(newConversationId); + selectConversation(newConversationId); } catch (error) { enqueueSnackbar('Something went wrong, please try again', { variant: 'error' }); } }, - [conversationMap, enqueueSnackbar, nirvanaUser], - ); - - 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], + [conversationMap, enqueueSnackbar, nirvanaUser, selectConversation], ); const [isUserSpeaking, setIsUserSpeaking] = useState(false); @@ -423,11 +284,6 @@ export function TerminalProvider({ children }: { children?: React.ReactNode }) { [handleOmniSearch], ); - const selectConversation = useCallback( - (conversationId: string) => setSelectedConversationId(conversationId), - [setSelectedConversationId], - ); - const [createConversationMode, setCreateConversationMode] = useState(false); const handleStartConversation = useCallback( @@ -453,29 +309,29 @@ export function TerminalProvider({ children }: { children?: React.ReactNode }) { nirvanaUser, conversationName ?? null, ); - setSelectedConversationId(newConversationId); + selectConversation(newConversationId); setCreateConversationMode(false); } catch (error) { enqueueSnackbar('Something went wrong, please try again', { variant: 'error' }); } }, - [nirvanaUser, handleQuickDial, enqueueSnackbar, setCreateConversationMode], + [nirvanaUser, handleQuickDial, enqueueSnackbar, setCreateConversationMode, selectConversation], ); const handleShowCreateConvoForm = useCallback(() => { - setSelectedConversationId(undefined); + selectConversation(undefined); setCreateConversationMode(true); - }, [setCreateConversationMode, setSelectedConversationId]); + }, [setCreateConversationMode, selectConversation]); const rendersCount = useRendersCount(); console.warn('RENDER COUNT | TERMINAL | ', rendersCount); const handleEscape = useCallback(() => { setCreateConversationMode(false); - setSelectedConversationId(undefined); + selectConversation(undefined); setSearchVal(''); - }, [setSelectedConversationId, setCreateConversationMode, setSearchVal]); + }, [selectConversation, setCreateConversationMode, setSearchVal]); useKeyPressEvent(KeyboardShortcuts.escape.shortcutKey, handleEscape); @@ -483,13 +339,8 @@ export function TerminalProvider({ children }: { children?: React.ReactNode }) { ) : ( - + )} diff --git a/packages/desktop/src/providers/ConversationProvider.tsx b/packages/desktop/src/providers/ConversationProvider.tsx index a6bffe2..97bd834 100644 --- a/packages/desktop/src/providers/ConversationProvider.tsx +++ b/packages/desktop/src/providers/ConversationProvider.tsx @@ -1,13 +1,184 @@ -import { ConversationContentMap, ConversationMap } from '../util/types'; +import { ConversationContentMap, ConversationMap, UserMap } from '../util/types'; +import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { Unsubscribe, onSnapshot } from 'firebase/firestore'; +import { + getConversationContentQueryLIVE, + getConversationsQueryLIVE, + getUserById, +} from '../firebase/firestore'; -import React from 'react'; +import Conversation from '@nirvana/core/src/models/conversation.model'; +import { User } from '@nirvana/core/src/models/user.model'; +import useAuth from './AuthProvider'; +import { useImmer } from 'use-immer'; +import { useSnackbar } from 'notistack'; +import { useUnmount } from 'react-use'; interface IConversationContext { conversationMap: ConversationMap; conversationContentMap: ConversationContentMap; + + selectedConversation?: Conversation; + selectConversation?: (conversationId: string) => void; + + getUser?: (userId: string) => Promise; } const ConversationContext = React.createContext({ conversationContentMap: {}, conversationMap: {}, }); + +export function ConversationProvider({ children }: { children: React.ReactNode }) { + const { enqueueSnackbar } = useSnackbar(); + + const { user } = 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; + }>({}); + + 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], + ); + // on unmount, get rid of all content listeners + useUnmount(() => { + Object.values(contentListeners).forEach((unsub) => unsub()); + }); + + // fetch conversations + useEffect(() => { + const unsub = onSnapshot(getConversationsQueryLIVE(user.uid), (querySnapshot) => { + 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]); + + 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], + ); + + // 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]); + + const selectConversation = useCallback( + (conversationId: string) => setSelectedConversationId(conversationId), + [setSelectedConversationId], + ); + + return ( + + {children} + + ); +} + +export default function useConversations() { + return useContext(ConversationContext); +}