Files
netbird/client/ui/frontend/src/modules/main/advanced/files/Files.tsx
T
Zoltan Papp 50197c7355 [client] Fix file drop delivery and refine the files UI
Seed the delivery directory from the IPC caller when the active profile
carries no username, so the default profile gets a destination instead of
failing every incoming transfer. Deliver text-only offers without requiring
a destination directory at all.

In the UI, drop the status label from the send picker rows, keep the dot,
and give the transfer list per-kind icons: a quote for text, a file with an
up or down arrow for files.
2026-09-05 19:12:43 +02:00

580 lines
22 KiB
TypeScript

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<FileDropTransfer[] | null>(null);
const [search, setSearch] = useState("");
const searchRef = useRef<HTMLInputElement>(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<string, FileDropTransfer[]>();
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 (
<div className={"flex h-full min-h-0 w-full flex-col"}>
<div className={"flex items-center gap-2 border-b border-nb-gray-910 px-6 py-2.5"}>
<div className={"min-w-0 flex-1"}>
<SearchInput
ref={searchRef}
placeholder={t("files.search.placeholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<SendActions onSent={refresh} />
</div>
{isEmpty ? (
<EmptyState
icon={FolderDownIcon}
title={t("files.empty.title")}
description={t("files.empty.description")}
/>
) : filtered.length === 0 ? (
<NoResults />
) : (
<ScrollArea.Root
type={"auto"}
className={"nb-scroll-clamp min-h-0 flex-1 overflow-hidden"}
>
<ScrollArea.Viewport className={"h-full w-full"}>
<div className={"flex flex-col pb-4 pt-2"}>
{pending.map((tr) => (
<PendingOfferRow key={tr.id} transfer={tr} onChanged={refresh} />
))}
{groups.map((group) => (
<div key={group.label} className={"flex flex-col"}>
<div
className={
"px-6 pb-1 pt-4 text-xs font-semibold uppercase tracking-wider text-nb-gray-500"
}
>
{group.label}
</div>
{group.list.map((tr) => (
<TransferRow
key={tr.id}
transfer={tr}
onChanged={refresh}
/>
))}
</div>
))}
</div>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
orientation={"vertical"}
className={"flex w-1.5 touch-none select-none bg-transparent py-1"}
>
<ScrollArea.Thumb
className={
"relative flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700"
}
/>
</ScrollArea.Scrollbar>
</ScrollArea.Root>
)}
</div>
);
};
const SendActions = ({ onSent }: { onSent: () => void }) => {
const { t } = useTranslation();
const [error, setError] = useState<string | null>(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 (
<div className={"relative flex shrink-0 items-center"}>
<SendPeerPicker
onPick={(peer, mode, clipboard) =>
void (mode === "files" ? sendFiles(peer) : sendClipboard(peer, clipboard))
}
>
<Button variant={"primary"} size={"xs"}>
<SendIcon size={12} aria-hidden={"true"} />
{t("files.send.trigger")}
<ChevronDownIcon size={12} aria-hidden={"true"} />
</Button>
</SendPeerPicker>
{error && (
<div
className={
"absolute right-0 top-full z-10 mt-1 max-w-xs truncate text-xs text-red-400"
}
>
{error}
</div>
)}
</div>
);
};
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.
<div
className={cn(
"mx-4 mt-2 flex flex-col gap-2.5 rounded-lg border border-netbird/30",
"bg-netbird/5 px-4 py-3",
"wails-no-draggable",
)}
>
<div className={"flex min-w-0 items-center gap-2.5"}>
<OfferIcon size={16} className={"shrink-0 text-netbird"} aria-hidden={"true"} />
<div className={"flex min-w-0 flex-1 flex-col leading-tight"}>
<span
className={
"flex min-w-0 items-baseline text-[0.81rem] font-medium text-nb-gray-100"
}
>
<TruncatedText
text={transferTitle(transfer)}
className={"block min-w-0 truncate"}
/>
<span className={"shrink-0 font-normal text-nb-gray-400"}>
{" · "}
{formatBytes(transfer.totalSize)}
</span>
</span>
<span className={"truncate text-xs text-nb-gray-400"}>
{t("files.offer.subtitle", { peer: transfer.peerName })}
</span>
</div>
</div>
<div className={"flex items-center gap-2 pl-[26px]"}>
<Button
variant={"primary"}
size={"xs"}
disabled={busy}
onClick={() => void decide(true)}
>
{t("files.offer.accept")}
</Button>
<Button
variant={"secondary"}
size={"xs"}
disabled={busy}
onClick={() => void decide(false)}
>
{t("files.offer.decline")}
</Button>
</div>
</div>
);
};
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 (
<div
className={cn(
"group relative flex items-center gap-2.5 py-2.5 pl-6 pr-4",
"transition-colors hover:bg-nb-gray-900/40",
"wails-no-draggable",
)}
>
<RowKindIcon
size={15}
className={cn("shrink-0", transfer.outgoing ? "text-netbird" : "text-green-400")}
aria-hidden={"true"}
/>
<div className={"flex min-w-0 flex-1 flex-col gap-0.5 leading-tight"}>
<TruncatedText
text={transferTitle(transfer)}
className={cn(
"block truncate text-[0.81rem] font-medium text-nb-gray-100",
isText && "italic",
)}
/>
<TruncatedText
text={peerLabel}
className={"block truncate text-xs text-nb-gray-400"}
/>
<span className={"flex min-w-0 items-baseline text-xs text-nb-gray-500"}>
<span className={"shrink-0 tabular-nums"}>{time}</span>
{!isText && (
<span className={"shrink-0"}>
{" · "}
{formatBytes(transfer.totalSize)}
</span>
)}
</span>
</div>
{/* 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. */}
<span
className={cn(
"max-w-[8.5rem] shrink-0 truncate pl-2 text-xs",
stateClass,
"transition-opacity group-hover:opacity-0",
)}
>
{stateLabel}
</span>
{/* 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. */}
<div
className={cn(
"absolute right-4 top-1/2 flex -translate-y-1/2 items-center gap-1",
// Fades the covered status text out rather than sitting on
// a flat block, which would not match the row's hover tint.
"bg-gradient-to-l from-nb-gray-940 via-nb-gray-940 to-transparent pl-8",
"opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100",
)}
>
{isText && (
<RowIconButton label={t("files.action.copy")} onClick={() => void copyText()}>
{copied ? (
<CheckIcon size={14} className={"text-green-400"} />
) : (
<CopyIcon size={14} />
)}
</RowIconButton>
)}
{delivered && (
<RowIconButton
label={t("files.action.reveal")}
onClick={() => void FileDrop.Reveal(transfer.deliveredPaths[0])}
>
<FolderOpenIcon size={14} />
</RowIconButton>
)}
{live && (
<RowIconButton
label={t("files.action.cancel")}
onClick={async () => {
await FileDrop.Cancel(transfer.id);
onChanged();
}}
>
<XIcon size={14} />
</RowIconButton>
)}
<TransferMenu transfer={transfer} delivered={delivered} onChanged={onChanged} />
</div>
</div>
);
};
const RowIconButton = ({
label,
onClick,
children,
}: {
label: string;
onClick: () => void;
children: React.ReactNode;
}) => (
<Tooltip content={label}>
<button
type={"button"}
aria-label={label}
onClick={onClick}
className={cn(
"flex h-7 w-7 items-center justify-center rounded-md",
"text-nb-gray-300 hover:bg-nb-gray-900 hover:text-nb-gray-100",
"cursor-default outline-none transition-colors",
"focus-visible:ring-2 focus-visible:ring-white/60",
)}
>
{children}
</button>
</Tooltip>
);
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.
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
type={"button"}
aria-label={t("files.action.more")}
className={cn(
"flex h-7 w-7 items-center justify-center rounded-md",
"text-nb-gray-300 hover:bg-nb-gray-900 hover:text-nb-gray-100",
"cursor-default outline-none transition-colors",
"focus-visible:ring-2 focus-visible:ring-white/60",
)}
>
<MoreVerticalIcon size={14} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align={"end"}>
{delivered && (
<DropdownMenuItem
onClick={() => void FileDrop.Open(transfer.deliveredPaths[0])}
>
<FolderOpenIcon size={14} className={"mr-2"} />
{t("files.action.open")}
</DropdownMenuItem>
)}
{!transfer.outgoing && (
<>
<DropdownMenuItem onClick={() => void setRule(PeerRule.Always)}>
<ShieldCheckIcon size={14} className={"mr-2"} />
{t("files.action.alwaysAccept")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => void setRule(PeerRule.Block)}>
<BanIcon size={14} className={"mr-2"} />
{t("files.action.block")}
</DropdownMenuItem>
</>
)}
<DropdownMenuItem variant={"danger"} onClick={() => void remove()}>
<Trash2Icon size={14} className={"mr-2"} />
{t("files.action.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};