From 3977a82224a62ef70a1bf0b1d264441346264fc9 Mon Sep 17 00:00:00 2001 From: Jeremy Kahn Date: Mon, 27 Mar 2023 21:51:33 -0500 Subject: [PATCH] feat: Connection status (#119) * feat: implement ConnectionTest * feat: display connection results * feat: keep network status up to date --- package-lock.json | 34 +++++++- package.json | 4 +- .../Shell/ConnectionTestResults.tsx | 49 ++++++++++++ src/components/Shell/PeerList.tsx | 10 +++ src/components/Shell/Shell.tsx | 6 ++ src/components/Shell/useConnectionTest.ts | 74 ++++++++++++++++++ src/contexts/ShellContext.ts | 3 + src/polyfills.ts | 1 + src/services/ConnectionTest/ConnectionTest.ts | 77 +++++++++++++++++++ 9 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 src/components/Shell/ConnectionTestResults.tsx create mode 100644 src/components/Shell/useConnectionTest.ts create mode 100644 src/services/ConnectionTest/ConnectionTest.ts diff --git a/package-lock.json b/package-lock.json index e20cef2..006d1ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,7 @@ "readable-web-to-node-stream": "^3.0.2", "remark-gfm": "^3.0.1", "sass": "^1.54.3", + "sdp": "^3.2.0", "secure-file-transfer": "^0.0.7", "streamsaver": "^2.0.6", "trystero": "^0.12.0", @@ -48,7 +49,8 @@ "typeface-roboto": "^1.1.13", "typescript": "^4.7.4", "uuid": "^8.3.2", - "web-vitals": "^2.1.4" + "web-vitals": "^2.1.4", + "webrtc-adapter": "^8.2.2" }, "devDependencies": { "@types/react-syntax-highlighter": "^15.5.5", @@ -23323,6 +23325,11 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/sdp": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.0.tgz", + "integrity": "sha512-d7wDPgDV3DDiqulJjKiV2865wKsJ34YI+NDREbm+FySq6WuKOikwyNQcm+doLAZ1O6ltdO0SeKle2xMpN3Brgw==" + }, "node_modules/secp256k1": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/secp256k1/-/secp256k1-4.0.3.tgz", @@ -26169,6 +26176,18 @@ "node": ">=4.0" } }, + "node_modules/webrtc-adapter": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-8.2.2.tgz", + "integrity": "sha512-jQWwqiAEAFZamWliJo0Q+dIC6ZMJ8BgCFvW/oXWVFby1Nw14dOUfPwZ3lVe4nafDXdTyCUT7xfLt5xXiioXUCQ==", + "dependencies": { + "sdp": "^3.2.0" + }, + "engines": { + "node": ">=6.0.0", + "npm": ">=3.10.0" + } + }, "node_modules/websocket-driver": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", @@ -43868,6 +43887,11 @@ "ajv-keywords": "^3.5.2" } }, + "sdp": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.0.tgz", + "integrity": "sha512-d7wDPgDV3DDiqulJjKiV2865wKsJ34YI+NDREbm+FySq6WuKOikwyNQcm+doLAZ1O6ltdO0SeKle2xMpN3Brgw==" + }, "secp256k1": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/secp256k1/-/secp256k1-4.0.3.tgz", @@ -45955,6 +45979,14 @@ "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==" }, + "webrtc-adapter": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-8.2.2.tgz", + "integrity": "sha512-jQWwqiAEAFZamWliJo0Q+dIC6ZMJ8BgCFvW/oXWVFby1Nw14dOUfPwZ3lVe4nafDXdTyCUT7xfLt5xXiioXUCQ==", + "requires": { + "sdp": "^3.2.0" + } + }, "websocket-driver": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", diff --git a/package.json b/package.json index 565f4e8..b343d10 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "readable-web-to-node-stream": "^3.0.2", "remark-gfm": "^3.0.1", "sass": "^1.54.3", + "sdp": "^3.2.0", "secure-file-transfer": "^0.0.7", "streamsaver": "^2.0.6", "trystero": "^0.12.0", @@ -44,7 +45,8 @@ "typeface-roboto": "^1.1.13", "typescript": "^4.7.4", "uuid": "^8.3.2", - "web-vitals": "^2.1.4" + "web-vitals": "^2.1.4", + "webrtc-adapter": "^8.2.2" }, "scripts": { "start": "react-scripts start", diff --git a/src/components/Shell/ConnectionTestResults.tsx b/src/components/Shell/ConnectionTestResults.tsx new file mode 100644 index 0000000..2df5a5d --- /dev/null +++ b/src/components/Shell/ConnectionTestResults.tsx @@ -0,0 +1,49 @@ +import { Typography } from '@mui/material' +import Circle from '@mui/icons-material/FiberManualRecord' + +import { ConnectionTestResults as IConnectionTestResults } from './useConnectionTest' + +interface ConnectionTestResultsProps { + connectionTestResults: IConnectionTestResults +} +export const ConnectionTestResults = ({ + connectionTestResults: { hasHost, hasRelay }, +}: ConnectionTestResultsProps) => { + if (hasHost && hasRelay) { + return ( + + ({ color: theme.palette.success.main })} + > + + {' '} + Full network connection + + ) + } else if (hasHost) { + return ( + + ({ color: theme.palette.warning.main })} + > + + {' '} + Partial network connection + + ) + } else { + return ( + + ({ color: theme.palette.error.main })} + > + + {' '} + No network connection + + ) + } +} diff --git a/src/components/Shell/PeerList.tsx b/src/components/Shell/PeerList.tsx index 4b2b951..16494d9 100644 --- a/src/components/Shell/PeerList.tsx +++ b/src/components/Shell/PeerList.tsx @@ -15,6 +15,8 @@ import { AudioState, Peer } from 'models/chat' import { PeerConnectionType } from 'services/PeerRoom/PeerRoom' import { PeerListItem } from './PeerListItem' +import { ConnectionTestResults as IConnectionTestResults } from './useConnectionTest' +import { ConnectionTestResults } from './ConnectionTestResults' export const peerListWidth = 300 @@ -26,6 +28,7 @@ export interface PeerListProps extends PropsWithChildren { peerConnectionTypes: Record audioState: AudioState peerAudios: Record + connectionTestResults: IConnectionTestResults } export const PeerList = ({ @@ -36,6 +39,7 @@ export const PeerList = ({ peerConnectionTypes, audioState, peerAudios, + connectionTestResults, }: PeerListProps) => { return ( + + + + {audioState === AudioState.PLAYING && ( diff --git a/src/components/Shell/Shell.tsx b/src/components/Shell/Shell.tsx index 459d75d..369c909 100644 --- a/src/components/Shell/Shell.tsx +++ b/src/components/Shell/Shell.tsx @@ -29,6 +29,7 @@ import { RouteContent } from './RouteContent' import { PeerList } from './PeerList' import { QRCodeDialog } from './QRCodeDialog' import { RoomShareDialog } from './RoomShareDialog' +import { useConnectionTest } from './useConnectionTest' export interface ShellProps extends PropsWithChildren { userPeerId: string @@ -90,6 +91,8 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => { setIsAlertShowing(true) }, []) + const { connectionTestResults } = useConnectionTest() + const shellContextValue = useMemo( () => ({ tabHasFocus, @@ -118,6 +121,7 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => { setPeerAudios, customUsername, setCustomUsername, + connectionTestResults, }), [ isPeerListOpen, @@ -143,6 +147,7 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => { setPeerAudios, customUsername, setCustomUsername, + connectionTestResults, ] ) @@ -326,6 +331,7 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => { peerConnectionTypes={peerConnectionTypes} audioState={audioState} peerAudios={peerAudios} + connectionTestResults={connectionTestResults} /> { + const [hasHost, setHasHost] = useState(false) + const [hasRelay, setHasRelay] = useState(false) + + useEffect(() => { + const checkConnection = async () => { + const connectionTest = new ConnectionTest() + + const handleHasHostChanged = ((event: ConnectionTestEvent) => { + if (event.detail.hasHost) { + setHasHost(true) + + connectionTest.removeEventListener( + ConnectionTestEvents.HAS_HOST_CHANGED, + handleHasHostChanged + ) + } + }) as EventListener + + connectionTest.addEventListener( + ConnectionTestEvents.HAS_HOST_CHANGED, + handleHasHostChanged + ) + + const handleHasRelayChanged = ((event: ConnectionTestEvent) => { + if (event.detail.hasRelay) { + setHasRelay(true) + + connectionTest.removeEventListener( + ConnectionTestEvents.HAS_RELAY_CHANGED, + handleHasRelayChanged + ) + } + }) as EventListener + + connectionTest.addEventListener( + ConnectionTestEvents.HAS_RELAY_CHANGED, + handleHasRelayChanged + ) + + try { + await connectionTest.runRtcPeerConnectionTest() + } catch (e) { + setHasHost(false) + setHasRelay(false) + console.error(e) + } + } + + ;(async () => { + while (true) { + await checkConnection() + await sleep(20 * 1000) + } + })() + }, []) + + return { + connectionTestResults: { hasHost, hasRelay }, + } +} diff --git a/src/contexts/ShellContext.ts b/src/contexts/ShellContext.ts index 5c2393d..cc789fb 100644 --- a/src/contexts/ShellContext.ts +++ b/src/contexts/ShellContext.ts @@ -3,6 +3,7 @@ import { createContext, Dispatch, SetStateAction } from 'react' import { AlertOptions } from 'models/shell' import { AudioState, ScreenShareState, VideoState, Peer } from 'models/chat' import { PeerConnectionType } from 'services/PeerRoom/PeerRoom' +import { ConnectionTestResults } from 'components/Shell/useConnectionTest' interface ShellContextProps { tabHasFocus: boolean @@ -32,6 +33,7 @@ interface ShellContextProps { setPeerAudios: Dispatch>> customUsername: string setCustomUsername: Dispatch> + connectionTestResults: ConnectionTestResults } export const ShellContext = createContext({ @@ -60,4 +62,5 @@ export const ShellContext = createContext({ setPeerAudios: () => {}, customUsername: '', setCustomUsername: () => {}, + connectionTestResults: { hasHost: false, hasRelay: false }, }) diff --git a/src/polyfills.ts b/src/polyfills.ts index 80b374e..fdfa270 100644 --- a/src/polyfills.ts +++ b/src/polyfills.ts @@ -1,3 +1,4 @@ +import 'webrtc-adapter' import { Buffer } from 'buffer' // @ts-ignore diff --git a/src/services/ConnectionTest/ConnectionTest.ts b/src/services/ConnectionTest/ConnectionTest.ts new file mode 100644 index 0000000..107b843 --- /dev/null +++ b/src/services/ConnectionTest/ConnectionTest.ts @@ -0,0 +1,77 @@ +import { rtcConfig } from 'config/rtcConfig' +import { parseCandidate } from 'sdp' + +export enum ConnectionTestEvents { + CONNECTION_TEST_RESULTS_UPDATED = 'CONNECTION_TEST_RESULTS_UPDATED', + HAS_HOST_CHANGED = 'HAS_HOST_CHANGED', + HAS_RELAY_CHANGED = 'HAS_RELAY_CHANGED', +} + +export type ConnectionTestEvent = CustomEvent + +export class ConnectionTest extends EventTarget { + hasHost = false + hasRelay = false + hasPeerReflexive = false + hasServerReflexive = false + + async runRtcPeerConnectionTest() { + if (typeof RTCPeerConnection === 'undefined') return + + const { iceServers } = rtcConfig + + const rtcPeerConnection = new RTCPeerConnection({ + iceServers, + }) + + rtcPeerConnection.addEventListener('icecandidate', event => { + if (event.candidate?.candidate.length) { + const parsedCandidate = parseCandidate(event.candidate.candidate) + let eventType: ConnectionTestEvents | undefined + + switch (parsedCandidate.type) { + case 'host': + this.hasHost = true + eventType = ConnectionTestEvents.HAS_HOST_CHANGED + break + + case 'relay': + this.hasRelay = true + eventType = ConnectionTestEvents.HAS_RELAY_CHANGED + break + + case 'prflx': + this.hasPeerReflexive = true + break + + case 'srflx': + this.hasServerReflexive = true + break + } + + if (typeof eventType !== 'undefined') { + this.dispatchEvent( + new CustomEvent(eventType, { + detail: this, + }) + ) + } + + this.dispatchEvent( + new Event(ConnectionTestEvents.CONNECTION_TEST_RESULTS_UPDATED) + ) + } + }) + + // Kick off the connection test + try { + const rtcSessionDescription = await rtcPeerConnection.createOffer({ + offerToReceiveAudio: true, + }) + + rtcPeerConnection.setLocalDescription(rtcSessionDescription) + } catch (e) {} + } +} + +export const connectionTest = new ConnectionTest()