Files
nirvana-v3/packages/desktop/src/providers/ConversationProvider.tsx
T
2022-06-19 17:01:35 -05:00

763 lines
26 KiB
TypeScript

import {
ConnectToLineRequest,
RtcAnswerSomeoneRequest,
RtcCallRequest,
RtcNewUserJoinedResponse,
RtcReceiveAnswerResponse,
ServerRequestChannels,
ServerResponseChannels,
SomeoneConnectedResponse,
SomeoneDisconnectedResponse,
SomeoneTunedResponse,
SomeoneUntunedFromLineResponse,
TuneToLineRequest,
UntuneFromLineRequest,
} from '@nirvana/core/sockets/channels';
import Conversation, { MemberState } from '@nirvana/core/models/conversation.model';
import { ConversationMap, MasterConversation } from '../util/types';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Updater, useImmer } from 'use-immer';
import {
checkIfOnOnOneExists,
createConversation,
getConversationById,
getConversations,
} from '../api/NirvanaApi';
import { useAsyncFn, useEffectOnce, useUnmount } from 'react-use';
import CreateConversationRequest from '@nirvana/core/requests/CreateConversationRequest.request';
import Peer from 'simple-peer';
import { Typography } from '@mui/material';
import User from '@nirvana/core/models/user.model';
import toast from 'react-hot-toast';
import useAuth from './AuthProvider';
import useSockets from './SocketProvider';
// responsible for firing socket events from the local client
function useSocketFire() {
const { $ws } = useSockets();
const handleConnectToLine = useCallback(
(lineId: string) => {
$ws.emit(ServerRequestChannels.CONNECT_TO_LINE, new ConnectToLineRequest(lineId));
},
[$ws],
);
const handleTuneIntoLine = useCallback(
(lineId: string) => {
$ws.emit(ServerRequestChannels.TUNE_INTO_LINE, new TuneToLineRequest(lineId));
},
[$ws],
);
const handleUntuneFromLine = useCallback(
(lineId: string) => {
$ws.emit(ServerRequestChannels.UNTUNE_FROM_LINE, new UntuneFromLineRequest(lineId));
},
[$ws],
);
return { handleConnectToLine, handleTuneIntoLine, handleUntuneFromLine };
}
interface IConversationContext {
conversationMap: ConversationMap;
selectedConversation?: MasterConversation;
selectConversation?: (conversationId: string, temporaryOverrideSort?: boolean) => Promise<void>;
handleStartConversation?: (otherUsers: User[], conversationName?: string) => Promise<boolean>;
}
const ConversationContext = React.createContext<IConversationContext>({
conversationMap: {},
});
export function ConversationProvider({ children }: { children: React.ReactNode }) {
const { user } = useAuth();
// the initial persistent fetch for conversations
const [fetchState, doFetch] = useAsyncFn(getConversations);
// the map that we manage throughout the app
const [conversationMap, setConversationMap] = useImmer<ConversationMap>({});
const [selectedConversation, setSelectedConversation] = useState<MasterConversation>(undefined);
const { $ws } = useSockets();
const { handleConnectToLine, handleTuneIntoLine, handleUntuneFromLine } = useSocketFire();
// fetch conversations
useEffect(() => {
doFetch();
}, [doFetch]);
// conversation socket listeners
useEffect(() => {
$ws.on(ServerResponseChannels.SOMEONE_CONNECTED_TO_LINE, (res: SomeoneConnectedResponse) => {
setConversationMap((draft) => {
if (!draft[res.lineId]) {
toast.error('no line');
return;
}
draft[res.lineId].connectedUserIds = res.allConnectedUsers;
draft[res.lineId].tunedInUsers = res.allTunedUsers;
});
});
$ws.on(ServerResponseChannels.SOMEONE_TUNED_INTO_LINE, (res: SomeoneTunedResponse) => {
setConversationMap((draft) => {
if (!draft[res.lineId]) {
toast.error('no line');
return;
}
draft[res.lineId].tunedInUsers = res.allUsers;
});
});
$ws.on(
ServerResponseChannels.SOMEONE_UNTUNED_FROM_LINE,
(res: SomeoneUntunedFromLineResponse) => {
setConversationMap((draft) => {
if (!draft[res.lineId]) {
toast.error('no line');
return;
}
draft[res.lineId].tunedInUsers = draft[res.lineId].tunedInUsers.filter(
(userId) => userId !== res.userId,
);
});
},
);
}, [$ws, setConversationMap]);
// remove them from the line connected list and tuned list if they are there
$ws.on(
ServerResponseChannels.SOMEONE_DISCONNECTED_FROM_LINE,
(res: SomeoneDisconnectedResponse) => {
setConversationMap((draft) => {
if (!draft[res.lineId]) {
toast.error('there was a problem updating rooms!!!');
return;
}
draft[res.lineId].connectedUserIds = draft[res.lineId].connectedUserIds?.filter(
(userId) => userId !== res.userId,
);
draft[res.lineId].tunedInUsers = draft[res.lineId].tunedInUsers?.filter(
(userId) => userId !== res.userId,
);
});
},
);
// add the conversation to the conversation map/client side cache
// has implications on realtime listening and also the ui on whether or not it's shown
const handleAddConversationCache = useCallback(
(conversation: Conversation, temporaryOverrideSort = false) => {
setConversationMap((draft) => {
draft[conversation._id.toString()] = {
...conversation,
tunedInUsers: [],
connectedUserIds: [],
temporaryOverrideSort,
};
});
handleConnectToLine(conversation._id.toString());
// if it's in my priority list, tune into it automatically
const currMemberForConversation = conversation.members.find(
(mem) => mem.email === user.email,
);
if (currMemberForConversation && currMemberForConversation.memberState === 'priority') {
handleTuneIntoLine(conversation._id.toString());
}
},
[setConversationMap, handleConnectToLine, handleTuneIntoLine, user],
);
// add conversations to cache on initial fetch
// the two stores | persistent and socket | are decoupled
useEffect(() => {
if (fetchState.value?.data) {
fetchState.value.data.forEach((conversationResult) => {
handleAddConversationCache(conversationResult);
});
}
}, [fetchState.value, handleAddConversationCache]);
/**
* temporaryOverrideSort: tell me if you want to temporarily prioritize this conversation above all others
*/
// selecting an id and making sure that it's in the map and the views just use the id to access from map
// get conversation from universe...hit if it's local, miss and fetch if not here
const selectConversation = useCallback(
async (conversationId: string, temporaryOverrideSort = false) => {
/**
* see if there is such a conversation in our map
* if not, then go and fetch it from backend
* set the temporary sort accordingly
*/
const allConversationIds = Object.keys(conversationMap);
let conversationToSelect: MasterConversation;
if (allConversationIds.includes(conversationId)) {
toast.success('we have that conversation!!!');
conversationToSelect = conversationMap[conversationId];
} else {
// TODO: somehow use handleAddToCache instead of this duplicated code
// fetch and add to master conversation cache
const retrieveConversationResult = await getConversationById(conversationId);
if (!retrieveConversationResult.data) {
toast.error('unable to find conversation');
return;
}
const newMasterConversation: MasterConversation = {
...retrieveConversationResult.data,
tunedInUsers: [],
connectedUserIds: [],
temporaryOverrideSort,
};
setConversationMap((draft) => {
draft[conversationId] = newMasterConversation;
});
conversationToSelect = newMasterConversation;
// connect to line so that we can listen in to changes
handleConnectToLine(newMasterConversation._id.toString());
}
// set selected conversation with full master conversation now that we have it
setSelectedConversation((prevConversation) => {
// untune if it was an inbox conversation and we are going away from this conversation
if (prevConversation) {
const currMemberForConversation = prevConversation.members.find(
(mem) => mem.email === user.email,
);
if (currMemberForConversation && currMemberForConversation.memberState === 'inbox') {
handleUntuneFromLine(prevConversation._id.toString());
}
}
handleTuneIntoLine(conversationToSelect._id.toString());
return conversationToSelect;
});
},
[
user,
conversationMap,
setSelectedConversation,
setConversationMap,
handleUntuneFromLine,
handleTuneIntoLine,
handleConnectToLine,
],
);
// !temporary fix for updated conversation content not being surfaced
// as the map value updates
useEffect(() => {
if (selectedConversation) {
setSelectedConversation(conversationMap[selectedConversation._id.toString()]);
}
}, [conversationMap, selectedConversation, setSelectedConversation]);
// handling quick dial or creating a conversation
const handleStartConversation = useCallback(
async (otherUsers: User[], conversationName?: string) => {
try {
/** group conversation
* 1. create it
* 2. select the conversation based on the id
*
* one on one:
* 1. hit backend to see if we already have a conversation with them
* 2. if so, then go and select it with priority sort flag
* 3. if not, then go ahead and create
* 4. use the conversation id to select it
*/
if (!otherUsers || otherUsers.length === 0) {
toast.error('Must provider users');
return false;
}
// todo: check to make sure that the other user is not the user himself, although backend should error out
if (otherUsers.length === 1) {
console.log('checking if exists....');
const conversationDmCheck = await checkIfOnOnOneExists(otherUsers[0]._id.toString());
console.log(conversationDmCheck);
// if we found a conversation
if (conversationDmCheck.data) {
toast.success('already have this user...quick dialing');
selectConversation(conversationDmCheck.data, true);
return true;
}
}
const createdConversationResult = await createConversation(
new CreateConversationRequest(otherUsers, conversationName),
);
selectConversation(createdConversationResult.data.conversationId.toString(), true);
toast.success('started conversation');
return true;
} catch (error) {
console.error(error);
toast.error(error.message);
}
return false;
},
[selectConversation],
);
const userTunedInConversations = useMemo(() => {
const tunedConversations: MasterConversation[] = [];
Object.values(conversationMap).forEach((currentMasterConversation) => {
if (currentMasterConversation.tunedInUsers.includes(user._id.toString())) {
tunedConversations.push(currentMasterConversation);
}
});
return tunedConversations;
}, [conversationMap, user]);
if (fetchState.error) {
return (
<Typography variant={'h6'} color={'danger'}>
Something went terribly wrong
</Typography>
);
}
return (
<ConversationContext.Provider
value={{
conversationMap,
handleStartConversation,
selectedConversation,
selectConversation,
}}
>
{children}
{/* with id specified, shouldn't unmount and mount unexpectedly */}
{userTunedInConversations.map((tunedConversation) => (
<Room
key={`streamRoom-${tunedConversation._id.toString()}`}
setConversationMap={setConversationMap}
conversation={tunedConversation}
/>
))}
</ConversationContext.Provider>
);
}
export default function useConversations() {
return React.useContext(ConversationContext);
}
const videoConstraints = {
frameRate: 15,
width: { max: 100 },
height: { max: 200 },
};
const iceServers = [
// { urls: 'stun:stun.l.google.com:19302' },
// { urls: 'stun:stun.l.google.com:19302' },
// { urls: 'stun:stun1.l.google.com:19302' },
{ urls: 'stun:stun2.l.google.com:19302' },
// { urls: 'stun:stun3.l.google.com:19302' },
// { urls: 'stun:stun4.l.google.com:19302' },
// { urls: 'stun:global.stun.twilio.com:3478?transport=udp' },
// {
// url: 'turn:numb.viagenie.ca',
// credential: 'muazkh',
// username: '[email protected]',
// },
// {
// url: 'turn:192.158.29.39:3478?transport=udp',
// credential: 'JZEOEt2V3Qb0y27GRntt2u2PAYA=',
// username: '28224511:1379330808',
// },
{
url: 'turn:turn.bistri.com:80',
credential: 'homeo',
username: 'homeo',
},
// {
// url: 'turn:turn.anyfirewall.com:443?transport=tcp',
// credential: 'webrtc',
// username: 'webrtc',
// },
// {
// url: 'turn:openrelay.metered.ca:80',
// credential: 'openrelayproject',
// username: 'openrelayproject',
// },
];
// renderless component to manage a room and send data upward for other components
// - decoupled from sockets and other data, render if you want to join x room
// - unmount if you want to leave room
// - have event listeners specific to this room
// - take in new users that come into the room
function Room({
conversation,
setConversationMap,
}: {
conversation: MasterConversation;
mediaStream?: MediaStream;
setConversationMap: Updater<ConversationMap>;
}) {
const { user } = useAuth();
const { $ws } = useSockets();
const [userLocalStream, setUserLocalStream] = useState<MediaStream>();
// have internal state to manage details of this "room"
// ============== STREAMING ===============
// handle incoming calls and accept calls and create objects for them
useEffectOnce(() => {
(async () => {
// TODO : bring this stream object higher and add this later to the room
// set up local stream for this room - temporary until we figure out global stream handling and passing down
const localMediaStream = await navigator.mediaDevices.getUserMedia({
video: videoConstraints,
audio: true,
});
console.log(localMediaStream);
setUserLocalStream(localMediaStream);
// ?will there be race condition where this room component is rendered but we don't have the latest
// ?list of tunedin folks and so we may just end up calling select few?
// ?in this case, start with initiating event to get all people in room first
if (conversation.tunedInUsers && conversation.tunedInUsers.length > 1) {
// going ahead and calling all of the other folks
const allOtherUserIds = conversation.tunedInUsers.filter(
(memberUserId) => memberUserId !== user._id.toString(),
);
toast.success('calling bunch of people');
// for each person, create peer object
allOtherUserIds.forEach((otherUserId) => {
const connectingToast = toast.loading('calling peer for a snappy experience');
// ========= PEER CREATION =============
// make sure this peer gets destroyed when it's time to remove this listener
const localPeerConnection = new Peer({
initiator: true,
stream: localMediaStream,
trickle: false, // prevents the multiple tries on different ice servers and signal from getting called a bunch of times,
config: {
iceServers,
},
});
// ========= PEER EVENT HANDLERS =============
localPeerConnection.on('signal', (signal) => {
console.log('have a signal to make call to someone ');
$ws.emit(
ServerRequestChannels.RTC_CALL_SOMEONE_FOR_LINE,
new RtcCallRequest(otherUserId, conversation._id.toString(), signal),
);
toast.dismiss(connectingToast);
// todo, take trickle into account to not overwrite stream
// notifying globally that we are in the process now
setConversationMap((draft) => {
if (draft[conversation._id.toString()]) {
draft[conversation._id.toString()].room = {
...(draft[conversation._id.toString()].room ?? {}),
[otherUserId]: { peer: localPeerConnection, isConnecting: true },
};
}
});
});
localPeerConnection.on('stream', (remoteStream: MediaStream) => {
// globally updating conversation so that other views can render what they want
toast.success('got stream from remote, going to add to our ');
console.log('got stream from master');
setConversationMap((draft) => {
if (draft[conversation._id.toString()].room[otherUserId]) {
draft[conversation._id.toString()].room[otherUserId].stream = remoteStream;
remoteStream.getTracks().forEach((track) => {
// TODO: add particular track to right place
});
}
});
});
localPeerConnection.on('connect', () => {
toast.success('successfully connected to another peer');
setConversationMap((draft) => {
if (draft[conversation._id.toString()].room[otherUserId]) {
draft[conversation._id.toString()].room[otherUserId].isConnecting = false;
}
});
});
localPeerConnection.on('track', (track, stream) => {
// TODO: add to room contents
toast('a peer added a track to a stream');
});
localPeerConnection.on('close', () => {
// the person will be removed from the tuned in list, but the connections here are decoupled from that flow
// we want to manage the room within the master conversation and remove it for ourselves
// !this is false, see unmount, we are relying on tuned list for disconnections
setConversationMap((draft) => {
if (draft[conversation._id.toString()].room[otherUserId]) {
draft[conversation._id.toString()].room[otherUserId].peer?.destroy();
delete draft[conversation._id.toString()].room[otherUserId];
}
});
toast.error('peer connection was closed');
});
localPeerConnection.on('error', (err) => {
console.error(err);
toast.error('there was a problem with the peer connection');
});
});
}
})();
});
// ======= LISTEN FOR INCOMING CALLS AND RETURN SIGNALS AND ACCEPT THEM
useEffect(() => {
const someoneJoinedChannelNameForRoom = `${
ServerResponseChannels.RTC_NEW_USER_JOINED
}:${conversation._id.toString()}`;
const mastersAnswerReceivedChannelNameForRoom = `${
ServerResponseChannels.RTC_RECEIVING_MASTER_ANSWER
}:${conversation._id.toString()}`;
$ws.on(someoneJoinedChannelNameForRoom, (res: RtcNewUserJoinedResponse) => {
toast.success('NEWBIE JOINED!!!');
console.log('someone calling me', res);
// todo, take trickle into account to not overwrite prev peer
const peerForMeAndNewbie = new Peer({
initiator: false,
stream: userLocalStream,
trickle: false, // prevents the multiple tries on different ice servers and signal from getting called a bunch of times
config: {
iceServers,
},
});
peerForMeAndNewbie.signal(res.simplePeerSignal);
setConversationMap((draft) => {
if (draft[conversation._id.toString()]) {
draft[conversation._id.toString()].room = {
...(draft[conversation._id.toString()].room ?? {}),
[res.userWhoCalled]: { peer: peerForMeAndNewbie, isConnecting: true },
};
}
});
peerForMeAndNewbie.on('signal', (signal) => {
console.log('sending an answer to the slave', res);
$ws.emit(
ServerRequestChannels.RTC_ANSWER_SOMEONE_FOR_LINE,
new RtcAnswerSomeoneRequest(res.userWhoCalled, res.lineId, signal),
);
});
peerForMeAndNewbie.on('stream', (remoteStream: MediaStream) => {
// globally updating conversation so that other views can render what they want
toast.success('got stream from remote, going to add to our ');
console.log('got stream from newbie');
setConversationMap((draft) => {
if (draft[conversation._id.toString()].room[res.userWhoCalled]) {
draft[conversation._id.toString()].room[res.userWhoCalled].stream = remoteStream;
remoteStream.getTracks().forEach((track) => {
// TODO: add particular track to right place
});
}
});
});
peerForMeAndNewbie.on('connect', () => {
toast.success('successfully connected to another peer');
setConversationMap((draft) => {
if (draft[conversation._id.toString()].room[res.userWhoCalled]) {
draft[conversation._id.toString()].room[res.userWhoCalled].isConnecting = false;
}
});
});
peerForMeAndNewbie.on('track', (track, stream) => {
// TODO: add to room contents
toast('a peer added a track to a stream');
});
peerForMeAndNewbie.on('close', () => {
// the person will be removed from the tuned in list, but the connections here are decoupled from that flow
// we want to manage the room within the master conversation and remove it for ourselves
// !this is false, see unmount, we are relying on tuned list for disconnections
setConversationMap((draft) => {
if (draft[conversation._id.toString()].room[res.userWhoCalled]) {
draft[conversation._id.toString()].room[res.userWhoCalled].peer?.destroy();
delete draft[conversation._id.toString()].room[res.userWhoCalled];
}
});
console.error('peer connection was closed');
toast.error('peer connection was closed');
});
peerForMeAndNewbie.on('error', (err) => {
console.error(err);
toast.error('there was a problem with the peer connection');
});
});
$ws.on(mastersAnswerReceivedChannelNameForRoom, (res: RtcReceiveAnswerResponse) => {
toast.success('MASTER gave me an answer!!!');
console.log('master gave me this answer: ', res);
// find our local connection to this peer in the room contents
setConversationMap((draft) => {
const localPeerForMasterAndMe = draft[res.lineId]?.room[res.masterUserId];
if (localPeerForMasterAndMe) {
localPeerForMasterAndMe.peer.signal(res.simplePeerSignal);
}
});
});
return () => {
$ws.removeAllListeners(mastersAnswerReceivedChannelNameForRoom);
$ws.removeAllListeners(someoneJoinedChannelNameForRoom);
};
}, [userLocalStream]);
// find when someone leaves the room and destroy peer and remove them from our map
useEffect(() => {
// go through tuned in users
// if we have someone in room who is not in tuned in, then destroy + delete
if (conversation.room) {
Object.entries(conversation.room).forEach(([roomUserId, roomContents]) => {
if (!conversation.tunedInUsers.includes(roomUserId)) {
console.log('someone left the room!!!');
roomContents.peer.destroy();
setConversationMap((draft) => {
delete draft[conversation._id.toString()].room[roomUserId];
});
}
});
}
}, [conversation.tunedInUsers, conversation.room]);
// go through all of the peers and destroy and update conversation map
useUnmount(() => {
setConversationMap((draft) => {
if (draft[conversation._id.toString()].room) {
Object.values(draft[conversation._id.toString()].room).forEach((roomPeerContents) => {
roomPeerContents.peer.destroy();
console.log('destroyed peer connection as I am leaving room');
});
}
delete draft[conversation._id.toString()].room;
});
});
// TODO: improved listener manager for each peer connection
const setPeerListeners = useCallback(
(peer) => {
//
},
[setConversationMap],
);
// share audio to foreground conversation | either push to talk or toggle broadcasting
const handleStartTalking = useCallback((record = false) => {
// if record, then start recording
// add stream if not there for this peer connection or just add track to stream
}, []);
const handleStopTalking = useCallback(() => {
// stop recording if was recording
// stop sharing audio | maybe stop track to avoid stereo distortion
}, []);
const handleShareVideo = useCallback(() => {
// add track and/or stream of video to the peer connections for currently selected room
}, []);
const handleShareScreen = useCallback(() => {
// add track and/or stream of screen to the peer connections for currently selected room
}, []);
// ============= END OF STREAMING MODULE ========
useEffect(() => {
// call all of the tuned in folks that are already here
console.log(conversation.tunedInUsers);
return () => {
// close peer connections here
// update the conversation map as well with proper states and data
};
}, []);
return null;
}
// ANOTHER APPROACH
// have one hook used in the upper level
// this hook renders multiple components for each room
// each component for each room returns data for that room
// this upper level rooms hook returns each room's data