* feat: [#21] stand up file sharing controls UI * feat: [#21] implement basic file transfer * feat: [#21] save transferred file * feat: [#21] transfer file via WebTorrent * fix: use external streamsaver assets * feat: [#21] initiate download by receiver click * fix: enable re-downloading of shared files * feat: [#21] implement sharing of multiple files * chore: enable offline development * feat: cache torrents in IndexedDB * feat: show alert when download is aborted * feat: [#21] clean up torrent data when principal offerer rescinds it * feat: clean up cached torrents on page unload * feat: show file transfer progress * fix: download files sequentially * feat: clean up file transfers when leaving the room * feat: clean up broken downloads upon leaving the page * fix: allow download animation to complete * feat: show tooltip for download button * feat: make file transfers work in browser private modes * feat: disable file share controls while creating offer
This commit is contained in:
parent
4e29bfbd24
commit
f006e76e80
3013
package-lock.json
generated
3013
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@ -16,9 +16,12 @@
|
|||||||
"@types/node": "^18.6.5",
|
"@types/node": "^18.6.5",
|
||||||
"@types/react": "^18.0.17",
|
"@types/react": "^18.0.17",
|
||||||
"@types/react-dom": "^18.0.6",
|
"@types/react-dom": "^18.0.6",
|
||||||
|
"buffer": "^6.0.3",
|
||||||
"classnames": "^2.3.1",
|
"classnames": "^2.3.1",
|
||||||
|
"detectincognitojs": "^1.1.2",
|
||||||
"fast-memoize": "^2.5.2",
|
"fast-memoize": "^2.5.2",
|
||||||
"fun-animal-names": "^0.1.1",
|
"fun-animal-names": "^0.1.1",
|
||||||
|
"idb-chunk-store": "^1.0.1",
|
||||||
"localforage": "^1.10.0",
|
"localforage": "^1.10.0",
|
||||||
"mui-markdown": "^0.5.5",
|
"mui-markdown": "^0.5.5",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
@ -30,17 +33,20 @@
|
|||||||
"react-syntax-highlighter": "^15.5.0",
|
"react-syntax-highlighter": "^15.5.0",
|
||||||
"remark-gfm": "^3.0.1",
|
"remark-gfm": "^3.0.1",
|
||||||
"sass": "^1.54.3",
|
"sass": "^1.54.3",
|
||||||
|
"streamsaver": "^2.0.6",
|
||||||
"trystero": "github:jeremyckahn/trystero#bugfix/stream-metadata-type",
|
"trystero": "github:jeremyckahn/trystero#bugfix/stream-metadata-type",
|
||||||
"typeface-public-sans": "^1.1.13",
|
"typeface-public-sans": "^1.1.13",
|
||||||
"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",
|
||||||
|
"webtorrent": "^1.9.3"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "react-scripts start",
|
"start": "react-scripts start",
|
||||||
"start:tracker": "bittorrent-tracker",
|
"start:tracker": "bittorrent-tracker",
|
||||||
"dev": "mprocs \"npx cross-env REACT_APP_TRACKER_URL=\"ws://localhost:8000\" npm run start\" \"npm run start:tracker\"",
|
"start:streamsaver": "serve -p 3015 node_modules/streamsaver",
|
||||||
|
"dev": "mprocs \"npx cross-env REACT_APP_TRACKER_URL=\"ws://localhost:8000\" REACT_APP_STREAMSAVER_URL=\"http://localhost:3015/mitm.html\" npm run start\" \"npm run start:tracker\" \"npm run start:streamsaver\"",
|
||||||
"build": "react-scripts build",
|
"build": "react-scripts build",
|
||||||
"test": "react-scripts test",
|
"test": "react-scripts test",
|
||||||
"prepare": "husky install",
|
"prepare": "husky install",
|
||||||
@ -71,7 +77,9 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react-syntax-highlighter": "^15.5.5",
|
"@types/react-syntax-highlighter": "^15.5.5",
|
||||||
|
"@types/streamsaver": "^2.0.1",
|
||||||
"@types/uuid": "^8.3.4",
|
"@types/uuid": "^8.3.4",
|
||||||
|
"@types/webtorrent": "^0.109.3",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.33.0",
|
"@typescript-eslint/eslint-plugin": "^5.33.0",
|
||||||
"@typescript-eslint/parser": "^5.33.0",
|
"@typescript-eslint/parser": "^5.33.0",
|
||||||
"autoprefixer": "^10.4.8",
|
"autoprefixer": "^10.4.8",
|
||||||
@ -88,6 +96,7 @@
|
|||||||
"postcss": "^8.4.16",
|
"postcss": "^8.4.16",
|
||||||
"prettier": "^2.7.1",
|
"prettier": "^2.7.1",
|
||||||
"pretty-quick": "^3.1.3",
|
"pretty-quick": "^3.1.3",
|
||||||
|
"serve": "^14.1.2",
|
||||||
"tailwindcss": "^3.1.8"
|
"tailwindcss": "^3.1.8"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
|
@ -16,6 +16,7 @@ import { useRoom } from './useRoom'
|
|||||||
import { RoomAudioControls } from './RoomAudioControls'
|
import { RoomAudioControls } from './RoomAudioControls'
|
||||||
import { RoomVideoControls } from './RoomVideoControls'
|
import { RoomVideoControls } from './RoomVideoControls'
|
||||||
import { RoomScreenShareControls } from './RoomScreenShareControls'
|
import { RoomScreenShareControls } from './RoomScreenShareControls'
|
||||||
|
import { RoomFileUploadControls } from './RoomFileUploadControls'
|
||||||
import { RoomVideoDisplay } from './RoomVideoDisplay'
|
import { RoomVideoDisplay } from './RoomVideoDisplay'
|
||||||
|
|
||||||
export interface RoomProps {
|
export interface RoomProps {
|
||||||
@ -95,6 +96,7 @@ export function Room({
|
|||||||
<RoomAudioControls peerRoom={peerRoom} />
|
<RoomAudioControls peerRoom={peerRoom} />
|
||||||
<RoomVideoControls peerRoom={peerRoom} />
|
<RoomVideoControls peerRoom={peerRoom} />
|
||||||
<RoomScreenShareControls peerRoom={peerRoom} />
|
<RoomScreenShareControls peerRoom={peerRoom} />
|
||||||
|
<RoomFileUploadControls peerRoom={peerRoom} />
|
||||||
</Box>
|
</Box>
|
||||||
</AccordionDetails>
|
</AccordionDetails>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
90
src/components/Room/RoomFileUploadControls.tsx
Normal file
90
src/components/Room/RoomFileUploadControls.tsx
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import { ChangeEventHandler, useRef } from 'react'
|
||||||
|
import Box from '@mui/material/Box'
|
||||||
|
import UploadFile from '@mui/icons-material/UploadFile'
|
||||||
|
import Cancel from '@mui/icons-material/Cancel'
|
||||||
|
import Fab from '@mui/material/Fab'
|
||||||
|
import Tooltip from '@mui/material/Tooltip'
|
||||||
|
|
||||||
|
import { PeerRoom } from 'services/PeerRoom/PeerRoom'
|
||||||
|
|
||||||
|
import { useRoomFileShare } from './useRoomFileShare'
|
||||||
|
|
||||||
|
export interface RoomFileUploadControlsProps {
|
||||||
|
peerRoom: PeerRoom
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoomFileUploadControls({
|
||||||
|
peerRoom,
|
||||||
|
}: RoomFileUploadControlsProps) {
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const {
|
||||||
|
isFileShareButtonEnabled,
|
||||||
|
isSharingFile,
|
||||||
|
handleFileShareStart,
|
||||||
|
handleFileShareStop,
|
||||||
|
sharedFiles,
|
||||||
|
} = useRoomFileShare({
|
||||||
|
peerRoom,
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleToggleScreenShareButtonClick = () => {
|
||||||
|
const { current: fileInput } = fileInputRef
|
||||||
|
|
||||||
|
if (isSharingFile) {
|
||||||
|
handleFileShareStop()
|
||||||
|
} else {
|
||||||
|
if (!fileInput) return
|
||||||
|
|
||||||
|
fileInput.click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileSelect: ChangeEventHandler<HTMLInputElement> = e => {
|
||||||
|
const { files } = e.target
|
||||||
|
|
||||||
|
if (!files) return
|
||||||
|
|
||||||
|
handleFileShareStart(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
const shareFileLabel =
|
||||||
|
(sharedFiles && sharedFiles.length === 1 && sharedFiles[0].name) || 'files'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
alignItems: 'center',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'center',
|
||||||
|
px: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
multiple
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
id="file-upload"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
title={
|
||||||
|
isSharingFile
|
||||||
|
? `Stop sharing ${shareFileLabel}`
|
||||||
|
: 'Share files with the room'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Fab
|
||||||
|
color={isSharingFile ? 'error' : 'success'}
|
||||||
|
aria-label="share screen"
|
||||||
|
onClick={handleToggleScreenShareButtonClick}
|
||||||
|
disabled={!isFileShareButtonEnabled}
|
||||||
|
>
|
||||||
|
{isSharingFile ? <Cancel /> : <UploadFile />}
|
||||||
|
</Fab>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
@ -8,11 +8,13 @@ import { PeerRoom } from 'services/PeerRoom/PeerRoom'
|
|||||||
|
|
||||||
import { useRoomScreenShare } from './useRoomScreenShare'
|
import { useRoomScreenShare } from './useRoomScreenShare'
|
||||||
|
|
||||||
export interface RoomVideoControlsProps {
|
export interface RoomFileUploadControlsProps {
|
||||||
peerRoom: PeerRoom
|
peerRoom: PeerRoom
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RoomScreenShareControls({ peerRoom }: RoomVideoControlsProps) {
|
export function RoomScreenShareControls({
|
||||||
|
peerRoom,
|
||||||
|
}: RoomFileUploadControlsProps) {
|
||||||
const { isSharingScreen, handleScreenShareStart, handleScreenShareStop } =
|
const { isSharingScreen, handleScreenShareStart, handleScreenShareStop } =
|
||||||
useRoomScreenShare({
|
useRoomScreenShare({
|
||||||
peerRoom,
|
peerRoom,
|
||||||
|
@ -70,6 +70,10 @@ export function useRoom(
|
|||||||
Record<string, MediaStream>
|
Record<string, MediaStream>
|
||||||
>({})
|
>({})
|
||||||
|
|
||||||
|
const [peerOfferedFileIds, setPeerOfferedFileIds] = useState<
|
||||||
|
Record<string, string>
|
||||||
|
>({})
|
||||||
|
|
||||||
const roomContextValue = useMemo(
|
const roomContextValue = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
selfVideoStream,
|
selfVideoStream,
|
||||||
@ -80,6 +84,8 @@ export function useRoom(
|
|||||||
setSelfScreenStream,
|
setSelfScreenStream,
|
||||||
peerScreenStreams,
|
peerScreenStreams,
|
||||||
setPeerScreenStreams,
|
setPeerScreenStreams,
|
||||||
|
peerOfferedFileIds,
|
||||||
|
setPeerOfferedFileIds,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
selfVideoStream,
|
selfVideoStream,
|
||||||
@ -90,6 +96,8 @@ export function useRoom(
|
|||||||
setSelfScreenStream,
|
setSelfScreenStream,
|
||||||
peerScreenStreams,
|
peerScreenStreams,
|
||||||
setPeerScreenStreams,
|
setPeerScreenStreams,
|
||||||
|
peerOfferedFileIds,
|
||||||
|
setPeerOfferedFileIds,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -153,6 +161,7 @@ export function useRoom(
|
|||||||
audioState: AudioState.STOPPED,
|
audioState: AudioState.STOPPED,
|
||||||
videoState: VideoState.STOPPED,
|
videoState: VideoState.STOPPED,
|
||||||
screenShareState: ScreenShareState.NOT_SHARING,
|
screenShareState: ScreenShareState.NOT_SHARING,
|
||||||
|
offeredFileId: null,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
} else {
|
} else {
|
||||||
|
126
src/components/Room/useRoomFileShare.ts
Normal file
126
src/components/Room/useRoomFileShare.ts
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import { useContext, useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import { sleep } from 'utils'
|
||||||
|
import { RoomContext } from 'contexts/RoomContext'
|
||||||
|
import { ShellContext } from 'contexts/ShellContext'
|
||||||
|
import { PeerActions } from 'models/network'
|
||||||
|
import { Peer } from 'models/chat'
|
||||||
|
import { PeerRoom, PeerHookType } from 'services/PeerRoom'
|
||||||
|
|
||||||
|
import { fileTransfer } from 'services/FileTransfer/index'
|
||||||
|
|
||||||
|
import { usePeerRoomAction } from './usePeerRoomAction'
|
||||||
|
|
||||||
|
interface UseRoomFileShareConfig {
|
||||||
|
peerRoom: PeerRoom
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRoomFileShare({ peerRoom }: UseRoomFileShareConfig) {
|
||||||
|
const shellContext = useContext(ShellContext)
|
||||||
|
const roomContext = useContext(RoomContext)
|
||||||
|
const [sharedFiles, setSharedFiles] = useState<FileList | null>(null)
|
||||||
|
const [selfFileOfferId, setFileOfferId] = useState<string | null>(null)
|
||||||
|
const [isFileShareButtonEnabled, setIsFileShareButtonEnabled] = useState(true)
|
||||||
|
|
||||||
|
const { peerList, setPeerList } = shellContext
|
||||||
|
const { peerOfferedFileIds, setPeerOfferedFileIds } = roomContext
|
||||||
|
|
||||||
|
const [sendFileOfferId, receiveFileOfferId] = usePeerRoomAction<
|
||||||
|
string | null
|
||||||
|
>(peerRoom, PeerActions.FILE_OFFER)
|
||||||
|
|
||||||
|
receiveFileOfferId((fileOfferId, peerId) => {
|
||||||
|
if (fileOfferId) {
|
||||||
|
setPeerOfferedFileIds({ [peerId]: fileOfferId })
|
||||||
|
} else {
|
||||||
|
const fileOfferId = peerOfferedFileIds[peerId]
|
||||||
|
|
||||||
|
if (fileOfferId && fileTransfer.isOffering(fileOfferId)) {
|
||||||
|
fileTransfer.rescind(fileOfferId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const newFileOfferIds = { ...peerOfferedFileIds }
|
||||||
|
delete newFileOfferIds[peerId]
|
||||||
|
|
||||||
|
setPeerOfferedFileIds(newFileOfferIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
const newPeerList = peerList.map(peer => {
|
||||||
|
const newPeer: Peer = { ...peer }
|
||||||
|
|
||||||
|
if (peer.peerId === peerId) {
|
||||||
|
newPeer.offeredFileId = fileOfferId
|
||||||
|
}
|
||||||
|
|
||||||
|
return newPeer
|
||||||
|
})
|
||||||
|
|
||||||
|
setPeerList(newPeerList)
|
||||||
|
})
|
||||||
|
|
||||||
|
peerRoom.onPeerJoin(PeerHookType.FILE_SHARE, async (peerId: string) => {
|
||||||
|
if (!selfFileOfferId) return
|
||||||
|
|
||||||
|
// This sleep is needed to prevent this peer from not appearing on other
|
||||||
|
// peers' peer lists. This is because Trystero's interaction between
|
||||||
|
// onPeerJoin and its actions is not totally compatible with React's
|
||||||
|
// lifecycle hooks. In this case, the reference to peerList in
|
||||||
|
// receiveFileOfferId is out of date and prevents this peer from ever being
|
||||||
|
// added to the receiver's peer list. Deferring the sendFileOfferId call to
|
||||||
|
// the next tick serves as a workaround.
|
||||||
|
await sleep(1)
|
||||||
|
|
||||||
|
sendFileOfferId(selfFileOfferId, peerId)
|
||||||
|
})
|
||||||
|
|
||||||
|
peerRoom.onPeerLeave(PeerHookType.FILE_SHARE, (peerId: string) => {
|
||||||
|
const fileOfferId = peerOfferedFileIds[peerId]
|
||||||
|
|
||||||
|
if (!fileOfferId) return
|
||||||
|
|
||||||
|
if (fileTransfer.isOffering(fileOfferId)) {
|
||||||
|
fileTransfer.rescind(fileOfferId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const newPeerFileOfferIds = { ...peerOfferedFileIds }
|
||||||
|
delete newPeerFileOfferIds[peerId]
|
||||||
|
setPeerOfferedFileIds(newPeerFileOfferIds)
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleFileShareStart = async (files: FileList) => {
|
||||||
|
setSharedFiles(files)
|
||||||
|
setIsFileShareButtonEnabled(false)
|
||||||
|
|
||||||
|
const fileOfferId = await fileTransfer.offer(files)
|
||||||
|
sendFileOfferId(fileOfferId)
|
||||||
|
setFileOfferId(fileOfferId)
|
||||||
|
|
||||||
|
setIsFileShareButtonEnabled(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileShareStop = () => {
|
||||||
|
sendFileOfferId(null)
|
||||||
|
setFileOfferId(null)
|
||||||
|
|
||||||
|
if (selfFileOfferId && fileTransfer.isOffering(selfFileOfferId)) {
|
||||||
|
fileTransfer.rescind(selfFileOfferId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
fileTransfer.rescindAll()
|
||||||
|
sendFileOfferId(null)
|
||||||
|
}
|
||||||
|
}, [sendFileOfferId])
|
||||||
|
|
||||||
|
const isSharingFile = Boolean(selfFileOfferId)
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleFileShareStart,
|
||||||
|
handleFileShareStop,
|
||||||
|
isFileShareButtonEnabled,
|
||||||
|
isSharingFile,
|
||||||
|
sharedFiles,
|
||||||
|
}
|
||||||
|
}
|
3
src/components/Shell/PeerDownloadFileButton.sass
Normal file
3
src/components/Shell/PeerDownloadFileButton.sass
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
.PeerDownloadFileButton
|
||||||
|
.MuiCircularProgress-circle
|
||||||
|
transition: none !important
|
72
src/components/Shell/PeerDownloadFileButton.tsx
Normal file
72
src/components/Shell/PeerDownloadFileButton.tsx
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import { useContext, useState } from 'react'
|
||||||
|
import Box from '@mui/material/Box'
|
||||||
|
import Fab from '@mui/material/Fab'
|
||||||
|
import Tooltip from '@mui/material/Tooltip'
|
||||||
|
import Download from '@mui/icons-material/Download'
|
||||||
|
import CircularProgress from '@mui/material/CircularProgress'
|
||||||
|
|
||||||
|
import { isError } from 'utils'
|
||||||
|
import { fileTransfer } from 'services/FileTransfer/index'
|
||||||
|
import { Peer } from 'models/chat'
|
||||||
|
import { ShellContext } from 'contexts/ShellContext'
|
||||||
|
|
||||||
|
import './PeerDownloadFileButton.sass'
|
||||||
|
import { getPeerName } from 'components/PeerNameDisplay/getPeerName'
|
||||||
|
|
||||||
|
interface PeerDownloadFileButtonProps {
|
||||||
|
peer: Peer
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PeerDownloadFileButton = ({
|
||||||
|
peer,
|
||||||
|
}: PeerDownloadFileButtonProps) => {
|
||||||
|
const [isDownloading, setIsDownloading] = useState(false)
|
||||||
|
const [downloadProgress, setDownloadProgress] = useState<number | null>(null)
|
||||||
|
const shellContext = useContext(ShellContext)
|
||||||
|
const { offeredFileId } = peer
|
||||||
|
|
||||||
|
const onProgress = (progress: number) => {
|
||||||
|
setDownloadProgress(progress * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!offeredFileId) {
|
||||||
|
return <></>
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDownloadFileClick = async () => {
|
||||||
|
setIsDownloading(true)
|
||||||
|
setDownloadProgress(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fileTransfer.download(offeredFileId, { onProgress })
|
||||||
|
} catch (e) {
|
||||||
|
if (isError(e)) {
|
||||||
|
shellContext.showAlert(e.message, {
|
||||||
|
severity: 'error',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsDownloading(false)
|
||||||
|
setDownloadProgress(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className="PeerDownloadFileButton" sx={{ mr: 2 }}>
|
||||||
|
{isDownloading ? (
|
||||||
|
<CircularProgress
|
||||||
|
variant={downloadProgress === null ? 'indeterminate' : 'determinate'}
|
||||||
|
value={downloadProgress === null ? undefined : downloadProgress}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Tooltip
|
||||||
|
title={`Download files being offered by ${getPeerName(peer.userId)}`}
|
||||||
|
>
|
||||||
|
<Fab color="primary" size="small" onClick={handleDownloadFileClick}>
|
||||||
|
<Download />
|
||||||
|
</Fab>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
@ -7,13 +7,14 @@ import Divider from '@mui/material/Divider'
|
|||||||
import IconButton from '@mui/material/IconButton'
|
import IconButton from '@mui/material/IconButton'
|
||||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight'
|
import ChevronRightIcon from '@mui/icons-material/ChevronRight'
|
||||||
import VolumeUp from '@mui/icons-material/VolumeUp'
|
import VolumeUp from '@mui/icons-material/VolumeUp'
|
||||||
import ListItemButton from '@mui/material/ListItemButton'
|
import ListItem from '@mui/material/ListItem'
|
||||||
|
|
||||||
import { PeerListHeader } from 'components/Shell/PeerListHeader'
|
import { PeerListHeader } from 'components/Shell/PeerListHeader'
|
||||||
import { PeerNameDisplay } from 'components/PeerNameDisplay'
|
import { PeerNameDisplay } from 'components/PeerNameDisplay'
|
||||||
|
|
||||||
import { AudioState, Peer } from 'models/chat'
|
import { AudioState, Peer } from 'models/chat'
|
||||||
|
|
||||||
|
import { PeerDownloadFileButton } from './PeerDownloadFileButton'
|
||||||
|
|
||||||
export const peerListWidth = 300
|
export const peerListWidth = 300
|
||||||
|
|
||||||
export interface PeerListProps extends PropsWithChildren {
|
export interface PeerListProps extends PropsWithChildren {
|
||||||
@ -51,7 +52,7 @@ export const PeerList = ({
|
|||||||
</PeerListHeader>
|
</PeerListHeader>
|
||||||
<Divider />
|
<Divider />
|
||||||
<List>
|
<List>
|
||||||
<ListItemButton disableRipple={true}>
|
<ListItem>
|
||||||
{audioState === AudioState.PLAYING && (
|
{audioState === AudioState.PLAYING && (
|
||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
<VolumeUp />
|
<VolumeUp />
|
||||||
@ -60,18 +61,19 @@ export const PeerList = ({
|
|||||||
<ListItemText>
|
<ListItemText>
|
||||||
<PeerNameDisplay>{userId}</PeerNameDisplay> (you)
|
<PeerNameDisplay>{userId}</PeerNameDisplay> (you)
|
||||||
</ListItemText>
|
</ListItemText>
|
||||||
</ListItemButton>
|
</ListItem>
|
||||||
{peerList.map((peer: Peer) => (
|
{peerList.map((peer: Peer) => (
|
||||||
<ListItemButton key={peer.peerId} disableRipple={true}>
|
<ListItem key={peer.peerId}>
|
||||||
|
<PeerDownloadFileButton peer={peer} />
|
||||||
|
<ListItemText>
|
||||||
|
<PeerNameDisplay>{peer.userId}</PeerNameDisplay>
|
||||||
|
</ListItemText>
|
||||||
{peer.audioState === AudioState.PLAYING && (
|
{peer.audioState === AudioState.PLAYING && (
|
||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
<VolumeUp />
|
<VolumeUp />
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
)}
|
)}
|
||||||
<ListItemText>
|
</ListItem>
|
||||||
<PeerNameDisplay>{peer.userId}</PeerNameDisplay>
|
|
||||||
</ListItemText>
|
|
||||||
</ListItemButton>
|
|
||||||
))}
|
))}
|
||||||
</List>
|
</List>
|
||||||
<Divider />
|
<Divider />
|
||||||
|
@ -49,7 +49,6 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => {
|
|||||||
const [screenState, setScreenState] = useState<ScreenShareState>(
|
const [screenState, setScreenState] = useState<ScreenShareState>(
|
||||||
ScreenShareState.NOT_SHARING
|
ScreenShareState.NOT_SHARING
|
||||||
)
|
)
|
||||||
|
|
||||||
const showAlert = useCallback<
|
const showAlert = useCallback<
|
||||||
(message: string, options?: AlertOptions) => void
|
(message: string, options?: AlertOptions) => void
|
||||||
>((message, options) => {
|
>((message, options) => {
|
||||||
|
8
src/config/streamSaverUrl.ts
Normal file
8
src/config/streamSaverUrl.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export const streamSaverUrl =
|
||||||
|
process.env.REACT_APP_STREAMSAVER_URL ??
|
||||||
|
// If you would like to host your own Chitchatter instance with an
|
||||||
|
// alternative StreamSaver fork to facilitate file sharing, change this
|
||||||
|
// string to its respective .mitm.html URL.
|
||||||
|
//
|
||||||
|
// See: https://github.com/jimmywarting/StreamSaver.js?#configuration
|
||||||
|
'https://jeremyckahn.github.io/StreamSaver.js/mitm.html'
|
@ -9,6 +9,8 @@ interface RoomContextProps {
|
|||||||
setSelfScreenStream: Dispatch<SetStateAction<MediaStream | null>>
|
setSelfScreenStream: Dispatch<SetStateAction<MediaStream | null>>
|
||||||
peerScreenStreams: Record<string, MediaStream>
|
peerScreenStreams: Record<string, MediaStream>
|
||||||
setPeerScreenStreams: Dispatch<SetStateAction<Record<string, MediaStream>>>
|
setPeerScreenStreams: Dispatch<SetStateAction<Record<string, MediaStream>>>
|
||||||
|
peerOfferedFileIds: Record<string, string>
|
||||||
|
setPeerOfferedFileIds: Dispatch<SetStateAction<Record<string, string>>>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RoomContext = createContext<RoomContextProps>({
|
export const RoomContext = createContext<RoomContextProps>({
|
||||||
@ -20,4 +22,6 @@ export const RoomContext = createContext<RoomContextProps>({
|
|||||||
setSelfScreenStream: () => {},
|
setSelfScreenStream: () => {},
|
||||||
peerScreenStreams: {},
|
peerScreenStreams: {},
|
||||||
setPeerScreenStreams: () => {},
|
setPeerScreenStreams: () => {},
|
||||||
|
peerOfferedFileIds: {},
|
||||||
|
setPeerOfferedFileIds: () => {},
|
||||||
})
|
})
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import { Buffer } from 'buffer'
|
||||||
|
|
||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import 'typeface-roboto'
|
import 'typeface-roboto'
|
||||||
|
|
||||||
@ -5,6 +7,9 @@ import 'index.sass'
|
|||||||
import Bootstrap from 'Bootstrap'
|
import Bootstrap from 'Bootstrap'
|
||||||
import reportWebVitals from 'reportWebVitals'
|
import reportWebVitals from 'reportWebVitals'
|
||||||
|
|
||||||
|
// Polyfill
|
||||||
|
window.Buffer = Buffer
|
||||||
|
|
||||||
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement)
|
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement)
|
||||||
root.render(<Bootstrap />)
|
root.render(<Bootstrap />)
|
||||||
|
|
||||||
|
@ -31,6 +31,7 @@ export interface Peer {
|
|||||||
audioState: AudioState
|
audioState: AudioState
|
||||||
videoState: VideoState
|
videoState: VideoState
|
||||||
screenShareState: ScreenShareState
|
screenShareState: ScreenShareState
|
||||||
|
offeredFileId: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReceivedMessage extends UnsentMessage {
|
export interface ReceivedMessage extends UnsentMessage {
|
||||||
|
@ -6,4 +6,5 @@ export enum PeerActions {
|
|||||||
AUDIO_CHANGE = 'AUDIO_CHANGE',
|
AUDIO_CHANGE = 'AUDIO_CHANGE',
|
||||||
VIDEO_CHANGE = 'VIDEO_CHANGE',
|
VIDEO_CHANGE = 'VIDEO_CHANGE',
|
||||||
SCREEN_SHARE = 'SCREEN_SHARE',
|
SCREEN_SHARE = 'SCREEN_SHARE',
|
||||||
|
FILE_OFFER = 'FILE_OFFER',
|
||||||
}
|
}
|
||||||
|
173
src/services/FileTransfer/FileTransfer.ts
Normal file
173
src/services/FileTransfer/FileTransfer.ts
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
import { WebTorrent as WebTorrentType, Torrent } from 'webtorrent'
|
||||||
|
// @ts-ignore
|
||||||
|
import streamSaver from 'streamsaver'
|
||||||
|
// @ts-ignore
|
||||||
|
import idbChunkStore from 'idb-chunk-store'
|
||||||
|
import { detectIncognito } from 'detectincognitojs'
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
import WebTorrent from 'webtorrent/webtorrent.min.js'
|
||||||
|
import { trackerUrls } from 'config/trackerUrls'
|
||||||
|
import { streamSaverUrl } from 'config/streamSaverUrl'
|
||||||
|
|
||||||
|
streamSaver.mitm = streamSaverUrl
|
||||||
|
|
||||||
|
interface DownloadOpts {
|
||||||
|
onProgress?: (progress: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FileTransfer {
|
||||||
|
private webTorrentClient = new (WebTorrent as unknown as WebTorrentType)()
|
||||||
|
|
||||||
|
private torrents: Record<Torrent['magnetURI'], Torrent> = {}
|
||||||
|
|
||||||
|
private async saveTorrentFiles(torrent: Torrent) {
|
||||||
|
for (const file of torrent.files) {
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const fileStream = streamSaver.createWriteStream(file.name, {
|
||||||
|
size: file.length,
|
||||||
|
})
|
||||||
|
|
||||||
|
const writeStream = fileStream.getWriter()
|
||||||
|
const readStream = file.createReadStream()
|
||||||
|
let aborted = false
|
||||||
|
|
||||||
|
const handleData = async (data: ArrayBuffer) => {
|
||||||
|
try {
|
||||||
|
await writeStream.write(data)
|
||||||
|
} catch (e) {
|
||||||
|
writeStream.abort()
|
||||||
|
readStream.off('data', handleData)
|
||||||
|
aborted = true
|
||||||
|
reject()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBeforePageUnloadForFile = () => {
|
||||||
|
// Clean up any broken downloads
|
||||||
|
writeStream.abort()
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', handleBeforePageUnloadForFile)
|
||||||
|
|
||||||
|
const handleEnd = async () => {
|
||||||
|
window.removeEventListener(
|
||||||
|
'beforeunload',
|
||||||
|
handleBeforePageUnloadForFile
|
||||||
|
)
|
||||||
|
|
||||||
|
if (aborted) return
|
||||||
|
|
||||||
|
await writeStream.close()
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
readStream.on('data', handleData).on('end', handleEnd)
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error('Download aborted')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
window.addEventListener('beforeunload', this.handleBeforePageUnload)
|
||||||
|
}
|
||||||
|
|
||||||
|
async download(magnetURI: string, { onProgress }: DownloadOpts = {}) {
|
||||||
|
let torrent = this.torrents[magnetURI]
|
||||||
|
|
||||||
|
if (!torrent) {
|
||||||
|
const { isPrivate } = await detectIncognito()
|
||||||
|
|
||||||
|
torrent = await new Promise<Torrent>(res => {
|
||||||
|
this.webTorrentClient.add(
|
||||||
|
magnetURI,
|
||||||
|
{
|
||||||
|
announce: trackerUrls,
|
||||||
|
// If the user is using their browser's private mode, IndexedDB
|
||||||
|
// will be unavailable and idbChunkStore will break all transfers.
|
||||||
|
// In that case, fall back to the default in-memory data store.
|
||||||
|
store: isPrivate ? undefined : idbChunkStore,
|
||||||
|
destroyStoreOnDestroy: true,
|
||||||
|
},
|
||||||
|
torrent => {
|
||||||
|
res(torrent)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
this.torrents[torrent.magnetURI] = torrent
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
onProgress?.(torrent.progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
torrent.on('download', handleDownload)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.saveTorrentFiles(torrent)
|
||||||
|
} catch (e) {
|
||||||
|
torrent.off('download', handleDownload)
|
||||||
|
|
||||||
|
// Propagate error to the UI
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async offer(files: FileList) {
|
||||||
|
const { isPrivate } = await detectIncognito()
|
||||||
|
|
||||||
|
const torrent = await new Promise<Torrent>(res => {
|
||||||
|
this.webTorrentClient.seed(
|
||||||
|
files,
|
||||||
|
{
|
||||||
|
announce: trackerUrls,
|
||||||
|
// If the user is using their browser's private mode, IndexedDB will
|
||||||
|
// be unavailable and idbChunkStore will break all transfers. In that
|
||||||
|
// case, fall back to the default in-memory data store.
|
||||||
|
store: isPrivate ? undefined : idbChunkStore,
|
||||||
|
destroyStoreOnDestroy: true,
|
||||||
|
},
|
||||||
|
torrent => {
|
||||||
|
res(torrent)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const { magnetURI } = torrent
|
||||||
|
this.torrents[magnetURI] = torrent
|
||||||
|
|
||||||
|
return magnetURI
|
||||||
|
}
|
||||||
|
|
||||||
|
rescind(magnetURI: string) {
|
||||||
|
const torrent = this.torrents[magnetURI]
|
||||||
|
|
||||||
|
if (torrent) {
|
||||||
|
torrent.destroy()
|
||||||
|
} else {
|
||||||
|
console.error(`Attempted to clean up nonexistent torrent: ${magnetURI}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete this.torrents[magnetURI]
|
||||||
|
}
|
||||||
|
|
||||||
|
rescindAll() {
|
||||||
|
for (const magnetURI in this.torrents) {
|
||||||
|
this.rescind(magnetURI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isOffering(magnetURI: string) {
|
||||||
|
return magnetURI in this.torrents
|
||||||
|
}
|
||||||
|
|
||||||
|
handleBeforePageUnload = () => {
|
||||||
|
this.rescindAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fileTransfer = new FileTransfer()
|
1
src/services/FileTransfer/index.ts
Normal file
1
src/services/FileTransfer/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './FileTransfer'
|
@ -8,6 +8,7 @@ export enum PeerHookType {
|
|||||||
AUDIO = 'AUDIO',
|
AUDIO = 'AUDIO',
|
||||||
VIDEO = 'VIDEO',
|
VIDEO = 'VIDEO',
|
||||||
SCREEN = 'SCREEN',
|
SCREEN = 'SCREEN',
|
||||||
|
FILE_SHARE = 'FILE_SHARE',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum PeerStreamType {
|
export enum PeerStreamType {
|
||||||
|
@ -7,3 +7,8 @@ import '@testing-library/jest-dom'
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
jest.restoreAllMocks()
|
jest.restoreAllMocks()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
jest.mock('webtorrent/webtorrent.min.js', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: class WebTorrent {},
|
||||||
|
}))
|
||||||
|
@ -10,3 +10,7 @@ export const isRecord = (variable: any): variable is Record<string, any> => {
|
|||||||
variable !== null
|
variable !== null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const isError = (e: any): e is Error => {
|
||||||
|
return e instanceof Error
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user