import { PostageBatch } from '@ethersphere/bee-js' import { DriveInfo } from '@solarpunkltd/file-manager-lib' import React, { ReactElement, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react' import { useSearch } from '../../../../pages/filemanager/SearchContext' import { useView } from '../../../../pages/filemanager/ViewContext' import { Context as FMContext } from '../../../../providers/FileManager' import { Context as SettingsContext } from '../../../../providers/Settings' import { FileAction, FileTransferType, TransferStatus, ViewType } from '../../constants/transfers' import { BulkActionsResult, useBulkActions } from '../../hooks/useBulkActions' import { useContextMenu } from '../../hooks/useContextMenu' import { useDragAndDrop } from '../../hooks/useDragAndDrop' import { useFileFiltering } from '../../hooks/useFileFiltering' import { SortDir, SortKey, useSorting } from '../../hooks/useSorting' import { useTransfers } from '../../hooks/useTransfers' import { handleDestroyAndForgetDrive } from '../../utils/bee' import { Dir, getFileId, Point, safeSetState } from '../../utils/common' import { computeContextMenuPosition } from '../../utils/ui' import { ProgressDestroyModal } from '../DestroyDriveModal/DestroyDriveModal' import { ErrorModal } from '../ErrorModal/ErrorModal' import { FileProgressNotification } from '../FileProgressNotification/FileProgressNotification' import { NotificationBar } from '../NotificationBar/NotificationBar' import { FileBrowserContent } from './FileBrowserContent/FileBrowserContent' import { FileBrowserHeader } from './FileBrowserHeader/FileBrowserHeader' import { FileBrowserContextMenu } from './FileBrowserMenu/FileBrowserContextMenu' import { FileBrowserTopBar } from './FileBrowserTopBar/FileBrowserTopBar' import { FileBrowserModals } from './FileBrowserModals' import './FileBrowser.scss' function DestroyProgressModal({ isDestroying, isProgressModalOpen, currentDrive, onMinimize, }: { isDestroying: boolean isProgressModalOpen: boolean currentDrive?: DriveInfo onMinimize: () => void }) { if (isProgressModalOpen && isDestroying && currentDrive) { return } return null } function DestroyingOverlay({ isDestroying, onClick }: { isDestroying: boolean; onClick: () => void }) { if (!isDestroying) return null return (
Destroying drive…
) } function ErrorModalBlock({ showError, label, onOk, }: { showError: boolean label: string onOk: () => void }): ReactElement | null { if (!showError) { return null } return } const extractFilesFromClipboardEvent = (e: React.ClipboardEvent): File[] => { const out: File[] = [] const items = e.clipboardData?.items ?? [] for (let i = 0; i < items.length; i++) { const it = items[i] if (it.kind === 'file') { const f = it.getAsFile() if (f) out.push(f) } } return out } interface FileBrowserProps { errorMessage?: string setErrorMessage?: (error: string) => void } type FileBrowserContextMenuBlockProps = { showContext: boolean contextRef: React.RefObject safePos: { x: number; y: number } dropDir: Dir drives: DriveInfo[] view: ViewType bulk: BulkActionsResult adminStamp: PostageBatch | undefined doRefresh: () => void onContextUploadFile: () => void setConfirmBulkRestore: (b: boolean) => void setShowBulkDeleteModal: (b: boolean) => void setShowDestroyDriveModal: (b: boolean) => void } function FileBrowserContextMenuBlock({ showContext, contextRef, safePos, dropDir, drives, view, bulk, adminStamp, doRefresh, onContextUploadFile, setConfirmBulkRestore, setShowBulkDeleteModal, setShowDestroyDriveModal, }: FileBrowserContextMenuBlockProps): ReactElement | null { if (!showContext) { return null } return (
e.stopPropagation()} onClick={e => e.stopPropagation()} > bulk.download(bulk.selectedFiles)} onBulkRestore={() => setConfirmBulkRestore(true)} onBulkDelete={() => setShowBulkDeleteModal(true)} onBulkDestroy={() => setShowDestroyDriveModal(true)} onBulkForget={() => bulk.forget(bulk.selectedFiles)} />
) } export function FileBrowser({ errorMessage, setErrorMessage }: FileBrowserProps): ReactElement { const { showContext, pos, contextRef, handleContextMenu, handleCloseContext } = useContextMenu() const { view, setActualItemView } = useView() const { beeApi } = useContext(SettingsContext) const { files, adminDrive, currentDrive, resync, drives, fm, showError, setShowError } = useContext(FMContext) const { uploadFiles, isUploading, uploadItems, isDownloading, downloadItems, trackDownload, conflictPortal, cancelOrDismissUpload, cancelOrDismissDownload, dismissAllUploads, dismissAllDownloads, } = useTransfers({ setErrorMessage }) const { query, scope, includeActive, includeTrashed } = useSearch() const [safePos, setSafePos] = useState(pos) const [dropDir, setDropDir] = useState(Dir.Down) const contentRef = useRef(null) const bodyRef = useRef(null) const isMountedRef = useRef(true) const rafIdRef = useRef(null) const [showBulkDeleteModal, setShowBulkDeleteModal] = useState(false) const [showDestroyDriveModal, setShowDestroyDriveModal] = useState(false) const [isDestroying, setIsDestroying] = useState(false) const [isProgressModalOpen, setIsProgressModalOpen] = useState(false) const [confirmBulkForget, setConfirmBulkForget] = useState(false) const [confirmBulkRestore, setConfirmBulkRestore] = useState(false) const [isRefreshing, setIsRefreshing] = useState(false) const [pendingCancelUpload, setPendingCancelUpload] = useState(null) const [pendingCancelDownload, setPendingCancelDownload] = useState(null) const q = query.trim().toLowerCase() const isSearchMode = q.length > 0 const getDriveName = useCallback( (driveId: string): string => { const match = drives.find(d => d.id.toString() === driveId) return match?.name ?? '' }, [drives], ) const openTopbarMenu = (anchorEl: HTMLElement) => { const r = anchorEl.getBoundingClientRect() const bodyRect = bodyRef.current?.getBoundingClientRect() const clickX = Math.round(r.right - 6) const minY = (bodyRect?.top ?? 0) + 8 const clickY = Math.max(Math.round(r.bottom + 6), minY) const fakeEvt = { preventDefault: () => {}, stopPropagation: () => {}, clientX: clickX, clientY: clickY, } as React.MouseEvent handleContextMenu(fakeEvt) } const { listToRender } = useFileFiltering({ files, currentDrive: currentDrive || null, view, isSearchMode, query: q, scope, includeActive, includeTrashed, }) const { sorted, sort, toggle, reset } = useSorting(listToRender, { persist: false, defaultState: { key: SortKey.Timestamp, dir: SortDir.Desc }, getDriveName, }) const sortedKey = sorted.map(f => getFileId(f)).join('|') // eslint-disable-next-line react-hooks/exhaustive-deps const stableSorted = useMemo(() => sorted, [sortedKey, files]) const bulk = useBulkActions({ listToRender, trackDownload, }) const { isDragging, handleDragEnter, handleDragOver, handleDragLeave, handleDrop, handleOverlayDrop } = useDragAndDrop({ onFilesDropped: uploadFiles, currentDrive: currentDrive || null, }) const onFileSelected = (e: React.ChangeEvent) => { const files = e.target.files if (files && files.length > 0) { uploadFiles(files) } e.target.value = '' } const onContextUploadFile = () => { bulk.uploadFromPicker() requestAnimationFrame(() => handleCloseContext()) } const handlePaste = (e: React.ClipboardEvent) => { const files = extractFilesFromClipboardEvent(e) if (files.length === 0) return e.preventDefault() uploadFiles(files) } const handleFileBrowserContextMenu = (e: React.MouseEvent) => { const t = e.target as HTMLElement if (t.closest('.fm-file-item-context-menu, .fm-file-browser-context-menu')) return if (!e.shiftKey && t.closest('.fm-file-item-content')) return e.preventDefault() e.stopPropagation() handleContextMenu(e) } const handleDeleteModalProceed = async (action: FileAction) => { setShowBulkDeleteModal(false) if (action === FileAction.Trash) { return await bulk.trash(bulk.selectedFiles) } if (action === FileAction.Forget) { return setConfirmBulkForget(true) } if (action === FileAction.Destroy) { return setShowDestroyDriveModal(true) } } const handleDestroyDriveConfirm = useCallback(async () => { if (!currentDrive) return setShowDestroyDriveModal(false) setIsProgressModalOpen(true) setIsDestroying(true) await handleDestroyAndForgetDrive({ beeApi, fm, drive: currentDrive, isDestroy: true, adminDrive, onSuccess: () => { setIsDestroying(false) setIsProgressModalOpen(false) setShowDestroyDriveModal(false) }, onError: e => { setIsDestroying(false) setIsProgressModalOpen(false) setShowDestroyDriveModal(false) setErrorMessage?.(`Error destroying drive: ${currentDrive.name}: ${e}`) setShowError(true) }, }) }, [ beeApi, fm, currentDrive, adminDrive, setErrorMessage, setIsProgressModalOpen, setShowDestroyDriveModal, setShowError, ]) const handleDownloadClose = (uuid: string) => { const row = downloadItems.find(i => i.uuid === uuid) if (row?.status === TransferStatus.Downloading) { setPendingCancelDownload(uuid) } else { cancelOrDismissDownload(uuid) } } const handleUploadClose = (uuid: string) => { const row = uploadItems.find(i => i.uuid === uuid) if (row?.status === TransferStatus.Uploading) { setPendingCancelUpload(uuid) } else { cancelOrDismissUpload(uuid) } } const updateContextMenuPosition = () => { const menu = contextRef.current const body = bodyRef.current if (!menu) return const rect = menu.getBoundingClientRect() const containerRect = body?.getBoundingClientRect() ?? null const { safePos: sp, dropDir: dd } = computeContextMenuPosition({ clickPos: pos, menuRect: rect, viewport: { w: window.innerWidth, h: window.innerHeight }, margin: 8, containerRect, }) const topLeft = containerRect ? { x: Math.round(sp.x - containerRect.left), y: Math.round(sp.y - containerRect.top + 2) } : sp setSafePos(topLeft) setDropDir(dd) rafIdRef.current = null } useLayoutEffect(() => { if (!showContext) return if (rafIdRef.current) { cancelAnimationFrame(rafIdRef.current) } rafIdRef.current = requestAnimationFrame(updateContextMenuPosition) return () => { if (rafIdRef.current) { cancelAnimationFrame(rafIdRef.current) rafIdRef.current = null } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [showContext, pos, contextRef]) useEffect(() => { isMountedRef.current = true return () => { isMountedRef.current = false if (rafIdRef.current) { cancelAnimationFrame(rafIdRef.current) } } }, []) useEffect(() => { let title = currentDrive?.name || '' if (isSearchMode) { title = 'Search results' if (scope === 'selected' && currentDrive?.name) { title += ` — ${currentDrive.name}` } } setActualItemView?.(title) }, [isSearchMode, scope, currentDrive, setActualItemView]) useEffect(() => { if (!isSearchMode) { bulk.clearAll() } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isSearchMode]) const doRefresh = async () => { handleCloseContext() if (isRefreshing) return setIsRefreshing(true) try { await resync() } finally { safeSetState(isMountedRef, setIsRefreshing)(false) } } const showDeleteModal = showBulkDeleteModal && bulk.selectedFiles.length > 0 && view === ViewType.File const showDragOverlay = isDragging && Boolean(currentDrive) const fileCountText = bulk.selectedFiles.length === 1 ? 'file' : 'files' const onBulk = useMemo( () => ({ download: () => bulk.download(bulk.selectedFiles), restore: () => setConfirmBulkRestore(true), forget: () => bulk.forget(bulk.selectedFiles), destroy: () => setShowDestroyDriveModal(true), delete: () => setShowBulkDeleteModal(true), }), [bulk], ) return ( <> {conflictPortal}
toggle(SortKey.Name)} onSortSize={() => toggle(SortKey.Size)} onSortDate={() => toggle(SortKey.Timestamp)} onSortDrive={() => toggle(SortKey.Drive)} onClearSort={reset} />
{ if (e.button !== 0) return handleCloseContext() }} > { setShowError(false) setErrorMessage?.('') return }} />
{showDragOverlay && (
{ e.preventDefault() e.dataTransfer.dropEffect = 'copy' }} onDrop={handleOverlayDrop} >
Drop file(s) to upload
)} setShowBulkDeleteModal(false)} onDeleteProceed={handleDeleteModalProceed} onForgetConfirm={async () => { await bulk.forget(bulk.selectedFiles) setConfirmBulkForget(false) }} onForgetCancel={() => setConfirmBulkForget(false)} onRestoreConfirm={async () => { await bulk.restore(bulk.selectedFiles) setConfirmBulkRestore(false) }} onRestoreCancel={() => setConfirmBulkRestore(false)} onDestroyCancel={() => setShowDestroyDriveModal(false)} onDestroyConfirm={handleDestroyDriveConfirm} onCancelUploadConfirm={() => { if (pendingCancelUpload) { cancelOrDismissUpload(pendingCancelUpload) setPendingCancelUpload(null) } }} onCancelUploadCancel={() => setPendingCancelUpload(null)} pendingCancelDownload={pendingCancelDownload} onCancelDownloadConfirm={() => { if (pendingCancelDownload) { cancelOrDismissDownload(pendingCancelDownload) setPendingCancelDownload(null) } }} onCancelDownloadCancel={() => setPendingCancelDownload(null)} /> {isRefreshing && (
Syncing latest files…
)} setIsProgressModalOpen(true)} /> setIsProgressModalOpen(false)} />
dismissAllUploads()} /> dismissAllDownloads()} />
) }