diff --git a/package.json b/package.json index 91d9d39..31f9afb 100644 --- a/package.json +++ b/package.json @@ -25,8 +25,6 @@ "debug": "^4.3.6", "express": "^4.19.2", "fp-ts": "^2.16.9", - "framer-motion": "^3.10.6", - "immer": "^8.0.4", "io-ts": "^2.2.21", "ioredis": "^4.28.5", "next": "^14.2.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4160c1e..4f8035c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,12 +20,6 @@ dependencies: fp-ts: specifier: ^2.16.9 version: 2.16.9 - framer-motion: - specifier: ^3.10.6 - version: 3.10.6(react-dom@18.3.1)(react@18.3.1) - immer: - specifier: ^8.0.4 - version: 8.0.4 io-ts: specifier: ^2.2.21 version: 2.2.21(fp-ts@2.16.9) @@ -1597,20 +1591,6 @@ packages: to-fast-properties: 2.0.0 dev: false - /@emotion/is-prop-valid@0.8.8: - resolution: {integrity: sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==} - requiresBuild: true - dependencies: - '@emotion/memoize': 0.7.4 - dev: false - optional: true - - /@emotion/memoize@0.7.4: - resolution: {integrity: sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==} - requiresBuild: true - dev: false - optional: true - /@eslint/eslintrc@0.4.3: resolution: {integrity: sha512-J6KFFz5QCYUJq3pf0mjEcCJVERbzv71PUIDczuh9JkwGEzced6CO5ADLHB1rbf/+oPBtoPfMYNOpGDzCANlbXw==} engines: {node: ^10.12.0 || >=12.0.0} @@ -4814,27 +4794,6 @@ packages: map-cache: 0.2.2 dev: false - /framer-motion@3.10.6(react-dom@18.3.1)(react@18.3.1): - resolution: {integrity: sha512-OxOtKgQS4km9a8dm0IMBtNNp4f0DiHfQ/IzxKs818+Kg9V/Ve/pRUJ2dtWBb6+W4lIPNLgRSpbOwOACVj15XcQ==} - peerDependencies: - react: '>=16.8 || ^17.0.0' - react-dom: '>=16.8 || ^17.0.0' - dependencies: - framesync: 5.2.0 - hey-listen: 1.0.8 - popmotion: 9.3.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - style-value-types: 4.1.1 - tslib: 1.14.1 - optionalDependencies: - '@emotion/is-prop-valid': 0.8.8 - dev: false - - /framesync@5.2.0: - resolution: {integrity: sha512-dcl92w5SHc0o6pRK3//VBVNvu6WkYkiXmHG6ZIXrVzmgh0aDYMDAaoA3p3LH71JIdN5qmhDcfONFA4Lmq22tNA==} - dev: false - /fresh@0.5.2: resolution: {integrity: sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=} engines: {node: '>= 0.6'} @@ -5200,10 +5159,6 @@ packages: hermes-estree: 0.23.0 dev: false - /hey-listen@1.0.8: - resolution: {integrity: sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==} - dev: false - /hoek@2.16.3: resolution: {integrity: sha512-V6Yw1rIcYV/4JsnggjBU0l4Kr+EXhpwqXRusENU1Xx6ro00IHPHYNynCuBTOZAPlr3AAmLvchH9I7N/VUdvOwQ==} engines: {node: '>=0.10.40'} @@ -5291,10 +5246,6 @@ packages: queue: 6.0.2 dev: false - /immer@8.0.4: - resolution: {integrity: sha512-jMfL18P+/6P6epANRvRk6q8t+3gGhqsJ9EuJ25AXE+9bNTYtssvzeYbEd0mXRYWCmmXSIbnlpz6vd6iJlmGGGQ==} - dev: false - /import-fresh@2.0.0: resolution: {integrity: sha512-eZ5H8rcgYazHbKC3PG4ClHNykCSxtAhxSSEM+2mb+7evD2CKF5V7c0dNum7AdpDh0ZdICwZY9sRSn8f+KH96sg==} engines: {node: '>=4'} @@ -7298,15 +7249,6 @@ packages: semver-compare: 1.0.0 dev: true - /popmotion@9.3.1: - resolution: {integrity: sha512-Qozvg8rz2OGeZwWuIjqlSXqqgWto/+QL24ll8sAAc0n71KY/wvN1W4sAASxTuHv8YWdDnk9u9IdadyPo2DGvDA==} - dependencies: - framesync: 5.2.0 - hey-listen: 1.0.8 - style-value-types: 4.1.1 - tslib: 1.14.1 - dev: false - /posix-character-classes@0.1.1: resolution: {integrity: sha512-xTgYBc3fuo7Yt7JbiuFxSYGToMoz8fLoE6TC9Wx1P/u+LfeThMOAqmuyECnlBaaJb+u1m9hHiXUEtwW4OzfUJg==} engines: {node: '>=0.10.0'} @@ -8607,13 +8549,6 @@ packages: resolution: {integrity: sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==} dev: false - /style-value-types@4.1.1: - resolution: {integrity: sha512-cNLrl6jk+I1T18ZI2KIp/fcqKVuykcNELDrOz7y+TYZR97xmNdN0ewupURvVFnQxVrRJv98TMBq92VMsggq3kw==} - dependencies: - hey-listen: 1.0.8 - tslib: 1.14.1 - dev: false - /styled-jsx@5.1.1(@babel/core@7.25.2)(react@18.3.1): resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==} engines: {node: '>= 12.0.0'} @@ -8858,6 +8793,7 @@ packages: /tslib@1.14.1: resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + dev: true /tslib@2.7.0: resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==} diff --git a/src/app/page.tsx b/src/app/page.tsx index f879839..272f017 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -8,7 +8,6 @@ import UploadFileList from '../components/UploadFileList' import Uploader from '../components/Uploader' import PasswordField from '../components/PasswordField' import StartButton from '../components/StartButton' -import StopButton from '../components/StopButton' import { UploadedFile } from '../types' import Spinner from '../components/Spinner' import Wordmark from '../components/Wordmark' @@ -102,9 +101,8 @@ function UploadingState({

- + - ) } diff --git a/src/components/ConnectionListItem.tsx b/src/components/ConnectionListItem.tsx new file mode 100644 index 0000000..27b5a17 --- /dev/null +++ b/src/components/ConnectionListItem.tsx @@ -0,0 +1,54 @@ +import React from 'react' +import { UploaderConnection, UploaderConnectionStatus } from '../types' +import ProgressBar from './ProgressBar' + +export function ConnectionListItem({ + conn, +}: { + conn: UploaderConnection +}): JSX.Element { + const getStatusColor = (status: UploaderConnectionStatus) => { + switch (status) { + case UploaderConnectionStatus.Uploading: + return 'bg-green-500' + case UploaderConnectionStatus.Paused: + return 'bg-yellow-500' + case UploaderConnectionStatus.Done: + return 'bg-blue-500' + case UploaderConnectionStatus.Closed: + return 'bg-red-500' + default: + return 'bg-gray-500' + } + } + + return ( +
+
+ + {conn.browserName && conn.browserVersion ? ( + <> + {conn.browserName}{' '} + v{conn.browserVersion} + + ) : ( + 'Downloader' + )} + + + {conn.status} + +
+ +
+ ) +} diff --git a/src/components/CopyableInput.tsx b/src/components/CopyableInput.tsx new file mode 100644 index 0000000..abb7b92 --- /dev/null +++ b/src/components/CopyableInput.tsx @@ -0,0 +1,32 @@ +import React from 'react' +import useClipboard from '../hooks/useClipboard' +import InputLabel from './InputLabel' + +export function CopyableInput({ + label, + value, +}: { + label: string + value: string +}): JSX.Element { + const { hasCopied, onCopy } = useClipboard(value) + + return ( +
+ {label} +
+ + +
+
+ ) +} diff --git a/src/components/Spinner.tsx b/src/components/Spinner.tsx index 659e975..00b618d 100644 --- a/src/components/Spinner.tsx +++ b/src/components/Spinner.tsx @@ -12,6 +12,7 @@ export default function Spinner({ return (
Pizza
{`Arrow
diff --git a/src/components/Uploader.tsx b/src/components/Uploader.tsx index 19defc7..a146b0f 100644 --- a/src/components/Uploader.tsx +++ b/src/components/Uploader.tsx @@ -1,378 +1,27 @@ -import React, { useEffect, useState } from 'react' +import React from 'react' import { UploadedFile } from '../types' import { useWebRTC } from './WebRTCProvider' -import { useQuery } from '@tanstack/react-query' -import Peer, { DataConnection } from 'peerjs' -import { decodeMessage, Message, MessageType } from '../messages' import QRCode from 'react-qr-code' -import produce from 'immer' -import * as t from 'io-ts' import Loading from './Loading' -import ProgressBar from './ProgressBar' -import useClipboard from '../hooks/useClipboard' -import InputLabel from './InputLabel' import { useUploaderChannelRenewal } from '../hooks/useUploaderChannelRenewal' +import StopButton from './StopButton' +import { useUploaderChannel } from '../hooks/useUploaderChannel' +import { useUploaderConnections } from '../hooks/useUploaderConnections' +import { CopyableInput } from './CopyableInput' +import { ConnectionListItem } from './ConnectionListItem' -enum UploaderConnectionStatus { - Pending = 'PENDING', - Paused = 'PAUSED', - Uploading = 'UPLOADING', - Done = 'DONE', - InvalidPassword = 'INVALID_PASSWORD', - Closed = 'CLOSED', -} - -type UploaderConnection = { - status: UploaderConnectionStatus - dataConnection: DataConnection - browserName?: string - browserVersion?: string - osName?: string - osVersion?: string - mobileVendor?: string - mobileModel?: string - uploadingFullPath?: string - uploadingOffset?: number - completedFiles: number - totalFiles: number - currentFileProgress: number -} - -// TODO(@kern): Use better values -const MAX_CHUNK_SIZE = 10 * 1024 * 1024 // 10 Mi const QR_CODE_SIZE = 128 -function generateURL(slug: string): string { - const hostPrefix = - window.location.protocol + - '//' + - window.location.hostname + - (['80', '443'].includes(window.location.port) - ? '' - : ':' + window.location.port) - return `${hostPrefix}/download/${slug}` -} - -function useUploaderChannel(uploaderPeerID: string): { - loading: boolean - error: Error | null - longSlug: string | undefined - shortSlug: string | undefined - longURL: string | undefined - shortURL: string | undefined -} { - const { isLoading, error, data } = useQuery({ - queryKey: ['uploaderChannel', uploaderPeerID], - queryFn: async () => { - const response = await fetch('/api/create', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ uploaderPeerID }), - }) - if (!response.ok) { - throw new Error('Network response was not ok') - } - return response.json() - }, - }) - - const longURL = data?.longSlug ? generateURL(data.longSlug) : undefined - const shortURL = data?.shortSlug ? generateURL(data.shortSlug) : undefined - - return { - loading: isLoading, - error: error as Error | null, - longSlug: data?.longSlug, - shortSlug: data?.shortSlug, - longURL, - shortURL, - } -} - -function validateOffset( - files: UploadedFile[], - fullPath: string, - offset: number, -): UploadedFile { - const validFile = files.find( - (file) => file.fullPath === fullPath && offset <= file.size, - ) - if (!validFile) { - throw new Error('invalid file offset') - } - return validFile -} - -function useUploaderConnections( - peer: Peer, - files: UploadedFile[], - password: string, -): Array { - const [connections, setConnections] = useState>([]) - - useEffect(() => { - peer.on('connection', (conn: DataConnection) => { - let sendChunkTimeout: NodeJS.Timeout | null = null - const newConn = { - status: UploaderConnectionStatus.Pending, - dataConnection: conn, - completedFiles: 0, - totalFiles: files.length, - currentFileProgress: 0, - } - - setConnections((conns) => [...conns, newConn]) - const updateConnection = ( - fn: (draftConn: UploaderConnection) => void, - ) => { - setConnections((conns) => - produce(conns, (draft) => { - const updatedConn = draft.find((c) => c.dataConnection === conn) - if (!updatedConn) { - return - } - - fn(updatedConn as UploaderConnection) - }), - ) - } - - conn.on('data', (data): void => { - try { - const message = decodeMessage(data) - switch (message.type) { - case MessageType.RequestInfo: { - if (message.password !== password) { - const request: t.TypeOf = { - type: MessageType.Error, - error: 'Invalid password', - } - - conn.send(request) - - updateConnection((draft) => { - if (draft.status !== UploaderConnectionStatus.Pending) { - return - } - - draft.status = UploaderConnectionStatus.InvalidPassword - draft.browserName = message.browserName - draft.browserVersion = message.browserVersion - draft.osName = message.osName - draft.osVersion = message.osVersion - draft.mobileVendor = message.mobileVendor - draft.mobileModel = message.mobileModel - }) - - return - } - - updateConnection((draft) => { - if (draft.status !== UploaderConnectionStatus.Pending) { - return - } - - draft.status = UploaderConnectionStatus.Paused - draft.browserName = message.browserName - draft.browserVersion = message.browserVersion - draft.osName = message.osName - draft.osVersion = message.osVersion - draft.mobileVendor = message.mobileVendor - draft.mobileModel = message.mobileModel - }) - - const fileInfo = files.map((f) => { - return { - fullPath: f.fullPath, - size: f.size, - type: f.type, - } - }) - - const request: t.TypeOf = { - type: MessageType.Info, - files: fileInfo, - } - conn.send(request) - break - } - - case MessageType.Start: { - const fullPath = message.fullPath - let offset = message.offset - const file = validateOffset(files, fullPath, offset) - updateConnection((draft) => { - if (draft.status !== UploaderConnectionStatus.Paused) { - return - } - - draft.status = UploaderConnectionStatus.Uploading - draft.uploadingFullPath = fullPath - draft.uploadingOffset = offset - draft.currentFileProgress = offset / file.size - }) - - const sendNextChunk = () => { - const end = Math.min(file.size, offset + MAX_CHUNK_SIZE) - const chunkSize = end - offset - const final = chunkSize < MAX_CHUNK_SIZE - const request: t.TypeOf = { - type: MessageType.Chunk, - fullPath, - offset, - bytes: file.slice(offset, end), - final, - } - conn.send(request) - - updateConnection((draft) => { - offset = end - draft.uploadingOffset = end - draft.currentFileProgress = end / file.size - - if (final) { - draft.status = UploaderConnectionStatus.Paused - draft.completedFiles += 1 - draft.currentFileProgress = 0 - } else { - sendChunkTimeout = setTimeout(() => { - sendNextChunk() - }, 0) - } - }) - } - sendNextChunk() - - break - } - - case MessageType.Pause: { - updateConnection((draft) => { - if (draft.status !== UploaderConnectionStatus.Uploading) { - return - } - - draft.status = UploaderConnectionStatus.Paused - if (sendChunkTimeout) { - clearTimeout(sendChunkTimeout) - sendChunkTimeout = null - } - }) - break - } - - case MessageType.Done: { - updateConnection((draft) => { - if (draft.status !== UploaderConnectionStatus.Paused) { - return - } - - draft.status = UploaderConnectionStatus.Done - conn.close() - }) - break - } - } - } catch (err) { - console.error(err) - } - }) - - conn.on('close', (): void => { - if (sendChunkTimeout) { - clearTimeout(sendChunkTimeout) - } - - updateConnection((draft) => { - if ( - [ - UploaderConnectionStatus.InvalidPassword, - UploaderConnectionStatus.Done, - ].includes(draft.status) - ) { - return - } - - draft.status = UploaderConnectionStatus.Closed - }) - }) - }) - }, [peer, files, password]) - - return connections -} - -function CopyableInput({ label, value }: { label: string; value: string }) { - const { hasCopied, onCopy } = useClipboard(value) - - return ( -
- {label} -
- - -
-
- ) -} - -function ConnectionListItem({ conn }: { conn: UploaderConnection }) { - const getStatusColor = (status: UploaderConnectionStatus) => { - switch (status) { - case UploaderConnectionStatus.Uploading: - return 'bg-green-500' - case UploaderConnectionStatus.Paused: - return 'bg-yellow-500' - case UploaderConnectionStatus.Done: - return 'bg-blue-500' - case UploaderConnectionStatus.Closed: - return 'bg-red-500' - default: - return 'bg-gray-500' - } - } - - return ( -
-
- - {conn.browserName} {conn.browserVersion} - - - {conn.status} - -
- -
- ) -} - export default function Uploader({ files, password, renewInterval = 5000, + onStop, }: { files: UploadedFile[] password: string renewInterval?: number + onStop: () => void }): JSX.Element { const peer = useWebRTC() const { longSlug, shortSlug, longURL, shortURL } = useUploaderChannel(peer.id) @@ -394,9 +43,18 @@ export default function Uploader({ - {connections.map((conn, i) => ( - - ))} +
+
+

+ {connections.length}{' '} + {connections.length === 1 ? 'Downloader' : 'Downloaders'} +

+ +
+ {connections.map((conn, i) => ( + + ))} +
) } diff --git a/src/hooks/useUploaderChannel.ts b/src/hooks/useUploaderChannel.ts new file mode 100644 index 0000000..031036c --- /dev/null +++ b/src/hooks/useUploaderChannel.ts @@ -0,0 +1,48 @@ +import { useQuery } from '@tanstack/react-query' + +function generateURL(slug: string): string { + const hostPrefix = + window.location.protocol + + '//' + + window.location.hostname + + (['80', '443'].includes(window.location.port) + ? '' + : ':' + window.location.port) + return `${hostPrefix}/download/${slug}` +} + +export function useUploaderChannel(uploaderPeerID: string): { + loading: boolean + error: Error | null + longSlug: string | undefined + shortSlug: string | undefined + longURL: string | undefined + shortURL: string | undefined +} { + const { isLoading, error, data } = useQuery({ + queryKey: ['uploaderChannel', uploaderPeerID], + queryFn: async () => { + const response = await fetch('/api/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ uploaderPeerID }), + }) + if (!response.ok) { + throw new Error('Network response was not ok') + } + return response.json() + }, + }) + + const longURL = data?.longSlug ? generateURL(data.longSlug) : undefined + const shortURL = data?.shortSlug ? generateURL(data.shortSlug) : undefined + + return { + loading: isLoading, + error: error as Error | null, + longSlug: data?.longSlug, + shortSlug: data?.shortSlug, + longURL, + shortURL, + } +} diff --git a/src/hooks/useUploaderConnections.ts b/src/hooks/useUploaderConnections.ts new file mode 100644 index 0000000..e60213f --- /dev/null +++ b/src/hooks/useUploaderConnections.ts @@ -0,0 +1,241 @@ +import { useState, useEffect } from 'react' +import Peer, { DataConnection } from 'peerjs' +import { + UploadedFile, + UploaderConnection, + UploaderConnectionStatus, +} from '../types' +import { decodeMessage, Message, MessageType } from '../messages' +import { validateOffset } from '../utils/fs' +import * as t from 'io-ts' + +// TODO(@kern): Test for better values +const MAX_CHUNK_SIZE = 10 * 1024 * 1024 // 10 Mi + +export function useUploaderConnections( + peer: Peer, + files: UploadedFile[], + password: string, +): Array { + const [connections, setConnections] = useState>([]) + + useEffect(() => { + const listener = (conn: DataConnection) => { + let sendChunkTimeout: NodeJS.Timeout | null = null + const newConn = { + status: UploaderConnectionStatus.Pending, + dataConnection: conn, + completedFiles: 0, + totalFiles: files.length, + currentFileProgress: 0, + } + + setConnections((conns) => [newConn, ...conns]) + const updateConnection = ( + fn: (c: UploaderConnection) => UploaderConnection, + ) => { + setConnections((conns) => + conns.map((c) => (c.dataConnection === conn ? fn(c) : c)), + ) + } + + conn.on('data', (data): void => { + try { + const message = decodeMessage(data) + console.log('message', message) + switch (message.type) { + case MessageType.RequestInfo: { + if (message.password !== password) { + console.log('invalid password') + const request: t.TypeOf = { + type: MessageType.Error, + error: 'Invalid password', + } + + conn.send(request) + + updateConnection((draft) => { + if (draft.status !== UploaderConnectionStatus.Pending) { + return draft + } + + return { + ...draft, + status: UploaderConnectionStatus.InvalidPassword, + browserName: message.browserName, + browserVersion: message.browserVersion, + osName: message.osName, + osVersion: message.osVersion, + mobileVendor: message.mobileVendor, + mobileModel: message.mobileModel, + } + }) + + return + } + + console.log('valid password') + + updateConnection((draft) => { + if (draft.status !== UploaderConnectionStatus.Pending) { + return draft + } + + return { + ...draft, + status: UploaderConnectionStatus.Paused, + browserName: message.browserName, + browserVersion: message.browserVersion, + osName: message.osName, + osVersion: message.osVersion, + mobileVendor: message.mobileVendor, + mobileModel: message.mobileModel, + } + }) + + const fileInfo = files.map((f) => { + return { + fullPath: f.fullPath ?? f.name ?? '', + size: f.size, + type: f.type, + } + }) + + const request: t.TypeOf = { + type: MessageType.Info, + files: fileInfo, + } + + console.log('sending info', request) + conn.send(request) + break + } + + case MessageType.Start: { + const fullPath = message.fullPath + let offset = message.offset + const file = validateOffset(files, fullPath, offset) + updateConnection((draft) => { + if (draft.status !== UploaderConnectionStatus.Paused) { + return draft + } + + return { + ...draft, + status: UploaderConnectionStatus.Uploading, + uploadingFullPath: fullPath, + uploadingOffset: offset, + currentFileProgress: offset / file.size, + } + }) + + const sendNextChunk = () => { + const end = Math.min(file.size, offset + MAX_CHUNK_SIZE) + const chunkSize = end - offset + const final = chunkSize < MAX_CHUNK_SIZE + const request: t.TypeOf = { + type: MessageType.Chunk, + fullPath, + offset, + bytes: file.slice(offset, end), + final, + } + conn.send(request) + + updateConnection((draft) => { + offset = end + if (final) { + return { + ...draft, + status: UploaderConnectionStatus.Paused, + completedFiles: draft.completedFiles + 1, + currentFileProgress: 0, + } + } else { + sendChunkTimeout = setTimeout(() => { + sendNextChunk() + }, 0) + return { + ...draft, + uploadingOffset: end, + currentFileProgress: end / file.size, + } + } + }) + } + sendNextChunk() + + break + } + + case MessageType.Pause: { + updateConnection((draft) => { + if (draft.status !== UploaderConnectionStatus.Uploading) { + return draft + } + + if (sendChunkTimeout) { + clearTimeout(sendChunkTimeout) + sendChunkTimeout = null + } + + return { + ...draft, + status: UploaderConnectionStatus.Paused, + } + }) + break + } + + case MessageType.Done: { + updateConnection((draft) => { + if (draft.status !== UploaderConnectionStatus.Paused) { + return draft + } + + conn.close() + return { + ...draft, + status: UploaderConnectionStatus.Done, + } + }) + break + } + } + } catch (err) { + console.error(err) + } + }) + + conn.on('close', (): void => { + if (sendChunkTimeout) { + clearTimeout(sendChunkTimeout) + } + + updateConnection((draft) => { + if ( + [ + UploaderConnectionStatus.InvalidPassword, + UploaderConnectionStatus.Done, + ].includes(draft.status) + ) { + return draft + } + + return { + ...draft, + status: UploaderConnectionStatus.Closed, + } + }) + }) + } + + peer.on('connection', listener) + + return () => { + peer.off('connection') + } + }, [peer, files, password]) + + return connections +} diff --git a/src/types.ts b/src/types.ts index 086673a..08919d8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1 +1,28 @@ -export type UploadedFile = File & { fullPath: string } +import type { DataConnection } from 'peerjs' + +export type UploadedFile = File & { fullPath?: string; name?: string } + +export enum UploaderConnectionStatus { + Pending = 'PENDING', + Paused = 'PAUSED', + Uploading = 'UPLOADING', + Done = 'DONE', + InvalidPassword = 'INVALID_PASSWORD', + Closed = 'CLOSED', +} + +export type UploaderConnection = { + status: UploaderConnectionStatus + dataConnection: DataConnection + browserName?: string + browserVersion?: string + osName?: string + osVersion?: string + mobileVendor?: string + mobileModel?: string + uploadingFullPath?: string + uploadingOffset?: number + completedFiles: number + totalFiles: number + currentFileProgress: number +} diff --git a/src/utils/fs.ts b/src/utils/fs.ts new file mode 100644 index 0000000..437417e --- /dev/null +++ b/src/utils/fs.ts @@ -0,0 +1,18 @@ +import { UploadedFile } from '../types' + +export function validateOffset( + files: UploadedFile[], + fullPath: string, + offset: number, +): UploadedFile { + const validFile = files.find( + (file) => + (file.fullPath === fullPath || file.name === fullPath) && + offset <= file.size, + ) + debugger + if (!validFile) { + throw new Error('invalid file offset') + } + return validFile +}