[client] Move the peer send actions into a footer behind a single Send menu

The two send buttons at the top of the peer details panel become one
Send button in a bottom bar, matching the Resources tab, with a menu
offering File and Clipboard. The clipboard entry is read on open and
disabled when empty, mirroring the Files page picker, and Escape closes
only the menu instead of the whole panel.
This commit is contained in:
Zoltán Papp
2026-09-09 16:36:09 +02:00
parent 290ad1507a
commit 78cb3ffdb8
12 changed files with 200 additions and 44 deletions
@@ -23,6 +23,7 @@ import {
ClipboardIcon,
ClockIcon,
Copy as CopyIcon,
FileIcon,
GaugeIcon,
HandshakeIcon,
KeyRoundIcon,
@@ -32,13 +33,19 @@ import {
MonitorIcon,
Radio,
RefreshCwIcon,
SendIcon,
UploadIcon,
WaypointsIcon,
} from "lucide-react";
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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/DropdownMenu";
import { useNavSection } from "@/contexts/NavSectionContext";
import { cn } from "@/lib/cn";
import { CopyToClipboard } from "@/components/CopyToClipboard";
@@ -256,10 +263,6 @@ 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"}>
{/* 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
@@ -276,6 +279,10 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
/>
</ScrollArea.Scrollbar>
</ScrollArea.Root>
{/* 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} />
</motion.div>
)}
</AnimatePresence>
@@ -286,6 +293,8 @@ const PeerSendActions = ({ peer }: { peer: PeerStatus }) => {
const { t } = useTranslation();
const { setSection } = useNavSection();
const { setSelected } = usePeerDetail();
const [menuOpen, setMenuOpen] = useState(false);
const [clipboard, setClipboard] = useState("");
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
@@ -294,12 +303,28 @@ const PeerSendActions = ({ peer }: { peer: PeerStatus }) => {
// to dial.
const canSend = peer.ip !== "";
useEffect(() => {
if (!menuOpen) return;
let cancelled = false;
FileDrop.ClipboardText()
.then((text) => {
if (!cancelled) setClipboard(text ?? "");
})
.catch(() => {
if (!cancelled) setClipboard("");
});
return () => {
cancelled = true;
};
}, [menuOpen]);
const finishSend = () => {
setSelected(null);
setSection("files");
};
const sendFiles = async () => {
setMenuOpen(false);
setError(null);
try {
const paths = await FileDrop.PickFiles();
@@ -313,18 +338,14 @@ const PeerSendActions = ({ peer }: { peer: PeerStatus }) => {
// 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 () => {
const stageClipboard = () => {
setMenuOpen(false);
setError(null);
try {
const text = await FileDrop.ClipboardText();
if (!text) {
setError(t("peers.details.sendClipboard.empty"));
return;
}
setPendingText(text);
} catch (e) {
setError(String(e));
if (!clipboard) {
setError(t("peers.details.sendClipboard.empty"));
return;
}
setPendingText(clipboard);
};
// Sends the staged text rather than re-reading, so what was confirmed is
@@ -342,40 +363,65 @@ const PeerSendActions = ({ peer }: { peer: PeerStatus }) => {
};
return (
<div className={"border-b border-nb-gray-920 px-5 py-3"}>
<div className={cn("shrink-0 px-6 py-3.5", "border-t border-nb-gray-910")}>
{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 className={"flex items-center justify-end"}>
<DropdownMenu modal={false} open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenuTrigger asChild className={"wails-no-draggable"}>
<Button
variant={"primary"}
size={"xs"}
disabled={!canSend}
aria-haspopup={"menu"}
aria-expanded={menuOpen}
>
<UploadIcon size={12} aria-hidden={"true"} />
{t("peers.details.send")}
<ChevronDownIcon
size={12}
aria-hidden={"true"}
className={cn(
"transition-transform duration-150",
menuOpen && "rotate-180",
)}
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
side={"top"}
align={"end"}
sideOffset={6}
className={"min-w-44 select-none"}
onEscapeKeyDown={(e) => e.stopPropagation()}
>
<DropdownMenuItem onClick={() => void sendFiles()}>
<div className={"flex w-full items-center gap-2"}>
<FileIcon size={14} aria-hidden={"true"} />
<span className={"flex-1"}>
{t("peers.details.sendMenu.file")}
</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem disabled={!clipboard} onClick={stageClipboard}>
<div className={"flex w-full items-center gap-2"}>
<ClipboardIcon size={14} aria-hidden={"true"} />
<span className={"flex-1"}>
{t("peers.details.sendMenu.clipboard")}
</span>
{!clipboard && (
<span className={"text-[0.65rem] text-nb-gray-400"}>
{t("peers.details.sendClipboard.empty")}
</span>
)}
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</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>
<div className={"flex items-center justify-end gap-2"}>
<Button
variant={"secondary"}
size={"xs"}
@@ -386,6 +432,14 @@ const PeerSendActions = ({ peer }: { peer: PeerStatus }) => {
>
{t("common.cancel")}
</Button>
<Button
variant={"primary"}
size={"xs"}
onClick={() => void confirmClipboard()}
>
<ClipboardIcon size={12} aria-hidden={"true"} />
{t("peers.details.sendClipboard")}
</Button>
</div>
</div>
)}
+9
View File
@@ -1471,6 +1471,15 @@
"peers.details.sendClipboard.empty": {
"message": "Zwischenablage ist leer"
},
"peers.details.send": {
"message": "Senden"
},
"peers.details.sendMenu.file": {
"message": "Datei…"
},
"peers.details.sendMenu.clipboard": {
"message": "Zwischenablage"
},
"settings.tabs.fileSharing": {
"message": "Dateifreigabe"
},
+12
View File
@@ -1991,6 +1991,18 @@
"message": "Clipboard is empty",
"description": "Error when the clipboard has no text."
},
"peers.details.send": {
"message": "Send",
"description": "Label of the single send button at the bottom of the peer details panel; opens the what-to-send menu. Keep short."
},
"peers.details.sendMenu.file": {
"message": "File…",
"description": "Send menu entry opening the file picker. Keep short."
},
"peers.details.sendMenu.clipboard": {
"message": "Clipboard",
"description": "Send menu entry sending the clipboard text. Keep short."
},
"settings.tabs.fileSharing": {
"message": "File sharing",
"description": "Settings tab for file transfer policy."
+9
View File
@@ -1471,6 +1471,15 @@
"peers.details.sendClipboard.empty": {
"message": "El portapapeles está vacío"
},
"peers.details.send": {
"message": "Enviar"
},
"peers.details.sendMenu.file": {
"message": "Archivo…"
},
"peers.details.sendMenu.clipboard": {
"message": "Portapapeles"
},
"settings.tabs.fileSharing": {
"message": "Compartir archivos"
},
+9
View File
@@ -1471,6 +1471,15 @@
"peers.details.sendClipboard.empty": {
"message": "Le presse-papiers est vide"
},
"peers.details.send": {
"message": "Envoyer"
},
"peers.details.sendMenu.file": {
"message": "Fichier…"
},
"peers.details.sendMenu.clipboard": {
"message": "Presse-papiers"
},
"settings.tabs.fileSharing": {
"message": "Partage de fichiers"
},
+9
View File
@@ -1471,6 +1471,15 @@
"peers.details.sendClipboard.empty": {
"message": "A vágólap üres"
},
"peers.details.send": {
"message": "Küldés"
},
"peers.details.sendMenu.file": {
"message": "Fájl…"
},
"peers.details.sendMenu.clipboard": {
"message": "Vágólap"
},
"settings.tabs.fileSharing": {
"message": "Fájlmegosztás"
},
+9
View File
@@ -1471,6 +1471,15 @@
"peers.details.sendClipboard.empty": {
"message": "Gli appunti sono vuoti"
},
"peers.details.send": {
"message": "Invia"
},
"peers.details.sendMenu.file": {
"message": "File…"
},
"peers.details.sendMenu.clipboard": {
"message": "Appunti"
},
"settings.tabs.fileSharing": {
"message": "Condivisione file"
},
+9
View File
@@ -1471,6 +1471,15 @@
"peers.details.sendClipboard.empty": {
"message": "クリップボードは空です"
},
"peers.details.send": {
"message": "送信"
},
"peers.details.sendMenu.file": {
"message": "ファイル…"
},
"peers.details.sendMenu.clipboard": {
"message": "クリップボード"
},
"settings.tabs.fileSharing": {
"message": "ファイル共有"
},
+9
View File
@@ -1471,6 +1471,15 @@
"peers.details.sendClipboard.empty": {
"message": "A área de transferência está vazia"
},
"peers.details.send": {
"message": "Enviar"
},
"peers.details.sendMenu.file": {
"message": "Arquivo…"
},
"peers.details.sendMenu.clipboard": {
"message": "Área de transferência"
},
"settings.tabs.fileSharing": {
"message": "Compartilhamento de arquivos"
},
+9
View File
@@ -1471,6 +1471,15 @@
"peers.details.sendClipboard.empty": {
"message": "Буфер обмена пуст"
},
"peers.details.send": {
"message": "Отправить"
},
"peers.details.sendMenu.file": {
"message": "Файл…"
},
"peers.details.sendMenu.clipboard": {
"message": "Буфер обмена"
},
"settings.tabs.fileSharing": {
"message": "Обмен файлами"
},
+9
View File
@@ -1493,6 +1493,15 @@
"peers.details.sendClipboard.empty": {
"message": "Буфер обміну порожній"
},
"peers.details.send": {
"message": "Надіслати"
},
"peers.details.sendMenu.file": {
"message": "Файл…"
},
"peers.details.sendMenu.clipboard": {
"message": "Буфер обміну"
},
"settings.tabs.fileSharing": {
"message": "Обмін файлами"
},
+9
View File
@@ -1471,6 +1471,15 @@
"peers.details.sendClipboard.empty": {
"message": "剪贴板为空"
},
"peers.details.send": {
"message": "发送"
},
"peers.details.sendMenu.file": {
"message": "文件…"
},
"peers.details.sendMenu.clipboard": {
"message": "剪贴板"
},
"settings.tabs.fileSharing": {
"message": "文件共享"
},