Files
PrivyDrop/frontend/components/ClipboardApp/FileListDisplay.tsx
T
david_bai 7e781631bb chore(ui): clear remaining frontend warnings
Resolve the remaining lint warnings without changing behavior by fixing hook dependency lists, removing the icon naming false positive, and switching the YouTube thumbnail to next/image for compliant rendering.
2026-03-27 17:20:49 +08:00

470 lines
17 KiB
TypeScript

import React, { useState, useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Download, Trash2 } from "lucide-react";
import { Tooltip } from "@/components/Tooltip";
import TransferProgress from "./TransferProgress";
import { formatFileSize, generateFileId } from "@/lib/fileUtils";
import { AutoPopupDialog } from "@/components/common/AutoPopupDialog";
import { FileMeta, CustomFile, Progress } from "@/types/webrtc";
import FileTransferButton from "./FileTransferButton";
import { useFileTransferStore } from "@/stores/fileTransferStore";
import { supportsAutoDownload } from "@/lib/browserUtils";
import { postLogToBackend } from "@/app/config/api";
const developmentEnv = process.env.NODE_ENV;
function formatFolderDis(template: string, num: number, size: string) {
return template.replace("{num}", num.toString()).replace("{size}", size);
}
function formatFolderTips(
template: string,
name: string,
num: number,
size: string
) {
return template
.replace("{name}", name)
.replace("{num}", num.toString())
.replace("{size}", size);
}
interface FileListDisplayProps {
mode: "sender" | "receiver";
files: FileMeta[] | CustomFile[];
fileProgresses: {
[fileId: string]: {
[peerId: string]: Progress;
};
};
isAnyFileTransferring: boolean; // State lifted up
onDownload?: (item: FileMeta) => void;
onRequest?: (item: FileMeta) => void; // Request file
onDelete?: (item: FileMeta) => void;
onLocationPick?: () => Promise<boolean>;
saveType?: { [fileId: string]: boolean }; // File stored on disk or in memory
largeFileThreshold?: number;
}
// Add type guard helper function
function isCustomFile(file: FileMeta | CustomFile): file is CustomFile {
return "lastModified" in file; // Use a property specific to File objects to check
}
function isFileMetaArray(
files: FileMeta[] | CustomFile[]
): files is FileMeta[] {
return files.length === 0 || !isCustomFile(files[0]);
}
const FileListDisplay: React.FC<FileListDisplayProps> = ({
mode,
files,
fileProgresses,
isAnyFileTransferring,
onDownload,
onRequest,
onDelete,
onLocationPick,
saveType,
largeFileThreshold = 500 * 1024 * 1024, // 500MB default
}) => {
const t = useTranslations("text.fileList");
// Get the cleaning method of the store
const { clearSendProgress, clearReceiveProgress } = useFileTransferStore();
const [showFinished, setShowFinished] = useState<{
[fileId: string]: boolean;
}>({});
// Add a ref to store the previous showFinished state
const prevShowFinishedRef = useRef<{ [fileId: string]: boolean }>({});
// Add save pending status - Used for manual saving on non-Chrome browsers
const [pendingSave, setPendingSave] = useState<{
[fileId: string]: boolean;
}>({});
const [pickedLocation, setPickedLocation] = useState<boolean>(false); // Whether a save directory has been selected
const [needPickLocation, setNeedPickLocation] = useState<boolean>(false); // Whether a save directory needs to be selected -- for large files, folders, or user choice
const [folders, setFolders] = useState<FileMeta[]>([]); // Extract folder items from files
const [singleFiles, setSingleFiles] = useState<FileMeta[]>([]); // Keep single files, not part of a folder
// Add tracking for currently displayed receiver
const [activeTransfers, setActiveTransfers] = useState<{
[fileId: string]: string;
}>({});
// Add state for download counts
const [downloadCounts, setDownloadCounts] = useState<{
[fileId: string]: number;
}>({});
// Handling manual save - for non-Chrome browsers
const handleManualSave = (item: FileMeta) => {
if (onDownload) {
onDownload(item);
// Clear the pending save state to display UI as "Completed"
setPendingSave((prev) => {
const updated = { ...prev };
delete updated[item.fileId];
return updated;
});
}
};
useEffect(() => {
// Separate single files and folders
const tempSingleFiles: FileMeta[] = [];
let folders_: { [folderName: string]: FileMeta } = {};
let needPick = false;
// If it's a CustomFile[] type, convert it to FileMeta[] first
const processedFiles: FileMeta[] = isFileMetaArray(files)
? files
: files.map((file) => ({
name: file.name,
size: file.size,
fullName: file.fullName,
folderName: file.folderName,
fileType: file.type,
fileId: generateFileId(file),
}));
for (let file of processedFiles) {
if (file.folderName !== "") {
folders_[file.folderName] = folders_[file.folderName] || {
// If the object doesn't exist, initialize it
name: file.folderName,
size: 0,
fullName: file.folderName, // The fullName of a folder is its folderName
fileType: "folder",
fileId: file.folderName,
folderName: file.folderName,
fileCount: 0,
fileNamesDis: "",
};
folders_[file.folderName].fileCount =
(folders_[file.folderName].fileCount ?? 0) + 1; // If fileCount is undefined, use default value 0
folders_[file.folderName].size += file.size;
folders_[file.folderName].fileNamesDis = folders_[file.folderName]
.fileNamesDis
? folders_[file.folderName].fileNamesDis +
`${file.name} ${formatFileSize(file.size)}\n`
: `${file.name} ${formatFileSize(file.size)}\n`;
needPick = true;
} else {
tempSingleFiles.push(file);
if (file.size >= largeFileThreshold) needPick = true;
}
}
// Use functional updates to ensure the state is updated correctly
setSingleFiles((prev) => {
return [...tempSingleFiles];
});
setFolders((prev) => {
return [...Object.values(folders_)];
});
setNeedPickLocation(needPick); // Set whether a save directory needs to be selected
}, [files, largeFileThreshold]);
useEffect(() => {
// If a file is requested by multiple receivers simultaneously, the first one will be displayed, then the second after the first finishes.
let fileIds = [...singleFiles, ...folders].map((file) => file.fileId);
fileIds.forEach((fileId) => {
const fileProgress = fileProgresses[fileId];
if (!fileProgress) return;
// Get all transfer progresses for the current file
const transfers = Object.entries(fileProgress);
// If there are no active transfers, select the first one that started
let newPeerId = "";
if (!activeTransfers[fileId] && transfers.length > 0) {
newPeerId = transfers[0][0];
setActiveTransfers((prev) => ({
...prev,
[fileId]: newPeerId, // Set the first peerId
}));
}
// set is an async operation, use newPeerId directly instead of reading from activeTransfers
const activePeerId = newPeerId || activeTransfers[fileId];
// Check if the current active transfer is complete
if (activePeerId && fileProgress[activePeerId]?.progress >= 1) {
// Current transfer is complete, wait 2 seconds before switching to the next incomplete transfer
if (!showFinished[fileId]) {
setShowFinished((prev) => ({ ...prev, [fileId]: true }));
setTimeout(() => {
setShowFinished((prev) => {
const updated = { ...prev };
delete updated[fileId];
return updated;
});
// Clean the corresponding progress data according to the pattern
if (mode === "sender") {
clearSendProgress(fileId, activePeerId);
} else {
clearReceiveProgress(fileId, activePeerId);
}
// Find the next outstanding transfer
const nextTransfer = transfers.find(
([pid, prog]) =>
pid !== activePeerId && prog.progress > 0 && prog.progress < 1
);
setActiveTransfers((prev) => {
const updated = { ...prev };
if (nextTransfer) {
updated[fileId] = nextTransfer[0];
} else {
delete updated[fileId];
}
return updated;
});
}, 3002);
}
}
});
}, [
files,
fileProgresses,
showFinished,
activeTransfers,
mode,
clearSendProgress,
clearReceiveProgress,
folders,
singleFiles,
]);
useEffect(() => {
//Monitor the Finished event from false/null to true to trigger the download
let files_ = [...singleFiles, ...folders];
files_.forEach((item: FileMeta) => {
const currentShowFinished = showFinished[item.fileId];
const prevShowFinished = prevShowFinishedRef.current[item.fileId];
const isSaveToDisk = saveType ? saveType[item.fileId] : false;
const fileProgress = fileProgresses[item.fileId];
const activePeerId = activeTransfers[item.fileId];
const currentProgress = activePeerId
? fileProgress?.[activePeerId]?.progress
: null;
// Detecting false -> true transitions
if (!prevShowFinished && currentShowFinished) {
if (!isSaveToDisk && onDownload) {
const isAutoDownloadSupported = supportsAutoDownload();
if (isAutoDownloadSupported) {
// Browsers that support automatic downloads like Chrome: Download directly
if (developmentEnv === "development") {
postLogToBackend(
`[Download Debug] Auto-downloading file: ${item.name}`
);
}
onDownload(item);
} else {
// Non-Chrome browsers: Set to save status, wait for user manual click
if (developmentEnv === "development") {
postLogToBackend(
`[Download Debug] Setting pendingSave for non-Chrome browser: ${item.name}`
);
}
setPendingSave((prev) => ({
...prev,
[item.fileId]: true,
}));
}
} else {
if (developmentEnv === "development") {
postLogToBackend(
`Skipping download logic - isSaveToDisk: ${isSaveToDisk}, onDownload: ${!!onDownload}`
);
}
}
// Increase download count - Increment download count upon completion of file transfer (counted only once)
setDownloadCounts((prevCounts) => ({
...prevCounts,
[item.fileId]: (prevCounts[item.fileId] || 0) + 1,
}));
}
// Update the last status
prevShowFinishedRef.current[item.fileId] = currentShowFinished;
});
}, [
showFinished,
singleFiles,
folders,
saveType,
onDownload,
activeTransfers,
fileProgresses,
]);
//Actions corresponding to each file - progress, download, delete
const renderItemActions = (item: FileMeta) => {
const fileProgress = fileProgresses[item.fileId];
const activePeerId = activeTransfers[item.fileId];
const progress = activePeerId ? fileProgress?.[activePeerId] : null;
const showCompletion =
showFinished[item.fileId] && !pendingSave[item.fileId]; // Only display completed when the transfer is finished and not in the save pending state
const isSaveToDisk = saveType ? saveType[item.fileId] : false;
const isPendingSave = pendingSave[item.fileId] || false;
// Get download count
const downloadCount = downloadCounts[item.fileId] || 0;
return (
<div className="flex flex-col sm:flex-row sm:items-center gap-2 min-w-0 flex-shrink-0">
{progress && progress.progress < 1 ? ( //Show progress or completed
<div className="w-full sm:w-auto">
<TransferProgress
message={
mode === "sender" ? t("sending") : t("receiving")
}
progress={progress}
/>
</div>
) : showCompletion ? (
<span className="text-sm text-green-500 whitespace-nowrap">
{t("finished")}
</span>
) : null}
<div className="flex items-center gap-1 sm:gap-2 flex-wrap">
{mode === "receiver" &&
onRequest &&
onDownload && ( //Request && Download
<FileTransferButton
onRequest={() => onRequest(item)}
onSave={() => handleManualSave(item)}
isCurrentFileTransferring={
progress
? progress.progress > 0 && progress.progress < 1
: false
}
isOtherFileTransferring={isAnyFileTransferring && !progress}
isSavedToDisk={saveType ? saveType[item.fileId] : false}
isPendingSave={isPendingSave}
/>
)}
{/* display download Num*/}
{mode === "sender" && (
<span className="text-xs sm:text-sm whitespace-nowrap">
{t("downloadCount")}: {downloadCount}
</span>
)}
{mode === "sender" && onDelete && (
<Button
onClick={() => {
onDelete(item);
}}
variant="destructive"
size="sm"
disabled={
progress
? progress?.progress > 0 && progress.progress < 1
: false
}
className="text-xs sm:text-sm px-2 sm:px-3"
>
<Trash2 className="h-3 w-3 sm:h-4 sm:w-4 sm:mr-2" />
<span className="hidden sm:inline">{t("delete")}</span>
</Button>
)}
</div>
</div>
);
};
//Display of each file --meta information
const renderItem = (item: FileMeta, isFolder: boolean) => {
const filenameDisplayLen = 30;
const formatSize = formatFileSize(item.size);
const tooltipContent = isFolder
? `${formatFolderTips(
t("folderSummary"),
item.name,
item.fileCount || 0,
formatSize
)}\n ${item.fileNamesDis}`
: `${item.name} ${formatSize}`;
return (
<div
key={item.name}
className="flex flex-col sm:flex-row sm:items-center gap-2 mb-2 p-2 sm:p-3 border border-border rounded-lg"
>
<Tooltip content={tooltipContent}>
<div className="flex-1 min-w-0">
<span className="block truncate text-sm sm:text-base">
{isFolder ? "📁 " : ""}
{item.name.length > filenameDisplayLen
? `${item.name.slice(0, filenameDisplayLen - 3)}...`
: item.name}
</span>
<span className="text-xs sm:text-sm text-muted-foreground">
{isFolder
? `${formatFolderDis(
t("folderInline"),
item.fileCount || 0,
formatSize
)}`
: ` ${formatSize}`}
</span>
</div>
</Tooltip>
<div className="w-full sm:w-auto sm:flex-shrink-0">
{renderItemActions(item)}
</div>
</div>
);
};
return (
<>
{(singleFiles.length > 0 || folders.length > 0) && (
<>
{/* Automatic pop-up component, only remind once when there are large files and folders */}
{mode === "receiver" && (
<div className="mb-2">
<AutoPopupDialog
storageKey="Choose-location-popup-shown"
title={t("saveDialog.title")}
description={t("saveDialog.description")}
condition={() => needPickLocation}
/>
{/* Regular reminder to select the save directory */}
<div className="flex items-center">
<p className="text-red-500 mb-2">{t("saveDialog.tip")}</p>
{onLocationPick && (
<Button
onClick={async () => {
const success = await onLocationPick();
if (success) setPickedLocation(true);
}}
variant="outline"
size="sm"
className="mr-2 text-red-500"
>
{t("saveDialog.button")}
</Button>
)}
</div>
</div>
)}
<div className="mb-2">
<div className="files-list">
{singleFiles.map((file) => (
<div key={`single-${file.name}`}>{renderItem(file, false)}</div>
))}
{folders.map((folder) => (
<div key={`folder-${folder.name}`}>
{renderItem(folder, true)}
</div>
))}
</div>
</div>
</>
)}
</>
);
};
export default FileListDisplay;