feat: Connection status (#119)

* feat: implement ConnectionTest
* feat: display connection results
* feat: keep network status up to date
This commit is contained in:
Jeremy Kahn 2023-03-27 21:51:33 -05:00 committed by GitHub
parent 4cf75b15b0
commit 3977a82224
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 256 additions and 2 deletions

34
package-lock.json generated
View File

@ -41,6 +41,7 @@
"readable-web-to-node-stream": "^3.0.2", "readable-web-to-node-stream": "^3.0.2",
"remark-gfm": "^3.0.1", "remark-gfm": "^3.0.1",
"sass": "^1.54.3", "sass": "^1.54.3",
"sdp": "^3.2.0",
"secure-file-transfer": "^0.0.7", "secure-file-transfer": "^0.0.7",
"streamsaver": "^2.0.6", "streamsaver": "^2.0.6",
"trystero": "^0.12.0", "trystero": "^0.12.0",
@ -48,7 +49,8 @@
"typeface-roboto": "^1.1.13", "typeface-roboto": "^1.1.13",
"typescript": "^4.7.4", "typescript": "^4.7.4",
"uuid": "^8.3.2", "uuid": "^8.3.2",
"web-vitals": "^2.1.4" "web-vitals": "^2.1.4",
"webrtc-adapter": "^8.2.2"
}, },
"devDependencies": { "devDependencies": {
"@types/react-syntax-highlighter": "^15.5.5", "@types/react-syntax-highlighter": "^15.5.5",
@ -23323,6 +23325,11 @@
"url": "https://opencollective.com/webpack" "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": { "node_modules/secp256k1": {
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/secp256k1/-/secp256k1-4.0.3.tgz", "resolved": "https://registry.npmjs.org/secp256k1/-/secp256k1-4.0.3.tgz",
@ -26169,6 +26176,18 @@
"node": ">=4.0" "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": { "node_modules/websocket-driver": {
"version": "0.7.4", "version": "0.7.4",
"resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz",
@ -43868,6 +43887,11 @@
"ajv-keywords": "^3.5.2" "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": { "secp256k1": {
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/secp256k1/-/secp256k1-4.0.3.tgz", "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", "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz",
"integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==" "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": { "websocket-driver": {
"version": "0.7.4", "version": "0.7.4",
"resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz",

View File

@ -37,6 +37,7 @@
"readable-web-to-node-stream": "^3.0.2", "readable-web-to-node-stream": "^3.0.2",
"remark-gfm": "^3.0.1", "remark-gfm": "^3.0.1",
"sass": "^1.54.3", "sass": "^1.54.3",
"sdp": "^3.2.0",
"secure-file-transfer": "^0.0.7", "secure-file-transfer": "^0.0.7",
"streamsaver": "^2.0.6", "streamsaver": "^2.0.6",
"trystero": "^0.12.0", "trystero": "^0.12.0",
@ -44,7 +45,8 @@
"typeface-roboto": "^1.1.13", "typeface-roboto": "^1.1.13",
"typescript": "^4.7.4", "typescript": "^4.7.4",
"uuid": "^8.3.2", "uuid": "^8.3.2",
"web-vitals": "^2.1.4" "web-vitals": "^2.1.4",
"webrtc-adapter": "^8.2.2"
}, },
"scripts": { "scripts": {
"start": "react-scripts start", "start": "react-scripts start",

View File

@ -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 (
<Typography variant="subtitle2">
<Typography
component="span"
sx={theme => ({ color: theme.palette.success.main })}
>
<Circle sx={{ fontSize: 'small' }} />
</Typography>{' '}
Full network connection
</Typography>
)
} else if (hasHost) {
return (
<Typography variant="subtitle2">
<Typography
component="span"
sx={theme => ({ color: theme.palette.warning.main })}
>
<Circle sx={{ fontSize: 'small' }} />
</Typography>{' '}
Partial network connection
</Typography>
)
} else {
return (
<Typography variant="subtitle2">
<Typography
component="span"
sx={theme => ({ color: theme.palette.error.main })}
>
<Circle sx={{ fontSize: 'small' }} />
</Typography>{' '}
No network connection
</Typography>
)
}
}

View File

@ -15,6 +15,8 @@ import { AudioState, Peer } from 'models/chat'
import { PeerConnectionType } from 'services/PeerRoom/PeerRoom' import { PeerConnectionType } from 'services/PeerRoom/PeerRoom'
import { PeerListItem } from './PeerListItem' import { PeerListItem } from './PeerListItem'
import { ConnectionTestResults as IConnectionTestResults } from './useConnectionTest'
import { ConnectionTestResults } from './ConnectionTestResults'
export const peerListWidth = 300 export const peerListWidth = 300
@ -26,6 +28,7 @@ export interface PeerListProps extends PropsWithChildren {
peerConnectionTypes: Record<string, PeerConnectionType> peerConnectionTypes: Record<string, PeerConnectionType>
audioState: AudioState audioState: AudioState
peerAudios: Record<string, HTMLAudioElement> peerAudios: Record<string, HTMLAudioElement>
connectionTestResults: IConnectionTestResults
} }
export const PeerList = ({ export const PeerList = ({
@ -36,6 +39,7 @@ export const PeerList = ({
peerConnectionTypes, peerConnectionTypes,
audioState, audioState,
peerAudios, peerAudios,
connectionTestResults,
}: PeerListProps) => { }: PeerListProps) => {
return ( return (
<MuiDrawer <MuiDrawer
@ -59,9 +63,15 @@ export const PeerList = ({
<IconButton onClick={onPeerListClose} aria-label="Close peer list"> <IconButton onClick={onPeerListClose} aria-label="Close peer list">
<ChevronRightIcon /> <ChevronRightIcon />
</IconButton> </IconButton>
<ListItem>
<ConnectionTestResults
connectionTestResults={connectionTestResults}
/>
</ListItem>
</PeerListHeader> </PeerListHeader>
<Divider /> <Divider />
<List> <List>
<Divider />
<ListItem divider={true}> <ListItem divider={true}>
{audioState === AudioState.PLAYING && ( {audioState === AudioState.PLAYING && (
<ListItemIcon> <ListItemIcon>

View File

@ -29,6 +29,7 @@ import { RouteContent } from './RouteContent'
import { PeerList } from './PeerList' import { PeerList } from './PeerList'
import { QRCodeDialog } from './QRCodeDialog' import { QRCodeDialog } from './QRCodeDialog'
import { RoomShareDialog } from './RoomShareDialog' import { RoomShareDialog } from './RoomShareDialog'
import { useConnectionTest } from './useConnectionTest'
export interface ShellProps extends PropsWithChildren { export interface ShellProps extends PropsWithChildren {
userPeerId: string userPeerId: string
@ -90,6 +91,8 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => {
setIsAlertShowing(true) setIsAlertShowing(true)
}, []) }, [])
const { connectionTestResults } = useConnectionTest()
const shellContextValue = useMemo( const shellContextValue = useMemo(
() => ({ () => ({
tabHasFocus, tabHasFocus,
@ -118,6 +121,7 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => {
setPeerAudios, setPeerAudios,
customUsername, customUsername,
setCustomUsername, setCustomUsername,
connectionTestResults,
}), }),
[ [
isPeerListOpen, isPeerListOpen,
@ -143,6 +147,7 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => {
setPeerAudios, setPeerAudios,
customUsername, customUsername,
setCustomUsername, setCustomUsername,
connectionTestResults,
] ]
) )
@ -326,6 +331,7 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => {
peerConnectionTypes={peerConnectionTypes} peerConnectionTypes={peerConnectionTypes}
audioState={audioState} audioState={audioState}
peerAudios={peerAudios} peerAudios={peerAudios}
connectionTestResults={connectionTestResults}
/> />
<QRCodeDialog <QRCodeDialog
isOpen={isQRCodeDialogOpen} isOpen={isQRCodeDialogOpen}

View File

@ -0,0 +1,74 @@
import { useEffect, useState } from 'react'
import { sleep } from 'utils'
import {
ConnectionTest,
ConnectionTestEvent,
ConnectionTestEvents,
} from 'services/ConnectionTest/ConnectionTest'
export interface ConnectionTestResults {
hasHost: boolean
hasRelay: boolean
}
export const useConnectionTest = () => {
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 },
}
}

View File

@ -3,6 +3,7 @@ import { createContext, Dispatch, SetStateAction } from 'react'
import { AlertOptions } from 'models/shell' import { AlertOptions } from 'models/shell'
import { AudioState, ScreenShareState, VideoState, Peer } from 'models/chat' import { AudioState, ScreenShareState, VideoState, Peer } from 'models/chat'
import { PeerConnectionType } from 'services/PeerRoom/PeerRoom' import { PeerConnectionType } from 'services/PeerRoom/PeerRoom'
import { ConnectionTestResults } from 'components/Shell/useConnectionTest'
interface ShellContextProps { interface ShellContextProps {
tabHasFocus: boolean tabHasFocus: boolean
@ -32,6 +33,7 @@ interface ShellContextProps {
setPeerAudios: Dispatch<SetStateAction<Record<string, HTMLAudioElement>>> setPeerAudios: Dispatch<SetStateAction<Record<string, HTMLAudioElement>>>
customUsername: string customUsername: string
setCustomUsername: Dispatch<SetStateAction<string>> setCustomUsername: Dispatch<SetStateAction<string>>
connectionTestResults: ConnectionTestResults
} }
export const ShellContext = createContext<ShellContextProps>({ export const ShellContext = createContext<ShellContextProps>({
@ -60,4 +62,5 @@ export const ShellContext = createContext<ShellContextProps>({
setPeerAudios: () => {}, setPeerAudios: () => {},
customUsername: '', customUsername: '',
setCustomUsername: () => {}, setCustomUsername: () => {},
connectionTestResults: { hasHost: false, hasRelay: false },
}) })

View File

@ -1,3 +1,4 @@
import 'webrtc-adapter'
import { Buffer } from 'buffer' import { Buffer } from 'buffer'
// @ts-ignore // @ts-ignore

View File

@ -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<ConnectionTest>
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()