refactored calls into ring context

This commit is contained in:
balzack 2025-02-03 23:06:15 -08:00
parent 45b20948dc
commit 56b701905d
8 changed files with 12413 additions and 8897 deletions

View File

@ -2250,7 +2250,7 @@ SPEC CHECKSUMS:
SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d
SocketRocket: abac6f5de4d4d62d24e11868d7a2f427e0ef940d
TOCropViewController: 80b8985ad794298fb69d3341de183f33d1853654
Yoga: a9ef4f5c2cd79ad812110525ef61048be6a582a4
Yoga: b05994d1933f507b0a28ceaa4fdb968dc18da178
PODFILE CHECKSUM: 9cf7373afef7b881c911fda82ff1f94eacee3e98

View File

@ -7,13 +7,13 @@ import {BlurView} from '@react-native-community/blur';
import { Confirm } from '../confirm/Confirm';
import { ActivityIndicator } from 'react-native-paper';
import FastImage from 'react-native-fast-image'
import LinearGradient from 'react-native-linear-gradient';
import { Colors } from '../constants/Colors';
import { type Card } from 'databag-client-sdk';
import { RTCView } from 'react-native-webrtc';
import { Card } from '../card/Card';
import { Card as Contact } from '../card/Card';
import { activateKeepAwake, deactivateKeepAwake} from "@sayem314/react-native-keep-awake";
export function Calling({ callCard }: { callCard: string }) {
export function Calling({ callCard }: { callCard: null|Card }) {
const { state, actions } = useCalling();
const [alert, setAlert] = useState(false);
const [connecting, setConnecting] = useState(false);
@ -42,9 +42,9 @@ export function Calling({ callCard }: { callCard: string }) {
if (!applyingVideo) {
setApplyingVideo(true);
try {
if (state.video && state.videoEnabled) {
if (state.videoEnabled) {
await actions.disableVideo();
} else if (state.video && !state.videoEnabled) {
} else if (!state.videoEnabled) {
await actions.enableVideo();
}
} catch (err) {
@ -59,9 +59,9 @@ export function Calling({ callCard }: { callCard: string }) {
if (!applyingAudio) {
setApplyingAudio(true);
try {
if (state.audio && state.audioEnabled) {
if (state.audioEnabled) {
await actions.disableAudio();
} else if (state.audio && !state.audioEnabled) {
} else if (!state.audioEnabled) {
await actions.enableAudio();
}
} catch (err) {
@ -85,11 +85,11 @@ export function Calling({ callCard }: { callCard: string }) {
}
}
const call = async (cardId: string) => {
const call = async (card) => {
if (!connecting) {
setConnecting(true);
try {
await actions.call(cardId);
await actions.call(card);
} catch (err) {
console.log(err);
setAlert(true);
@ -149,9 +149,9 @@ export function Calling({ callCard }: { callCard: string }) {
};
useEffect(() => {
const { cardId } = callCard;
if (cardId) {
call(cardId);
const { card } = callCard;
if (card) {
call(card);
}
}, [callCard]);
@ -171,7 +171,7 @@ export function Calling({ callCard }: { callCard: string }) {
const acceptButton = <IconButton key="accept" style={styles.circleIcon} iconColor="white" containerColor={Colors.primary} icon="phone-outline" compact="true" mode="contained" size={24} loading={accepting===callId} onPress={()=>accept(callId, card)} />
return (
<Surface mode="flat" key={index}>
<Card containerStyle={styles.card} placeholder={''} imageUrl={imageUrl} name={name} node={node} handle={handle} actions={[ignoreButton, declineButton, acceptButton]} />
<Contact containerStyle={styles.card} placeholder={''} imageUrl={imageUrl} name={name} node={node} handle={handle} actions={[ignoreButton, declineButton, acceptButton]} />
</Surface>
)
});
@ -199,27 +199,14 @@ export function Calling({ callCard }: { callCard: string }) {
<View style={{ ...styles.container, backgroundColor: surface.base }}>
<View style={{ ...styles.frame, top: frameOffset, width: frameWidth > 400 ? 400 : frameWidth, height: frameHeight > 400 ? 400 : frameHeight }}>
<Image
style={{ ...styles.image, opacity: state.loaded ? 1 : 0 }}
style={styles.image}
resizeMode="contain"
source={{ uri: state.calling.imageUrl }}
onLayout={actions.loaded}
/>
{ state.loaded && (
<LinearGradient style={{...styles.overlap, width: '100%', height: 16, top: 0, borderRadius: 8}} start={{x: 0, y: 0}} end={{x: 0, y: 1}} colors={[surface.base, surface.blend]} />
)}
{ state.loaded && (
<LinearGradient style={{...styles.overlap, width: '100%', height: 16, bottom: 0, borderRadius: 8}} start={{x: 0, y: 0.2}} end={{x: 0, y: 1}} colors={[surface.blend, surface.base]} />
)}
{ state.loaded && (
<LinearGradient style={{...styles.overlap, height: '100%', width: 16, right: 0}} start={{x: 0, y: 0}} end={{x: 1, y: 0}} colors={[surface.blend, surface.base]} />
)}
{ state.loaded && (
<LinearGradient style={{...styles.overlap, height: '100%', width: 16, left: 0}} start={{x: 1, y: 0}} end={{x: 0, y: 0}} colors={[surface.blend, surface.base]} />
)}
</View>
</View>
)}
{ state.calling && state.loaded && (
{ state.calling && (
<View style={{ ...styles.overlap, top: 64 }}>
<View style={{backgroundColor: surface.title, borderRadius: 16 }}>
{ state.calling.name && (
@ -231,45 +218,37 @@ export function Calling({ callCard }: { callCard: string }) {
</View>
</View>
)}
{ state.calling && state.loaded && state.remote && (
{ state.calling && state.remoteStream && state.remoteVideo && (
<View style={{ ...styles.canvas, backgroundColor: surface.base }}>
<RTCView
style={styles.full}
mirror={true}
objectFit={'contain'}
streamURL={state.remote.toURL()}
/>
<RTCView
style={styles.full}
mirror={true}
objectFit={'contain'}
streamURL={state.remoteStream.toURL()}
/>
</View>
)}
{ state.calling && state.loaded && state.local && !state.remote && (
{ state.calling && state.localStream && state.localVideo && (
<View style={{ ...styles.canvas, backgroundColor: surface.base }}>
<RTCView
style={styles.full}
mirror={true}
objectFit={'contain'}
streamURL={state.local.toURL()}
/>
<RTCView
style={ state.remoteVideo ? styles.box : styles.full}
mirror={true}
objectFit={'contain'}
streamURL={state.localStream.toURL()}
zOrder={2}
/>
</View>
)}
{ state.calling && state.loaded && state.local && state.remote && (
<RTCView
style={{ ...styles.box, top: frameOffset }}
mirror={true}
objectFit={'contain'}
streamURL={state.local.toURL()}
zOrder={2}
/>
)}
{ state.calling && state.loaded && (
{ state.calling && (
<View style={{ ...styles.overlap, bottom: 64 }}>
<View style={{ paddingTop: 8, paddingBottom: 8, paddingLeft: 16, paddingRight: 16, gap: 16, display: 'flex', flexDirection: 'row', borderRadius: 16, backgroundColor: surface.control }}>
<IconButton style={styles.closeIcon} iconColor="white" containerColor={Colors.primary} icon={state.audioEnabled ? 'microphone' : 'microphone-off'} loading={applyingAudio} disabled={!state.audio} compact="true" mode="contained" size={32} onPress={toggleAudio} />
<IconButton style={styles.closeIcon} iconColor="white" containerColor={Colors.primary} icon={state.videoEnabled ? 'video-outline' : 'video-off-outline'} loading={applyingVideo} disabled={!state.video} compact="true" mode="contained" size={32} onPress={toggleVideo} />
<IconButton style={styles.closeIcon} iconColor="white" disabled={!state.connected} containerColor={Colors.primary} icon={state.audioEnabled ? 'microphone' : 'microphone-off'} loading={applyingAudio} compact="true" mode="contained" size={32} onPress={toggleAudio} />
<IconButton style={styles.closeIcon} iconColor="white" disabled={!state.connected} containerColor={Colors.primary} icon={state.videoEnabled ? 'video-outline' : 'video-off-outline'} loading={applyingVideo} compact="true" mode="contained" size={32} onPress={toggleVideo} />
<IconButton style={styles.closeIcon} iconColor="white" containerColor={Colors.danger} icon="phone-hangup-outline" compact="true" mode="contained" size={32} onPress={end} />
</View>
</View>
)}
{ state.calling && state.loaded && !state.connected && (
{ state.calling && !state.connected && (
<View style={{ ...styles.overlap, bottom: 24 }}>
<View style={{backgroundColor: surface.title, borderRadius: 16 }}>
<Text style={styles.connecting}>{ state.strings.connecting }</Text>

View File

@ -1,48 +1,25 @@
import { useState, useContext, useEffect, useRef } from 'react'
import { DisplayContext } from '../context/DisplayContext';
import { AppContext } from '../context/AppContext'
import { RingContext } from '../context/RingContext'
import { DisplayContext } from '../context/DisplayContext'
import { ContextType } from '../context/ContextType'
import { Link, type Card } from 'databag-client-sdk';
import InCallManager from 'react-native-incall-manager';
import {
ScreenCapturePickerView,
RTCPeerConnection,
RTCIceCandidate,
RTCSessionDescription,
RTCView,
MediaStream,
MediaiStreamTrack,
mediaDevices,
registerGlobals
} from 'react-native-webrtc';
import { Card } from 'databag-client-sdk';
export function useCalling() {
const app = useContext(AppContext) as ContextType;
const ring = useContext(RingContext) as ContextType;
const display = useContext(DisplayContext) as ContextType;
const call = useRef(null as { policy: string, peer: RTCPeerConnection, link: Link, candidates: RTCIceCandidate[] } | null);
const localStream = useRef(null);
const remoteStream = useRef(null);
const updatingPeer = useRef(false);
const peerUpdate = useRef([]);
const [state, setState] = useState({
strings: {},
ringing: [],
calls: [],
cards: [],
strings: display.state.strings,
calls: [] as { callId: string, card: Card }[],
calling: null as null | Card,
failed: false,
loaded: false,
panelOffset: 0,
local: null,
remote: null,
audio: null,
localStream: null as null|MediaStream,
remoteStream: null as null|MediaStream,
localVideo: false,
remoteVideo: false,
audioEnabled: false,
video: null,
videoEnabled: false,
videoAdded: false,
connected: false,
failed: false,
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -51,317 +28,10 @@ export function useCalling() {
}
useEffect(() => {
const calls = state.ringing
.map(ring => ({ callId: ring.callId, card: state.cards.find(card => ring.cardId === card.cardId) }) )
.filter(ring => (ring.card && !ring.card.blocked));
updateState({ calls });
}, [state.ringing, state.cards]);
const { calls, calling, localStream, remoteStream, localVideo, remoteVideo, audioEnabled, videoEnabled, connected, failed } = ring.state;
updateState({ calls, calling, localStream, remoteStream, localVideo, remoteVideo, audioEnabled, videoEnabled, connected, failed });
}, [ring.state]);
useEffect(() => {
const { strings } = display.state;
updateState({ strings });
}, [display.state]);
const constraints = {
mandatory: {
OfferToReceiveAudio: true,
OfferToReceiveVideo: false,
VoiceActivityDetection: true
}
};
const linkStatus = async (status: string) => {
if (call.current) {
const { policy, peer, link } = call.current;
if (status === 'connected') {
try {
remoteStream.current = new MediaStream();
localStream.current = await mediaDevices.getUserMedia({
audio: true,
video: {
frameRate: 30,
facingMode: 'user'
}
});
const audio = localStream.current.getTracks().find(track => track.kind === 'audio');
const video = localStream.current.getTracks().find(track => track.kind === 'video');
if (audio) {
audio.enabled = true;
await updatePeer('local_track', audio);
}
if (video) {
video.enabled = false;
}
InCallManager.start({media: 'audio'});
updateState({ audio, video, audioAdded: true, audioEnabled: true, videoAdded: false, videoEnabled: false, connected: true });
} catch (err) {
console.log(err);
updateState({ failed: true });
}
} else if (status === 'closed') {
updatePeer('close');
}
}
}
const linkMessage = async (message: any) => {
if (call.current) {
const { peer, link, policy } = call.current;
try {
if (message.description) {
const offer = new RTCSessionDescription(message.description);
await peer.setRemoteDescription(offer);
if (message.description.type === 'offer') {
const description = await peer.createAnswer();
await peer.setLocalDescription(description);
link.sendMessage({ description });
}
if (call.current) {
const { candidates } = call.current;
call.current.candidates = [];
for (const candidate of candidates) {
await peer.addIceCandidate(candidate);
};
}
} else if (message.candidate) {
const candidate = new RTCIceCandidate(message.candidate);
if (peer.remoteDescription == null) {
candidates.push(candidate);
} else {
await peer.addIceCandidate(candidate);
}
}
} catch (err) {
console.log(err);
updateState({ failed: true });
}
}
}
const peerCandidate = async (candidate) => {
if (call.current && candidate) {
const { link } = call.current;
await link.sendMessage({ candidate });
}
}
const peerNegotiate = async () => {
if (call.current) {
try {
const { peer, link } = call.current;
const description = await peer.createOffer(constraints);
await peer.setLocalDescription(description);
await link.sendMessage({ description });
} catch (err) {
console.log(err);
}
}
}
const peerTrack = async (track) => {
if (call.current) {
try {
const { peer } = call.current;
peer.addTrack(track, localStream.current);
} catch (err) {
console.log(err);
}
}
}
const updatePeer = async (type: string, data: any) => {
peerUpdate.current.push({ type, data });
if (!updatingPeer.current) {
updatingPeer.current = true;
while (peerUpdate.current.length > 0) {
const { type, data } = peerUpdate.current.shift();
if (type === 'negotiate') {
await peerNegotiate();
} else if (type === 'candidate') {
await peerCandidate(data);
} else if (type === 'message') {
await linkMessage(data);
} else if (type === 'remote_track') {
remoteStream.current.addTrack(data, remoteStream.current);
if (data.kind === 'video') {
InCallManager.setForceSpeakerphoneOn(true);
updateState({ remote: remoteStream.current });
}
} else if (type === 'local_track') {
await peerTrack(data);
if (data.kind === 'video') {
InCallManager.setForceSpeakerphoneOn(true);
}
} else if (type === 'close' && call.current) {
peerUpdate.current = [];
const { peer, link } = call.current;
call.current = null;
try {
InCallManager.stop();
peer.close();
link.close();
} catch (err) {
console.log(err);
}
localStream.current = null;
remoteStream.current = null,
updateState({ calling: null, failed: false, audio: null, video: null, local: null, remote: null });
}
}
updatingPeer.current = false;
}
}
const transmit = (ice: { urls: string; username: string; credential: string }[]) => {
const peerConnection = new RTCPeerConnection({ iceServers: ice });
peerConnection.addEventListener( 'connectionstatechange', event => {
console.log("CONNECTION STATE", event);
});
peerConnection.addEventListener( 'icecandidate', event => {
updatePeer('candidate', event.candidate);
});
peerConnection.addEventListener( 'icecandidateerror', event => {
console.log("ICE ERROR");
});
peerConnection.addEventListener( 'iceconnectionstatechange', event => {
console.log("ICE STATE CHANGE", event);
});
peerConnection.addEventListener( 'negotiationneeded', event => {
updatePeer('negotiate');
});
peerConnection.addEventListener( 'signalingstatechange', event => {
console.log("ICE SIGNALING", event);
});
peerConnection.addEventListener( 'track', event => {
updatePeer('remote_track', event.track);
});
return peerConnection;
}
useEffect(() => {
if (app.state.session) {
const setRinging = (ringing: { cardId: string, callId: string }[]) => {
updateState({ ringing });
}
const setContacts = (cards: Card[]) => {
updateState({ cards });
}
const ring = app.state.session.getRing();
ring.addRingingListener(setRinging);
const contact = app.state.session.getContact();
contact.addCardListener(setContacts);
return () => {
ring.removeRingingListener(setRinging);
contact.removeCardListener(setContacts);
}
}
}, [app.state.session]);
const actions = {
ignore: async (callId: string, card: Card) => {
const ring = app.state.session.getRing();
await ring.ignore(card.cardId, callId);
},
decline: async (callId: string, card: Card) => {
const ring = app.state.session.getRing();
await ring.decline(card.cardId, callId);
},
end: async () => {
if (!call.current) {
throw new Error('no active call');
}
const { link, peer } = call.current;
try {
peer.close();
link.close();
} catch (err) {
console.log(err);
}
call.current = null;
localStream.current = null;
remoteStream.current = null;
updateState({ calling: null, audio: null, video: null, local: null, remote: null });
},
accept: async (callId: string, card: Card) => {
if (call.current) {
throw new Error('active call in progress');
}
const { cardId, node } = card;
const ring = app.state.session.getRing();
const link = await ring.accept(cardId, callId, node);
const ice = link.getIce();
const peer = transmit(ice);
const policy = 'impolite';
const candidates = [];
call.current = { policy, peer, link, candidates };
link.setStatusListener(linkStatus);
link.setMessageListener((msg) => updatePeer('message', msg));
updateState({ calling: card, connected: false });
},
call: async (cardId: string) => {
if (call.current) {
throw new Error('active call in proegress');
}
const card = state.cards.find(contact => contact.cardId === cardId);
if (!card) {
throw new Error('calling contact not found');
}
const contact = app.state.session.getContact();
const link = await contact.callCard(cardId);
const ice = link.getIce();
const peer = transmit(ice);
const policy = 'polite';
const candidates = [];
call.current = { policy, peer, link, candidates };
link.setStatusListener(linkStatus);
link.setMessageListener((msg) => updatePeer('message', msg));
updateState({ calling: card, connected: false });
},
loaded: (e) => {
const { width, height } = e.nativeEvent.layout;
if (width > (height + 80)) {
updateState({ panelOffset: 0, loaded: true });
} else {
updateState({ panelOffset: ((height - width) - 80) / 2, loaded: true });
}
},
enableAudio: async () => {
if (!call.current || !state.audio || !state.audioAdded) {
throw new Error('cannot unmute audio');
}
state.audio.enabled = true;
updateState({ audioEnabled: true });
},
disableAudio: () => {
if (!call.current || !state.audio || !state.audioAdded) {
throw new Error('cannot mute audio');
}
state.audio.enabled = false;
updateState({ audioEnabled: false });
},
enableVideo: () => {
if (!call.current || !state.video) {
throw new Error('cannot start video');
}
state.video.enabled = true;
if (!state.videoAdded) {
const local = new MediaStream();
local.addTrack(state.video, local);
updateState({ local });
updatePeer('local_track', state.video);
}
updateState({ videoAdded: true, videoEnabled: true });
},
disableVideo: () => {
if (!call.current || !state.video) {
throw new Error('cannot stop video');
}
state.video.enabled = false;
updateState({ videoEnabled: false });
},
}
return { state, actions }
const actions = ring.actions;
return { state, actions };
}

View File

@ -18,7 +18,7 @@ function Action({icon, color, select}: {icon: string; color: string; select: ()
return <IconButton style={styles.icon} loading={loading} iconColor={color} mode="contained" icon={icon} onPress={onPress} />;
}
export function Contacts({openRegistry, openContact, callContact, textContact}: {openRegistry: () => void; openContact: (params: ContactParams) => void, callContact: (cardId: null|string)=>void, textContact: (cardId: null|string)=>void}) {
export function Contacts({openRegistry, openContact, callContact, textContact}: {openRegistry: () => void; openContact: (params: ContactParams) => void, callContact: (card: null|Card)=>void, textContact: (cardId: null|string)=>void}) {
const theme = useTheme();
const {state, actions} = useContacts();
const [alert, setAlert] = useState(false);
@ -74,7 +74,7 @@ export function Contacts({openRegistry, openContact, callContact, textContact}:
key="call"
icon="phone-outline"
color={Colors.connected}
select={()=>callContact(item.cardId)}
select={()=>callContact(item)}
/>,
<Action
key="text"

View File

@ -0,0 +1,9 @@
import React, { ReactNode, createContext } from 'react'
import { useRingContext } from './useRingContext.hook'
export const RingContext = createContext({})
export function RingContextProvider({ children }: { children: ReactNode }) {
const { state, actions } = useRingContext()
return <RingContext.Provider value={{ state, actions }}>{children}</RingContext.Provider>
}

View File

@ -0,0 +1,357 @@
import { useState, useContext, useEffect, useRef } from 'react'
import { DisplayContext } from '../context/DisplayContext';
import { AppContext } from '../context/AppContext'
import { ContextType } from '../context/ContextType'
import { Link, type Card } from 'databag-client-sdk';
import InCallManager from 'react-native-incall-manager';
import {
ScreenCapturePickerView,
RTCPeerConnection,
RTCIceCandidate,
RTCSessionDescription,
RTCView,
MediaStream,
MediaiStreamTrack,
mediaDevices,
registerGlobals
} from 'react-native-webrtc';
const CLOSE_POLL_MS = 100;
export function useRingContext() {
const app = useContext(AppContext) as ContextType;
const display = useContext(DisplayContext) as ContextType;
const call = useRef(null as { peer: RTCPeerConnection, link: Link, candidates: RTCIceCandidate[] } | null);
const sourceStream = useRef(null as null|MediaStream);
const localStream = useRef(null as null|MediaStream);
const localAudio = useRef(null as null|MediaStreamTrack);
const localVideo = useRef(null as null|MediaStreamTrack);
const localAudioAdded = useRef(false);
const localVideoAdded = useRef(false);
const remoteStream = useRef(null as null|MediaStream);
const updatingPeer = useRef(false);
const peerUpdate = useRef([] as {type: string, data?: any}[]);
const connecting = useRef(false);
const closing = useRef(false);
const [ringing, setRinging] = useState([] as { cardId: string, callId: string }[]);
const [cards, setCards] = useState([] as Card[]);
const [state, setState] = useState({
calls: [] as { callId: string, cardId: string}[],
calling: null as null | Card,
localStream: null as null|MediaStream,
remoteStream: null as null|MediaStream,
localVideo: false,
remoteVideo: false,
audioEnabled: false,
videoEnabled: false,
connected: false,
failed: false,
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const updateState = (value: any) => {
setState((s) => ({ ...s, ...value }))
}
useEffect(() => {
const calls = ringing.map(ring => ({ callId: ring.callId, card: cards.find(card => ring.cardId === card.cardId) }))
.filter(ring => (ring.card && !ring.card.blocked));
updateState({ calls });
}, [ringing, cards]);
const constraints = {
mandatory: {
OfferToReceiveAudio: true,
OfferToReceiveVideo: false,
VoiceActivityDetection: true
}
};
const linkStatus = async (status: string) => {
if (call.current) {
const { peer, link } = call.current;
if (status === 'connected') {
updateState({ connected: true });
await actions.enableAudio();
} else if (status === 'closed') {
await cleanup();
}
}
}
const updatePeer = async (type: string, data?: any) => {
peerUpdate.current.push({ type, data });
if (!updatingPeer.current) {
updatingPeer.current = true;
while (!closing.current && call.current && peerUpdate.current.length > 0) {
const { peer, link, candidates } = call.current;
const { type, data } = peerUpdate.current.shift() || { type: '' };
try {
switch (type) {
case 'negotiate':
const description = await peer.createOffer();
await peer.setLocalDescription(description);
await link.sendMessage({ description });
break;
case 'candidate':
await link.sendMessage({ data });
break;
case 'message':
if (data.description) {
const offer = new RTCSessionDescription(data.description);
await peer.setRemoteDescription(offer);
if (data.description.type === 'offer') {
const description = await peer.createAnswer();
await peer.setLocalDescription(description);
link.sendMessage({ description });
}
for (const candidate of candidates) {
await peer.addIceCandidate(candidate);
};
call.current.candidates = [];
} else if (data.candidate) {
const candidate = new RTCIceCandidate(data.candidate);
if (peer.remoteDescription == null) {
candidates.push(candidate);
} else {
await peer.addIceCandidate(candidate);
}
}
break;
case 'remote_track':
if (remoteStream.current) {
remoteStream.current.addTrack(data);
if (data.kind === 'video') {
InCallManager.setForceSpeakerphoneOn(true);
updateState({ remoteVideo: true });
}
}
break;
case 'local_track':
peer.addTrack(data, sourceStream.current);
if (data.kind === 'video') {
InCallManager.setForceSpeakerphoneOn(true);
updateState({ localVideo: true })
}
break;
default:
console.log('unknown event');
break;
}
} catch (err) {
console.log(err);
updateState({ failed: true });
}
}
updatingPeer.current = false;
}
}
const setup = async (link: Link, card: Card) => {
remoteStream.current = new MediaStream();
localStream.current = new MediaStream();
sourceStream.current = await mediaDevices.getUserMedia({
audio: true,
video: {
frameRate: 30,
facingMode: 'user'
}
});
InCallManager.start({media: 'audio'});
localAudio.current = sourceStream.current.getTracks().find(track => track.kind === 'audio');
localVideo.current = sourceStream.current.getTracks().find(track => track.kind === 'video');
if (localAudio.current) {
localAudio.current.enabled = false;
}
if (localVideo.current) {
localVideo.current.enabled = false;
}
const ice = link.getIce();
const peer = transmit(ice);
const candidates = [] as RTCIceCandidate[];
call.current = { peer, link, candidates };
link.setStatusListener(linkStatus);
link.setMessageListener((msg: any) => updatePeer('message', msg));
updateState({ calling: card, failed: false, connected: false,
audioEnabled: false, videoEnabled: false, localVideo: false, remoteVideo: false,
localStream: localStream.current, remoteStream: remoteStream.current });
}
const cleanup = async () => {
closing.current = true;
while (updatingPeer.current || connecting.current) {
await new Promise((r) => setTimeout(r, CLOSE_POLL_MS));
}
if (call.current) {
const { peer, link } = call.current;
peer.close();
link.close();
call.current = null;
}
if (localVideo.current) {
localVideo.current.stop();
localVideo.current = null;
localVideoAdded.current = false;
}
if (localAudio.current) {
localAudio.current.stop();
localAudio.current = null;
localAudioAdded.current = false;
}
localStream.current = null;
remoteStream.current = null;
sourceStream.current = null;
peerUpdate.current = [];
InCallManager.stop();
updateState({ calling: null, failed: false, localStream: null, remoteStream: null, localVideo: false, remoteVideo: false });
closing.current = false;
}
const transmit = (ice: { urls: string; username: string; credential: string }[]) => {
const peerConnection = new RTCPeerConnection({ iceServers: ice });
peerConnection.addEventListener( 'connectionstatechange', event => {
console.log("CONNECTION STATE", event);
});
peerConnection.addEventListener( 'icecandidate', event => {
updatePeer('candidate', event.candidate);
});
peerConnection.addEventListener( 'icecandidateerror', event => {
console.log("ICE ERROR");
});
peerConnection.addEventListener( 'iceconnectionstatechange', event => {
console.log("ICE STATE CHANGE", event);
});
peerConnection.addEventListener( 'negotiationneeded', event => {
updatePeer('negotiate');
});
peerConnection.addEventListener( 'signalingstatechange', event => {
console.log("ICE SIGNALING", event);
});
peerConnection.addEventListener( 'track', event => {
updatePeer('remote_track', event.track);
});
return peerConnection;
}
useEffect(() => {
if (app.state.session) {
const setRing = (ringing: { cardId: string, callId: string }[]) => {
setRinging(ringing);
}
const setContacts = (cards: Card[]) => {
setCards(cards);
}
const ring = app.state.session.getRing();
ring.addRingingListener(setRinging);
const contact = app.state.session.getContact();
contact.addCardListener(setContacts);
return () => {
ring.removeRingingListener(setRing);
contact.removeCardListener(setContacts);
cleanup();
}
}
}, [app.state.session]);
const actions = {
ignore: async (callId: string, card: Card) => {
const ring = app.state.session.getRing();
await ring.ignore(card.cardId, callId);
},
decline: async (callId: string, card: Card) => {
const ring = app.state.session.getRing();
await ring.decline(card.cardId, callId);
},
end: async () => {
await cleanup();
},
accept: async (callId: string, card: Card) => {
if (connecting.current || closing.current || call.current) {
throw new Error('not ready to accept calls');
}
try {
connecting.current = true;
const { cardId, node } = card;
const ring = app.state.session.getRing();
const link = await ring.accept(cardId, callId, node);
await setup(link, card);
connecting.current = false;
} catch (err) {
connecting.current = false;
throw err;
}
},
call: async (card: Card) => {
if (connecting.current || closing.current || call.current) {
throw new Error('not ready make calls');
}
try {
connecting.current = true;
const contact = app.state.session.getContact();
const link = await contact.callCard(card.cardId);
await setup(link, card);
connecting.current = false;
} catch (err) {
connecting.current = false;
throw err;
}
},
enableAudio: async () => {
if (connecting.current || closing.current || !call.current) {
throw new Error('cannot unmute audio');
}
if (!localAudio.current) {
throw new Error('audio not available');
} else {
if (!localAudioAdded.current) {
localAudioAdded.current = true;
updatePeer('local_track', localAudio.current);
}
localAudio.current.enabled = true;
}
updateState({ audioEnabled: true });
},
disableAudio: async () => {
if (!call.current) {
throw new Error('cannot mute audio');
}
if (localAudio.current) {
localAudio.current.enabled = false;
}
updateState({ audioEnabled: false });
},
enableVideo: async () => {
if (connecting.current || closing.current || !call.current) {
throw new Error('cannot start video');
}
if (!localVideo.current) {
throw new Error('video not available');
} else {
if (!localVideoAdded.current) {
localVideoAdded.current = true;
localStream.current.addTrack(localVideo.current, localStream.current);
updatePeer('local_track', localVideo.current);
}
localVideo.current.enabled = true;
}
updateState({ videoEnabled: true });
},
disableVideo: async () => {
if (!call.current) {
throw new Error('cannot stop video');
}
if (localVideo.current) {
localVideo.current.enabled = false;
}
updateState({ videoEnabled: false });
},
}
return { state, actions }
}

View File

@ -1,5 +1,6 @@
import React, {useState, useCallback, useEffect} from 'react';
import {SafeAreaView, Pressable, View, useColorScheme} from 'react-native';
import {RingContextProvider} from '../context/RingContext';
import {styles} from './Session.styled';
import {IconButton, Surface, Text, Icon} from 'react-native-paper';
import {Settings} from '../settings/Settings';
@ -12,7 +13,7 @@ import {Identity} from '../identity/Identity';
import {Conversation} from '../conversation/Conversation';
import {useSession} from './useSession.hook';
import {TransitionPresets} from '@react-navigation/stack';
import {Focus} from 'databag-client-sdk';
import {Focus, Card} from 'databag-client-sdk';
import {NavigationContainer, DefaultTheme, DarkTheme} from '@react-navigation/native';
import {createDrawerNavigator} from '@react-navigation/drawer';
import {createNativeStackNavigator} from '@react-navigation/native-stack';
@ -33,7 +34,7 @@ export function Session() {
const scheme = useColorScheme();
const [tab, setTab] = useState('content');
const [textCard, setTextCard] = useState({ cardId: null} as {cardId: null|string});
const [callCard, setCallCard] = useState({ cardId: null} as {cardId: null|string});
const [callCard, setCallCard] = useState({ card: null} as {card: null|Card});
const [dismissed, setDismissed] = useState(false);
const [disconnected, setDisconnected] = useState(false);
const [showDisconnected, setShowDisconnected] = useState(false);
@ -42,8 +43,8 @@ export function Session() {
setTextCard({ cardId });
}
const callContact = (cardId: null|string) => {
setCallCard({ cardId });
const callContact = (card: null|Card) => {
setCallCard({ card });
}
const sessionNav = {strings: state.strings, callContact, callCard, textContact, textCard};
@ -77,126 +78,128 @@ export function Session() {
}, [state.appState, state.sdkState]);
return (
<View style={styles.session}>
{state.layout !== 'large' && (
<Surface elevation={3}>
<SafeAreaView style={styles.full}>
<View style={styles.screen}>
<View
style={{
...styles.body,
...showContent,
}}>
<ContentTab textCard={textCard} scheme={scheme} contentTab={contentTab} />
<RingContextProvider>
<View style={styles.session}>
{state.layout !== 'large' && (
<Surface elevation={3}>
<SafeAreaView style={styles.full}>
<View style={styles.screen}>
<View
style={{
...styles.body,
...showContent,
}}>
<ContentTab textCard={textCard} scheme={scheme} contentTab={contentTab} />
</View>
<View
style={{
...styles.body,
...showContact,
}}>
<ContactTab textContact={textContact} callContact={callContact} scheme={scheme} />
</View>
<View
style={{
...styles.body,
...showSettings,
}}>
<Surface elevation={0}>
<Settings showLogout={true} />
</Surface>
</View>
<View style={styles.tabs}>
{tab === 'content' && (
<IconButton
style={styles.activeTab}
mode="contained"
icon={'comment-multiple'}
size={28}
onPress={() => {
setTab('content');
}}
/>
)}
{tab !== 'content' && (
<IconButton
style={styles.idleTab}
mode="contained"
icon={'comment-multiple-outline'}
size={28}
onPress={() => {
setTab('content');
}}
/>
)}
{tab === 'contacts' && (
<IconButton
style={styles.activeTab}
mode="contained"
icon={'contacts'}
size={28}
onPress={() => {
setTab('contacts');
}}
/>
)}
{tab !== 'contacts' && (
<IconButton
style={styles.idleTab}
mode="contained"
icon={'contacts-outline'}
size={28}
onPress={() => {
setTab('contacts');
}}
/>
)}
{tab === 'settings' && (
<IconButton
style={styles.activeTab}
mode="contained"
icon={'cog'}
size={28}
onPress={() => {
setTab('settings');
}}
/>
)}
{tab !== 'settings' && (
<IconButton
style={styles.idleTab}
mode="contained"
icon={'cog-outline'}
size={28}
onPress={() => {
setTab('settings');
}}
/>
)}
</View>
</View>
<View
style={{
...styles.body,
...showContact,
}}>
<ContactTab textContact={textContact} callContact={callContact} scheme={scheme} />
</View>
<View
style={{
...styles.body,
...showSettings,
}}>
<Surface elevation={0}>
<Settings showLogout={true} />
</Surface>
</View>
<View style={styles.tabs}>
{tab === 'content' && (
<IconButton
style={styles.activeTab}
mode="contained"
icon={'comment-multiple'}
size={28}
onPress={() => {
setTab('content');
}}
/>
)}
{tab !== 'content' && (
<IconButton
style={styles.idleTab}
mode="contained"
icon={'comment-multiple-outline'}
size={28}
onPress={() => {
setTab('content');
}}
/>
)}
{tab === 'contacts' && (
<IconButton
style={styles.activeTab}
mode="contained"
icon={'contacts'}
size={28}
onPress={() => {
setTab('contacts');
}}
/>
)}
{tab !== 'contacts' && (
<IconButton
style={styles.idleTab}
mode="contained"
icon={'contacts-outline'}
size={28}
onPress={() => {
setTab('contacts');
}}
/>
)}
{tab === 'settings' && (
<IconButton
style={styles.activeTab}
mode="contained"
icon={'cog'}
size={28}
onPress={() => {
setTab('settings');
}}
/>
)}
{tab !== 'settings' && (
<IconButton
style={styles.idleTab}
mode="contained"
icon={'cog-outline'}
size={28}
onPress={() => {
setTab('settings');
}}
/>
)}
</View>
</View>
</SafeAreaView>
</Surface>
)}
{state.layout === 'large' && (
<NavigationContainer theme={scheme === 'dark' ? DarkTheme : DefaultTheme}>
<View style={styles.container}>
<DetailsScreen nav={sessionNav} />
</View>
</NavigationContainer>
)}
{ disconnected && showDisconnected && !dismissed && (
<View style={styles.alert}>
<Surface elevation={5} style={styles.alertArea}>
<Icon color={Colors.offsync} size={20} source="alert-circle-outline" />
<Text style={styles.alertLabel}>{ state.strings.disconnected }</Text>
<Pressable onPress={dismiss}>
<Icon color={Colors.offsync} size={20} source="close" />
</Pressable>
</SafeAreaView>
</Surface>
</View>
)}
<Calling callCard={callCard} />
</View>
)}
{state.layout === 'large' && (
<NavigationContainer theme={scheme === 'dark' ? DarkTheme : DefaultTheme}>
<View style={styles.container}>
<DetailsScreen nav={sessionNav} />
</View>
</NavigationContainer>
)}
{ disconnected && showDisconnected && !dismissed && (
<View style={styles.alert}>
<Surface elevation={5} style={styles.alertArea}>
<Icon color={Colors.offsync} size={20} source="alert-circle-outline" />
<Text style={styles.alertLabel}>{ state.strings.disconnected }</Text>
<Pressable onPress={dismiss}>
<Icon color={Colors.offsync} size={20} source="close" />
</Pressable>
</Surface>
</View>
)}
<Calling callCard={callCard} />
</View>
</RingContextProvider>
);
}
@ -231,7 +234,7 @@ function ContentTab({scheme, textCard, contentTab}: {scheme: string, textCard: {
);
}
function ContactTab({scheme, textContact, callContact}: {scheme: string, textContact: (cardId: string)=>void, callContact: (cardId: string)=>void}) {
function ContactTab({scheme, textContact, callContact}: {scheme: string, textContact: (cardId: string)=>void, callContact: (card: Card)=>void}) {
const [contactParams, setContactParams] = useState({
guid: '',
} as ContactParams);

File diff suppressed because it is too large Load Diff