import { ReactElement, useContext, useLayoutEffect, useMemo, useState, useRef, useEffect, useCallback } from 'react' import './FileItem.scss' import { GetIconElement } from '../../../utils/GetIconElement' import { ContextMenu } from '../../ContextMenu/ContextMenu' import { useContextMenu } from '../../../hooks/useContextMenu' import { Context as SettingsContext } from '../../../../../providers/Settings' import { ActionTag, DownloadProgress, TrackDownloadProps, ViewType } from '../../../constants/transfers' import { GetInfoModal } from '../../GetInfoModal/GetInfoModal' import { VersionHistoryModal } from '../../VersionHistoryModal/VersionHistoryModal' import { DeleteFileModal } from '../../DeleteFileModal/DeleteFileModal' import { RenameFileModal } from '../../RenameFileModal/RenameFileModal' import { buildGetInfoGroups } from '../../../utils/infoGroups' import type { FilePropertyGroup } from '../../../utils/infoGroups' import { useView } from '../../../../../pages/filemanager/ViewContext' import type { DriveInfo, FileInfo } from '@solarpunkltd/file-manager-lib' import { Context as FMContext } from '../../../../../providers/FileManager' import { DestroyDriveModal } from '../../DestroyDriveModal/DestroyDriveModal' import { ConfirmModal } from '../../ConfirmModal/ConfirmModal' import { capitalizeFirstLetter, Dir, formatBytes, isTrashed, safeSetState } from '../../../utils/common' import { FileAction } from '../../../constants/transfers' import { startDownloadingQueue, createDownloadAbort } from '../../../utils/download' import { computeContextMenuPosition } from '../../../utils/ui' import { getUsableStamps, handleDestroyDrive } from '../../../utils/bee' import { PostageBatch } from '@ethersphere/bee-js' interface FileItemProps { fileInfo: FileInfo onDownload: (props: TrackDownloadProps) => (dp: DownloadProgress) => void showDriveColumn?: boolean driveName: string selected?: boolean onToggleSelected?: (fi: FileInfo, checked: boolean) => void bulkSelectedCount?: number onBulk: { download?: () => void restore?: () => void forget?: () => void destroy?: () => void delete?: () => void } setErrorMessage?: (error: string) => void } export function FileItem({ fileInfo, onDownload, showDriveColumn, driveName, selected = false, onToggleSelected, bulkSelectedCount, onBulk, setErrorMessage, }: FileItemProps): ReactElement { const { showContext, pos, contextRef, handleContextMenu, handleCloseContext } = useContextMenu() const { fm, currentDrive, files, drives, setShowError } = useContext(FMContext) const { beeApi } = useContext(SettingsContext) const { view } = useView() const [driveStamp, setDriveStamp] = useState(undefined) const [safePos, setSafePos] = useState(pos) const [dropDir, setDropDir] = useState(Dir.Down) const [showGetInfoModal, setShowGetInfoModal] = useState(false) const [infoGroups, setInfoGroups] = useState(null) const [showVersionHistory, setShowVersionHistory] = useState(false) const [showDeleteModal, setShowDeleteModal] = useState(false) const [showRenameModal, setShowRenameModal] = useState(false) const [showDestroyDriveModal, setShowDestroyDriveModal] = useState(false) const [destroyDrive, setDestroyDrive] = useState(null) const [confirmForget, setConfirmForget] = useState(false) const isMountedRef = useRef(true) const rafIdRef = useRef(null) const size = formatBytes(fileInfo.customMetadata?.size) const dateMod = new Date(fileInfo.timestamp || 0).toLocaleDateString() const isTrashedFile = isTrashed(fileInfo) const statusLabel = isTrashedFile ? 'Trash' : 'Active' useEffect(() => { isMountedRef.current = true const getStamps = async () => { const stamps = await getUsableStamps(beeApi) const driveStamp = stamps.find(s => drives.some(d => d.batchId.toString() === s.batchID.toString() && d.id === fileInfo.driveId), ) safeSetState(isMountedRef, setDriveStamp)(driveStamp) } getStamps() return () => { isMountedRef.current = false if (rafIdRef.current !== null) { cancelAnimationFrame(rafIdRef.current) } } }, [beeApi, drives, fileInfo.driveId]) const openGetInfo = useCallback(async () => { if (!fm || !isMountedRef.current) return const groups = await buildGetInfoGroups(fm, fileInfo, driveName, driveStamp) setInfoGroups(groups) setShowGetInfoModal(true) }, [fm, fileInfo, driveName, driveStamp]) const takenNames = useMemo(() => { if (!currentDrive || !files) return new Set() const wanted = currentDrive.batchId.toString() const sameDrive = files.filter(fi => fi.batchId.toString() === wanted) const out = new Set() sameDrive.forEach(fi => { if (fi.topic.toString() !== fileInfo.topic.toString()) out.add(fi.name) }) return out }, [files, currentDrive, fileInfo.topic]) const handleItemContextMenu = (e: React.MouseEvent) => { if (e.shiftKey) return handleContextMenu(e) } // TODO: handleOpen shall only be available for images, videos etc... -> do not download 10GB into memory const handleDownload = useCallback( async (isNewWindow?: boolean) => { if (!fm || !beeApi) return handleCloseContext() const rawSize = fileInfo.customMetadata?.size const expectedSize = rawSize ? Number(rawSize) : undefined createDownloadAbort(fileInfo.name) await startDownloadingQueue( fm, [fileInfo], [onDownload({ name: fileInfo.name, size: formatBytes(rawSize), expectedSize })], isNewWindow, ) }, [handleCloseContext, fm, beeApi, fileInfo, onDownload], ) // TODO: refactor doTrash, doRecover, doForget to a single function with action param and remove switch case mybe const doTrash = useCallback(async () => { if (!fm) return const withMeta: FileInfo = { ...fileInfo, customMetadata: { ...(fileInfo.customMetadata ?? {}), lifecycle: capitalizeFirstLetter(ActionTag.Trashed), lifecycleAt: new Date().toISOString(), }, } await fm.trashFile(withMeta) }, [fm, fileInfo]) const doRecover = useCallback(async () => { if (!fm) return const withMeta: FileInfo = { ...fileInfo, customMetadata: { ...(fileInfo.customMetadata ?? {}), lifecycle: capitalizeFirstLetter(ActionTag.Recovered), lifecycleAt: new Date().toISOString(), }, } await fm.recoverFile(withMeta) }, [fm, fileInfo]) const doForget = useCallback(async () => { if (!fm) return await fm.forgetFile(fileInfo) }, [fm, fileInfo]) const showDestroyDrive = useCallback(() => { setDestroyDrive(currentDrive || null) setShowDestroyDriveModal(true) }, [currentDrive]) const doRename = useCallback( async (newName: string) => { if (!fm || !currentDrive) return if (takenNames.has(newName)) throw new Error('name-taken') try { await fm.upload( currentDrive, { name: newName, topic: fileInfo.topic, file: { reference: fileInfo.file.reference, historyRef: fileInfo.file.historyRef, }, customMetadata: fileInfo.customMetadata, files: [], }, { actHistoryAddress: fileInfo.file.historyRef, }, ) } catch (e: unknown) { setErrorMessage?.(`Error renaming file ${fileInfo.name}`) setShowError(true) } }, [fm, currentDrive, fileInfo, takenNames, setErrorMessage, setShowError], ) const MenuItem = ({ disabled, danger, onClick, children, }: { disabled?: boolean danger?: boolean onClick?: () => void children: React.ReactNode }) => (
{children}
) const isBulk = (bulkSelectedCount ?? 0) > 1 const renderContextMenuItems = useCallback(() => { const viewItem = ( handleDownload(true)}> View / Open ) const downloadItem = isBulk ? ( Download ) : ( handleDownload(false)}>Download ) const getInfoItem = ( { handleCloseContext() openGetInfo() }} > Get info ) if (view === ViewType.File) { return ( <> {viewItem} {downloadItem} { handleCloseContext() setShowRenameModal(true) }} > Rename
{ handleCloseContext() setShowVersionHistory(true) }} > Version history { handleCloseContext() if (isBulk) onBulk.delete?.() else setShowDeleteModal(true) }} > Delete
{getInfoItem} ) } return ( <> {viewItem} {downloadItem}
{isBulk ? ( <> Restore Destroy Forget permanently ) : ( <> { handleCloseContext() doRecover() }} > Restore { handleCloseContext() const parentDrive = drives.find(d => d.id.toString() === fileInfo.driveId.toString()) if (parentDrive) { setDestroyDrive(parentDrive) setShowDestroyDriveModal(true) } else if (currentDrive) { setDestroyDrive(currentDrive) setShowDestroyDriveModal(true) } else { setErrorMessage?.('Unable to resolve drive for this file.') setShowError(true) } }} > Destroy { handleCloseContext() setConfirmForget(true) }} > Forget permanently )}
{getInfoItem} ) }, [ isBulk, view, handleDownload, handleCloseContext, openGetInfo, doRecover, onBulk, currentDrive, drives, fileInfo.driveId, setErrorMessage, setShowError, ]) useLayoutEffect(() => { if (!showContext) return if (rafIdRef.current !== null) { cancelAnimationFrame(rafIdRef.current) } rafIdRef.current = requestAnimationFrame(() => { const menu = contextRef.current if (!menu) return const menuRect = menu.getBoundingClientRect() const containerEl = (menu.offsetParent as HTMLElement) ?? null const containerRect = containerEl?.getBoundingClientRect() ?? null const { safePos: s, dropDir: d } = computeContextMenuPosition({ clickPos: pos, menuRect: menuRect, viewport: { w: window.innerWidth, h: window.innerHeight }, margin: 8, containerRect, }) const topLeft = containerRect ? { x: Math.round(s.x - containerRect.left), y: Math.round(s.y - containerRect.top) } : s setSafePos(topLeft) setDropDir(d) rafIdRef.current = null }) return () => { if (rafIdRef.current !== null) { cancelAnimationFrame(rafIdRef.current) rafIdRef.current = null } } }, [showContext, pos, contextRef]) if (!currentDrive || !fm || !beeApi) { return
Error
} return (
onToggleSelected?.(fileInfo, e.target.checked)} onClick={e => e.stopPropagation()} />
handleDownload(true)}> {fileInfo.name}
{showDriveColumn && (
{driveName} {statusLabel}
)}
{size}
{dateMod}
{showContext && (
e.stopPropagation()} onClick={e => e.stopPropagation()} > {renderContextMenuItems()}
)} {showGetInfoModal && infoGroups && ( { setShowGetInfoModal(false) }} /> )} {showVersionHistory && ( { setShowVersionHistory(false) }} onDownload={onDownload} /> )} {showDeleteModal && ( { setShowDeleteModal(false) }} onProceed={action => { setShowDeleteModal(false) switch (action) { case FileAction.Trash: doTrash() break case FileAction.Forget: setConfirmForget(true) break case FileAction.Destroy: showDestroyDrive() break default: break } }} /> )} {showRenameModal && ( { const sameDrive = files.filter(fi => fi.driveId.toString() === currentDrive.id.toString()) const names = sameDrive.map(fi => fi.name).filter(n => n && n !== fileInfo.name) return new Set(names) })()} onCancelClick={() => { setShowRenameModal(false) }} onProceed={async newName => { try { setShowRenameModal(false) await doRename(newName) } catch { safeSetState(isMountedRef, setShowRenameModal)(true) } }} /> )} {confirmForget && ( This removes {fileInfo.name} from your view.
The data remains on Swarm until the drive expires. } confirmLabel="Forget" cancelLabel="Cancel" onConfirm={async () => { await doForget() safeSetState(isMountedRef, setConfirmForget)(false) }} onCancel={() => { setConfirmForget(false) }} /> )} {showDestroyDriveModal && destroyDrive && ( { setShowDestroyDriveModal(false) setDestroyDrive(null) }} doDestroy={async () => { setShowDestroyDriveModal(false) await handleDestroyDrive( beeApi, fm, destroyDrive, () => { setShowDestroyDriveModal(false) setDestroyDrive(null) }, e => { setShowDestroyDriveModal(false) setErrorMessage?.(`Error destroying drive: ${destroyDrive.name}: ${e}`) setShowError(true) }, ) }} /> )}
) }