support accepting calls

This commit is contained in:
balzack 2025-01-28 19:41:30 -08:00
parent e6535a14b6
commit a9e5c3ff85
5 changed files with 153 additions and 80 deletions

View File

@ -16,7 +16,6 @@ export const styles = StyleSheet.create({
position: 'absolute',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgb(64,64,64)',
},
inactive: {
display: 'none',
@ -25,7 +24,13 @@ export const styles = StyleSheet.create({
position: 'absolute',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgb(64,64,64)',
},
base: {
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
container: {
width: '100%',
@ -33,6 +38,11 @@ export const styles = StyleSheet.create({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgb(64,64,64)',
},
calls: {
borderRadius: 8,
overflow: 'hidden',
},
call: {
width: '100%',
@ -56,6 +66,11 @@ export const styles = StyleSheet.create({
closeIcon: {
borderRadius: 8,
},
circleIcon: {
},
flipIcon: {
transform: [{ rotate: '135deg' }],
},
name: {
fontSize: 28,
color: '#aaaaaa',
@ -87,4 +102,12 @@ export const styles = StyleSheet.create({
height: '100%',
backgroundColor: 'rgb(64,64,64)',
},
card: {
width: '100%',
height: 48,
paddingTop: 8,
paddingBottom: 8,
paddingLeft: 16,
borderBottomWidth: 1,
}
});

View File

@ -10,6 +10,7 @@ import FastImage from 'react-native-fast-image'
import LinearGradient from 'react-native-linear-gradient';
import { Colors } from '../constants/Colors';
import { RTCView } from 'react-native-webrtc';
import { Card } from '../card/Card';
export function Calling({ callCard }: { callCard: string }) {
const { state, actions } = useCalling();
@ -80,6 +81,19 @@ export function Calling({ callCard }: { callCard: string }) {
}
}
const accept = async (callId, card) => {
if (!connecting) {
setConnecting(true);
try {
await actions.accept(callId, card);
} catch (err) {
console.log(err);
setAlert(true);
}
setConnecting(false);
}
}
const alertParams = {
title: state.strings.operationFailed,
prompt: state.strings.tryAgain,
@ -98,17 +112,40 @@ export function Calling({ callCard }: { callCard: string }) {
}
}, [callCard]);
const calls = state.calls.map((contact, index) => {
const { callId, card } = contact;
const { name, handle, node, imageUrl } = card;
const ignore = <IconButton key="ignore" style={styles.circleIcon} iconColor="white" containerColor={Colors.pending} icon="eye-off-outline" compact="true" mode="contained" size={24} onPress={()=>{}} />
const decline = <IconButton key="decline" style={styles.flipIcon} iconColor="white" containerColor={Colors.offsync} icon="phone-outline" compact="true" mode="contained" size={24} onPress={()=>{}} />
const accept = <IconButton key="accept" style={styles.circleIcon} iconColor="white" containerColor={Colors.primary} icon="phone-outline" compact="true" mode="contained" size={24} onPress={()=>actions.accept(callId, card)} />
return (
<Surface mode="flat" key={index}>
<Card containerStyle={styles.card} placeholder={''} imageUrl={imageUrl} name={name} node={node} handle={handle} actions={[ignore, decline, accept]} />
</Surface>
)
});
const overlap = (width + 128) > height;
const frameWidth = width > height ? height : width - 16;
const frameHeight = frameWidth;
const frameOffset = (height - frameHeight) / 8;
const frameOffset = (height - frameHeight) / 4;
return (
<SafeAreaView style={(connecting || state.calling || state.ringing.length > 0 || alert) ? styles.active : styles.inactive}>
<View style={styles.container}>
{ connecting && !state.calling && (
<View style={(connecting || state.calling || state.calls.length > 0 || alert) ? styles.active : styles.inactive}>
{ state.calls.length > 0 && !connecting && !state.calling && (
<View style={styles.base}>
<BlurView style={styles.blur} />
<View style={styles.calls}>
{ calls }
</View>
</View>
)}
{ connecting && !state.calling && (
<View style={styles.container}>
<ActivityIndicator size={72} />
)}
{ state.calling && (
</View>
)}
{ state.calling && (
<View style={styles.container}>
<View style={{ ...styles.frame, top: frameOffset, width: frameWidth, height: frameHeight }}>
<Image
style={{ ...styles.image, opacity: state.loaded ? 1 : 0 }}
@ -129,58 +166,58 @@ export function Calling({ callCard }: { callCard: string }) {
<LinearGradient style={{...styles.overlap, height: '100%', width: 16, left: 0}} start={{x: 1, y: 0}} end={{x: 0, y: 0}} colors={['rgba(64,64,64,0)', 'rgba(64,64,64, 1)']} />
)}
</View>
)}
{ state.calling && state.loaded && (
<View style={{ ...styles.overlap, top: 16 }}>
<View style={{backgroundColor: 'rgba(32,32,32,0.8', borderRadius: 4 }}>
{ state.calling.name && (
<Text style={styles.name} adjustsFontSizeToFit={true} numberOfLines={1}>{ state.calling.name }</Text>
)}
{ !state.calling.name && (
<Text style={styles.name} adjustsFontSizeToFit={true} numberOfLines={1}>{ `${state.calling.handle}/${state.calling.node}` }</Text>
)}
</View>
</View>
)}
{ state.calling && state.loaded && (
<View style={{ ...styles.overlap, top: 64 }}>
<View style={{backgroundColor: 'rgba(32,32,32,0.8', borderRadius: 4 }}>
{ state.calling.name && (
<Text style={styles.name} adjustsFontSizeToFit={true} numberOfLines={1}>{ state.calling.name }</Text>
)}
{ !state.calling.name && (
<Text style={styles.name} adjustsFontSizeToFit={true} numberOfLines={1}>{ `${state.calling.handle}/${state.calling.node}` }</Text>
)}
</View>
)}
{ state.calling && state.loaded && state.remote && (
<RTCView
style={styles.full}
mirror={true}
objectFit={'contain'}
streamURL={state.remote.toURL()}
zOrder={2}
/>
)}
{ state.calling && state.loaded && state.local && !state.remote && (
<RTCView
style={styles.full}
mirror={true}
objectFit={'contain'}
streamURL={state.local.toURL()}
zOrder={2}
/>
)}
{ state.calling && state.loaded && state.local && state.remote && (
<RTCView
style={styles.box}
mirror={true}
objectFit={'contain'}
streamURL={state.local.toURL()}
zOrder={2}
/>
)}
{ state.calling && state.loaded && (
<View style={{ ...styles.overlap, bottom: frameOffset }}>
<View style={{ paddingTop: 8, paddingBottom: 8, paddingLeft: 16, paddingRight: 16, gap: 16, display: 'flex', flexDirection: 'row', borderRadius: 16, backgroundColor: 'rgba(128,128,128,0.6)' }}>
<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" containerColor={Colors.danger} icon="phone-hangup-outline" compact="true" mode="contained" size={32} onPress={end} />
</View>
</View>
)}
{ state.calling && state.loaded && state.remote && (
<RTCView
style={styles.full}
mirror={true}
objectFit={'contain'}
streamURL={state.remote.toURL()}
zOrder={2}
/>
)}
{ state.calling && state.loaded && state.local && !state.remote && (
<RTCView
style={styles.full}
mirror={true}
objectFit={'contain'}
streamURL={state.local.toURL()}
zOrder={2}
/>
)}
{ state.calling && state.loaded && state.local && state.remote && (
<RTCView
style={styles.box}
mirror={true}
objectFit={'contain'}
streamURL={state.local.toURL()}
zOrder={2}
/>
)}
{ state.calling && state.loaded && (
<View style={{ ...styles.overlap, bottom: frameOffset }}>
<View style={{ paddingTop: 8, paddingBottom: 8, paddingLeft: 16, paddingRight: 16, gap: 16, display: 'flex', flexDirection: 'row', borderRadius: 16, backgroundColor: 'rgba(128,128,128,0.6)' }}>
<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" containerColor={Colors.danger} icon="phone-hangup-outline" compact="true" mode="contained" size={32} onPress={end} />
</View>
)}
</View>
</View>
)}
<Confirm show={alert} params={alertParams} />
</SafeAreaView>
</View>
);
}

View File

@ -112,7 +112,7 @@ export function useCalling() {
const linkMessage = async (message: any) => {
if (call.current) {
const { peer, link, candidates, policy } = call.current;
const { peer, link, policy } = call.current;
try {
if (message.description) {
const offer = new RTCSessionDescription(message.description);
@ -123,11 +123,13 @@ export function useCalling() {
link.sendMessage({ description });
}
call.current.candidates = [];
for (const candidate of candidates) {
await peer.addIceCandidate(candidate);
};
candidates.length = 0;
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) {
@ -189,6 +191,9 @@ export function useCalling() {
await linkMessage(data);
} else if (type === 'remote_track') {
await remoteStream.current.addTrack(data, remoteStream.current);
if (data.kind === 'video') {
updateState({ remote: remoteStream.current });
}
} else if (type === 'local_track') {
await peerTrack(data);
}
@ -219,10 +224,6 @@ export function useCalling() {
});
peerConnection.addEventListener( 'track', event => {
updatePeer('remote_track', event.track);
if (event.track.kind === 'video') {
updateState({ remote: remoteStream.current });
}
});
return peerConnection;
}
@ -263,13 +264,15 @@ export function useCalling() {
remoteStream.current = null;
updateState({ calling: null, audio: null, video: null, local: null, remote: null });
},
accept: async (callId: string, call: Call) => {
accept: async (callId: string, card: Card) => {
if (call.current) {
throw new Error('active call in progress');
}
const { cardId, node } = call;
const { cardId, node } = card;
const ring = app.state.session.getRing();
console.log("ACCEPTING");
const link = await ring.accept(cardId, callId, node);
console.log("ACCEPTED");
const ice = link.getIce();
const peer = transmit(ice);
const policy = 'impolite';
@ -277,7 +280,8 @@ export function useCalling() {
call.current = { policy, peer, link, candidates };
link.setStatusListener(linkStatus);
link.setMessageListener((msg) => updatePeer('message', msg));
updateState({ calling: call.card });
updateState({ calling: card });
console.log("DONE");
},
call: async (cardId: string) => {
if (call.current) {
@ -295,7 +299,7 @@ export function useCalling() {
const candidates = [];
call.current = { policy, peer, link, candidates };
link.setStatusListener(linkStatus);
link.setMessageListener(linkMessage);
link.setMessageListener((msg) => updatePeer('message', msg));
updateState({ calling: card });
},
loaded: (e) => {

View File

@ -53,7 +53,13 @@ export class LinkModule implements Link {
public async call(node: string, secure: boolean, token: string, cardId: string, contactNode: string, contactGuid: string, contactToken: string) {
const call = await addCall(node, secure, token, cardId);
this.cleanup = () => { removeCall(node, secure, token, call.id) };
this.cleanup = async () => {
try {
await removeCall(node, secure, token, call.id)
} catch (err) {
this.log.error(err);
}
}
const { id, keepAlive, calleeToken, callerToken, ice } = call;
const insecure = /^(?!0)(?!.*\.$)((1?\d?\d|25[0-5]|2[0-4]\d)(\.|:\d+$|$)){4}$/.test(contactNode);
@ -84,7 +90,13 @@ export class LinkModule implements Link {
public async join(server: string, access: string, ice: { urls: string; username: string; credential: string }[]) {
this.ice = ice;
const insecure = /^(?!0)(?!.*\.$)((1?\d?\d|25[0-5]|2[0-4]\d)(\.|:\d+$|$)){4}$/.test(server);
this.cleanup = () => { removeContactCall(server, !insecure, access); }
this.cleanup = async () => {
try {
await removeContactCall(server, !insecure, access);
} catch (err) {
this.log.error(err);
}
}
this.connect(access, server, !insecure);
}
@ -163,8 +175,6 @@ export class LinkModule implements Link {
await this.notifyMessage(message);
}
} catch (err) {
console.log("HERE!");
this.log.error(err, data);
this.log.error('failed to process signal message');
this.notifyStatus('error');
}

View File

@ -19,7 +19,6 @@ export class RingModule implements Ring {
this.log = log;
this.emitter = new EventEmitter();
this.calls = new Map<string, { call: Call, expires: number }>();
this.ringing = [];
this.expire = null;
this.closed = false;
}
@ -58,20 +57,20 @@ export class RingModule implements Ring {
public async accept(cardId: string, callId: string, contactNode: string): Promise<Link> {
const now = (new Date()).getTime();
const id = `${cardId}:${callId}`;
const entry = this.ringing.get(id);
const entry = this.calls.get(id);
if (!entry || entry.expires < now || entry.status !== 'ringing') {
throw new Error('invalid ringing entry');
}
entry.status = 'accepted';
this.emitRinging();
const link = new LinkModule(this.log);
await link.join(contactNode, entry.call.calleeToken, ice);
await link.join(contactNode, entry.call.calleeToken, entry.call.ice);
return link;
}
public async ignore(cardId: stirng, callId: string): Promise<void> {
const id = `${cardId}:${callId}`;
const entry = this.ringing.get(id);
const entry = this.calls.get(id);
if (!entry || entry.expires < now || entry.status !== 'ringing') {
throw new Error('invalid ringing entry');
}
@ -81,7 +80,7 @@ export class RingModule implements Ring {
public async decline(cardId: string, callId: string, contactNode: string): Promise<void> {
const id = `${cardId}:${callId}`;
const entry = this.ringing.get(id);
const entry = this.calls.get(id);
if (!entry || entry.expires < now || entry.status !== 'ringing') {
throw new Error('invalid ringing entry');
}