* 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/react": "^18.0.17",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"buffer": "^6.0.3",
|
||||
"classnames": "^2.3.1",
|
||||
"detectincognitojs": "^1.1.2",
|
||||
"fast-memoize": "^2.5.2",
|
||||
"fun-animal-names": "^0.1.1",
|
||||
"idb-chunk-store": "^1.0.1",
|
||||
"localforage": "^1.10.0",
|
||||
"mui-markdown": "^0.5.5",
|
||||
"react": "^18.2.0",
|
||||
@ -30,17 +33,20 @@
|
||||
"react-syntax-highlighter": "^15.5.0",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"sass": "^1.54.3",
|
||||
"streamsaver": "^2.0.6",
|
||||
"trystero": "github:jeremyckahn/trystero#bugfix/stream-metadata-type",
|
||||
"typeface-public-sans": "^1.1.13",
|
||||
"typeface-roboto": "^1.1.13",
|
||||
"typescript": "^4.7.4",
|
||||
"uuid": "^8.3.2",
|
||||
"web-vitals": "^2.1.4"
|
||||
"web-vitals": "^2.1.4",
|
||||
"webtorrent": "^1.9.3"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"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",
|
||||
"test": "react-scripts test",
|
||||
"prepare": "husky install",
|
||||
@ -71,7 +77,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react-syntax-highlighter": "^15.5.5",
|
||||
"@types/streamsaver": "^2.0.1",
|
||||
"@types/uuid": "^8.3.4",
|
||||
"@types/webtorrent": "^0.109.3",
|
||||
"@typescript-eslint/eslint-plugin": "^5.33.0",
|
||||
"@typescript-eslint/parser": "^5.33.0",
|
||||
"autoprefixer": "^10.4.8",
|
||||
@ -88,6 +96,7 @@
|
||||
"postcss": "^8.4.16",
|
||||
"prettier": "^2.7.1",
|
||||
"pretty-quick": "^3.1.3",
|
||||
"serve": "^14.1.2",
|
||||
"tailwindcss": "^3.1.8"
|
||||
},
|
||||
"jest": {
|
||||
|
@ -16,6 +16,7 @@ import { useRoom } from './useRoom'
|
||||
import { RoomAudioControls } from './RoomAudioControls'
|
||||
import { RoomVideoControls } from './RoomVideoControls'
|
||||
import { RoomScreenShareControls } from './RoomScreenShareControls'
|
||||
import { RoomFileUploadControls } from './RoomFileUploadControls'
|
||||
import { RoomVideoDisplay } from './RoomVideoDisplay'
|
||||
|
||||
export interface RoomProps {
|
||||
@ -95,6 +96,7 @@ export function Room({
|
||||
<RoomAudioControls peerRoom={peerRoom} />
|
||||
<RoomVideoControls peerRoom={peerRoom} />
|
||||
<RoomScreenShareControls peerRoom={peerRoom} />
|
||||
<RoomFileUploadControls peerRoom={peerRoom} />
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</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'
|
||||
|
||||
export interface RoomVideoControlsProps {
|
||||
export interface RoomFileUploadControlsProps {
|
||||
peerRoom: PeerRoom
|
||||
}
|
||||
|
||||
export function RoomScreenShareControls({ peerRoom }: RoomVideoControlsProps) {
|
||||
export function RoomScreenShareControls({
|
||||
peerRoom,
|
||||
}: RoomFileUploadControlsProps) {
|
||||
const { isSharingScreen, handleScreenShareStart, handleScreenShareStop } =
|
||||
useRoomScreenShare({
|
||||
peerRoom,
|
||||
|
@ -70,6 +70,10 @@ export function useRoom(
|
||||
Record<string, MediaStream>
|
||||
>({})
|
||||
|
||||
const [peerOfferedFileIds, setPeerOfferedFileIds] = useState<
|
||||
Record<string, string>
|
||||
>({})
|
||||
|
||||
const roomContextValue = useMemo(
|
||||
() => ({
|
||||
selfVideoStream,
|
||||
@ -80,6 +84,8 @@ export function useRoom(
|
||||
setSelfScreenStream,
|
||||
peerScreenStreams,
|
||||
setPeerScreenStreams,
|
||||
peerOfferedFileIds,
|
||||
setPeerOfferedFileIds,
|
||||
}),
|
||||
[
|
||||
selfVideoStream,
|
||||
@ -90,6 +96,8 @@ export function useRoom(
|
||||
setSelfScreenStream,
|
||||
peerScreenStreams,
|
||||
setPeerScreenStreams,
|
||||
peerOfferedFileIds,
|
||||
setPeerOfferedFileIds,
|
||||
]
|
||||
)
|
||||
|
||||
@ -153,6 +161,7 @@ export function useRoom(
|
||||
audioState: AudioState.STOPPED,
|
||||
videoState: VideoState.STOPPED,
|
||||
screenShareState: ScreenShareState.NOT_SHARING,
|
||||
offeredFileId: null,
|
||||
},
|
||||
])
|
||||
} 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 ChevronRightIcon from '@mui/icons-material/ChevronRight'
|
||||
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 { PeerNameDisplay } from 'components/PeerNameDisplay'
|
||||
|
||||
import { AudioState, Peer } from 'models/chat'
|
||||
|
||||
import { PeerDownloadFileButton } from './PeerDownloadFileButton'
|
||||
|
||||
export const peerListWidth = 300
|
||||
|
||||
export interface PeerListProps extends PropsWithChildren {
|
||||
@ -51,7 +52,7 @@ export const PeerList = ({
|
||||
</PeerListHeader>
|
||||
<Divider />
|
||||
<List>
|
||||
<ListItemButton disableRipple={true}>
|
||||
<ListItem>
|
||||
{audioState === AudioState.PLAYING && (
|
||||
<ListItemIcon>
|
||||
<VolumeUp />
|
||||
@ -60,18 +61,19 @@ export const PeerList = ({
|
||||
<ListItemText>
|
||||
<PeerNameDisplay>{userId}</PeerNameDisplay> (you)
|
||||
</ListItemText>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
{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 && (
|
||||
<ListItemIcon>
|
||||
<VolumeUp />
|
||||
</ListItemIcon>
|
||||
)}
|
||||
<ListItemText>
|
||||
<PeerNameDisplay>{peer.userId}</PeerNameDisplay>
|
||||
</ListItemText>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
<Divider />
|
||||
|
@ -49,7 +49,6 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => {
|
||||
const [screenState, setScreenState] = useState<ScreenShareState>(
|
||||
ScreenShareState.NOT_SHARING
|
||||
)
|
||||
|
||||
const showAlert = useCallback<
|
||||
(message: string, options?: AlertOptions) => void
|
||||
>((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>>
|
||||
peerScreenStreams: Record<string, MediaStream>
|
||||
setPeerScreenStreams: Dispatch<SetStateAction<Record<string, MediaStream>>>
|
||||
peerOfferedFileIds: Record<string, string>
|
||||
setPeerOfferedFileIds: Dispatch<SetStateAction<Record<string, string>>>
|
||||
}
|
||||
|
||||
export const RoomContext = createContext<RoomContextProps>({
|
||||
@ -20,4 +22,6 @@ export const RoomContext = createContext<RoomContextProps>({
|
||||
setSelfScreenStream: () => {},
|
||||
peerScreenStreams: {},
|
||||
setPeerScreenStreams: () => {},
|
||||
peerOfferedFileIds: {},
|
||||
setPeerOfferedFileIds: () => {},
|
||||
})
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { Buffer } from 'buffer'
|
||||
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import 'typeface-roboto'
|
||||
|
||||
@ -5,6 +7,9 @@ import 'index.sass'
|
||||
import Bootstrap from 'Bootstrap'
|
||||
import reportWebVitals from 'reportWebVitals'
|
||||
|
||||
// Polyfill
|
||||
window.Buffer = Buffer
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement)
|
||||
root.render(<Bootstrap />)
|
||||
|
||||
|
@ -31,6 +31,7 @@ export interface Peer {
|
||||
audioState: AudioState
|
||||
videoState: VideoState
|
||||
screenShareState: ScreenShareState
|
||||
offeredFileId: string | null
|
||||
}
|
||||
|
||||
export interface ReceivedMessage extends UnsentMessage {
|
||||
|
@ -6,4 +6,5 @@ export enum PeerActions {
|
||||
AUDIO_CHANGE = 'AUDIO_CHANGE',
|
||||
VIDEO_CHANGE = 'VIDEO_CHANGE',
|
||||
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',
|
||||
VIDEO = 'VIDEO',
|
||||
SCREEN = 'SCREEN',
|
||||
FILE_SHARE = 'FILE_SHARE',
|
||||
}
|
||||
|
||||
export enum PeerStreamType {
|
||||
|
@ -7,3 +7,8 @@ import '@testing-library/jest-dom'
|
||||
afterEach(() => {
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
export const isError = (e: any): e is Error => {
|
||||
return e instanceof Error
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user