import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Events } from "@wailsio/runtime"; import * as ScrollArea from "@radix-ui/react-scroll-area"; import { BanIcon, CheckIcon, ChevronDownIcon, CopyIcon, FileDownIcon, FileUpIcon, FolderDownIcon, FolderOpenIcon, MoreVerticalIcon, QuoteIcon, SendIcon, ShieldCheckIcon, Trash2Icon, XIcon, } from "lucide-react"; import { FileDrop } from "@bindings/services"; import type { FileDropTransfer, PeerStatus } from "@bindings/services/models.js"; import { cn } from "@/lib/cn"; import { formatBytes } from "@/lib/formatters"; import { FailureReason, isTerminalState, PeerRule, stateLabelKey, TransferState, } from "@/lib/filedrop"; import { SearchInput } from "@/components/inputs/SearchInput"; import { EmptyState } from "@/components/empty-state/EmptyState"; import { NoResults } from "@/components/empty-state/NoResults"; import { Button } from "@/components/buttons/Button"; import { Tooltip } from "@/components/Tooltip"; import { TruncatedText } from "@/components/TruncatedText"; import { useConfirm } from "@/contexts/DialogContext"; import { SendPeerPicker } from "@/modules/main/advanced/files/SendPeerPicker"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/DropdownMenu"; const EVENT_FILEDROP = "netbird:filedrop"; const POLL_MS = 1000; const transferTitle = (transfer: FileDropTransfer): string => { const files = transfer.files ?? []; if (files.length === 1 && files[0].isText) { return `“${files[0].text}”`; } return files.map((f) => f.name).join(", "); }; const isTextTransfer = (transfer: FileDropTransfer): boolean => (transfer.files ?? []).length === 1 && transfer.files[0].isText; const kindIcon = (transfer: FileDropTransfer) => { if (isTextTransfer(transfer)) return QuoteIcon; return transfer.outgoing ? FileUpIcon : FileDownIcon; }; const timeLabel = (iso: string): string => { const d = new Date(iso); if (Number.isNaN(d.getTime())) return ""; return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); }; // The outcome column has its own legend, separate from the icon's: green is a // transfer that arrived, either way round, red is one the far side refused or // that broke, grey is everything else. A transfer in flight is grey too, the // progress bar already says it is moving. const outcomeClass = (transfer: FileDropTransfer, inProgress: boolean): string => { if (inProgress) return "text-nb-gray-500"; switch (transfer.state) { case TransferState.Declined: case TransferState.Failed: return "text-red-400"; case TransferState.Completed: return "text-green-400"; default: return "text-nb-gray-500"; } }; export const Files = () => { const { t } = useTranslation(); const [transfers, setTransfers] = useState(null); const [search, setSearch] = useState(""); const searchRef = useRef(null); const refresh = useCallback(async () => { try { setTransfers(await FileDrop.List()); } catch { setTransfers((prev) => prev ?? []); } }, []); useEffect(() => { searchRef.current?.focus(); void refresh(); const id = setInterval(() => void refresh(), POLL_MS); const off = Events.On(EVENT_FILEDROP, () => void refresh()); return () => { clearInterval(id); off(); }; }, [refresh]); const filtered = useMemo(() => { const all = transfers ?? []; const q = search.trim().toLowerCase(); if (!q) return all; return all.filter( (tr) => transferTitle(tr).toLowerCase().includes(q) || tr.peerName.toLowerCase().includes(q), ); }, [transfers, search]); const pending = filtered.filter((tr) => !tr.outgoing && tr.state === TransferState.Pending); const rest = filtered.filter((tr) => tr.outgoing || tr.state !== TransferState.Pending); const groups = useMemo(() => { const byDay = new Map(); for (const tr of rest) { const d = new Date(tr.createdAt as unknown as string); const key = Number.isNaN(d.getTime()) ? "" : d.toDateString(); const list = byDay.get(key) ?? []; list.push(tr); byDay.set(key, list); } const today = new Date().toDateString(); const yesterday = new Date(Date.now() - 86400000).toDateString(); return Array.from(byDay.entries()).map(([key, list]) => { let label = key; if (key === today) label = t("files.group.today"); else if (key === yesterday) label = t("files.group.yesterday"); else if (key) label = new Date(key).toLocaleDateString(); return { label, list }; }); }, [rest, t]); const isEmpty = transfers !== null && transfers.length === 0; return (
setSearch(e.target.value)} />
{isEmpty ? ( ) : filtered.length === 0 ? ( ) : (
{pending.map((tr) => ( ))} {groups.map((group) => (
{group.label}
{group.list.map((tr) => ( ))}
))}
)}
); }; const SendActions = ({ onSent }: { onSent: () => void }) => { const { t } = useTranslation(); const [error, setError] = useState(null); const sendFiles = async (peer: PeerStatus) => { setError(null); try { const paths = await FileDrop.PickFiles(); if (!paths || paths.length === 0) return; await FileDrop.Send(peer.pubKey, paths, ""); onSent(); } catch (e) { setError(String(e)); } }; // Sends the text the picker previewed, not a fresh read: the clipboard may // have changed between showing it and confirming the recipient. const sendClipboard = async (peer: PeerStatus, text: string) => { setError(null); if (!text) { setError(t("peers.details.sendClipboard.empty")); return; } try { await FileDrop.Send(peer.pubKey, [], text); onSent(); } catch (e) { setError(String(e)); } }; return (
void (mode === "files" ? sendFiles(peer) : sendClipboard(peer, clipboard)) } > {error && (
{error}
)}
); }; type RowProps = { transfer: FileDropTransfer; onChanged: () => void; }; const PendingOfferRow = ({ transfer, onChanged }: RowProps) => { const { t } = useTranslation(); const [busy, setBusy] = useState(false); const OfferIcon = kindIcon(transfer); const decide = async (accept: boolean) => { if (busy) return; setBusy(true); try { await FileDrop.Decide(transfer.id, accept); } finally { setBusy(false); onChanged(); } }; return ( // The panel is a fixed ~500px wide, so the buttons sit under the text // rather than beside it: side by side they would squeeze the file name // down to a few characters.
{" · "} {formatBytes(transfer.totalSize)} {t("files.offer.subtitle", { peer: transfer.peerName })}
); }; const TransferRow = ({ transfer, onChanged }: RowProps) => { const { t } = useTranslation(); const [copied, setCopied] = useState(false); const isText = isTextTransfer(transfer); const RowKindIcon = kindIcon(transfer); const live = !isTerminalState(transfer.state); const delivered = !transfer.outgoing && transfer.state === TransferState.Completed && (transfer.deliveredPaths ?? []).length > 0; const progress = transfer.state === TransferState.Transferring && transfer.totalSize > 0 ? Math.min(100, Math.floor((transfer.transferred / transfer.totalSize) * 100)) : null; const peerLabel = transfer.outgoing ? t("files.row.to", { peer: transfer.peerName }) : t("files.row.from", { peer: transfer.peerName }); let stateLabel: string; if (progress !== null) { stateLabel = t("files.state.progress", { percent: progress }); } else if (transfer.state === TransferState.Completed) { stateLabel = t(transfer.outgoing ? "files.state.sent" : "files.state.received"); } else if ( transfer.state === TransferState.Failed && transfer.reason === FailureReason.Unreachable ) { stateLabel = t("files.state.unreachable"); } else { stateLabel = t(stateLabelKey(transfer.state)); } const stateClass = outcomeClass(transfer, progress !== null); const time = timeLabel(transfer.createdAt as unknown as string); const copyText = async () => { await navigator.clipboard.writeText(transfer.files[0]?.text ?? ""); setCopied(true); setTimeout(() => setCopied(false), 1500); }; return (
{time} {!isText && ( {" · "} {formatBytes(transfer.totalSize)} )}
{/* Own column, capped: the window is a fixed 900px and some translations of the state labels are long enough to eat the file name if they are given free rein. */} {stateLabel} {/* The window is a fixed 900px wide and cannot be resized, so the row actions overlay the status column on hover instead of reserving width that the file name would otherwise lose. */}
{isText && ( void copyText()}> {copied ? ( ) : ( )} )} {delivered && ( void FileDrop.Reveal(transfer.deliveredPaths[0])} > )} {live && ( { await FileDrop.Cancel(transfer.id); onChanged(); }} > )}
); }; const RowIconButton = ({ label, onClick, children, }: { label: string; onClick: () => void; children: React.ReactNode; }) => ( ); const TransferMenu = ({ transfer, delivered, onChanged }: RowProps & { delivered: boolean }) => { const { t } = useTranslation(); const confirm = useConfirm(); const setRule = async (rule: PeerRule) => { await FileDrop.SetPeerRule(transfer.peerKey, rule); onChanged(); }; const remove = async () => { const ok = await confirm({ title: t("files.delete.title"), description: t("files.delete.message"), confirmLabel: t("common.delete"), danger: true, }); if (!ok) return; await FileDrop.Delete(transfer.id); onChanged(); }; return ( // modal={false}: the item handler opens a confirm dialog, and a modal // dropdown would keep the focus trap that the dialog then fights over. {delivered && ( void FileDrop.Open(transfer.deliveredPaths[0])} > {t("files.action.open")} )} {!transfer.outgoing && ( <> void setRule(PeerRule.Always)}> {t("files.action.alwaysAccept")} void setRule(PeerRule.Block)}> {t("files.action.block")} )} void remove()}> {t("files.action.delete")} ); };