518 changes: 518 additions & 0 deletions safe_webrtc_example/app/app_store.js

Large diffs are not rendered by default.

29 changes: 29 additions & 0 deletions safe_webrtc_example/app/components/active_public_name.js
@@ -0,0 +1,29 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { inject, observer } from "mobx-react";
import CONST from '../constants';

@inject("store")
@observer
export default class ActivePublicName extends Component {
render() {
const { history, publicName, disableOptions } = this.props;

return (
<div className="active-public-name">
<div className="active-public-name-b">
<div className="label">{CONST.UI.LABELS.activePubName}</div>
<div className="value">{publicName}</div>
{!disableOptions ? (<div className="opt">
<button className="btn" onClick={() => {
history.push('switch-public-name');
}}>{CONST.UI.LABELS.switch}</button>
</div>) : null}
</div>
</div>
);
}
}

ActivePublicName.propTypes = {
};
19 changes: 19 additions & 0 deletions safe_webrtc_example/app/components/app.js
@@ -0,0 +1,19 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { inject, observer } from "mobx-react";

@inject("store")
@observer
export default class App extends Component {
render() {
return (
<div className="root-b">
{this.props.children}
</div>
);
}
}

App.propTypes = {
children: PropTypes.element.isRequired,
};
74 changes: 74 additions & 0 deletions safe_webrtc_example/app/components/bootstrap.js
@@ -0,0 +1,74 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { inject, observer } from "mobx-react";

import CONST from '../constants';

@inject("store")
@observer
export default class Bootstrap extends Component {
componentDidMount() {
const { store, history } = this.props;

store.authorisation()
.then(() => store.fetchPublicNames())
.then(() => store.setupPublicName())
.then(() => {
history.push('home');
})
}

getError(err) {
if(!err) {
return <span></span>;
}

return (
<div className="context-b error">
<div className="icn"></div>
<div className="desc">{err}</div>
</div>
);
}

getProgress(desc) {
if (!desc) {
return <span></span>;
}

return (
<div className="context-b">
<div className="icn spinner"></div>
<div className="desc">{desc}</div>
</div>
);
}

render() {
const { store } = this.props;

let container = undefined;

if (store.error) {
container = this.getError(store.error);
} else {
container = this.getProgress(store.progress);
}

return (
<div className="card-1 bootstrap">
<div className="logo">
<div className="logo-img"></div>
<div className="logo-desc">{CONST.UI.LABELS.title}</div>
</div>
<div className="context">
{container}
</div>
</div>
);
}
}

Bootstrap.propTypes = {

};
349 changes: 349 additions & 0 deletions safe_webrtc_example/app/components/chat_room.js
@@ -0,0 +1,349 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { observable } from 'mobx';
import { inject, observer } from "mobx-react";
import classNames from 'classnames';
import CONST from '../constants';
@inject("store")
@observer
export default class ChatRoom extends Component {
@observable originConn = null;
@observable destConn = null;

constructor() {
super();
this.friendID = null;
this.friendUID = null;
this.offerOptions = CONST.CONFIG.OFFER;
this.mediaOffer = CONST.CONFIG.MEDIA_OFFER;
this.originStream = null;
this.originCandidates = [];
this.destCandidates = [];
this.onCreateOfferSuccess = this.onCreateOfferSuccess.bind(this);
this.onClickCancel = this.onClickCancel.bind(this);
this.setTimer = this.setTimer.bind(this);
this.getConnectionStatus = this.getConnectionStatus.bind(this);
this.timer = null;
}

componentWillMount() {
if (!this.props.store.isAuthorised) {
return this.props.history.push('/');
}
this.friendID = this.props.match.params.friendId;
this.friendUID = this.props.match.params.uid;
this.props.store.initialiseConnInfo(this.friendID, this.friendUID)
.then(() => {
this.startStream()
.then(() => this.setupOrigin())
.then(() => this.setupRemote())
.then(() => {
this.originConn.createOffer(this.offerOptions)
.then(this.onCreateOfferSuccess, (err) => {
console.error('create offer error :: ', err);
});
});
});
}

componentWillUnmount() {
this.reset();
}

setTimer(fn) {
const { store } = this.props;
this.timer = setTimeout(() => {
fn.call(store).then((res) => {
clearTimeout(this.timer);
if (!res) {
this.setTimer(fn);
return;
}
if (store.persona === CONST.USER_POSITION.CALLER && store.remoteOffer && store.state === CONST.CONN_STATE.INVITE_ACCEPTED) {
this.call();
}

if (store.persona === CONST.USER_POSITION.CALLEE && store.remoteAnswer) {
this.finishConnection();
}
});
}, CONST.UI.TIMER_INTERVAL.CONNECTION_POLL);
}

startStream() {
return window.navigator.mediaDevices.getUserMedia(this.mediaOffer)
.then((stream) => {
this.originStream = stream;
this.origin.srcObject = stream;
});
}

stopAllStreams() {
if (!this.originStream) {
return;
}
this.originStream.getTracks().forEach((track) => {
track.stop();
});
}

setupOrigin() {
return new Promise((resolve) => {
this.originConn = new window.RTCPeerConnection(CONST.CONFIG.SERVER);
this.originConn.onicecandidate = (e) => {
if (!e.candidate) {
this.props.store.setOfferCandidates(this.originCandidates);
if (!this.friendID) {
this.props.store.sendInvite()
.then(() => {
this.setTimer(this.props.store.checkInviteAccepted);
})
} else {
this.call();
}
return;
}
if (!this.originCandidates) {
this.originCandidates = [];
}
this.originCandidates.push(e.candidate);
};

this.originConn.addStream(this.originStream);
resolve();
});
}

setupRemote() {
return new Promise((resolve) => {
this.destConn = new window.RTCPeerConnection(CONST.CONFIG.SERVER);

this.destConn.onicecandidate = (e) => {
if (!e.candidate) {
this.props.store.setAnswerCandidates(this.destCandidates);
if (!this.friendID) {
this.props.store.calling().then(() => {
this.setTimer(this.props.store.checkCallAccepted);
});
} else {
this.props.store.acceptInvite().then(() => {
this.setTimer(this.props.store.checkForCalling);
});
}
return;
}
if (!this.destCandidates) {
this.destCandidates = [];
}
this.destCandidates.push(e.candidate);
};

this.destConn.onaddstream = (e) => {
this.destinaton.srcObject = e.stream;
}
resolve();
});
}

call() {
const { store } = this.props;
this.destConn.setRemoteDescription(store.remoteOffer)
.then(() => {
return Promise.all(store.remoteOfferCandidates.map((can) => {
return this.destConn.addIceCandidate(new RTCIceCandidate(can))
.then(() => {
console.log('set ICE candidate success');
}, (err) => {
console.error('set ICE candidate failed ::', err);
});
}));
}, (err) => {
console.error('set destination remote session failed ::', err);
})
.then(() => this.destConn.createAnswer())
.then((ansDesc) => {
this.onCreateAnswerSuccess(ansDesc);
}, (err) => {
console.error('create answer error :: ', err);
})
.then(() => {
if (store.persona === CONST.USER_POSITION.CALLER && store.remoteAnswer) {
this.finishConnection();
}
});
}

onCreateOfferSuccess(offer) {
this.originConn.setLocalDescription(offer)
.then(() => {
console.log('set origin local session success');
return this.props.store.setOffer(offer);
}, (err) => {
console.error('set origin local session failed ::', err);
});
}

onCreateAnswerSuccess(answer) {
const { store } = this.props;
this.destConn.setLocalDescription(answer)
.then(() => {
return store.setAnswer(answer);
console.log('set destination local session success');
}, (err) => {
console.error('set destination local session failed ::', err);
});
}

reset() {
clearTimeout(this.timer);
this.props.store.resetConnInfo();
this.stopAllStreams();
}

endCall(e) {
e.preventDefault();
this.originConn.close();
this.destConn.close();
this.originConn = null;
this.destConn = null;
this.reset();
this.props.history.push('/home');
}

onClickCancel(e) {
e.preventDefault();
const self = this;
const moveHome = () => {
self.reset();
self.props.history.push('/home');
};
this.props.store.deleteInvite()
.then(moveHome, moveHome);
}

getProgress(progress, error) {
if (error) {
return (
<div className="progress error">
<div className="progress-b">
<div className="icn"></div>
<div className="desc">{error}</div>
</div>
</div>
);
} else if (progress) {
return (
<div className="progress">
<div className="progress-b">
<div className="icn spinner"></div>
<div className="desc">{progress}</div>
</div>
</div>
);
}
return <span></span>;
}

getConnectionStatus() {
let connectionMsg = null;
const { store } = this.props;
const { connectionState } = store;
const { CONN_STATE, UI } = CONST;
const { CONN_MSGS } = UI;

const isConnected = (connectionState === CONN_STATE.CONNECTED);

if (!isConnected) {
switch (connectionState) {
case CONN_STATE.INIT:
connectionMsg = CONN_MSGS.INIT;
break;
case CONN_STATE.SEND_INVITE:
connectionMsg = CONN_MSGS.SEND_INVITE;
break;
case CONN_STATE.INVITE_ACCEPTED:
connectionMsg = CONN_MSGS.INVITE_ACCEPTED;
break;
case CONN_STATE.CALLING:
connectionMsg = CONN_MSGS.CALLING;
break;
default:
connectionMsg = UI.DEFAULT_LOADING_DESC
}
}

const statusClassName = classNames('status', {
'connected': connectionState === CONN_STATE.CONNECTED
});

return (
<div className={statusClassName}>
<div className="status-b">
<div className="card-1">
<div className="logo logo-sm">
<div className="logo-img"></div>
</div>
<div className="call-for">
<div className="call-for-b">
<div className="caller">{store.activePublicName}</div>
<div className="split"></div>
<div className="callee">{this.friendID || store.friendID}</div>
</div>
<div className="id">#{this.friendUID || store.uid}</div>
</div>
{this.getProgress(connectionMsg, store.chatRoomError)}
<div className="opts">
<div className="opt">
<button className="btn" onClick={this.onClickCancel}>{CONST.UI.LABELS.cancel}</button>
</div>
</div>
</div>
</div>
</div>
);
}

finishConnection() {
const { store } = this.props;
this.originConn.setRemoteDescription(store.remoteAnswer)
.then(() => {
Promise.all(store.remoteAnswerCandidates.map((can) => {
return this.originConn.addIceCandidate(new RTCIceCandidate(can))
.then(() => {
console.log('set ICE candidate origin success');
}, (err) => {
console.error('set ICE candidate origin failed ::', err);
});
})).then(() => {
if (store.persona === CONST.USER_POSITION.CALLER) {
return;
}
store.connected();
});
}, (err) => {
console.error('set origin remote session failed ::', err);
});
}

render() {
return (
<div className="chat-room">
<div className="remote">
<video ref={(c) => { this.destinaton = c; }} autoPlay></video>
<div className="origin">
<video ref={(c) => { this.origin = c; }} autoPlay></video>
</div>
<div className="opts">
<div className="opt">
<button className="btn end-call" onClick={this.endCall.bind(this)}></button>
</div>
</div>
</div>
{this.getConnectionStatus()}
</div>
);
}
}

ChatRoom.propTypes = {
};
90 changes: 90 additions & 0 deletions safe_webrtc_example/app/components/home.js
@@ -0,0 +1,90 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { inject, observer } from "mobx-react";

import CONST from '../constants';
import ActivePublicName from './active_public_name';

@inject("store")
@observer
export default class Home extends Component {
constructor() {
super();
this.timer = null;
}

componentWillMount() {
if (!this.props.store.isAuthorised) {
return this.props.history.push('/');
}
}

componentDidMount() {
this.pollInvite();
}

componentWillUnmount() {
clearTimeout(this.timer);
this.timer = null;
this.props.store.reset();
}

pollInvite() {
const { store } = this.props;
const self = this;
const poll = () => {
clearTimeout(self.timer);
self.pollInvite();
};
this.timer = setTimeout(() => {
store.fetchInvites(true).then(poll, poll);
}, CONST.UI.TIMER_INTERVAL.FETCH_INVITES_POLL);
}

getActivePublicContainer() {
const { store, history } = this.props;
if (!store.activePublicName) {
return <span></span>
}

return <ActivePublicName history={history} publicName={store.activePublicName} />
}

render() {
const { store, history } = this.props;

return (
<div className="card-1 home">
<div className="logo logo-sm">
<div className="logo-img"></div>
</div>
<div className="split-view">
<div className="split-view-b">
<div className="split-view-30 split">
<div className="invite-label">
<div className="invite-label-b">
<div className="icn"></div>
<div className="desc">
<a href="#" onClick={e => {
e.preventDefault();
history.push('invites');
}}>{`${CONST.UI.LABELS.invites} ${store.invitesCount ? `(${store.invitesCount})` : ''}`}</a>
</div>
</div>
</div>
</div>
<div className="split-view-70">
<button className="btn start-call" onClick={() => {
history.push('new-chat');
}}>{CONST.UI.LABELS.newVideoCall}</button>
</div>
</div>
</div>
{this.getActivePublicContainer()}
</div>
);
}
}

Home.propTypes = {
};
172 changes: 172 additions & 0 deletions safe_webrtc_example/app/components/invites.js
@@ -0,0 +1,172 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { inject, observer } from "mobx-react";
import classNames from 'classnames';
import CONST from '../constants';
import ActivePublicName from './active_public_name';

@inject("store")
@observer
export default class Invites extends Component {
constructor() {
super();
this.state = {
selectedInvite: {
publicId: null,
uid: null
}
};
}

componentWillMount() {
if (!this.props.store.isAuthorised) {
return this.props.history.push('/');
}
this.props.store.fetchInvites();
}

componentWillUnmount() {
this.props.store.reset();
}

onClickInvite(invite) {
if (!invite.publicId || !invite.uid) { return };

this.setState({ selectedInvite: invite });
}

getOptions(onlyCancel) {
const { store, history } = this.props;
return (
<div className="opts">
{
!onlyCancel ? (
<div className="opt">
<button
className="btn primary"
disabled={!this.state.selectedInvite.publicId || !this.state.selectedInvite.uid || (store.invites.length === 0)}
onClick={() => {
history.push(`chat-room/${this.state.selectedInvite.publicId}/${this.state.selectedInvite.uid}`);
}}>{CONST.UI.LABELS.connect}</button>
</div>
) : null
}
<div className="opt">
<button className="btn" onClick={() => {
history.push('/home');
}}>{CONST.UI.LABELS.cancel}</button>
</div>
</div>
);
}

getError(msg) {
return (
<div>
<div className="progress error">
<div className="progress-b">
<div className="icn"></div>
<div className="desc">{msg}</div>
</div>
</div>
{this.getOptions(true)}
</div>
);
}

getProgressLoader(msg) {
return (
<div className="progress">
<div className="progress-b">
<div className="icn spinner"></div>
<div className="desc">{msg}</div>
</div>
</div>
)
}

// getProgress() {
// const { store } = this.props;

// if (store.error) {
// return (
// <div className="progress error">
// <div className="progress-b">
// <div className="icn"></div>
// <div className="desc">{store.error}</div>
// </div>
// </div>
// );
// }
// return this.getProgressLoader(store.progress);
// }

getInvitesList() {
const { store, history } = this.props;
let container = undefined;
if (store.invites.length === 0) {
container = <div className="default">{CONST.UI.LABELS.noInvites}</div>
} else {
container = (
<ul>
{
store.invites.map((invite, i) => {
const listClassName = classNames({
active: (invite.uid === this.state.selectedInvite.uid) && (invite.publicId === this.state.selectedInvite.publicId)
});
return (
<li key={i} className={listClassName} onClick={() => {
this.onClickInvite(invite);
}}>{invite.publicId} {invite.uid}</li>
);
})
}
</ul>
);
}
return (
<div>
<h3>{CONST.UI.LABELS.chooseInvite}</h3>
{container}
{this.getOptions()}
</div>
);
}

getActivePublicContainer() {
const { store, history } = this.props;
if (!store.activePublicName) {
return <span></span>
}

return <ActivePublicName history={history} publicName={store.activePublicName} disableOptions />
}

render() {
const { store } = this.props;
let container = undefined;

if (store.error) {
container = this.getError(store.error);
} else if (store.progress) {
container = this.getProgressLoader(store.progress);
} else {
container = this.getInvitesList();
}

return (
<div className="card-1 home">
<div className="logo logo-sm">
<div className="logo-img"></div>
</div>
<div className="list">
{container}
</div>
{this.getActivePublicContainer()}
</div>
);
}
}

Invites.propTypes = {
};
128 changes: 128 additions & 0 deletions safe_webrtc_example/app/components/new_chat.js
@@ -0,0 +1,128 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { inject, observer } from "mobx-react";
import CONST from '../constants';
import ActivePublicName from './active_public_name';
@inject("store")
@observer
export default class NewChat extends Component {
constructor() {
super();
this.onSubmitFriendID = this.onSubmitFriendID.bind(this);
this.onFocusInput = this.onFocusInput.bind(this);
}

componentWillMount() {
if (!this.props.store.isAuthorised) {
return this.props.history.push('/');
}
}

componentDidMount() {
this.friendID.focus();
}

componentWillUnmount() {
this.props.store.resetNewChatState();
}

getActivePublicContainer() {
const { store } = this.props;
if (!store.activePublicName) {
return <span></span>
}

return <ActivePublicName history={history} publicName={store.activePublicName} disableOptions />
}

getOptions(onlyCancel) {
const { history } = this.props;
return (
<div className="opts">
{!onlyCancel ? (<div className="opt">
<button type="submit" className="btn primary-green">{CONST.UI.LABELS.connect}</button>
</div>) : null}
<div className="opt">
<button type="button" className="btn" onClick={() => {
history.push('/home');
}}>{CONST.UI.LABELS.cancel}</button>
</div>
</div>
);
}

getProgress() {
const { store } = this.props;

if (store.newChatError) {
return (
<div className="progress error">
<div className="progress-b">
<div className="icn"></div>
<div className="desc">{store.newChatError}</div>
</div>
</div>
);
}
if (store.newChatProgress) {
return (
<div className="progress">
<div className="progress-b">
<div className="icn spinner"></div>
<div className="desc">{store.newChatProgress}</div>
</div>
</div>
)
}

return <span></span>
}

onSubmitFriendID(e) {
e.preventDefault();
const { history } = this.props;
this.props.store.resetNewChatState();
const friendID = this.friendID.value.trim();
this.props.store.connect(friendID)
.then(() => {
history.push('chat-room');
});
}

onFocusInput(e) {
this.props.store.resetNewChatState();
}

render() {
const { store } = this.props;
return (
<div className="card-1 home">
<div className="logo logo-sm">
<div className="logo-img"></div>
</div>
<div className="new-chat">
<h3>{CONST.UI.LABELS.newVideoCall}</h3>
<div className="new-chat-form">
<form onSubmit={this.onSubmitFriendID}>
<div className="inpt">
<input
type="text"
name="friendPubId"
required="required"
ref={c => { this.friendID = c }}
onFocus={this.onFocusInput}
placeholder={CONST.UI.LABELS.friendIdPlaceholder} />
</div>
{!store.newChatProgress ? this.getOptions() : null}
</form>
{this.getProgress()}
</div>
</div>
{this.getActivePublicContainer()}
</div>
);
}
}

NewChat.propTypes = {
};
179 changes: 179 additions & 0 deletions safe_webrtc_example/app/components/switch_public_name.js
@@ -0,0 +1,179 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { inject, observer } from "mobx-react";
import classNames from 'classnames';

import CONST from '../constants';
import ActivePublicName from './active_public_name';

@inject("store")
@observer
export default class SwitchPublicName extends Component {
constructor() {
super();
this.state = {
selectedPubName: null
};
}
componentWillMount() {
if (!this.props.store.isAuthorised) {
return this.props.history.push('/');
}
this.props.store.fetchPublicNames();
}

componentWillUnmount() {
this.props.store.reset();
this.props.store.resetSwitchIDState();
}

onClickPubName(name) {
if (!name) { return };
this.props.store.resetSwitchIDState();
this.setState({ selectedPubName: name });
}

getOptions(onlyCancel) {
const { store, history } = this.props;

return (
<div className="opts">
{
!onlyCancel ? (
<div className="opt">
<button className="btn primary" disabled={!this.state.selectedPubName} onClick={() => {
store.activatePublicName(this.state.selectedPubName)
.then(() => {
history.push('/home');
});
}}>{CONST.UI.LABELS.activate}</button>
</div>
) : null
}
<div className="opt">
<button className="btn" onClick={() => {
history.push('/home');
}}>{CONST.UI.LABELS.cancel}</button>
</div>
</div>
);
}

getProgressLoader(msg) {
return (
<div className="progress">
<div className="progress-b">
<div className="icn spinner"></div>
<div className="desc">{msg}</div>
</div>
</div>
)
}

getError(msg) {
return (
<div>
<div className="progress error">
<div className="progress-b">
<div className="icn"></div>
<div className="desc">{msg}</div>
</div>
</div>
{this.getOptions(true)}
</div>
);
}

getProgress() {
const { store } = this.props;

if (store.switchIDError) {
return (
<div className="progress error">
<div className="progress-b">
<div className="icn"></div>
<div className="desc">{store.switchIDError}</div>
</div>
</div>
);
}
if (store.switchIDProgress) {
return this.getProgressLoader(store.switchIDProgress);
}

return <span></span>
}

getPubNamesList() {
const { store, history } = this.props;
let container = undefined;
if (store.publicNames.length === 1) {
container = <div className="default">{CONST.UI.LABELS.noPublicName}</div>
} else {
container = (
<ul>
{
store.publicNames.map((pub, i) => {
if (pub === store.activePublicName) {
return null;
}
const listClassName = classNames({
active: pub === this.state.selectedPubName
});
return (
<li key={i} className={listClassName} onClick={() => {
this.onClickPubName(pub);
}}>{pub}</li>
);
})
}
</ul>
)
}
return (
<div>
<h3>{CONST.UI.LABELS.choosePublicName}</h3>
{container}
{!store.switchIDProgress ? this.getOptions() : null}
</div>
);
}

getActivePublicContainer() {
const { store, history } = this.props;
if (!store.activePublicName) {
return <span></span>
}

return <ActivePublicName history={history} publicName={store.activePublicName} disableOptions />
}

render() {
const { store } = this.props;
let container = undefined;

if (store.error) {
container = this.getError(store.error);
} else if (store.progress) {
container = this.getProgressLoader(store.progress);
} else {
container = this.getPubNamesList();
}

return (
<div className="card-1 home">
<div className="logo logo-sm">
<div className="logo-img"></div>
</div>
<div className="list">
{container}
</div>
{this.getProgress()}
{this.getActivePublicContainer()}
</div>
);
}
}

SwitchPublicName.propTypes = {
};
110 changes: 110 additions & 0 deletions safe_webrtc_example/app/constants.js
@@ -0,0 +1,110 @@
export default {
UI: {
LABELS: {
title: 'SAFE WebRTC Signalling',
activePubName: 'Active Public Name',
newVideoCall: 'New video call',
invites: 'Invites',
switch: 'Switch',
connect: 'Connect',
cancel: 'Cancel',
activate: 'Activate',
friendIdPlaceholder: 'Enter remote peer\'s Public Name',
chooseInvite: 'Choose an invite',
noInvites: 'No invites available',
noPublicName: 'No Public Name available to switch',
choosePublicName: 'Select Public Name',
},
MESSAGES: {
authorise: 'Authorising with Authenticator',
authoriseFail: 'Authorisation failed',
initialise: 'Initialising application',
initialiseFail: 'Failed to initialise application',
noPubNameFound: 'No Public Name found.',
fetchPublicName: 'Fetching Public Names',
fetchPublicNameFail: 'Unable to fetch Public Names',
fetchInvites: 'Fetching invites',
fetchInvitesFail: 'Unable to fetch invites',
activatePublicName: 'Activating Public Name',
activatePublicNameFail: 'Failed to activate Public Name',
connecting: 'Connecting with remote peer',
connectingFail: 'Failed to connect with remote peer',
invalidPublicName: 'Invalid Public Name',
cantInviteYourself: 'Can\'t invite yourself',
inviteAcceptFail: 'Failed to accept invite',
callAcceptFail: 'Failed after remote peer accepted the call',
checkCallingFail: 'Failed to accept remote peer call',
initialisationFail: 'Failed to initialise the connection',
sendInviteFail: 'Failed to send invitation to remote peer',
callingFail: 'Failed to call remote peer',
connectingFail: 'Failed to connect with remote peer'
},
DEFAULT_LOADING_DESC: 'Please wait...',
CONN_MSGS: {
INIT: 'Initialising connection',
SEND_INVITE: 'Invite sent. Waiting for the remote peer to accept the connection',
INVITE_ACCEPTED: 'Invite accepted. Establishing connection with remote peer',
CALLING: 'Remote peer accepted invite. Establishing connection',
},
CONN_TIMER_INTERVAL: 2000,
TIMER_INTERVAL: {
FETCH_INVITES_POLL: 5000,
CONNECTION_POLL: 4000,
},
},
CONFIG: {
SERVER: {
iceServers: [
{ url: 'STUN_SERVER_URL' }, // fill STUN Server url
{
url: 'TURN_SERVER_URL', // fill turn server url
credential: 'TURN_PASSWORD', // fill turn server password
username: 'TURN_USERNAME' // fill turn server username
},
]
},
OFFER: {
offerToReceiveAudio: 1,
offerToReceiveVideo: 1
},
MEDIA_OFFER: {
audio: true,
video: true
},
},
USER_POSITION: {
CALLER: 'CALLER',
CALLEE: 'CALLEE',
},
CONN_STATE: {
INIT: 'INIT',
SEND_INVITE: 'SEND_INVITE',
INVITE_ACCEPTED: 'INVITE_ACCEPTED',
CALLING: 'CALLING',
CONNECTED: 'CONNECTED',
},
NET_STATE: {
INIT: 'Init',
DISCONNECTED: 'Disconnected',
CONNECTED: 'Connected',
UNKNOWN: 'Unknown',
},
PERMISSIONS: {
READ: 'Read',
INSERT: 'Insert',
UPDATE: 'Update',
},
MD_KEY: '@webrtcSignalSample',
SELECTED_PUB_NAME_KEY: 'selected_pub_name',
MD_META_KEY: '_metadata',
TYPE_TAG: {
CHANNEL: 15005,
DNS: 15001,
},
CRYPTO_KEYS: {
SEC_SIGN_KEY: '__SEC_SIGN_KEY__',
PUB_SIGN_KEY: '__PUB_SIGN_KEY__',
SEC_ENC_KEY: '__SEC_ENC_KEY__',
PUB_ENC_KEY: '__PUB_ENC_KEY__',
},
};
39 changes: 39 additions & 0 deletions safe_webrtc_example/app/images/call.svg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions safe_webrtc_example/app/images/checked.svg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
42 changes: 42 additions & 0 deletions safe_webrtc_example/app/images/error.svg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions safe_webrtc_example/app/images/invites.svg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions safe_webrtc_example/app/images/loader.svg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added safe_webrtc_example/app/images/logo.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
40 changes: 40 additions & 0 deletions safe_webrtc_example/app/images/more.svg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
40 changes: 40 additions & 0 deletions safe_webrtc_example/app/images/notification.svg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions safe_webrtc_example/app/images/start-chat.svg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 9 additions & 0 deletions safe_webrtc_example/app/index.html
@@ -0,0 +1,9 @@
<html>
<head>
<title>SAFE WebRTC example</title>
</head>
<body>
<div id="__WEBRTC_APP__" class="root"></div>
<script defer src="/static/bundle.js"></script>
</body>
</html>
29 changes: 29 additions & 0 deletions safe_webrtc_example/app/main.js
@@ -0,0 +1,29 @@
import React from 'react';
import {render} from 'react-dom';
import createHashHistory from 'history/createHashHistory';
import { Provider } from 'mobx-react';
import { RouterStore, syncHistoryWithStore } from 'mobx-react-router';
import { Router } from 'react-router';
import AppRouter from './router';
import AppStore from './app_store';

const browserHistory = createHashHistory();
const routingStore = new RouterStore();
const appStore = new AppStore();


const stores = {
routing: routingStore,
store: appStore,
};

const history = syncHistoryWithStore(browserHistory, routingStore);

render(
<Provider {...stores}>
<Router history={history}>
<AppRouter />
</Router>
</Provider>,
document.getElementById('__WEBRTC_APP__')
);
22 changes: 22 additions & 0 deletions safe_webrtc_example/app/router.js
@@ -0,0 +1,22 @@
import React from 'react';
import { Switch, Route } from 'react-router';
import App from './components/app';
import Bootstrap from './components/bootstrap';
import Home from './components/home';
import SwitchPublicName from './components/switch_public_name';
import Invites from './components/invites';
import NewChat from './components/new_chat';
import ChatRoom from './components/chat_room';

export default () => (
<App>
<Switch>
<Route path="/home" component={Home} />
<Route path="/switch-public-name" component={SwitchPublicName} />
<Route path="/invites" component={Invites} />
<Route path="/new-chat" component={NewChat} />
<Route path="/chat-room/:friendId?/:uid?" component={ChatRoom} />
<Route path="/" component={Bootstrap} />
</Switch>
</App>
);
631 changes: 631 additions & 0 deletions safe_webrtc_example/app/safe_comm.js

Large diffs are not rendered by default.

45 changes: 45 additions & 0 deletions safe_webrtc_example/app/sass/button.sass
@@ -0,0 +1,45 @@
.btn
+btnDefault()
font-size: 14px
line-height: 30px
padding: 0 16px
font-weight: 600
border-radius: 3px
color: $color-smoke-medium
&:hover
background-color: $color-smoke-light
color: $color-snow-light
&:disabled
background-color: transparent !important
color: $color-smoke-light !important
&.start-call
font-size: 24px
background-color: $color-green
color: #ffffff
line-height: 70px
padding: 0 48px
font-weight: normal
&:hover
background-color: $color-green-dark
&.primary
color: $color-pr-blue
&:hover
background-color: $color-pr-blue
color: #fff
&.danger
color: $color-error
&:hover
background-color: $color-snow-light
color: $color-error
&.end-call
width: 50px
height: 50px
background-color: $color-end-call
background-image: url($end-call-url)
background-position: center
background-size: 24px
background-repeat: no-repeat
border-radius: 50%
transform: rotate(135deg)
&:hover
background-color: $color-end-call-hover
126 changes: 126 additions & 0 deletions safe_webrtc_example/app/sass/chat_room.sass
@@ -0,0 +1,126 @@
.chat-room
position: fixed
width: 100%
height: 100%
background-color: $color-smoke-dark
.remote
width: $remote-width
height: $remote-width
background: $color-smoke-medium
position: relative
top: 130px
left: 50%
transform: translate(-50%, 0)
border-radius: 5px
overflow: hidden
video
width: 100%
height: 100%
transform: scale(1.3)
.origin
width: $origin-width
height: $origin-width
background: $color-smoke-light
position: absolute
top: 0
right: 0
border-radius: 5px
overflow: hidden
video
width: 100%
height: 100%
transform: scale(1.3)
.opts
width: 100%
position: absolute
left: 0
bottom: 24px
text-align: center
.opts
display: inline-block
.status
text-align: center
.status-b
width: 100%
height: 100%
background-color: rgba(0, 0, 0, 0.5)
position: absolute
top: 0
left: 0
.card-1
min-height: 220px
.call-for
text-align: center
width: 100%
position: relative
.call-for-b
display: inline-block
.caller,
.callee
font-size: 18px
color: $color-smoke-medium
line-height: 50px
width: 50%
.caller
float: left
padding-right: 50px
text-align: right
.callee
float: left
padding-left: 50px
text-align: left
.split
float: left
width: 100px
height: 30px
margin: 10px 0
background-image: url($connect-url)
background-position: center
background-size: contain
background-repeat: no-repeat
position: absolute
left: 50%
transform: translate(-50%, 0)
&.connected
.status-b
height: auto
background-color: transparent
.card-1
top: 16px
transform: translate(-50%, 0)
min-height: 80px
padding: 16px
.logo
width: $logo-xs
height: $logo-xs
top: 16px
left: 16px
&.logo-sm
.logo-img
height: 30px
.call-for
.caller,
.callee
line-height: 30px
.split
margin: 0
.opts
display: none !important
.id
font-size: 14px
color: $color-smoke-light
font-weight: 600
width: 100%
margin-top: 8px
line-height: 24px
.opts
display: inline-block
.opt
margin: 16px 0 0
button.primary-green
font-size: 18px
background-color: $color-green
color: #ffffff
line-height: 46px
padding: 0 36px

26 changes: 26 additions & 0 deletions safe_webrtc_example/app/sass/common.sass
@@ -0,0 +1,26 @@
*
margin: 0
padding: 0
box-sizing: border-box

body
font-family: 'Open Sans', sans-serif
background-color: $body-bg

.card-1
width: $card-width
min-height: $card-height
background-color: #ffffff
border-radius: 5px
transform: translate(-50%, -50%)
position: absolute
top: 50%
left: 50%
padding: $card-padd

.default
font-size: 16px
color: $color-smoke-medium
text-align: center
line-height: 36px
padding: 8px 0
247 changes: 247 additions & 0 deletions safe_webrtc_example/app/sass/components.sass
@@ -0,0 +1,247 @@
.logo
text-align: center
.logo-img
display: block
height: $logo-lg
background-image: url($logo-url)
background-position: center
background-size: contain
background-repeat: no-repeat
.logo-desc
font-size: 14px
font-weight: normal
color: $color-logo-desc
transform: translateY(-10px)
&.logo-sm
width: $logo-sm
position: absolute
top: $card-padd
left: $card-padd
.logo-img
height: $logo-sm

.bootstrap
padding: 24px
.logo
margin-top: 20px
.context
margin-top: 20px
text-align: center
.context-b
display: inline-block
.icn
display: inline-block
width: 30px
height: 30px
margin-right: 16px
float: left
.desc
display: inline-block
line-height: 30px
float: left
font-size: 18px
font-weight: normal
color: $color-smoke-medium
&.error
.icn
background-image: url($error-url)
.desc
color: $color-error

.home
min-height: 500px
.split-view
width: 100%
margin-top: 10px
padding: 24px
display: block
position: absolute
top: 50%
left: 50%
transform: translate(-50%, -50%)
.split-view-b
width: 100%
display: table
min-height: 130px
.split-view-30,
.split-view-70
display: table-cell
vertical-align: middle
text-align: center
position: relative
.split-view-30
width: 30%
&.split
&:after
content: ""
position: absolute
width: 2px
height: 80%
background-color: $color-smoke-light
top: 10%
right: -2px
z-index: 9
.split-view-70
width: 70%

.invite-label
.invite-label-b
display: inline-block
.icn
width: 24px
height: 24px
background-image: url($invite-url)
background-position: center
background-size: contain
background-repeat: no-repeat
float: left
margin-right: 16px
.desc
font-size: 18px
line-height: 24px
float: left
color: $color-smoke-medium
a
text-decoration: none
color: inherit
&:hover
.desc
a
color: $color-smoke-dark

.active-public-name
text-align: center
width: 100%
position: absolute
bottom: $card-padd + 5px
left: 0
.active-public-name-b
display: inline-block
.label
float: left
line-height: 30px
margin-right: 16px
font-size: 14px
font-weight: normal
color: $color-smoke-medium
.value
float: left
line-height: 30px
margin-right: 16px
font-size: 18px
font-weight: 600
color: $color-smoke-dark
.opt
line-height: 30px
float: left
button
color: $color-pr-blue
&:hover
background-color: $color-snow-light
color: $color-pr-blue

.list
width: 80%
margin: 60px auto 0
text-align: center
h3
font-size: 18px
color: $color-smoke-medium
font-weight: 600
margin-bottom: 30px
ul
list-style: none
margin-bottom: 24px
li
font-size: 18px
line-height: 36px
font-weight: normal
color: $color-smoke-dark
margin-bottom: 8px
cursor: pointer
&.active,
&:hover
background-color: $color-snow-light
color: $color-pr-blue
.opts
display: inline-block
margin-top: 24px
.opt
float: left
margin: 0 8px

.progress
text-align: center
padding-top: 24px
.progress-b
display: inline-block
.icn
width: 30px
height: 30px
float: left
.desc
font-size: 18px
font-weight: normal
float: left
margin-left: 16px
color: $color-smoke-medium
&.error
.icn
background-image: url($error-url)
background-position: center
background-size: contain
background-repeat: no-repeat
.desc
color: $color-error

.new-chat
margin-top: 60px
text-align: center
h3
font-size: 18px
color: $color-smoke-medium
font-weight: 600
margin-bottom: 30px
.new-chat-form
padding-top: 24px
.inpt
width: 70%
margin: 0 auto 24px
input
width: 100%
line-height: 30px
border: 0
border-bottom: 2px solid $color-smoke-light
font-size: 16px
outline: 0
&:focus
border-color: $color-pr-blue
.opts
display: inline-block
.opt
margin: 16px 0 0
button.primary-green
font-size: 18px
background-color: $color-green
color: #ffffff
line-height: 46px
padding: 0 36px
&:hover
background-color: $color-green-dark

.spinner
width: 100%
height: 100%
background-image: url($spinner-url)
background-position: center
background-size: contain
background-repeat: no-repeat
animation: rotator .5s infinite linear


@keyframes rotator
from
transform: rotate(0deg)
to
transform: rotate(360deg)

8 changes: 8 additions & 0 deletions safe_webrtc_example/app/sass/main.sass
@@ -0,0 +1,8 @@
$FontPathOpenSans: "../../node_modules/npm-font-open-sans/fonts"
@import '../../node_modules/npm-font-open-sans/open-sans.scss'
@import 'variables'
@import 'mixins'
@import 'button'
@import 'common'
@import 'components'
@import 'chat_room'
15 changes: 15 additions & 0 deletions safe_webrtc_example/app/sass/mixins.sass
@@ -0,0 +1,15 @@
=clearfix()
&:after
content: ""
display: block
clear: both
visibility: hidden

=btnDefault()
border: 0
outline: 0
background-color: transparent
cursor: pointer
text-transform: uppercase
&:disabled
cursor: not-allowed
32 changes: 32 additions & 0 deletions safe_webrtc_example/app/sass/variables.sass
@@ -0,0 +1,32 @@

$logo-lg: 120px
$logo-sm: 50px
$logo-xs: 30px

$color-pr-blue: #5593d7
$color-smoke-dark: #555555
$color-smoke-medium: #8B8B8B
$color-smoke-light: #c2c1c1
$color-snow-light: #fafafa
$color-green: #9ED167
$color-green-dark: #7AB639
$color-logo-desc: $color-pr-blue
$color-end-call: #F20808
$color-end-call-hover: #FD5454
$color-error: #FFBBBB

$body-bg: #fafafa

$logo-url: '../images/logo.png'
$error-url: '../images/error.svg'
$spinner-url: '../images/loader.svg'
$invite-url: '../images/notification.svg'
$connect-url: '../images/more.svg'
$end-call-url: '../images/call.svg'

$card-width: 650px
$card-height: 400px
$card-padd: 24px

$remote-width: 70%
$origin-width: 30%
36 changes: 36 additions & 0 deletions safe_webrtc_example/app/utils.js
@@ -0,0 +1,36 @@

export const stringifyConnInfo = (connInfo) => {
return JSON.stringify(connInfo);
};

export const parseConnInfo = (connInfo) => {
return JSON.parse(connInfo);
};

export const putLog = (msg, data) => {
if (!msg) {
return;
}
// console.log(`${(new Date()).toISOString()} :: ${msg} :: `, data);
};

export const bufToArr = (buf) => {
if (!(buf instanceof Uint8Array)) {
throw new Error('buf is not instance of Uint8Array');
}
return Array.from(buf);
};

export const arrToBuf = (arr) => {
if (!(arr instanceof Array)) {
throw new Error('arr is not instance of Array');
}
return new Uint8Array(arr);
};

export const uint8ToStr = (buf) => {
if (!(buf instanceof Uint8Array)) {
throw new Error('buf is not instance of Uint8Array');
}
return String.fromCharCode.apply(null, new Uint8Array(buf));
};
65 changes: 65 additions & 0 deletions safe_webrtc_example/package.json
@@ -0,0 +1,65 @@
{
"name": "safe_webrtc_example",
"productName": "SAFE WebRTC Example",
"version": "0.1.0",
"description": "Application to showcase webRTC secure signalling on SAFE Network",
"main": "app/main.js",
"repository": {
"type": "git",
"url": "git+https://github.com/maidsafe/safe_examples.git"
},
"author": {
"name": "MaidSafe",
"email": "dev@maidsafe.net",
"url": "https://maidsafe.net"
},
"bugs": {
"url": "https://github.com/maidsafe/safe_examples/issues"
},
"keywords": [
"MaidSafe",
"DemoApp",
"Safe webRTC",
"Safe webRTC example"
],
"homepage": "https://github.com/maidsafe/safe_examples#readme",
"license": "MIT",
"devDependencies": {
"babel-core": "^6.26.0",
"babel-loader": "^7.1.2",
"babel-plugin-transform-async-to-generator": "^6.24.1",
"babel-plugin-transform-decorators-legacy": "^1.3.4",
"babel-plugin-transform-object-rest-spread": "^6.26.0",
"babel-plugin-transform-runtime": "^6.23.0",
"babel-polyfill": "^6.26.0",
"babel-preset-es2015": "^6.24.1",
"babel-preset-react": "^6.24.1",
"babel-preset-stage-1": "^6.24.1",
"css-loader": "^0.28.7",
"extract-text-webpack-plugin": "^3.0.2",
"file-loader": "^1.1.5",
"html-webpack-plugin": "^2.30.1",
"node-sass": "^4.7.2",
"sass-loader": "^6.0.6",
"style-loader": "^0.19.0",
"url-loader": "^0.6.2",
"webpack": "^3.9.1",
"webpack-dev-server": "^2.9.5"
},
"dependencies": {
"classnames": "^2.2.5",
"mobx": "^3.3.2",
"mobx-react": "^4.3.5",
"mobx-react-router": "^4.0.1",
"npm-font-open-sans": "^1.1.0",
"prop-types": "^15.6.0",
"react": "^16.2.0",
"react-dom": "^16.2.0",
"react-route": "^1.0.3",
"react-router-dom": "^4.2.2"
},
"scripts": {
"start": "webpack-dev-server --hot --open --config webpack.dev.config.js --content-base ./app",
"build": "webpack --config webpack.prod.config.js"
}
}
101 changes: 101 additions & 0 deletions safe_webrtc_example/webpack.dev.config.js
@@ -0,0 +1,101 @@
const path = require('path');
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ExtractTextPlugin = require('extract-text-webpack-plugin');

module.exports = {
devtool: 'eval',
entry: [
'babel-polyfill',
'./main.js',
'./sass/main.sass'
],
context: path.resolve(__dirname, 'app'),
output: {
path: path.join(__dirname, 'dist'),
filename: 'bundle.js',
publicPath: '/static/'
},
plugins: [
new webpack.HotModuleReplacementPlugin(),
new ExtractTextPlugin({ filename: './styles/style.css', disable: false, allChunks: true }),
],
resolve: {
extensions: ['.js', '.jsx']
},
devServer: {
historyApiFallback: true,
},
module: {
rules: [
{
test: /\.js?$/,
use: ['babel-loader'],
include: path.join(__dirname, 'app')
},
{
test: /\.sass$/,
exclude: /node_modules/,
use: [
{ loader: "style-loader" },
{ loader: "css-loader" },
{ loader: 'sass-loader', query: { sourceMap: false } },
],
},
// SVG Font
{
test: /\.svg(\?v=\d+\.\d+\.\d+)?$/,
use: {
loader: 'url-loader',
options: {
limit: 10000,
mimetype: 'image/svg+xml',
}
}
},
// Common Image Formats
{
test: /\.(?:ico|gif|png|jpg|jpeg|webp)$/,
use: 'url-loader',
},
// WOFF Font
{
test: /\.woff(\?v=\d+\.\d+\.\d+)?$/,
use: {
loader: 'url-loader',
options: {
limit: 10000,
mimetype: 'application/font-woff',
}
},
},
// WOFF2 Font
{
test: /\.woff2(\?v=\d+\.\d+\.\d+)?$/,
use: {
loader: 'url-loader',
options: {
limit: 10000,
mimetype: 'application/font-woff',
}
}
},
// TTF Font
{
test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/,
use: {
loader: 'url-loader',
options: {
limit: 10000,
mimetype: 'application/octet-stream'
}
}
},
// EOT Font
{
test: /\.eot(\?v=\d+\.\d+\.\d+)?$/,
use: 'file-loader',
}
]
}
};
124 changes: 124 additions & 0 deletions safe_webrtc_example/webpack.prod.config.js
@@ -0,0 +1,124 @@
const { resolve, join } = require('path');
var webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ExtractTextPlugin = require('extract-text-webpack-plugin');

module.exports = {
devtool: 'cheap-module-source-map',
entry: [
'./main.js',
'./sass/main.sass',
],

context: resolve(__dirname, 'app'),

output: {
filename: 'safe-webrtc.js',
path: resolve(__dirname, 'dist'),
publicPath: '',
},
plugins: [
new webpack.optimize.ModuleConcatenationPlugin(),
new HtmlWebpackPlugin({
template: `${__dirname}/app/index.html`,
filename: 'index.html',
inject: 'body',
}),
new webpack.optimize.OccurrenceOrderPlugin(),
new webpack.LoaderOptionsPlugin({
minimize: true,
debug: false,
}),
new webpack.optimize.UglifyJsPlugin({
beautify: false,
sourceMap: false
}),
new webpack.NoEmitOnErrorsPlugin(),
new webpack.DefinePlugin({ 'process.env': { NODE_ENV: JSON.stringify('production') } }),
new ExtractTextPlugin({ filename: './styles/style.css', disable: false, allChunks: true }),
],
resolve: {
extensions: ['.js', '.jsx']
},
module: {
rules: [
{
test: /\.js?$/,
use: ['babel-loader'],
include: join(__dirname, 'app')
},
{
test: /\.sass$/,
exclude: /node_modules/,
use: [
{ loader: "style-loader" },
{ loader: "css-loader" },
{ loader: 'sass-loader', query: { sourceMap: false } },
],
},
// SVG Font
{
test: /\.svg(\?v=\d+\.\d+\.\d+)?$/,
use: {
loader: 'url-loader',
options: {
name: 'images/[name].[ext]',
limit: 10000,
mimetype: 'image/svg+xml',
}
}
},
// Common Image Formats
{
test: /\.(?:ico|gif|png|jpg|jpeg|webp)$/,
use: 'url-loader',
},
// WOFF Font
{
test: /\.woff(\?v=\d+\.\d+\.\d+)?$/,
use: {
loader: 'url-loader',
options: {
name: 'fonts/[name].[ext]',
limit: 10000,
mimetype: 'application/font-woff',
}
},
},
// WOFF2 Font
{
test: /\.woff2(\?v=\d+\.\d+\.\d+)?$/,
use: {
loader: 'url-loader',
options: {
name: 'fonts/[name].[ext]',
limit: 10000,
mimetype: 'application/font-woff',
}
}
},
// TTF Font
{
test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/,
use: {
loader: 'url-loader',
options: {
name: 'fonts/[name].[ext]',
limit: 10000,
mimetype: 'application/octet-stream'
}
}
},
// EOT Font
{
test: /\.eot(\?v=\d+\.\d+\.\d+)?$/,
use: {
loader: 'file-loader',
options: {
name: 'fonts/[name].[ext]',
}
},
}
]
}
};
5,305 changes: 5,305 additions & 0 deletions safe_webrtc_example/yarn.lock

Large diffs are not rendered by default.