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 (
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
+}