feat: [closes #21] File sharing (#72)

* 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:
Jeremy Kahn 2022-11-24 00:16:34 -06:00 committed by GitHub
parent 4e29bfbd24
commit f006e76e80
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 3471 additions and 87 deletions

3013
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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": {

View File

@ -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>

View 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>
)
}

View File

@ -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,

View File

@ -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 {

View 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,
}
}

View File

@ -0,0 +1,3 @@
.PeerDownloadFileButton
.MuiCircularProgress-circle
transition: none !important

View 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>
)
}

View File

@ -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 />

View File

@ -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) => {

View 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'

View File

@ -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: () => {},
})

View File

@ -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 />)

View File

@ -31,6 +31,7 @@ export interface Peer {
audioState: AudioState
videoState: VideoState
screenShareState: ScreenShareState
offeredFileId: string | null
}
export interface ReceivedMessage extends UnsentMessage {

View File

@ -6,4 +6,5 @@ export enum PeerActions {
AUDIO_CHANGE = 'AUDIO_CHANGE',
VIDEO_CHANGE = 'VIDEO_CHANGE',
SCREEN_SHARE = 'SCREEN_SHARE',
FILE_OFFER = 'FILE_OFFER',
}

View 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()

View File

@ -0,0 +1 @@
export * from './FileTransfer'

View File

@ -8,6 +8,7 @@ export enum PeerHookType {
AUDIO = 'AUDIO',
VIDEO = 'VIDEO',
SCREEN = 'SCREEN',
FILE_SHARE = 'FILE_SHARE',
}
export enum PeerStreamType {

View File

@ -7,3 +7,8 @@ import '@testing-library/jest-dom'
afterEach(() => {
jest.restoreAllMocks()
})
jest.mock('webtorrent/webtorrent.min.js', () => ({
__esModule: true,
default: class WebTorrent {},
}))

View File

@ -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
}