Skip to content

Commit 211518c

Browse files
authored
[Closes #386] First pass telehealth integration (#388)
* Setting up Agora RTM integration * Agora token auth * Starting Agora integration * First pass working RTM * Refactor agora token auth, add hospitalTeamActivation to patient data * First pass, call handling * Switch to Agora generated int uids * First pass, Agora RTC calling * Avoiding hooks * Starting to design UI, RTM refactor * Call controls * More call interface * Show Answered status * More call handling * Add ability to call back on Ringdown * Fix tests * Expose AGORA env vars to test environment * Include hospitalTeamActivation status in RingdownCard rendering * Update active user on changes * Starting signalling refactor * More signalling refactor * Handle call declined * Use constants * More call handling tweaks * Remove debugging output * Properly catch/suppress error * Code cleanup
1 parent d8f9dca commit 211518c

52 files changed

Lines changed: 1305 additions & 125 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.circleci/config.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ jobs:
2828
- run:
2929
command: yarn workspace server sequelize db:seed:all
3030
- run:
31-
command: eval $(cat example.env | grep -v -e 'DATABASE_URL' -e 'SMTP_HOST' | sed 's/^/export /'); yarn run test
31+
command: eval $(cat example.env | grep -v -e 'AGORA_APP_CERTIFICATE' -e 'REACT_APP_AGORA_APP_ID' -e 'DATABASE_URL' -e 'SMTP_HOST' | sed 's/^/export /'); yarn run test
3232

3333
workflows:
3434
test:

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,5 @@ yarn-error.log*
3030
/test-results/
3131
/playwright-report/
3232
/playwright/.cache/
33+
34+
*.dump

client/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
"@testing-library/jest-dom": "^4.2.4",
99
"@testing-library/react": "^9.5.0",
1010
"@testing-library/user-event": "^7.2.1",
11+
"agora-rtc-react": "^2.5.0",
12+
"agora-rtc-sdk-ng": "^4.24.1",
13+
"agora-rtm-sdk": "^2.2.3-1",
1114
"axios": "^0.21.4",
1215
"classnames": "^2.3.1",
1316
"http-proxy-middleware": "^1.3.1",
@@ -28,7 +31,8 @@
2831
"shared": "file:../shared",
2932
"sortablejs": "^1.15.2",
3033
"use-sound": "^4.0.1",
31-
"uswds": "^2.13.3"
34+
"uswds": "^2.13.3",
35+
"uuid": "^13.0.0"
3236
},
3337
"scripts": {
3438
"start": "react-scripts --openssl-legacy-provider start",

client/public/img/user.png

2.83 KB
Loading

client/public/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222

2323
<script>
2424
window.env = {};
25+
window.env.REACT_APP_AGORA_APP_ID = '%REACT_APP_AGORA_APP_ID%';
2526
window.env.REACT_APP_PR_AUTH = '%REACT_APP_PR_AUTH%';
2627
window.env.REACT_APP_DISABLE_CODE_3 = '%REACT_APP_DISABLE_CODE_3%';
2728
window.env.REACT_APP_DISABLE_PILOT_HOSPITALS = '%REACT_APP_DISABLE_PILOT_HOSPITALS%';

client/src/Admin/Ringdowns/RingdownsTable.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useContext, useEffect, useState } from 'react';
1+
import { useContext, useEffect, useState } from 'react';
22

33
import ApiService from '../../ApiService';
44
import Context from '../../Context';

client/src/ApiService.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@ instance.interceptors.response.use(
2828
);
2929

3030
export default {
31+
agora: {
32+
getRtcToken(channelName) {
33+
return instance.get('/api/agora/rtc-token', { params: { channelName } });
34+
},
35+
getRtmToken(userId) {
36+
return instance.get('/api/agora/rtm-token', { params: { userId } });
37+
},
38+
},
3139
ambulances: {
3240
getIdentifiers(organizationId) {
3341
return instance.get('/api/ambulances/identifiers', { params: { organizationId } });

client/src/App.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import NewPassword from './Auth/NewPassword';
1414
import TwoFactor from './Auth/TwoFactor';
1515
import Invite from './Invites/Invite';
1616
import Events from './Events/Events';
17+
import Call from './Call/Call';
1718

1819
vh.init();
1920

@@ -40,6 +41,7 @@ function App() {
4041
<Route path="/er/*" element={<ER />} />
4142
<Route path="/admin/*" element={<Admin />} />
4243
<Route path="/events/*" element={<Events />} />
44+
<Route path="/call/*" element={<Call />} />
4345
{!user && (
4446
<>
4547
<Route path="/invites/:id" element={<Invite />} />

client/src/Call/Call.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { useEffect, useMemo, useState } from 'react';
2+
import { useSearchParams } from 'react-router-dom';
3+
import AgoraRTC from 'agora-rtc-sdk-ng';
4+
import { AgoraRTCProvider } from 'agora-rtc-react';
5+
import { v4 as uuid } from 'uuid';
6+
7+
import CallInterface from './CallInterface';
8+
9+
const callId = uuid();
10+
11+
export default function Call() {
12+
const [searchParams] = useSearchParams();
13+
const id = searchParams.get('id');
14+
const ringdownId = searchParams.get('ringdownId');
15+
const [call, setCall] = useState({});
16+
17+
useEffect(() => {
18+
let channel = new BroadcastChannel('callCoordination');
19+
channel.onmessage = (event) => {
20+
setCall((call) => {
21+
if (event.data.id === call.id || event.data.id === id || event.data.ringdown?.id === ringdownId) {
22+
return { ...call, ...event.data };
23+
}
24+
return call;
25+
});
26+
};
27+
if (id) {
28+
channel.postMessage({
29+
id,
30+
status: 'answered',
31+
answeredAt: new Date().toISOString(),
32+
});
33+
} else if (ringdownId) {
34+
channel.postMessage({
35+
callId,
36+
ringdownId,
37+
});
38+
}
39+
return () => channel.close();
40+
}, [id, ringdownId]);
41+
42+
const client = useMemo(() => AgoraRTC.createClient({ mode: 'rtc', codec: 'vp8' }), []);
43+
44+
return (
45+
<>
46+
<AgoraRTCProvider client={client}>{call && <CallInterface call={call} setCall={setCall} />}</AgoraRTCProvider>
47+
</>
48+
);
49+
}

client/src/Call/CallInterface.js

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { useEffect, useState } from 'react';
2+
import AgoraRTC from 'agora-rtc-sdk-ng';
3+
import { useJoin, usePublish, useRemoteUsers, useRTCClient, LocalUser, RemoteUser } from 'agora-rtc-react';
4+
5+
import { CallStatus } from 'shared/constants';
6+
7+
import Alert from '../Components/Alert';
8+
import ApiService from '../ApiService';
9+
import MicrophoneButton from './MicrophoneButton';
10+
import CameraButton from './CameraButton';
11+
import Ringdown from '../Models/Ringdown';
12+
import RingdownDetails from '../Components/RingdownDetails';
13+
import Spinner from '../Components/Spinner';
14+
15+
import './CallInterface.scss';
16+
import EndCallButton from './EndCallButton';
17+
18+
export default function CallInterface({ call, setCall }) {
19+
const channel = call?.userId;
20+
const client = useRTCClient();
21+
const { isConnected } = useJoin(async () => {
22+
const response = await ApiService.agora.getRtcToken(channel);
23+
const { token } = response.data;
24+
return {
25+
appid: window.env.REACT_APP_AGORA_APP_ID,
26+
channel,
27+
token,
28+
};
29+
}, !!channel);
30+
31+
const [isMicOn, setMicOn] = useState(true);
32+
const [isCameraOn, setCameraOn] = useState(false);
33+
34+
const [localMicrophoneTrack, setLocalMicrophoneTrack] = useState();
35+
useEffect(() => {
36+
if (isConnected) {
37+
if (!localMicrophoneTrack && isMicOn) {
38+
AgoraRTC.createMicrophoneAudioTrack()
39+
.then((result) => {
40+
setLocalMicrophoneTrack(result);
41+
})
42+
.catch((err) => {
43+
console.error(err);
44+
});
45+
}
46+
}
47+
}, [client, isConnected, localMicrophoneTrack, isMicOn]);
48+
const [localCameraTrack, setLocalCameraTrack] = useState();
49+
useEffect(() => {
50+
if (isConnected) {
51+
if (!localCameraTrack && isCameraOn) {
52+
AgoraRTC.createCameraVideoTrack()
53+
.then((result) => {
54+
setLocalCameraTrack(result);
55+
})
56+
.catch((err) => {
57+
console.error(err);
58+
});
59+
}
60+
}
61+
}, [client, isConnected, localCameraTrack, isCameraOn]);
62+
usePublish([localMicrophoneTrack, localCameraTrack]);
63+
64+
const remoteUsers = useRemoteUsers();
65+
const [activeUser, setActiveUser] = useState();
66+
useEffect(() => {
67+
if (remoteUsers?.length > 0) {
68+
setActiveUser((prevActiveUser) => {
69+
if (!prevActiveUser && call?.status === CallStatus.RINGING) {
70+
setCall({ ...call, status: CallStatus.ANSWERED, answeredAt: new Date().toISOString() });
71+
}
72+
return remoteUsers[0];
73+
});
74+
} else {
75+
setActiveUser((prevActiveUser) => {
76+
if (prevActiveUser) {
77+
setEnded(true);
78+
}
79+
});
80+
}
81+
}, [remoteUsers, call, setCall]);
82+
83+
const [isShowingConfirmEnd, setShowingConfirmEnd] = useState(false);
84+
const [isEnded, setEnded] = useState(false);
85+
86+
return (
87+
<div className="grid-row call-interface">
88+
<div className="tablet:grid-col-9">
89+
<div className="call-interface-content">
90+
<div className="call-interface-content__video">
91+
{activeUser && <RemoteUser user={activeUser} videoPlayerConfig={{ fit: 'contain' }} cover="/img/user.png" />}
92+
{isConnected && (
93+
<div className="call-interface-content__local-user">
94+
<LocalUser
95+
audioTrack={localMicrophoneTrack}
96+
cameraOn={isCameraOn}
97+
cover="/img/user.png"
98+
micOn={isMicOn}
99+
playAudio={false}
100+
playVideo={isCameraOn}
101+
videoTrack={localCameraTrack}
102+
/>
103+
</div>
104+
)}
105+
</div>
106+
{(!isConnected || !CallStatus.is(call?.status, CallStatus.ANSWERED)) && (
107+
<div className="call-interface-content__status">
108+
<Spinner />
109+
{!isConnected && 'Connecting...'}
110+
{isConnected && call?.status === CallStatus.RINGING && 'Ringing...'}
111+
</div>
112+
)}
113+
<div className="call-interface-content__controls">
114+
<MicrophoneButton disabled={!isConnected} isMicOn={isMicOn} onClick={() => setMicOn((prev) => !prev)} />
115+
<CameraButton disabled={!isConnected} isCameraOn={isCameraOn} onClick={() => setCameraOn((prev) => !prev)} />
116+
<EndCallButton disabled={!isConnected} onClick={() => setShowingConfirmEnd(true)} />
117+
</div>
118+
</div>
119+
</div>
120+
<div className="tablet:grid-col-3 call-interface-sidebar">
121+
<div className="usa-accordion">
122+
<div className="usa-accordion__content">
123+
<RingdownDetails ringdown={new Ringdown(call.ringdown)} />
124+
</div>
125+
</div>
126+
</div>
127+
{call?.status === CallStatus.DECLINED && (
128+
<Alert
129+
type="error"
130+
title="Call Declined"
131+
cancel="No"
132+
primary="Yes"
133+
onCancel={() => window.close()}
134+
onPrimary={() => window.location.reload()}
135+
>
136+
The call was declined on the receiving end. Ring again?
137+
</Alert>
138+
)}
139+
{isShowingConfirmEnd && (
140+
<Alert
141+
type="info"
142+
title="End Call?"
143+
cancel="No"
144+
primary="Yes"
145+
onCancel={() => setShowingConfirmEnd(false)}
146+
onPrimary={() => window.close()}
147+
>
148+
Are you sure you want to end the call?
149+
</Alert>
150+
)}
151+
{isEnded && (
152+
<Alert type="info" title="Call Ended" primary="OK" onPrimary={() => window.close()}>
153+
The call was ended by the other side.
154+
</Alert>
155+
)}
156+
</div>
157+
);
158+
}

0 commit comments

Comments
 (0)