From 5d3d019cd6a528e7c89d45cde0ed92440d599a12 Mon Sep 17 00:00:00 2001 From: Jeremy Kahn Date: Mon, 28 Nov 2022 21:18:41 -0600 Subject: [PATCH] feat: [closes #33] Render inline media (#73) * refactor: pass inline media upload data to useRoom * feat: render inline media * fix: don't rescind inline media file offers * refactor: send file offer metadata object * fix: enable re-seeding of inline media files * feat: show loading indicator for inline media * feat: rescind any evicted inline media * feat: display media rendering failure message * feat: prevent user from uploading file if message is sending --- .../ChatTranscript/ChatTranscript.tsx | 4 +- src/components/Message/InlineMedia.tsx | 67 +++++++++ src/components/Message/Message.tsx | 28 ++-- src/components/Room/Room.tsx | 6 +- .../Room/RoomFileUploadControls.tsx | 15 +- src/components/Room/useRoom.ts | 90 ++++++++++-- src/components/Room/useRoomFileShare.ts | 128 ++++++++++++------ .../Shell/PeerDownloadFileButton.tsx | 2 +- src/contexts/RoomContext.ts | 13 +- src/models/chat.ts | 35 ++++- src/models/network.ts | 1 + src/services/FileTransfer/FileTransfer.ts | 21 +-- 12 files changed, 321 insertions(+), 89 deletions(-) create mode 100644 src/components/Message/InlineMedia.tsx diff --git a/src/components/ChatTranscript/ChatTranscript.tsx b/src/components/ChatTranscript/ChatTranscript.tsx index 2fc8634..f4c9268 100644 --- a/src/components/ChatTranscript/ChatTranscript.tsx +++ b/src/components/ChatTranscript/ChatTranscript.tsx @@ -2,11 +2,11 @@ import { HTMLAttributes, useRef, useEffect, useState } from 'react' import cx from 'classnames' import Box from '@mui/material/Box' -import { Message as IMessage } from 'models/chat' +import { Message as IMessage, InlineMedia } from 'models/chat' import { Message } from 'components/Message' export interface ChatTranscriptProps extends HTMLAttributes { - messageLog: Array + messageLog: Array userId: string } diff --git a/src/components/Message/InlineMedia.tsx b/src/components/Message/InlineMedia.tsx new file mode 100644 index 0000000..d1a92f5 --- /dev/null +++ b/src/components/Message/InlineMedia.tsx @@ -0,0 +1,67 @@ +import { useEffect, useRef, useState } from 'react' +import CircularProgress from '@mui/material/CircularProgress' + +import { fileTransfer } from 'services/FileTransfer' +import { Typography } from '@mui/material' + +type TorrentFiles = Awaited> + +interface InlineMediaProps { + magnetURI: string +} + +interface InlineFileProps { + file: TorrentFiles[0] +} + +export const InlineFile = ({ file }: InlineFileProps) => { + const containerRef = useRef(null) + const [didRenderingMediaFail, setDidRenderingMediaFail] = useState(false) + + useEffect(() => { + const { current: container } = containerRef + + if (!container) return + + try { + file.appendTo(container) + } catch (e) { + setDidRenderingMediaFail(true) + } + }, [file, containerRef]) + + return ( +
+ {didRenderingMediaFail && ( + + Media failed to render + + )} +
+ ) +} + +export const InlineMedia = ({ magnetURI }: InlineMediaProps) => { + const [hasDownloadInitiated, setHasDownloadInitiated] = useState(false) + const [downloadedFiles, setDownloadedFiles] = useState([]) + + useEffect(() => { + ;(async () => { + if (hasDownloadInitiated) return + + setHasDownloadInitiated(true) + const files = await fileTransfer.download(magnetURI) + setDownloadedFiles(files) + })() + }, [hasDownloadInitiated, magnetURI]) + + return ( + <> + {hasDownloadInitiated && downloadedFiles.length === 0 ? ( + + ) : ( + downloadedFiles.map(file => ) + )} + + ) +} diff --git a/src/components/Message/Message.tsx b/src/components/Message/Message.tsx index 49e5a19..de8f05f 100644 --- a/src/components/Message/Message.tsx +++ b/src/components/Message/Message.tsx @@ -20,13 +20,19 @@ import { CodeProps } from 'react-markdown/lib/ast-to-react' // @ts-ignore import remarkGfm from 'remark-gfm' -import { Message as IMessage, isMessageReceived } from 'models/chat' +import { + InlineMedia as I_InlineMedia, + Message as IMessage, + isMessageReceived, + isInlineMedia, +} from 'models/chat' import { PeerNameDisplay } from 'components/PeerNameDisplay' +import { InlineMedia } from './InlineMedia' import './Message.sass' export interface MessageProps { - message: IMessage + message: IMessage | I_InlineMedia showAuthor: boolean userId: string } @@ -123,13 +129,17 @@ export const Message = ({ message, showAuthor, userId }: MessageProps) => { }} maxWidth="85%" > - - {message.text} - + {isInlineMedia(message) ? ( + + ) : ( + + {message.text} + + )} diff --git a/src/components/Room/Room.tsx b/src/components/Room/Room.tsx index 391dab9..ea8b856 100644 --- a/src/components/Room/Room.tsx +++ b/src/components/Room/Room.tsx @@ -37,6 +37,7 @@ export function Room({ }: RoomProps) { const { isMessageSending, + handleInlineMediaUpload, messageLog, peerRoom, roomContextValue, @@ -107,7 +108,10 @@ export function Room({ - + diff --git a/src/components/Room/RoomFileUploadControls.tsx b/src/components/Room/RoomFileUploadControls.tsx index cc6949c..f05314a 100644 --- a/src/components/Room/RoomFileUploadControls.tsx +++ b/src/components/Room/RoomFileUploadControls.tsx @@ -1,31 +1,38 @@ -import { ChangeEventHandler, useRef } from 'react' +import { ChangeEventHandler, useContext, 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 { RoomContext } from 'contexts/RoomContext' import { PeerRoom } from 'services/PeerRoom/PeerRoom' import { useRoomFileShare } from './useRoomFileShare' export interface RoomFileUploadControlsProps { + onInlineMediaUpload: (files: File[]) => void peerRoom: PeerRoom } export function RoomFileUploadControls({ peerRoom, + onInlineMediaUpload, }: RoomFileUploadControlsProps) { + const roomContext = useContext(RoomContext) const fileInputRef = useRef(null) + const { isMessageSending } = roomContext + const { - isFileShareButtonEnabled, + isFileSharingEnabled, isSharingFile, handleFileShareStart, handleFileShareStop, sharedFiles, } = useRoomFileShare({ peerRoom, + onInlineMediaUpload, }) const handleToggleScreenShareButtonClick = () => { @@ -51,6 +58,8 @@ export function RoomFileUploadControls({ const shareFileLabel = (sharedFiles && sharedFiles.length === 1 && sharedFiles[0].name) || 'files' + const disableFileUpload = !isFileSharingEnabled || isMessageSending + return ( {isSharingFile ? : } diff --git a/src/components/Room/useRoom.ts b/src/components/Room/useRoom.ts index cbdf572..ae119de 100644 --- a/src/components/Room/useRoom.ts +++ b/src/components/Room/useRoom.ts @@ -11,14 +11,20 @@ import { Message, ReceivedMessage, UnsentMessage, + InlineMedia, + ReceivedInlineMedia, + UnsentInlineMedia, VideoState, ScreenShareState, isMessageReceived, + isInlineMedia, + FileOfferMetadata, } from 'models/chat' import { getPeerName } from 'components/PeerNameDisplay' import { NotificationService } from 'services/Notification' import { Audio as AudioService } from 'services/Audio' import { PeerRoom, PeerHookType } from 'services/PeerRoom' +import { fileTransfer } from 'services/FileTransfer' import { messageTranscriptSizeLimit } from 'config/messaging' @@ -52,14 +58,30 @@ export function useRoom( } = useContext(ShellContext) const settingsContext = useContext(SettingsContext) const [isMessageSending, setIsMessageSending] = useState(false) - const [messageLog, _setMessageLog] = useState< - Array - >([]) + const [messageLog, _setMessageLog] = useState>( + [] + ) const [newMessageAudio] = useState( () => new AudioService(process.env.PUBLIC_URL + '/sounds/new-message.aac') ) - const setMessageLog = (messages: Message[]) => { + const setMessageLog = (messages: Array) => { + if (messages.length > messageTranscriptSizeLimit) { + const evictedMessages = messages.slice( + 0, + messages.length - messageTranscriptSizeLimit + ) + + for (const message of evictedMessages) { + if ( + isInlineMedia(message) && + fileTransfer.isOffering(message.magnetURI) + ) { + fileTransfer.rescind(message.magnetURI) + } + } + } + _setMessageLog(messages.slice(-messageTranscriptSizeLimit)) } @@ -77,12 +99,13 @@ export function useRoom( Record >({}) - const [peerOfferedFileIds, setPeerOfferedFileIds] = useState< - Record + const [peerOfferedFileMetadata, setPeerOfferedFileMetadata] = useState< + Record >({}) const roomContextValue = useMemo( () => ({ + isMessageSending, selfVideoStream, setSelfVideoStream, peerVideoStreams, @@ -91,10 +114,11 @@ export function useRoom( setSelfScreenStream, peerScreenStreams, setPeerScreenStreams, - peerOfferedFileIds, - setPeerOfferedFileIds, + peerOfferedFileMetadata, + setPeerOfferedFileMetadata, }), [ + isMessageSending, selfVideoStream, setSelfVideoStream, peerVideoStreams, @@ -103,8 +127,8 @@ export function useRoom( setSelfScreenStream, peerScreenStreams, setPeerScreenStreams, - peerOfferedFileIds, - setPeerOfferedFileIds, + peerOfferedFileMetadata, + setPeerOfferedFileMetadata, ] ) @@ -129,12 +153,15 @@ export function useRoom( ) const [sendMessageTranscript, receiveMessageTranscript] = usePeerRoomAction< - ReceivedMessage[] + Array >(peerRoom, PeerActions.MESSAGE_TRANSCRIPT) const [sendPeerMessage, receivePeerMessage] = usePeerRoomAction(peerRoom, PeerActions.MESSAGE) + const [sendPeerInlineMedia, receivePeerInlineMedia] = + usePeerRoomAction(peerRoom, PeerActions.MEDIA_MESSAGE) + const sendMessage = async (message: string) => { if (isMessageSending) return @@ -254,7 +281,48 @@ export function useRoom( Object.values({ ...peerVideoStreams, ...peerScreenStreams }).length > 0 ) + const handleInlineMediaUpload = async (files: File[]) => { + const fileOfferId = await fileTransfer.offer(files) + + const unsentInlineMedia: UnsentInlineMedia = { + authorId: userId, + magnetURI: fileOfferId, + timeSent: Date.now(), + id: getUuid(), + } + + setIsMessageSending(true) + setMessageLog([...messageLog, unsentInlineMedia]) + + await sendPeerInlineMedia(unsentInlineMedia) + + setMessageLog([ + ...messageLog, + { ...unsentInlineMedia, timeReceived: Date.now() }, + ]) + setIsMessageSending(false) + } + + receivePeerInlineMedia(inlineMedia => { + const userSettings = settingsContext.getUserSettings() + + if (!tabHasFocus) { + if (userSettings.playSoundOnNewMessage) { + newMessageAudio.play() + } + + if (userSettings.showNotificationOnNewMessage) { + NotificationService.showNotification( + `${getPeerName(inlineMedia.authorId)} shared media` + ) + } + } + + setMessageLog([...messageLog, { ...inlineMedia, timeReceived: Date.now() }]) + }) + return { + handleInlineMediaUpload, isMessageSending, messageLog, peerRoom, diff --git a/src/components/Room/useRoomFileShare.ts b/src/components/Room/useRoomFileShare.ts index a3233a6..5483c97 100644 --- a/src/components/Room/useRoomFileShare.ts +++ b/src/components/Room/useRoomFileShare.ts @@ -4,7 +4,7 @@ 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 { FileOfferMetadata, Peer } from 'models/chat' import { PeerRoom, PeerHookType } from 'services/PeerRoom' import { fileTransfer } from 'services/FileTransfer/index' @@ -12,44 +12,61 @@ import { fileTransfer } from 'services/FileTransfer/index' import { usePeerRoomAction } from './usePeerRoomAction' interface UseRoomFileShareConfig { + onInlineMediaUpload: (files: File[]) => void peerRoom: PeerRoom } -export function useRoomFileShare({ peerRoom }: UseRoomFileShareConfig) { +const isInlineMediaFile = (file: File) => { + return ['image', 'audio', 'video'].includes(file.type.split('/')[0]) +} + +export function useRoomFileShare({ + onInlineMediaUpload, + peerRoom, +}: UseRoomFileShareConfig) { const shellContext = useContext(ShellContext) const roomContext = useContext(RoomContext) const [sharedFiles, setSharedFiles] = useState(null) - const [selfFileOfferId, setFileOfferId] = useState(null) - const [isFileShareButtonEnabled, setIsFileShareButtonEnabled] = useState(true) + const [selfFileOfferMagnetUri, setFileOfferMagnetUri] = useState< + string | null + >(null) + const [isFileSharingEnabled, setIsFileSharingEnabled] = useState(true) const { peerList, setPeerList } = shellContext - const { peerOfferedFileIds, setPeerOfferedFileIds } = roomContext + const { peerOfferedFileMetadata, setPeerOfferedFileMetadata } = roomContext - const [sendFileOfferId, receiveFileOfferId] = usePeerRoomAction< - string | null - >(peerRoom, PeerActions.FILE_OFFER) + const [sendFileOfferMetadata, receiveFileOfferMetadata] = + usePeerRoomAction( + peerRoom, + PeerActions.FILE_OFFER + ) - receiveFileOfferId((fileOfferId, peerId) => { - if (fileOfferId) { - setPeerOfferedFileIds({ [peerId]: fileOfferId }) + receiveFileOfferMetadata((fileOfferMetadata, peerId) => { + if (fileOfferMetadata) { + setPeerOfferedFileMetadata({ [peerId]: fileOfferMetadata }) } else { - const fileOfferId = peerOfferedFileIds[peerId] + const fileOfferMetadata = peerOfferedFileMetadata[peerId] + const { magnetURI, isAllInlineMedia } = fileOfferMetadata - if (fileOfferId && fileTransfer.isOffering(fileOfferId)) { - fileTransfer.rescind(fileOfferId) + if ( + fileOfferMetadata && + fileTransfer.isOffering(magnetURI) && + !isAllInlineMedia + ) { + fileTransfer.rescind(magnetURI) } - const newFileOfferIds = { ...peerOfferedFileIds } - delete newFileOfferIds[peerId] + const newFileOfferMetadata = { ...peerOfferedFileMetadata } + delete newFileOfferMetadata[peerId] - setPeerOfferedFileIds(newFileOfferIds) + setPeerOfferedFileMetadata(newFileOfferMetadata) } const newPeerList = peerList.map(peer => { const newPeer: Peer = { ...peer } if (peer.peerId === peerId) { - newPeer.offeredFileId = fileOfferId + newPeer.offeredFileId = fileOfferMetadata?.magnetURI ?? null } return newPeer @@ -58,68 +75,93 @@ export function useRoomFileShare({ peerRoom }: UseRoomFileShareConfig) { setPeerList(newPeerList) }) + const isEveryFileInlineMedia = (files: FileList | null) => + Boolean(files && [...files].every(isInlineMediaFile)) + peerRoom.onPeerJoin(PeerHookType.FILE_SHARE, async (peerId: string) => { - if (!selfFileOfferId) return + if (!selfFileOfferMagnetUri) 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. + // receiveFileOfferMetadata is out of date and prevents this peer from ever + // being added to the receiver's peer list. Deferring the + // sendFileOfferMetadata call to the next tick serves as a workaround. await sleep(1) - sendFileOfferId(selfFileOfferId, peerId) + sendFileOfferMetadata( + { + magnetURI: selfFileOfferMagnetUri, + isAllInlineMedia: isEveryFileInlineMedia(sharedFiles), + }, + peerId + ) }) peerRoom.onPeerLeave(PeerHookType.FILE_SHARE, (peerId: string) => { - const fileOfferId = peerOfferedFileIds[peerId] + const fileOfferMetadata = peerOfferedFileMetadata[peerId] - if (!fileOfferId) return + if (!fileOfferMetadata) return - if (fileTransfer.isOffering(fileOfferId)) { - fileTransfer.rescind(fileOfferId) + const { magnetURI, isAllInlineMedia } = fileOfferMetadata + + if (fileTransfer.isOffering(magnetURI) && !isAllInlineMedia) { + fileTransfer.rescind(magnetURI) } - const newPeerFileOfferIds = { ...peerOfferedFileIds } - delete newPeerFileOfferIds[peerId] - setPeerOfferedFileIds(newPeerFileOfferIds) + const newPeerFileOfferMetadata = { ...peerOfferedFileMetadata } + delete newPeerFileOfferMetadata[peerId] + setPeerOfferedFileMetadata(newPeerFileOfferMetadata) }) const handleFileShareStart = async (files: FileList) => { + const inlineMediaFiles = [...files].filter(isInlineMediaFile) + setSharedFiles(files) - setIsFileShareButtonEnabled(false) + setIsFileSharingEnabled(false) - const fileOfferId = await fileTransfer.offer(files) - sendFileOfferId(fileOfferId) - setFileOfferId(fileOfferId) + const magnetURI = await fileTransfer.offer(files) - setIsFileShareButtonEnabled(true) + if (inlineMediaFiles.length > 0) { + onInlineMediaUpload(inlineMediaFiles) + } + + sendFileOfferMetadata({ + magnetURI, + isAllInlineMedia: isEveryFileInlineMedia(files), + }) + + setFileOfferMagnetUri(magnetURI) + setIsFileSharingEnabled(true) } const handleFileShareStop = () => { - sendFileOfferId(null) - setFileOfferId(null) + sendFileOfferMetadata(null) + setFileOfferMagnetUri(null) - if (selfFileOfferId && fileTransfer.isOffering(selfFileOfferId)) { - fileTransfer.rescind(selfFileOfferId) + if ( + selfFileOfferMagnetUri && + fileTransfer.isOffering(selfFileOfferMagnetUri) && + !isEveryFileInlineMedia(sharedFiles) + ) { + fileTransfer.rescind(selfFileOfferMagnetUri) } } useEffect(() => { return () => { fileTransfer.rescindAll() - sendFileOfferId(null) + sendFileOfferMetadata(null) } - }, [sendFileOfferId]) + }, [sendFileOfferMetadata]) - const isSharingFile = Boolean(selfFileOfferId) + const isSharingFile = Boolean(selfFileOfferMagnetUri) return { handleFileShareStart, handleFileShareStop, - isFileShareButtonEnabled, + isFileSharingEnabled, isSharingFile, sharedFiles, } diff --git a/src/components/Shell/PeerDownloadFileButton.tsx b/src/components/Shell/PeerDownloadFileButton.tsx index 991ba2f..48b14fc 100644 --- a/src/components/Shell/PeerDownloadFileButton.tsx +++ b/src/components/Shell/PeerDownloadFileButton.tsx @@ -38,7 +38,7 @@ export const PeerDownloadFileButton = ({ setDownloadProgress(null) try { - await fileTransfer.download(offeredFileId, { onProgress }) + await fileTransfer.download(offeredFileId, { doSave: true, onProgress }) } catch (e) { if (isError(e)) { shellContext.showAlert(e.message, { diff --git a/src/contexts/RoomContext.ts b/src/contexts/RoomContext.ts index a2fcba6..37c4e18 100644 --- a/src/contexts/RoomContext.ts +++ b/src/contexts/RoomContext.ts @@ -1,6 +1,8 @@ import { createContext, Dispatch, SetStateAction } from 'react' +import { FileOfferMetadata } from 'models/chat' interface RoomContextProps { + isMessageSending: boolean selfVideoStream: MediaStream | null setSelfVideoStream: Dispatch> peerVideoStreams: Record @@ -9,11 +11,14 @@ interface RoomContextProps { setSelfScreenStream: Dispatch> peerScreenStreams: Record setPeerScreenStreams: Dispatch>> - peerOfferedFileIds: Record - setPeerOfferedFileIds: Dispatch>> + peerOfferedFileMetadata: Record + setPeerOfferedFileMetadata: Dispatch< + SetStateAction> + > } export const RoomContext = createContext({ + isMessageSending: false, selfVideoStream: null, setSelfVideoStream: () => {}, peerVideoStreams: {}, @@ -22,6 +27,6 @@ export const RoomContext = createContext({ setSelfScreenStream: () => {}, peerScreenStreams: {}, setPeerScreenStreams: () => {}, - peerOfferedFileIds: {}, - setPeerOfferedFileIds: () => {}, + peerOfferedFileMetadata: {}, + setPeerOfferedFileMetadata: () => {}, }) diff --git a/src/models/chat.ts b/src/models/chat.ts index efb0147..bb5a6f3 100644 --- a/src/models/chat.ts +++ b/src/models/chat.ts @@ -5,6 +5,22 @@ export interface UnsentMessage { authorId: string } +export interface ReceivedMessage extends UnsentMessage { + timeReceived: number +} + +export type Message = UnsentMessage | ReceivedMessage + +export interface UnsentInlineMedia extends Omit { + magnetURI: string +} + +export interface ReceivedInlineMedia extends UnsentInlineMedia { + timeReceived: number +} + +export type InlineMedia = UnsentInlineMedia | ReceivedInlineMedia + export enum AudioState { PLAYING = 'PLAYING', STOPPED = 'STOPPED', @@ -34,12 +50,17 @@ export interface Peer { offeredFileId: string | null } -export interface ReceivedMessage extends UnsentMessage { - timeReceived: number +export const isMessageReceived = ( + message: Message | InlineMedia +): message is ReceivedMessage | ReceivedInlineMedia => 'timeReceived' in message + +export const isInlineMedia = ( + message: Message | InlineMedia +): message is InlineMedia => { + return 'magnetURI' in message } -export const isMessageReceived = ( - message: Message -): message is ReceivedMessage => 'timeReceived' in message - -export type Message = UnsentMessage | ReceivedMessage +export interface FileOfferMetadata { + magnetURI: string + isAllInlineMedia: boolean +} diff --git a/src/models/network.ts b/src/models/network.ts index 068d02e..6dfd329 100644 --- a/src/models/network.ts +++ b/src/models/network.ts @@ -1,6 +1,7 @@ // NOTE: Action names are limited to 12 characters, otherwise Trystero breaks. export enum PeerActions { MESSAGE = 'MESSAGE', + MEDIA_MESSAGE = 'MEDIA_MSG', MESSAGE_TRANSCRIPT = 'MSG_XSCRIPT', PEER_NAME = 'PEER_NAME', AUDIO_CHANGE = 'AUDIO_CHANGE', diff --git a/src/services/FileTransfer/FileTransfer.ts b/src/services/FileTransfer/FileTransfer.ts index c1d4845..fc9d779 100644 --- a/src/services/FileTransfer/FileTransfer.ts +++ b/src/services/FileTransfer/FileTransfer.ts @@ -10,6 +10,7 @@ import { streamSaverUrl } from 'config/streamSaverUrl' streamSaver.mitm = streamSaverUrl interface DownloadOpts { + doSave?: boolean onProgress?: (progress: number) => void } @@ -72,7 +73,7 @@ export class FileTransfer { window.addEventListener('beforeunload', this.handleBeforePageUnload) } - async download(magnetURI: string, { onProgress }: DownloadOpts = {}) { + async download(magnetURI: string, { onProgress, doSave }: DownloadOpts = {}) { let torrent = this.torrents[magnetURI] if (!torrent) { @@ -104,17 +105,21 @@ export class FileTransfer { torrent.on('download', handleDownload) - try { - await this.saveTorrentFiles(torrent) - } catch (e) { - torrent.off('download', handleDownload) + if (doSave) { + try { + await this.saveTorrentFiles(torrent) + } catch (e) { + torrent.off('download', handleDownload) - // Propagate error to the UI - throw e + // Propagate error to the UI + throw e + } } + + return torrent.files } - async offer(files: FileList) { + async offer(files: Parameters[0]) { const { isPrivate } = await detectIncognito() const torrent = await new Promise(res => {