Add peer-to-peer file drop

Files move directly between peers over the overlay, with no server in the
path. The receiver listens on the WireGuard address only, so the port is
unreachable from outside the tunnel, and every offer is matched to a known
peer before anything is read.

Consent is the default: an offer carries metadata alone, and no payload
moves until the receiver accepts. Policy is per profile and device-local —
off, ask, or auto-accept, with per-sender exceptions on top.

Policy and history live in the profile's preferences, so removing a profile
takes its file drop state with it. Transfers interrupted by a restart are
settled on load; nothing survives to finish them, and left alone they would
sit in the log as permanently pending.

The Android bindings pull payload bytes through a chunk-returning stream:
gomobile copies a []byte argument into a fresh Java array and never copies
it back, so a fill-my-buffer method would hand back the right length with
no data.
This commit is contained in:
Zoltán Papp
2026-08-16 22:04:38 +02:00
parent 14aab0fc6e
commit 73cffdb702
70 changed files with 11240 additions and 332 deletions

View File

@@ -1,6 +1,6 @@
import { createContext, useContext, useMemo, useState, type ReactNode } from "react";
export type NavSection = "peers" | "networks";
export type NavSection = "peers" | "networks" | "files";
type NavSectionContextValue = {
section: NavSection;

View File

@@ -0,0 +1,47 @@
export const enum TransferState {
Pending = 0,
Transferring = 1,
Completed = 2,
Declined = 3,
Expired = 4,
Cancelled = 5,
Failed = 6,
}
export const enum ReceiveMode {
Off = 0,
Ask = 1,
Auto = 2,
}
export const enum PeerRule {
Default = 0,
Always = 1,
Block = 2,
}
export const enum FailureReason {
None = 0,
Unreachable = 1,
}
export const isTerminalState = (state: number): boolean => state >= TransferState.Completed;
export const stateLabelKey = (state: number): string => {
switch (state) {
case TransferState.Pending:
return "files.state.pending";
case TransferState.Transferring:
return "files.state.transferring";
case TransferState.Completed:
return "files.state.received";
case TransferState.Declined:
return "files.state.declined";
case TransferState.Expired:
return "files.state.expired";
case TransferState.Cancelled:
return "files.state.cancelled";
default:
return "files.state.failed";
}
};

View File

@@ -11,6 +11,7 @@ import { NotConnectedState } from "@/components/empty-state/NotConnectedState";
import { useStatus } from "@/contexts/StatusContext";
import { Peers } from "@/modules/main/advanced/peers/Peers";
import { Networks } from "@/modules/main/advanced/networks/Networks";
import { Files } from "@/modules/main/advanced/files/Files";
import { NetworksProvider } from "@/contexts/NetworksContext";
import { PeerDetailProvider, usePeerDetail } from "@/contexts/PeerDetailContext";
import { useRestrictions } from "@/contexts/RestrictionsContext";
@@ -44,7 +45,10 @@ const MainBody = () => {
const isAdvanced = viewMode === "advanced";
return (
<main className={"wails-draggable flex min-h-0 flex-1"}>
// min-w-0 matters here: without it this flex parent keeps its automatic
// minimum width, and a long file name in the right panel pushes the
// layout wider than the window, which cannot be resized.
<main className={"wails-draggable flex min-h-0 min-w-0 flex-1"}>
{/* Windows narrower width compensates for the OS frame Wails counts differently than macOS.
See https://github.com/wailsapp/wails/issues/3260 */}
<div
@@ -98,10 +102,14 @@ const AdvancedAppRightPanel = () => {
role={"tabpanel"}
id={`nb-tabpanel-${section}`}
aria-labelledby={`nb-tab-${section}`}
className={"flex min-h-0 flex-1 flex-col"}
// min-w-0: this is the section's direct parent, so without
// it a long file name sets the floor for the whole panel
// and the row overflows instead of truncating.
className={"flex min-h-0 min-w-0 flex-1 flex-col"}
>
{section === "peers" && <Peers />}
{section === "networks" && <Networks />}
{section === "files" && <Files />}
</div>
</div>
{!isConnected && (

View File

@@ -1,6 +1,6 @@
import { type ComponentType, type KeyboardEvent, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Layers3Icon, type LucideProps, MonitorSmartphoneIcon } from "lucide-react";
import { FolderDownIcon, Layers3Icon, type LucideProps, MonitorSmartphoneIcon } from "lucide-react";
import { cn } from "@/lib/cn";
import { useNavSection, type NavSection } from "@/contexts/NavSectionContext";
import { useStatus } from "@/contexts/StatusContext";
@@ -40,6 +40,11 @@ export const Navigation = () => {
icon: Layers3Icon,
});
}
tabs.push({
value: "files",
label: t("nav.files.title"),
icon: FolderDownIcon,
});
const tabRefs = useRef<Record<string, HTMLButtonElement | null>>({});
@@ -103,7 +108,7 @@ export const Navigation = () => {
onKeyDown={handleKeyDown}
disabled={isDisabled}
className={cn(
"group relative flex flex-1 items-center justify-center",
"group relative flex min-w-0 flex-1 items-center justify-center",
"gap-2.5 px-5 py-3.5",
"outline-none transition-all",
isFirst && "rounded-tl-xl",
@@ -113,8 +118,8 @@ export const Navigation = () => {
isDisabled ? "cursor-not-allowed opacity-50" : "cursor-default",
)}
>
<Icon size={14} aria-hidden={"true"} />
<span className={"text-sm font-normal"}>{tab.label}</span>
<Icon size={14} className={"shrink-0"} aria-hidden={"true"} />
<span className={"truncate text-sm font-normal"}>{tab.label}</span>
<span
aria-hidden={"true"}
className={cn(

View File

@@ -0,0 +1,494 @@
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 {
ArrowDownIcon,
ArrowUpIcon,
BanIcon,
CheckIcon,
CopyIcon,
FolderDownIcon,
FolderOpenIcon,
MoreVerticalIcon,
ShieldCheckIcon,
Trash2Icon,
XIcon,
} from "lucide-react";
import { FileDrop } from "@bindings/services";
import type { FileDropTransfer } 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 {
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 timeLabel = (iso: string): string => {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
};
// Colours only what the eye should catch scanning the outcome column: a refusal
// or failure in red, a completed send in green. Everything else, a received file
// included, stays neutral so the exceptions stand out.
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 transfer.outgoing ? "text-green-400" : "text-nb-gray-500";
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]);
if (transfers !== null && transfers.length === 0) {
return (
<EmptyState
icon={FolderDownIcon}
title={t("files.empty.title")}
description={t("files.empty.description")}
/>
);
}
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>
</div>
{filtered.length === 0 ? (
<NoResults />
) : (
<ScrollArea.Root type={"auto"} className={"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>
);
};
type RowProps = {
transfer: FileDropTransfer;
onChanged: () => void;
};
const PendingOfferRow = ({ transfer, onChanged }: RowProps) => {
const { t } = useTranslation();
const [busy, setBusy] = useState(false);
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"}>
<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"}>
{" · "}
{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 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;
// 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));
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",
)}
>
{transfer.outgoing ? (
<ArrowUpIcon size={15} className={"shrink-0 text-netbird"} aria-hidden={"true"} />
) : (
<ArrowDownIcon
size={15}
className={"shrink-0 text-green-400"}
aria-hidden={"true"}
/>
)}
<div className={"flex min-w-0 flex-1 flex-col leading-tight"}>
<span
className={cn(
"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(" · ")}
</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. */}
<span
className={cn(
"flex max-w-[8.5rem] shrink-0 flex-col items-end leading-tight",
"pl-2 text-xs text-nb-gray-500",
"transition-opacity group-hover:opacity-0",
)}
>
<span className={"tabular-nums"}>{time}</span>
<span className={cn("max-w-full truncate", stateClass)}>{stateLabel}</span>
</span>
{/* The window is a fixed 900px wide and cannot be resized, so the
row actions overlay the outcome 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
// 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 setRule = async (rule: PeerRule) => {
await FileDrop.SetPeerRule(transfer.peerKey, rule);
onChanged();
};
return (
<DropdownMenu>
<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={async () => {
await FileDrop.Delete(transfer.id);
onChanged();
}}
>
<Trash2Icon size={14} className={"mr-2"} />
{t("files.action.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};

View File

@@ -20,6 +20,7 @@ import {
Check as CheckIcon,
ChevronDownIcon,
ChevronsLeftRightEllipsisIcon,
ClipboardIcon,
ClockIcon,
Copy as CopyIcon,
GaugeIcon,
@@ -31,9 +32,13 @@ import {
MonitorIcon,
Radio,
RefreshCwIcon,
SendIcon,
WaypointsIcon,
} from "lucide-react";
import { FileDrop } from "@bindings/services";
import type { PeerStatus } from "@bindings/services/models.js";
import { Button } from "@/components/buttons/Button";
import { useNavSection } from "@/contexts/NavSectionContext";
import { cn } from "@/lib/cn";
import { CopyToClipboard } from "@/components/CopyToClipboard";
import { Tooltip } from "@/components/Tooltip";
@@ -250,6 +255,7 @@ 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} />
<PeerDetails peer={selected} now={now} />
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
@@ -272,6 +278,76 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
);
};
const PeerSendActions = ({ peer }: { peer: PeerStatus }) => {
const { t } = useTranslation();
const { setSection } = useNavSection();
const { setSelected } = usePeerDetail();
const [error, setError] = 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
// to dial.
const canSend = peer.ip !== "";
const finishSend = () => {
setSelected(null);
setSection("files");
};
const sendFiles = async () => {
setError(null);
try {
const paths = await FileDrop.PickFiles();
if (!paths || paths.length === 0) return;
await FileDrop.Send(peer.pubKey, paths, "");
finishSend();
} catch (e) {
setError(String(e));
}
};
const sendClipboard = async () => {
setError(null);
try {
const text = await FileDrop.ClipboardText();
if (!text) {
setError(t("peers.details.sendClipboard.empty"));
return;
}
await FileDrop.Send(peer.pubKey, [], text);
finishSend();
} catch (e) {
setError(String(e));
}
};
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>
{error && <div className={"mt-2 text-xs text-red-400"}>{error}</div>}
</div>
);
};
const PeerDetails = ({ peer, now }: { peer: PeerStatus; now: number }) => {
const { t } = useTranslation();
const formatAge = (unix: number, fallback: string): string => {

View File

@@ -1,9 +1,21 @@
import { type KeyboardEvent, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import {
type DragEvent,
type KeyboardEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { Events } from "@wailsio/runtime";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso";
import { ChevronRightIcon, MonitorSmartphoneIcon } from "lucide-react";
import { ChevronRightIcon, MonitorSmartphoneIcon, SendIcon } from "lucide-react";
import { FileDrop } from "@bindings/services";
import type { PeerStatus } from "@bindings/services/models.js";
import { useNavSection } from "@/contexts/NavSectionContext";
import { cn } from "@/lib/cn";
import { reconcileOrder } from "@/lib/sorting";
import { CopyToClipboard } from "@/components/CopyToClipboard";
@@ -41,13 +53,36 @@ export const peerStatusLabelKey = (connStatus: string): string => {
}
};
const EVENT_FILES_DROPPED = "netbird:files:dropped";
export const Peers = () => {
const { t } = useTranslation();
const { status } = useStatus();
const { setSection } = useNavSection();
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [scrollParent, setScrollParent] = useState<HTMLDivElement | null>(null);
const searchRef = useRef<HTMLInputElement>(null);
const [dropTarget, setDropTarget] = useState<string | null>(null);
const dropTargetRef = useRef<string | null>(null);
const setDropTargetBoth = useCallback((pubKey: string | null) => {
dropTargetRef.current = pubKey;
setDropTarget(pubKey);
}, []);
useEffect(() => {
const off = Events.On(EVENT_FILES_DROPPED, (ev: { data: string[] | string[][] }) => {
const target = dropTargetRef.current;
setDropTargetBoth(null);
if (!target) return;
const raw = ev.data;
const paths = (Array.isArray(raw[0]) ? raw[0] : raw) as string[];
if (paths.length === 0) return;
void FileDrop.Send(target, paths, "").then(() => setSection("files"));
});
return off;
}, [setDropTargetBoth, setSection]);
useEffect(() => {
searchRef.current?.focus();
@@ -135,9 +170,26 @@ export const Peers = () => {
{filtered.length === 0 ? (
<NoResults />
) : (
<ScrollArea.Root type={"auto"} className={"min-h-0 flex-1 overflow-hidden"}>
<ScrollArea.Root
type={"auto"}
className={"min-h-0 flex-1 overflow-hidden"}
onDragLeave={(e: DragEvent) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
setDropTargetBoth(null);
}
}}
onDragOver={(e: DragEvent) => e.preventDefault()}
onDrop={(e: DragEvent) => e.preventDefault()}
>
<ScrollArea.Viewport ref={setScrollParent} className={"h-full w-full"}>
{scrollParent && <PeersList data={filtered} scrollParent={scrollParent} />}
{scrollParent && (
<PeersList
data={filtered}
scrollParent={scrollParent}
dropTarget={dropTarget}
onDropTarget={setDropTargetBoth}
/>
)}
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
orientation={"vertical"}
@@ -163,9 +215,11 @@ const ListTopSpacer = () => <div className={"h-2"} />;
type PeersListProps = {
data: PeerStatus[];
scrollParent: HTMLElement;
dropTarget: string | null;
onDropTarget: (pubKey: string | null) => void;
};
const PeersList = ({ data, scrollParent }: PeersListProps) => {
const PeersList = ({ data, scrollParent, dropTarget, onDropTarget }: PeersListProps) => {
const { setSelected } = usePeerDetail();
const virtuosoRef = useRef<VirtuosoHandle>(null);
const rowRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
@@ -221,9 +275,15 @@ const PeersList = ({ data, scrollParent }: PeersListProps) => {
};
const ctx = useMemo<PeerRowContext>(
() => ({ onKeyDown: handleRowKeyDown, onSelect: setSelected, setRowRef }),
() => ({
onKeyDown: handleRowKeyDown,
onSelect: setSelected,
setRowRef,
dropTarget,
onDropTarget,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[data, setSelected],
[data, setSelected, dropTarget, onDropTarget],
);
return (
@@ -244,6 +304,8 @@ type PeerRowContext = {
onKeyDown: (e: KeyboardEvent<Element>, index: number) => void;
onSelect: (peer: PeerStatus) => void;
setRowRef: (pubKey: string, el: HTMLButtonElement | null) => void;
dropTarget: string | null;
onDropTarget: (pubKey: string | null) => void;
};
const renderPeerRow = (index: number, peer: PeerStatus, ctx: PeerRowContext): ReactNode => (
@@ -253,6 +315,8 @@ const renderPeerRow = (index: number, peer: PeerStatus, ctx: PeerRowContext): Re
onKeyDown={ctx.onKeyDown}
onSelect={ctx.onSelect}
setRowRef={ctx.setRowRef}
isDropTarget={ctx.dropTarget === peer.pubKey}
onDropTarget={ctx.onDropTarget}
/>
);
@@ -262,9 +326,19 @@ type PeerRowProps = {
onKeyDown: (e: KeyboardEvent<Element>, index: number) => void;
onSelect: (peer: PeerStatus) => void;
setRowRef: (pubKey: string, el: HTMLButtonElement | null) => void;
isDropTarget: boolean;
onDropTarget: (pubKey: string | null) => void;
};
const PeerRow = ({ peer, index, onKeyDown, onSelect, setRowRef }: PeerRowProps) => {
const PeerRow = ({
peer,
index,
onKeyDown,
onSelect,
setRowRef,
isDropTarget,
onDropTarget,
}: PeerRowProps) => {
const { t } = useTranslation();
const isConnected = peer.connStatus === "Connected";
const peerName = shortenDns(peer.fqdn) || peer.ip;
@@ -272,12 +346,29 @@ const PeerRow = ({ peer, index, onKeyDown, onSelect, setRowRef }: PeerRowProps)
const handleKey = (e: KeyboardEvent<Element>) => onKeyDown(e, index);
return (
<div
onDragOver={(e: DragEvent) => {
if (!isConnected) return;
e.preventDefault();
onDropTarget(peer.pubKey);
}}
className={cn(
"group relative flex min-w-0 items-start gap-2.5 py-3 pl-6 pr-4",
"transition-colors hover:bg-nb-gray-900/40",
"wails-no-draggable",
)}
>
{isDropTarget && (
<div
className={cn(
"pointer-events-none absolute inset-x-2 inset-y-0.5 z-10",
"flex items-center justify-center gap-2 rounded-lg",
"border border-netbird bg-nb-gray-940/90 text-netbird",
)}
>
<SendIcon size={14} aria-hidden={"true"} />
<span className={"text-sm"}>{t("peers.dropToSend")}</span>
</div>
)}
<button
type={"button"}
tabIndex={0}

View File

@@ -0,0 +1,222 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { BanIcon, CheckIcon, XIcon } from "lucide-react";
import { FileDrop } from "@bindings/services";
import { FileDropSettings } from "@bindings/services/models.js";
import { cn } from "@/lib/cn";
import { PeerRule, ReceiveMode } from "@/lib/filedrop";
import { shortenDns } from "@/lib/formatters";
import { Button } from "@/components/buttons/Button";
import { HelpText } from "@/components/typography/HelpText";
import { Label } from "@/components/typography/Label";
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
import { useStatus } from "@/contexts/StatusContext";
const MODES: { value: ReceiveMode; labelKey: string; helpKey: string }[] = [
{
value: ReceiveMode.Off,
labelKey: "settings.fileSharing.mode.off",
helpKey: "settings.fileSharing.mode.off.help",
},
{
value: ReceiveMode.Ask,
labelKey: "settings.fileSharing.mode.ask",
helpKey: "settings.fileSharing.mode.ask.help",
},
{
value: ReceiveMode.Auto,
labelKey: "settings.fileSharing.mode.auto",
helpKey: "settings.fileSharing.mode.auto.help",
},
];
export function SettingsFileSharing() {
const { t } = useTranslation();
const { status } = useStatus();
const [settings, setSettings] = useState<FileDropSettings | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
FileDrop.GetSettings()
.then(setSettings)
.catch((e: unknown) => setError(String(e)));
}, []);
const apply = useCallback(
async (next: FileDropSettings) => {
const prev = settings;
setSettings(next);
try {
await FileDrop.SetSettings(next);
setError(null);
} catch (e) {
setSettings(prev);
setError(String(e));
}
},
[settings],
);
const changeDirectory = async () => {
if (!settings) return;
const dir = await FileDrop.PickDirectory();
if (!dir) return;
await apply(new FileDropSettings({ ...settings, destinationDir: dir }));
};
const removeRule = async (peerKey: string) => {
if (!settings) return;
try {
await FileDrop.SetPeerRule(peerKey, PeerRule.Default);
setSettings(await FileDrop.GetSettings());
} catch (e) {
setError(String(e));
}
};
const peerName = (key: string): string => {
const peer = (status?.peers ?? []).find((p) => p.pubKey === key);
return peer ? shortenDns(peer.fqdn) || peer.ip : key.slice(0, 12) + "…";
};
if (!settings) {
return (
<SectionGroup title={t("settings.fileSharing.section")}>
<HelpText>{error ?? t("settings.fileSharing.loading")}</HelpText>
</SectionGroup>
);
}
const rules = Object.entries(settings.peerRules ?? {});
return (
<SectionGroup title={t("settings.fileSharing.section")}>
{error && <HelpText className={"text-red-400"}>{error}</HelpText>}
<div>
<Label>{t("settings.fileSharing.mode.label")}</Label>
<HelpText>{t("settings.fileSharing.mode.help")}</HelpText>
<div
role={"radiogroup"}
aria-label={t("settings.fileSharing.mode.label")}
className={"mt-2 flex flex-col gap-1.5"}
>
{MODES.map((mode) => {
const active = settings.mode === mode.value;
return (
<button
key={mode.value}
type={"button"}
role={"radio"}
aria-checked={active}
onClick={() =>
void apply(
new FileDropSettings({ ...settings, mode: mode.value }),
)
}
className={cn(
"flex items-center gap-3 rounded-lg border px-4 py-3 text-left",
"cursor-default outline-none transition-colors",
"focus-visible:ring-2 focus-visible:ring-white/60",
active
? "border-netbird/60 bg-netbird/5"
: "border-nb-gray-900 hover:border-nb-gray-800",
)}
>
<span
aria-hidden={"true"}
className={cn(
"flex h-4 w-4 shrink-0 items-center justify-center rounded-full border",
active ? "border-netbird bg-netbird" : "border-nb-gray-600",
)}
>
{active && <CheckIcon size={11} className={"text-white"} />}
</span>
<span className={"flex flex-col leading-tight"}>
<span className={"text-sm text-nb-gray-100"}>
{t(mode.labelKey)}
</span>
<span className={"text-xs text-nb-gray-400"}>
{t(mode.helpKey)}
</span>
</span>
</button>
);
})}
</div>
</div>
<div>
<Label>{t("settings.fileSharing.destination.label")}</Label>
<HelpText>{t("settings.fileSharing.destination.help")}</HelpText>
<div className={"mt-2 flex items-center gap-3"}>
<span
className={cn(
"min-w-0 flex-1 truncate rounded-md border border-nb-gray-900",
"px-3 py-2 font-mono text-xs text-nb-gray-300",
)}
>
{settings.destinationDir || t("settings.fileSharing.destination.unset")}
</span>
<Button
variant={"secondary"}
size={"xs"}
onClick={() => void changeDirectory()}
>
{t("settings.fileSharing.destination.change")}
</Button>
</div>
</div>
<div>
<Label>{t("settings.fileSharing.exceptions.label")}</Label>
<HelpText>{t("settings.fileSharing.exceptions.help")}</HelpText>
{rules.length === 0 ? (
<HelpText className={"mt-2"}>
{t("settings.fileSharing.exceptions.empty")}
</HelpText>
) : (
<ul className={"mt-2 flex flex-col divide-y divide-nb-gray-920"}>
{rules.map(([key, rule]) => (
<li key={key} className={"flex items-center gap-3 py-2"}>
{rule === PeerRule.Block ? (
<BanIcon
size={14}
className={"shrink-0 text-red-400"}
aria-hidden={"true"}
/>
) : (
<CheckIcon
size={14}
className={"shrink-0 text-green-400"}
aria-hidden={"true"}
/>
)}
<span
className={"min-w-0 flex-1 truncate text-sm text-nb-gray-100"}
>
{peerName(key)}
</span>
<span className={"shrink-0 text-xs text-nb-gray-400"}>
{rule === PeerRule.Block
? t("settings.fileSharing.exceptions.blocked")
: t("settings.fileSharing.exceptions.always")}
</span>
<button
type={"button"}
aria-label={t("settings.fileSharing.exceptions.remove")}
onClick={() => void removeRule(key)}
className={cn(
"flex h-6 w-6 shrink-0 items-center justify-center rounded-md",
"text-nb-gray-400 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",
)}
>
<XIcon size={13} />
</button>
</li>
))}
</ul>
)}
</div>
</SectionGroup>
);
}

View File

@@ -6,6 +6,7 @@ import { useClientVersion } from "@/contexts/ClientVersionContext.tsx";
import { useRestrictions } from "@/contexts/RestrictionsContext.tsx";
import {
BoltIcon,
FolderDownIcon,
InfoIcon,
LifeBuoyIcon,
NetworkIcon,
@@ -49,6 +50,11 @@ export const SettingsNavigation = () => {
/>
</>
)}
<VerticalTabs.Trigger
value={"fileSharing"}
icon={FolderDownIcon}
title={t("settings.tabs.fileSharing")}
/>
{!features.disableProfiles && (
<VerticalTabs.Trigger
value={"profiles"}

View File

@@ -12,6 +12,7 @@ import { SettingsGeneral } from "@/modules/settings/SettingsGeneral.tsx";
import { SettingsNetwork } from "@/modules/settings/SettingsNetwork.tsx";
import { SettingsSecurity } from "@/modules/settings/SettingsSecurity.tsx";
import { ProfilesTab } from "@/modules/profiles/ProfilesTab.tsx";
import { SettingsFileSharing } from "@/modules/settings/SettingsFileSharing.tsx";
import { SettingsSSH } from "@/modules/settings/SettingsSSH.tsx";
import { SettingsAdvanced } from "@/modules/settings/SettingsAdvanced.tsx";
import { SettingsTroubleshooting } from "@/modules/settings/SettingsTroubleshooting.tsx";
@@ -24,6 +25,7 @@ const enum Tab {
General = "general",
Network = "network",
Security = "security",
FileSharing = "fileSharing",
Profiles = "profiles",
SSH = "ssh",
Advanced = "advanced",
@@ -35,6 +37,7 @@ const TAB_CONTENT: Record<Tab, ReactNode> = {
[Tab.General]: <SettingsGeneral />,
[Tab.Network]: <SettingsNetwork />,
[Tab.Security]: <SettingsSecurity />,
[Tab.FileSharing]: <SettingsFileSharing />,
[Tab.Profiles]: <ProfilesTab />,
[Tab.SSH]: <SettingsSSH />,
[Tab.Advanced]: <SettingsAdvanced />,
@@ -53,6 +56,7 @@ export const SettingsPage = () => {
[Tab.General]: true,
[Tab.Network]: editable,
[Tab.Security]: editable,
[Tab.FileSharing]: true,
[Tab.Profiles]: !features.disableProfiles,
[Tab.SSH]: mdm.allowServerSSH ?? editable,
[Tab.Advanced]: editable,