[client] Fix the Files tab layout and add sending from the Files page

The Files rows overflowed the fixed-width panel instead of truncating.
Radix wraps ScrollArea.Viewport children in a div carrying an inline
`min-width:100%; display:table`, and a table box is shrink-to-fit, so it
grew to the widest row's intrinsic width and never gave `truncate` a
finite bound. Since overflowX is hidden when only the vertical scrollbar
is enabled, the result was clipped content rather than a scrollbar. A
scoped `.nb-scroll-clamp` rule overrides that display (the inline style
needs `!important`), leaving other ScrollAreas untouched.

Each transfer row is now three lines -- file name, to/from peer, then
time and size -- with the file name and the peer label both truncating
and revealing the full text on hover via TruncatedText. The status label
keeps its own right-hand column, vertically centred against the block,
so a refusal or failure stays scannable.

The Files page could not start a transfer at all: the send buttons lived
only on the peer detail panel, and an empty history replaced the whole
page, hiding the header. The header now always renders and carries one
primary Send button opening a two-step picker -- payload first, then the
peer list with search. One entry point means the peer list is built in
one place instead of two, and one button leaves the search field room to
survive the longer translations.

Clipboard sends showed nothing before leaving the machine. Both the
picker and the peer panel now preview the text first, and both send the
previewed string rather than re-reading the clipboard, which could have
changed between the preview and the confirmation. The picker disables
its clipboard entry when the clipboard is empty, so the failure surfaces
before the recipient is chosen. PeerSendActions is keyed on the peer so
a staged confirmation cannot carry over to a different recipient.

Deleting a history entry now asks first, reusing the app-wide useConfirm
hook. The wording states that received files stay on disk, matching what
DeleteTransfer does -- it drops the history record and cancels a live
transfer, and never touches delivered files. The dropdown is
modal={false} because DropdownMenuItem calls preventDefault, which keeps
a modal menu's focus trap alive against the dialog.
This commit is contained in:
Zoltán Papp
2026-09-01 14:25:28 +02:00
parent fa47b32b92
commit 2be390f426
17 changed files with 785 additions and 78 deletions
@@ -0,0 +1,40 @@
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
import { formatBytes } from "@/lib/formatters";
// Enough to recognise which snippet is on the clipboard without turning the
// preview into a text editor; the box clamps to three lines on top of this.
const PREVIEW_CHARS = 280;
export const ClipboardPreview = ({ text, className }: { text: string; className?: string }) => {
const { t } = useTranslation();
const clipped = text.length > PREVIEW_CHARS;
const bytes = new TextEncoder().encode(text).length;
return (
<div
role={"group"}
aria-label={t("files.send.clipboardPreview")}
className={cn(
"flex flex-col gap-1 rounded-md px-2 py-1.5",
"border border-nb-gray-850 bg-nb-gray-930",
className,
)}
>
<p
className={cn(
"m-0 whitespace-pre-wrap break-words text-[0.7rem] leading-snug",
"italic text-nb-gray-300",
// Three lines keeps the box from crowding out whatever it
// is embedded in.
"line-clamp-3",
)}
>
{clipped ? `${text.slice(0, PREVIEW_CHARS)}` : text}
</p>
<span className={"self-end text-[0.65rem] tabular-nums text-nb-gray-500"}>
{formatBytes(bytes)}
</span>
</div>
);
};
@@ -91,6 +91,11 @@ const buttonVariants = cva(
danger: [
"dark:bg-red-600 dark:text-red-100 dark:hover:border-red-800/50 hover:dark:bg-red-700 dark:focus:bg-red-700 dark:focus:ring-red-700/20",
],
yellow: [
"enabled:bg-yellow-300 enabled:text-yellow-900 enabled:hover:bg-yellow-200 enabled:focus:ring-yellow-300/50",
"dark:ring-offset-neutral-950/50 dark:focus:ring-yellow-300/40",
"enabled:dark:bg-yellow-300 enabled:dark:text-yellow-900 enabled:dark:hover:bg-yellow-200 disabled:dark:bg-nb-gray-900",
],
},
size: {
xs: "px-3.5 py-2.5 text-xs",
+13
View File
@@ -43,3 +43,16 @@ body {
.wails-no-draggable {
--wails-draggable: no-drag;
}
/*
* Radix wraps ScrollArea.Viewport children in a div with
* `min-width:100%; display:table`. A table box is shrink-to-fit, so it grows
* to the widest row's intrinsic width instead of stopping at 100%, which
* defeats `truncate` on a long file name and pushes the row out sideways.
* The window is a fixed width and cannot be resized, so the content box is
* capped here and the rows truncate as intended.
*/
.nb-scroll-clamp [data-radix-scroll-area-viewport] > div {
display: block !important;
max-width: 100%;
}
@@ -7,16 +7,18 @@ import {
ArrowUpIcon,
BanIcon,
CheckIcon,
ChevronDownIcon,
CopyIcon,
FolderDownIcon,
FolderOpenIcon,
MoreVerticalIcon,
SendIcon,
ShieldCheckIcon,
Trash2Icon,
XIcon,
} from "lucide-react";
import { FileDrop } from "@bindings/services";
import type { FileDropTransfer } from "@bindings/services/models.js";
import type { FileDropTransfer, PeerStatus } from "@bindings/services/models.js";
import { cn } from "@/lib/cn";
import { formatBytes } from "@/lib/formatters";
import {
@@ -31,6 +33,9 @@ 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,
@@ -133,15 +138,7 @@ export const Files = () => {
});
}, [rest, t]);
if (transfers !== null && transfers.length === 0) {
return (
<EmptyState
icon={FolderDownIcon}
title={t("files.empty.title")}
description={t("files.empty.description")}
/>
);
}
const isEmpty = transfers !== null && transfers.length === 0;
return (
<div className={"flex h-full min-h-0 w-full flex-col"}>
@@ -154,11 +151,21 @@ export const Files = () => {
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<SendActions onSent={refresh} />
</div>
{filtered.length === 0 ? (
{isEmpty ? (
<EmptyState
icon={FolderDownIcon}
title={t("files.empty.title")}
description={t("files.empty.description")}
/>
) : filtered.length === 0 ? (
<NoResults />
) : (
<ScrollArea.Root type={"auto"} className={"min-h-0 flex-1 overflow-hidden"}>
<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) => (
@@ -200,6 +207,64 @@ export const Files = () => {
);
};
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;
@@ -232,15 +297,18 @@ const PendingOfferRow = ({ transfer, onChanged }: RowProps) => {
)}
>
<div className={"flex min-w-0 items-center gap-2.5"}>
<ArrowDownIcon
size={16}
className={"shrink-0 text-netbird"}
aria-hidden={"true"}
/>
<ArrowDownIcon size={16} className={"shrink-0 text-netbird"} aria-hidden={"true"} />
<div className={"flex min-w-0 flex-1 flex-col leading-tight"}>
<span className={"truncate text-[0.81rem] font-medium text-nb-gray-100"}>
{transferTitle(transfer)}
<span className={"font-normal text-nb-gray-400"}>
<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>
@@ -286,14 +354,9 @@ const TransferRow = ({ transfer, onChanged }: RowProps) => {
? Math.min(100, Math.floor((transfer.transferred / transfer.totalSize) * 100))
: null;
// The subtitle carries who and how big; the outcome sits on the right next
// to the time, so a glance down that column reads the results.
const subtitleParts = [
transfer.outgoing
? t("files.row.to", { peer: transfer.peerName })
: t("files.row.from", { peer: transfer.peerName }),
];
if (!isText) subtitleParts.push(formatBytes(transfer.totalSize));
const peerLabel = transfer.outgoing
? t("files.row.to", { peer: transfer.peerName })
: t("files.row.from", { peer: transfer.peerName });
let stateLabel: string;
if (progress !== null) {
@@ -335,39 +398,47 @@ const TransferRow = ({ transfer, onChanged }: RowProps) => {
aria-hidden={"true"}
/>
)}
<div className={"flex min-w-0 flex-1 flex-col leading-tight"}>
<span
<div className={"flex min-w-0 flex-1 flex-col gap-0.5 leading-tight"}>
<TruncatedText
text={transferTitle(transfer)}
className={cn(
"truncate text-[0.81rem] font-medium text-nb-gray-100",
"block truncate text-[0.81rem] font-medium text-nb-gray-100",
isText && "italic",
)}
>
{transferTitle(transfer)}
</span>
<span className={"truncate text-xs text-nb-gray-400"}>
{subtitleParts.join(" · ")}
/>
<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>
{/* Time over outcome, capped: the window is a fixed 900px and some
translations of the state labels are long enough to eat the file
name if they share one line with the timestamp. */}
{/* 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(
"flex max-w-[8.5rem] shrink-0 flex-col items-end leading-tight",
"pl-2 text-xs text-nb-gray-500",
"max-w-[8.5rem] shrink-0 truncate pl-2 text-xs",
stateClass,
"transition-opacity group-hover:opacity-0",
)}
>
<span className={"tabular-nums"}>{time}</span>
<span className={cn("max-w-full truncate", stateClass)}>{stateLabel}</span>
{stateLabel}
</span>
{/* The window is a fixed 900px wide and cannot be resized, so the
row actions overlay the outcome column on hover instead of
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 flex items-center gap-1",
// Fades the covered outcome text out rather than sitting on
"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",
@@ -435,14 +506,29 @@ const RowIconButton = ({
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 (
<DropdownMenu>
// 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"}
@@ -478,13 +564,7 @@ const TransferMenu = ({ transfer, delivered, onChanged }: RowProps & { delivered
</DropdownMenuItem>
</>
)}
<DropdownMenuItem
variant={"danger"}
onClick={async () => {
await FileDrop.Delete(transfer.id);
onChanged();
}}
>
<DropdownMenuItem variant={"danger"} onClick={() => void remove()}>
<Trash2Icon size={14} className={"mr-2"} />
{t("files.action.delete")}
</DropdownMenuItem>
@@ -0,0 +1,275 @@
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import * as Popover from "@radix-ui/react-popover";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { Command } from "cmdk";
import { ChevronLeftIcon, ClipboardIcon, Search, SendIcon } from "lucide-react";
import { FileDrop } from "@bindings/services";
import type { PeerStatus } from "@bindings/services/models.js";
import { cn } from "@/lib/cn";
import { shortenDns } from "@/lib/formatters";
import { useStatus } from "@/contexts/StatusContext";
import { ClipboardPreview } from "@/components/ClipboardPreview";
import { dotClass, peerStatusLabelKey } from "@/modules/main/advanced/peers/Peers";
export type SendMode = "files" | "clipboard";
type Props = {
children: ReactNode;
onPick: (peer: PeerStatus, mode: SendMode, clipboard: string) => void;
};
const itemClass = cn(
"my-0.5 flex cursor-default items-center gap-2",
"rounded-md px-2 py-2 outline-none",
"text-xs text-nb-gray-200",
"data-[selected=true]:bg-nb-gray-850 data-[selected=true]:text-nb-gray-50",
);
const noticeClass = "px-3 py-4 text-center text-[0.7rem] text-nb-gray-400";
const modeLabelKey = (mode: SendMode): string =>
mode === "files" ? "peers.details.sendFile" : "peers.details.sendClipboard";
// What to send first, then to whom: the payload is the cheap, two-option
// decision, so it gets out of the way before the list that needs searching.
export const SendPeerPicker = ({ children, onPick }: Props) => {
const [open, setOpen] = useState(false);
const [mode, setMode] = useState<SendMode | null>(null);
const [clipboard, setClipboard] = useState("");
const { status } = useStatus();
// Read on open so the clipboard entry can be disabled and previewed before
// the peer is chosen, instead of failing after it.
useEffect(() => {
if (!open) return;
let cancelled = false;
FileDrop.ClipboardText()
.then((text) => {
if (!cancelled) setClipboard(text ?? "");
})
.catch(() => {
if (!cancelled) setClipboard("");
});
return () => {
cancelled = true;
};
}, [open]);
// A peer without an overlay address has nothing to dial. Connection state
// is deliberately not a filter: an idle peer is the normal resting state
// under lazy connections, and the transfer's own packets wake it.
const peers = useMemo(
() =>
(status?.peers ?? [])
.filter((p) => p.ip !== "")
.sort((a, b) =>
(a.fqdn || a.ip).toLowerCase().localeCompare((b.fqdn || b.ip).toLowerCase()),
),
[status?.peers],
);
const setOpenState = (next: boolean) => {
setOpen(next);
if (!next) setMode(null);
};
const choose = (peer: PeerStatus) => {
if (!mode) return;
const picked = mode;
const text = clipboard;
setOpenState(false);
onPick(peer, picked, text);
};
return (
<Popover.Root open={open} onOpenChange={setOpenState}>
<Popover.Trigger asChild>{children}</Popover.Trigger>
<Popover.Portal>
<Popover.Content
align={"end"}
sideOffset={6}
className={cn(
"wails-no-draggable z-50 w-64 select-none rounded-lg p-1 shadow-lg",
"border border-nb-gray-850 bg-nb-gray-920",
"data-[side=bottom]:origin-top data-[side=top]:origin-bottom",
"data-[state=open]:animate-in data-[state=open]:fade-in-0",
"data-[state=open]:zoom-in-95",
"data-[side=bottom]:slide-in-from-top-1",
"data-[side=top]:slide-in-from-bottom-1",
"duration-150 ease-out",
)}
>
{mode ? (
<PeerStep
mode={mode}
clipboard={clipboard}
peers={peers}
onBack={() => setMode(null)}
onSelect={choose}
/>
) : (
<ModeStep clipboard={clipboard} onChoose={setMode} />
)}
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
};
const ModeStep = ({
clipboard,
onChoose,
}: {
clipboard: string;
onChoose: (mode: SendMode) => void;
}) => {
const { t } = useTranslation();
const rootRef = useRef<HTMLDivElement>(null);
const hasClipboard = clipboard !== "";
// This step has no Command.Input, and cmdk drives arrows/Enter from the
// focused root, so focus it on mount to keep the flow keyboard-navigable.
useEffect(() => {
rootRef.current?.focus();
}, []);
return (
<Command loop ref={rootRef} className={"flex flex-col outline-none"}>
<Command.List>
<Command.Item
value={"files"}
onSelect={() => onChoose("files")}
className={itemClass}
>
<SendIcon size={13} aria-hidden={"true"} className={"shrink-0"} />
<span className={"min-w-0 flex-1 truncate"}>{t("peers.details.sendFile")}</span>
</Command.Item>
<Command.Item
value={"clipboard"}
disabled={!hasClipboard}
onSelect={() => onChoose("clipboard")}
className={cn(itemClass, "data-[disabled=true]:opacity-40")}
>
<ClipboardIcon size={13} aria-hidden={"true"} className={"shrink-0"} />
<span className={"min-w-0 flex-1 truncate"}>
{t("peers.details.sendClipboard")}
</span>
{!hasClipboard && (
<span className={"shrink-0 text-[0.65rem] text-nb-gray-400"}>
{t("peers.details.sendClipboard.empty")}
</span>
)}
</Command.Item>
</Command.List>
</Command>
);
};
const PeerStep = ({
mode,
clipboard,
peers,
onBack,
onSelect,
}: {
mode: SendMode;
clipboard: string;
peers: PeerStatus[];
onBack: () => void;
onSelect: (peer: PeerStatus) => void;
}) => {
const { t } = useTranslation();
return (
<Command
loop
className={"flex flex-col"}
onKeyDown={(e) => {
// Escape steps back to the mode choice rather than discarding
// the whole flow; a second Escape then closes the popover.
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
onBack();
}
}}
>
<button
type={"button"}
onClick={onBack}
aria-label={t("files.send.back")}
className={cn(
"flex w-full items-center gap-1.5 rounded-md px-2 py-1.5",
"cursor-default text-left outline-none transition-colors",
"text-nb-gray-400 hover:text-nb-gray-100",
"focus-visible:ring-2 focus-visible:ring-white/60",
)}
>
<ChevronLeftIcon size={12} aria-hidden={"true"} className={"shrink-0"} />
<span className={"min-w-0 flex-1 truncate text-xs font-medium text-nb-gray-200"}>
{t(modeLabelKey(mode))}
</span>
</button>
{mode === "clipboard" && <ClipboardPreview text={clipboard} className={"mx-1 mb-1"} />}
<div className={"-mx-1 my-1 h-px bg-nb-gray-850"} />
<div role={"search"} className={"flex h-8 items-center gap-2 px-2 pb-1"}>
<Search size={14} aria-hidden={"true"} className={"shrink-0 text-nb-gray-200"} />
<Command.Input
autoFocus
placeholder={t("files.send.search")}
aria-label={t("files.send.search")}
className={cn(
"w-full border-none bg-transparent text-xs outline-none",
"text-nb-gray-100 placeholder:text-nb-gray-300",
)}
/>
</div>
<ScrollArea.Root type={"auto"} className={"-mx-1 overflow-hidden"}>
<ScrollArea.Viewport className={"max-h-64 px-1"}>
<Command.List>
<Command.Empty>
<div className={noticeClass}>{t("files.send.empty")}</div>
</Command.Empty>
{peers.length === 0 && (
<div className={noticeClass}>{t("files.send.noPeers")}</div>
)}
{peers.map((peer) => (
<Command.Item
key={peer.pubKey}
value={`${peer.fqdn} ${peer.ip}`}
onSelect={() => onSelect(peer)}
className={itemClass}
>
<span
aria-hidden={"true"}
className={cn(
"h-1.5 w-1.5 shrink-0 rounded-full",
dotClass(peer.connStatus),
)}
/>
<span className={"min-w-0 flex-1 truncate font-medium"}>
{shortenDns(peer.fqdn) || peer.ip}
</span>
<span className={"shrink-0 text-nb-gray-400"}>
{t(peerStatusLabelKey(peer.connStatus))}
</span>
</Command.Item>
))}
</Command.List>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
orientation={"vertical"}
className={"flex w-1.5 touch-none select-none bg-transparent"}
>
<ScrollArea.Thumb
className={cn(
"relative flex-1 rounded-full",
"bg-nb-gray-800 hover:bg-nb-gray-700",
)}
/>
</ScrollArea.Scrollbar>
</ScrollArea.Root>
</Command>
);
};
@@ -38,6 +38,7 @@ import {
import { FileDrop } from "@bindings/services";
import type { PeerStatus } from "@bindings/services/models.js";
import { Button } from "@/components/buttons/Button";
import { ClipboardPreview } from "@/components/ClipboardPreview";
import { useNavSection } from "@/contexts/NavSectionContext";
import { cn } from "@/lib/cn";
import { CopyToClipboard } from "@/components/CopyToClipboard";
@@ -255,7 +256,10 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
</div>
<ScrollArea.Root type={"auto"} className={"min-h-0 flex-1 overflow-hidden"}>
<ScrollArea.Viewport className={"h-full w-full"}>
<PeerSendActions peer={selected} />
{/* Keyed on the peer so a staged clipboard
confirmation cannot carry over to a different
recipient when the selection changes. */}
<PeerSendActions key={selected.pubKey} peer={selected} />
<PeerDetails peer={selected} now={now} />
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
@@ -283,6 +287,7 @@ const PeerSendActions = ({ peer }: { peer: PeerStatus }) => {
const { setSection } = useNavSection();
const { setSelected } = usePeerDetail();
const [error, setError] = useState<string | null>(null);
const [pendingText, setPendingText] = useState<string | null>(null);
// Deliberately not gated on connStatus: an idle peer is the normal resting
// state under lazy connections, and the outgoing packets of the transfer are
// exactly what wakes it. Only a peer without an overlay address has nothing
@@ -306,7 +311,9 @@ const PeerSendActions = ({ peer }: { peer: PeerStatus }) => {
}
};
const sendClipboard = async () => {
// Clipboard text is shown for confirmation first: sending it blind gave no
// way to tell what was about to leave the machine.
const stageClipboard = async () => {
setError(null);
try {
const text = await FileDrop.ClipboardText();
@@ -314,7 +321,20 @@ const PeerSendActions = ({ peer }: { peer: PeerStatus }) => {
setError(t("peers.details.sendClipboard.empty"));
return;
}
await FileDrop.Send(peer.pubKey, [], text);
setPendingText(text);
} catch (e) {
setError(String(e));
}
};
// Sends the staged text rather than re-reading, so what was confirmed is
// what goes out.
const confirmClipboard = async () => {
if (pendingText === null) return;
setError(null);
try {
await FileDrop.Send(peer.pubKey, [], pendingText);
setPendingText(null);
finishSend();
} catch (e) {
setError(String(e));
@@ -323,26 +343,52 @@ const PeerSendActions = ({ peer }: { peer: PeerStatus }) => {
return (
<div className={"border-b border-nb-gray-920 px-5 py-3"}>
<div className={"flex items-center gap-2"}>
<Button
variant={"secondary"}
size={"xs"}
disabled={!canSend}
onClick={() => void sendFiles()}
>
<SendIcon size={12} aria-hidden={"true"} />
{t("peers.details.sendFile")}
</Button>
<Button
variant={"secondary"}
size={"xs"}
disabled={!canSend}
onClick={() => void sendClipboard()}
>
<ClipboardIcon size={12} aria-hidden={"true"} />
{t("peers.details.sendClipboard")}
</Button>
</div>
{pendingText === null ? (
<div className={"flex items-center gap-2"}>
<Button
variant={"secondary"}
size={"xs"}
disabled={!canSend}
onClick={() => void sendFiles()}
>
<SendIcon size={12} aria-hidden={"true"} />
{t("peers.details.sendFile")}
</Button>
<Button
variant={"secondary"}
size={"xs"}
disabled={!canSend}
onClick={() => void stageClipboard()}
>
<ClipboardIcon size={12} aria-hidden={"true"} />
{t("peers.details.sendClipboard")}
</Button>
</div>
) : (
<div className={"flex flex-col gap-2"}>
<ClipboardPreview text={pendingText} />
<div className={"flex items-center gap-2"}>
<Button
variant={"primary"}
size={"xs"}
onClick={() => void confirmClipboard()}
>
<ClipboardIcon size={12} aria-hidden={"true"} />
{t("peers.details.sendClipboard")}
</Button>
<Button
variant={"secondary"}
size={"xs"}
onClick={() => {
setPendingText(null);
setError(null);
}}
>
{t("common.cancel")}
</Button>
</div>
</div>
)}
{error && <div className={"mt-2 text-xs text-red-400"}>{error}</div>}
</div>
);
@@ -31,7 +31,7 @@ import { PeerFilters, type StatusFilter } from "./PeerFilters";
const isOnline = (connStatus: string) => connStatus === "Connected";
const dotClass = (connStatus: string): string => {
export const dotClass = (connStatus: string): string => {
switch (connStatus) {
case "Connected":
return "bg-green-400";
+24
View File
@@ -1435,6 +1435,30 @@
"files.action.delete": {
"message": "Löschen"
},
"files.delete.title": {
"message": "Aus dem Verlauf entfernen?"
},
"files.delete.message": {
"message": "Dieser Eintrag wird aus dem Übertragungsverlauf entfernt.\nBereits empfangene Dateien bleiben auf der Festplatte."
},
"files.send.search": {
"message": "Peers suchen"
},
"files.send.empty": {
"message": "Kein passender Peer"
},
"files.send.noPeers": {
"message": "Keine Peers verfügbar"
},
"files.send.trigger": {
"message": "Senden…"
},
"files.send.back": {
"message": "Zurück"
},
"files.send.clipboardPreview": {
"message": "Vorschau der Zwischenablage"
},
"peers.dropToSend": {
"message": "Zum Senden ablegen"
},
+32
View File
@@ -1943,6 +1943,38 @@
"message": "Delete",
"description": "Remove the entry from the history."
},
"files.delete.title": {
"message": "Remove from history?",
"description": "Confirmation-dialog title for removing a transfer from the file history."
},
"files.delete.message": {
"message": "This entry is removed from the transfer history.\nAlready received files are kept on disk.",
"description": "Confirmation body for removing a history entry; states that received files stay on disk. Contains a line break (\\n) — keep it."
},
"files.send.search": {
"message": "Search peers",
"description": "Placeholder of the peer-picker search field."
},
"files.send.empty": {
"message": "No matching peer",
"description": "Shown when the peer-picker search matches nothing."
},
"files.send.noPeers": {
"message": "No peers available",
"description": "Shown in the peer picker when the peer list is empty."
},
"files.send.trigger": {
"message": "Send…",
"description": "Label of the single send button in the Files header. Keep short."
},
"files.send.back": {
"message": "Back",
"description": "Returns from the what-to-send step to the peer list. Keep short."
},
"files.send.clipboardPreview": {
"message": "Clipboard preview",
"description": "Accessible label of the clipboard-text preview shown before sending."
},
"peers.dropToSend": {
"message": "Drop to send",
"description": "Overlay shown while dragging files over a peer row."
+24
View File
@@ -1435,6 +1435,30 @@
"files.action.delete": {
"message": "Eliminar"
},
"files.delete.title": {
"message": "¿Quitar del historial?"
},
"files.delete.message": {
"message": "Esta entrada se quitará del historial de transferencias.\nLos archivos ya recibidos se conservan en el disco."
},
"files.send.search": {
"message": "Buscar peers"
},
"files.send.empty": {
"message": "Ningún peer coincide"
},
"files.send.noPeers": {
"message": "No hay peers disponibles"
},
"files.send.trigger": {
"message": "Enviar…"
},
"files.send.back": {
"message": "Atrás"
},
"files.send.clipboardPreview": {
"message": "Vista previa del portapapeles"
},
"peers.dropToSend": {
"message": "Suelta para enviar"
},
+24
View File
@@ -1435,6 +1435,30 @@
"files.action.delete": {
"message": "Supprimer"
},
"files.delete.title": {
"message": "Retirer de lhistorique ?"
},
"files.delete.message": {
"message": "Cette entrée est retirée de lhistorique des transferts.\nLes fichiers déjà reçus sont conservés sur le disque."
},
"files.send.search": {
"message": "Rechercher des pairs"
},
"files.send.empty": {
"message": "Aucun pair correspondant"
},
"files.send.noPeers": {
"message": "Aucun pair disponible"
},
"files.send.trigger": {
"message": "Envoyer…"
},
"files.send.back": {
"message": "Retour"
},
"files.send.clipboardPreview": {
"message": "Aperçu du presse-papiers"
},
"peers.dropToSend": {
"message": "Déposer pour envoyer"
},
+24
View File
@@ -1435,6 +1435,30 @@
"files.action.delete": {
"message": "Törlés"
},
"files.delete.title": {
"message": "Eltávolítás az előzményekből?"
},
"files.delete.message": {
"message": "A bejegyzés törlődik az átviteli előzményekből.\nA már fogadott fájlok a lemezen maradnak."
},
"files.send.search": {
"message": "Gépek keresése"
},
"files.send.empty": {
"message": "Nincs találat"
},
"files.send.noPeers": {
"message": "Nincs elérhető gép"
},
"files.send.trigger": {
"message": "Küldés…"
},
"files.send.back": {
"message": "Vissza"
},
"files.send.clipboardPreview": {
"message": "Vágólap előnézete"
},
"peers.dropToSend": {
"message": "Engedd el a küldéshez"
},
+24
View File
@@ -1435,6 +1435,30 @@
"files.action.delete": {
"message": "Elimina"
},
"files.delete.title": {
"message": "Rimuovere dalla cronologia?"
},
"files.delete.message": {
"message": "Questa voce viene rimossa dalla cronologia dei trasferimenti.\nI file già ricevuti vengono conservati su disco."
},
"files.send.search": {
"message": "Cerca peer"
},
"files.send.empty": {
"message": "Nessun peer corrispondente"
},
"files.send.noPeers": {
"message": "Nessun peer disponibile"
},
"files.send.trigger": {
"message": "Invia…"
},
"files.send.back": {
"message": "Indietro"
},
"files.send.clipboardPreview": {
"message": "Anteprima degli appunti"
},
"peers.dropToSend": {
"message": "Rilascia per inviare"
},
+24
View File
@@ -1435,6 +1435,30 @@
"files.action.delete": {
"message": "削除"
},
"files.delete.title": {
"message": "履歴から削除しますか?"
},
"files.delete.message": {
"message": "この項目は転送履歴から削除されます。\n受信済みのファイルはディスクに残ります。"
},
"files.send.search": {
"message": "ピアを検索"
},
"files.send.empty": {
"message": "一致するピアがありません"
},
"files.send.noPeers": {
"message": "利用可能なピアがありません"
},
"files.send.trigger": {
"message": "送信…"
},
"files.send.back": {
"message": "戻る"
},
"files.send.clipboardPreview": {
"message": "クリップボードのプレビュー"
},
"peers.dropToSend": {
"message": "ドロップして送信"
},
+24
View File
@@ -1435,6 +1435,30 @@
"files.action.delete": {
"message": "Excluir"
},
"files.delete.title": {
"message": "Remover do histórico?"
},
"files.delete.message": {
"message": "Esta entrada é removida do histórico de transferências.\nOs ficheiros já recebidos são mantidos no disco."
},
"files.send.search": {
"message": "Pesquisar peers"
},
"files.send.empty": {
"message": "Nenhum peer correspondente"
},
"files.send.noPeers": {
"message": "Nenhum peer disponível"
},
"files.send.trigger": {
"message": "Enviar…"
},
"files.send.back": {
"message": "Voltar"
},
"files.send.clipboardPreview": {
"message": "Pré-visualização da área de transferência"
},
"peers.dropToSend": {
"message": "Solte para enviar"
},
+24
View File
@@ -1435,6 +1435,30 @@
"files.action.delete": {
"message": "Удалить"
},
"files.delete.title": {
"message": "Удалить из истории?"
},
"files.delete.message": {
"message": "Запись будет удалена из истории передач.\nУже полученные файлы останутся на диске."
},
"files.send.search": {
"message": "Поиск узлов"
},
"files.send.empty": {
"message": "Нет подходящих узлов"
},
"files.send.noPeers": {
"message": "Нет доступных узлов"
},
"files.send.trigger": {
"message": "Отправить…"
},
"files.send.back": {
"message": "Назад"
},
"files.send.clipboardPreview": {
"message": "Предпросмотр буфера обмена"
},
"peers.dropToSend": {
"message": "Отпустите для отправки"
},
+24
View File
@@ -1435,6 +1435,30 @@
"files.action.delete": {
"message": "删除"
},
"files.delete.title": {
"message": "从历史记录中移除?"
},
"files.delete.message": {
"message": "此条目将从传输历史记录中移除。\n已接收的文件会保留在磁盘上。"
},
"files.send.search": {
"message": "搜索对等节点"
},
"files.send.empty": {
"message": "没有匹配的对等节点"
},
"files.send.noPeers": {
"message": "没有可用的对等节点"
},
"files.send.trigger": {
"message": "发送…"
},
"files.send.back": {
"message": "返回"
},
"files.send.clipboardPreview": {
"message": "剪贴板预览"
},
"peers.dropToSend": {
"message": "放开即发送"
},