diff --git a/src/components/FileTransfer/Controls.css b/src/components/FileTransfer/Controls.css index bc9048c0..18fce632 100644 --- a/src/components/FileTransfer/Controls.css +++ b/src/components/FileTransfer/Controls.css @@ -1,17 +1,78 @@ -.file-transfer-controls { - display: flex; - gap: var(--space-3); - margin-bottom: var(--space-4); -} - -.file-transfer-controls input[type="file"], -.file-transfer-controls input[type="text"] { - flex-grow: 1; -} - -.file-transfer-controls button { - background: var(--gradient-success); - color: #ffffff; +.file-transfer-controls { + display: flex; + gap: var(--space-3); + margin-bottom: var(--space-4); + align-items: flex-start; +} + +.file-transfer-controls input[type="file"], +.file-transfer-controls input[type="text"] { + flex-grow: 1; +} + +.file-transfer-recipient { + flex: 1 1 12rem; + min-width: 10rem; + position: relative; +} + +.file-transfer-recipient input[type="text"] { + box-sizing: border-box; + width: 100%; +} + +.file-transfer-recipient-list { + background: var(--color-bg-elevated); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-lg); + left: 0; + list-style: none; + margin: var(--space-1) 0 0; + max-height: 12rem; + min-width: 100%; + overflow-y: auto; + padding: var(--space-1); + position: absolute; + right: 0; + z-index: 20; +} + +.file-transfer-recipient-option, +.file-transfer-recipient-empty { + align-items: center; + border-radius: var(--radius-sm); + color: var(--color-text); + display: flex; + font-size: var(--font-size-sm); + gap: var(--space-2); + justify-content: space-between; + min-height: 2rem; + padding: var(--space-2) var(--space-3); + white-space: nowrap; +} + +.file-transfer-recipient-option { + cursor: pointer; +} + +.file-transfer-recipient-option.active, +.file-transfer-recipient-option:hover { + background: var(--color-bg-hover); +} + +.file-transfer-recipient-status, +.file-transfer-recipient-empty { + color: var(--color-text-muted); +} + +.file-transfer-recipient-status { + font-size: var(--font-size-xs); +} + +.file-transfer-controls button { + background: var(--gradient-success); + color: #ffffff; border: none; padding: var(--space-3) var(--space-4); text-align: center; diff --git a/src/components/FileTransfer/Controls.test.tsx b/src/components/FileTransfer/Controls.test.tsx new file mode 100644 index 00000000..983c98b1 --- /dev/null +++ b/src/components/FileTransfer/Controls.test.tsx @@ -0,0 +1,74 @@ +import React, { useState } from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { TransferPeer } from "../../fileTransferPeers"; +import Controls from "./Controls"; + +const recipients: TransferPeer[] = [ + { + id: "#100", + label: "Quinn", + transferAddress: "Quinn", + away: false, + idle: false, + }, + { + id: "#200", + label: "Riley", + transferAddress: "Riley", + away: true, + idle: false, + }, +]; + +const selectedFile = new File(["hello"], "hello.txt", { + type: "text/plain", +}); + +function renderControls(onSendFile = vi.fn()) { + function Harness() { + const [selectedRecipient, setSelectedRecipient] = + useState(null); + + return ( + + ); + } + + render(); + return { onSendFile }; +} + +describe("FileTransfer Controls", () => { + it("keeps Send disabled for arbitrary typed recipients", () => { + renderControls(); + + fireEvent.change(screen.getByLabelText("Recipient"), { + target: { value: "Not Connected" }, + }); + + expect( + (screen.getByRole("button", { name: "Send File" }) as HTMLButtonElement) + .disabled + ).toBe(true); + }); + + it("enables Send after selecting a connected player", () => { + renderControls(); + + fireEvent.focus(screen.getByLabelText("Recipient")); + fireEvent.mouseDown(screen.getByRole("option", { name: "Quinn" })); + + expect( + (screen.getByRole("button", { name: "Send File" }) as HTMLButtonElement) + .disabled + ).toBe(false); + }); +}); diff --git a/src/components/FileTransfer/Controls.tsx b/src/components/FileTransfer/Controls.tsx index 12e2d4cc..33cb0c4c 100644 --- a/src/components/FileTransfer/Controls.tsx +++ b/src/components/FileTransfer/Controls.tsx @@ -1,12 +1,14 @@ -import React from "react"; +import React, { useEffect, useMemo, useState } from "react"; +import type { TransferPeer } from "../../fileTransferPeers"; import "./Controls.css"; interface FileTransferControlsProps { onFileChange: (file: File) => void; - onRecipientChange: (recipient: string) => void; + onRecipientChange: (recipient: TransferPeer | null) => void; onSendFile: () => void; selectedFile: File | null; - recipient: string; + selectedRecipient: TransferPeer | null; + recipients: TransferPeer[]; } const Controls: React.FC = ({ @@ -14,32 +16,170 @@ const Controls: React.FC = ({ onRecipientChange, onSendFile, selectedFile, - recipient, + selectedRecipient, + recipients, }) => { + const recipientInputId = React.useId(); + const recipientListId = React.useId(); + const [recipientQuery, setRecipientQuery] = useState(""); + const [isRecipientListOpen, setIsRecipientListOpen] = useState(false); + const [activeRecipientIndex, setActiveRecipientIndex] = useState(0); + + const matchingRecipients = useMemo(() => { + const normalizedQuery = recipientQuery.trim().toLowerCase(); + if (!normalizedQuery) { + return recipients; + } + + return recipients.filter((recipient) => + recipient.label.toLowerCase().includes(normalizedQuery) + ); + }, [recipientQuery, recipients]); + + useEffect(() => { + setRecipientQuery(selectedRecipient?.label ?? ""); + }, [selectedRecipient]); + + useEffect(() => { + if (activeRecipientIndex >= matchingRecipients.length) { + setActiveRecipientIndex(0); + } + }, [activeRecipientIndex, matchingRecipients.length]); + const handleFileChange = (event: React.ChangeEvent) => { if (event.target.files && event.target.files.length > 0) { onFileChange(event.target.files[0]); } }; - const handleRecipientChange = (event: React.ChangeEvent) => { - onRecipientChange(event.target.value); + const selectRecipient = (recipient: TransferPeer) => { + onRecipientChange(recipient); + setRecipientQuery(recipient.label); + setIsRecipientListOpen(false); + }; + + const handleRecipientChange = ( + event: React.ChangeEvent + ) => { + const nextQuery = event.target.value; + const exactMatch = + recipients.find( + (recipient) => + recipient.label.toLowerCase() === nextQuery.trim().toLowerCase() + ) ?? null; + + setRecipientQuery(nextQuery); + onRecipientChange(exactMatch); + setIsRecipientListOpen(true); + setActiveRecipientIndex(0); + }; + + const handleRecipientKeyDown = ( + event: React.KeyboardEvent + ) => { + if (event.key === "ArrowDown") { + event.preventDefault(); + setIsRecipientListOpen(true); + setActiveRecipientIndex((index) => + matchingRecipients.length === 0 + ? 0 + : (index + 1) % matchingRecipients.length + ); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + setIsRecipientListOpen(true); + setActiveRecipientIndex((index) => + matchingRecipients.length === 0 + ? 0 + : (index - 1 + matchingRecipients.length) % + matchingRecipients.length + ); + } else if (event.key === "Enter" && isRecipientListOpen) { + const activeRecipient = matchingRecipients[activeRecipientIndex]; + if (activeRecipient) { + event.preventDefault(); + selectRecipient(activeRecipient); + } + } else if (event.key === "Escape") { + setIsRecipientListOpen(false); + } }; return (
- -
); }; - -export default Controls; + +export default Controls; diff --git a/src/components/FileTransfer/PendingTransfer.tsx b/src/components/FileTransfer/PendingTransfer.tsx index ddfa72aa..059deb5a 100644 --- a/src/components/FileTransfer/PendingTransfer.tsx +++ b/src/components/FileTransfer/PendingTransfer.tsx @@ -1,37 +1,39 @@ -import React from "react"; -import "./PendingTransfer.css"; - -interface PendingOffer { - sender: string; - filename: string; - filesize: number; - hash: string; -} - +import React from "react"; +import "./PendingTransfer.css"; + +interface PendingOffer { + sender: string; + filename: string; + filesize: number; + hash: string; +} + interface PendingTransferProps { offer: PendingOffer; + senderLabel: string; onAccept: (sender: string, hash: string) => void; onReject: (sender: string, hash: string) => void; } - + const PendingTransfer: React.FC = ({ offer, + senderLabel, onAccept, onReject, }) => { - return ( -
+ return ( +

- Incoming file: {offer.filename} from {offer.sender} + Incoming file: {offer.filename} from {senderLabel}

- - -
- ); -}; - -export default PendingTransfer; + + +
+ ); +}; + +export default PendingTransfer; diff --git a/src/components/FileTransfer/index.tsx b/src/components/FileTransfer/index.tsx index 61c0cad8..06d104d0 100644 --- a/src/components/FileTransfer/index.tsx +++ b/src/components/FileTransfer/index.tsx @@ -1,256 +1,298 @@ -import React, { useCallback, useEffect, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import MudClient from "../../client"; +import type { UserlistPlayer } from "../../mcp"; +import { + findTransferPeerByAddress, + userlistPlayersToTransferPeers, +} from "../../fileTransferPeers"; +import type { TransferPeer } from "../../fileTransferPeers"; import Controls from "./Controls"; import ProgressBar from "./ProgressBar"; import PendingTransfer from "./PendingTransfer"; -import History from "./History"; -import "./styles.css"; - +import History from "./History"; +import "./styles.css"; + interface FileTransferUIProps { client: MudClient; expanded: boolean; + users: UserlistPlayer[]; } - -interface PendingOffer { - sender: string; - filename: string; - filesize: number; - hash: string; -} - + +interface PendingOffer { + sender: string; + filename: string; + filesize: number; + hash: string; +} + const FileTransferUI: React.FC = ({ client, expanded, + users, }) => { const [selectedFile, setSelectedFile] = useState(null); - const [recipient, setRecipient] = useState(""); + const [selectedRecipient, setSelectedRecipient] = + useState(null); const [sendProgress, setSendProgress] = useState(0); const [receiveProgress, setReceiveProgress] = useState(0); const [pendingOffers, setPendingOffers] = useState([]); const [transferHistory, setTransferHistory] = useState([]); + const recipients = useMemo( + () => userlistPlayersToTransferPeers(users), + [users] + ); const addToTransferHistory = useCallback((message: string) => { setTransferHistory((prevHistory) => [...prevHistory, message].slice(-10)); }, []); - const handleFileSendProgress = useCallback( - (data: { filename: string; sentBytes: number; totalBytes: number }) => { - const progress = (data.sentBytes / data.totalBytes) * 100; - setSendProgress(progress); - }, - [] - ); - - const handleFileReceiveProgress = useCallback( - (data: { filename: string; receivedBytes: number; totalBytes: number }) => { - const progress = (data.receivedBytes / data.totalBytes) * 100; - setReceiveProgress(progress); - }, - [] - ); - - const handleFileSendComplete = useCallback( - (data: { filename: string; hash: string }) => { - addToTransferHistory(`File sent successfully: ${data.filename}`); - setSendProgress(0); - }, - [addToTransferHistory] - ); - - const handleFileReceiveComplete = useCallback( - (data: { filename: string; file: Blob }) => { - addToTransferHistory(`File received successfully: ${data.filename}`); - setReceiveProgress(0); - // Optionally handle the received file blob here (e.g., prompt download) - }, - [addToTransferHistory] - ); - - const handleFileTransferError = useCallback( - (data: { - filename: string; - direction: "send" | "receive"; - error: string; - }) => { - addToTransferHistory( - `Error ${data.direction}ing file ${data.filename}: ${data.error}` - ); - if (data.direction === "send") { - setSendProgress(0); - } else { - setReceiveProgress(0); - } - }, - [addToTransferHistory] - ); - - const handleFileTransferCancelled = useCallback( - (data: { filename: string; direction: "send" | "receive" }) => { - addToTransferHistory( - `File transfer cancelled: ${data.filename} (${data.direction})` - ); - if (data.direction === "send") { - setSendProgress(0); - } else { - setReceiveProgress(0); - } - }, - [addToTransferHistory] + const getPeerLabel = useCallback( + (address: string) => + findTransferPeerByAddress(recipients, address)?.label ?? address, + [recipients] ); + useEffect(() => { + if ( + selectedRecipient && + !recipients.some((recipient) => recipient.id === selectedRecipient.id) + ) { + setSelectedRecipient(null); + } + }, [recipients, selectedRecipient]); + + const handleFileSendProgress = useCallback( + (data: { filename: string; sentBytes: number; totalBytes: number }) => { + const progress = (data.sentBytes / data.totalBytes) * 100; + setSendProgress(progress); + }, + [] + ); + + const handleFileReceiveProgress = useCallback( + (data: { filename: string; receivedBytes: number; totalBytes: number }) => { + const progress = (data.receivedBytes / data.totalBytes) * 100; + setReceiveProgress(progress); + }, + [] + ); + + const handleFileSendComplete = useCallback( + (data: { filename: string; hash: string }) => { + addToTransferHistory(`File sent successfully: ${data.filename}`); + setSendProgress(0); + }, + [addToTransferHistory] + ); + + const handleFileReceiveComplete = useCallback( + (data: { filename: string; file: Blob }) => { + addToTransferHistory(`File received successfully: ${data.filename}`); + setReceiveProgress(0); + // Optionally handle the received file blob here (e.g., prompt download) + }, + [addToTransferHistory] + ); + + const handleFileTransferError = useCallback( + (data: { + filename: string; + direction: "send" | "receive"; + error: string; + }) => { + addToTransferHistory( + `Error ${data.direction}ing file ${data.filename}: ${data.error}` + ); + if (data.direction === "send") { + setSendProgress(0); + } else { + setReceiveProgress(0); + } + }, + [addToTransferHistory] + ); + + const handleFileTransferCancelled = useCallback( + (data: { filename: string; direction: "send" | "receive" }) => { + addToTransferHistory( + `File transfer cancelled: ${data.filename} (${data.direction})` + ); + if (data.direction === "send") { + setSendProgress(0); + } else { + setReceiveProgress(0); + } + }, + [addToTransferHistory] + ); + const handleFileTransferRejected = useCallback( (data: { sender: string; filename: string }) => { addToTransferHistory( - `File transfer rejected: ${data.filename} from ${data.sender}` + `File transfer rejected: ${data.filename} from ${getPeerLabel( + data.sender + )}` ); }, - [addToTransferHistory] + [addToTransferHistory, getPeerLabel] ); const handleFileTransferAccepted = useCallback( (data: { sender: string; filename: string }) => { // Just log acceptance; actual sending is handled internally by FileTransferManager. addToTransferHistory( - `File transfer accepted: ${data.filename} by ${data.sender}` + `File transfer accepted: ${data.filename} by ${getPeerLabel( + data.sender + )}` ); }, - [addToTransferHistory] + [addToTransferHistory, getPeerLabel] ); - - const handleFileTransferOffer = useCallback( - (data: PendingOffer) => { + + const handleFileTransferOffer = useCallback( + (data: PendingOffer) => { setPendingOffers((prevOffers) => [...prevOffers, data]); addToTransferHistory( - `Incoming file offer: ${data.filename} from ${data.sender}` + `Incoming file offer: ${data.filename} from ${getPeerLabel( + data.sender + )}` ); }, - [addToTransferHistory] + [addToTransferHistory, getPeerLabel] ); - - useEffect(() => { - // Set up event listeners - client.on("fileTransferOffer", handleFileTransferOffer); - client.on("fileTransferAccepted", handleFileTransferAccepted); - client.fileTransferManager.on("fileSendProgress", handleFileSendProgress); - client.fileTransferManager.on( - "fileReceiveProgress", - handleFileReceiveProgress - ); - client.on("fileTransferError", handleFileTransferError); - client.on("fileTransferCancelled", handleFileTransferCancelled); - client.on("fileTransferRejected", handleFileTransferRejected); - client.on("fileSendComplete", handleFileSendComplete); - client.fileTransferManager.on( - "fileReceiveComplete", - handleFileReceiveComplete - ); - - return () => { - // Clean up event listeners - client.off("fileTransferOffer", handleFileTransferOffer); - client.off("fileTransferAccepted", handleFileTransferAccepted); - client.fileTransferManager.off( - "fileSendProgress", - handleFileSendProgress - ); - client.fileTransferManager.off( - "fileReceiveProgress", - handleFileReceiveProgress - ); - client.off("fileTransferError", handleFileTransferError); - client.off("fileTransferCancelled", handleFileTransferCancelled); - client.off("fileTransferRejected", handleFileTransferRejected); - client.off("fileSendComplete", handleFileSendComplete); - client.fileTransferManager.off( - "fileReceiveComplete", - handleFileReceiveComplete - ); - }; - }, [ - client, - handleFileTransferOffer, - handleFileTransferAccepted, - handleFileSendProgress, - handleFileReceiveProgress, - handleFileTransferError, - handleFileTransferCancelled, - handleFileTransferRejected, - handleFileSendComplete, - handleFileReceiveComplete, - ]); - + + useEffect(() => { + // Set up event listeners + client.on("fileTransferOffer", handleFileTransferOffer); + client.on("fileTransferAccepted", handleFileTransferAccepted); + client.fileTransferManager.on("fileSendProgress", handleFileSendProgress); + client.fileTransferManager.on( + "fileReceiveProgress", + handleFileReceiveProgress + ); + client.on("fileTransferError", handleFileTransferError); + client.on("fileTransferCancelled", handleFileTransferCancelled); + client.on("fileTransferRejected", handleFileTransferRejected); + client.on("fileSendComplete", handleFileSendComplete); + client.fileTransferManager.on( + "fileReceiveComplete", + handleFileReceiveComplete + ); + + return () => { + // Clean up event listeners + client.off("fileTransferOffer", handleFileTransferOffer); + client.off("fileTransferAccepted", handleFileTransferAccepted); + client.fileTransferManager.off( + "fileSendProgress", + handleFileSendProgress + ); + client.fileTransferManager.off( + "fileReceiveProgress", + handleFileReceiveProgress + ); + client.off("fileTransferError", handleFileTransferError); + client.off("fileTransferCancelled", handleFileTransferCancelled); + client.off("fileTransferRejected", handleFileTransferRejected); + client.off("fileSendComplete", handleFileSendComplete); + client.fileTransferManager.off( + "fileReceiveComplete", + handleFileReceiveComplete + ); + }; + }, [ + client, + handleFileTransferOffer, + handleFileTransferAccepted, + handleFileSendProgress, + handleFileReceiveProgress, + handleFileTransferError, + handleFileTransferCancelled, + handleFileTransferRejected, + handleFileSendComplete, + handleFileReceiveComplete, + ]); + const handleSendFile = () => { - if (selectedFile && recipient) { + if (selectedFile && selectedRecipient) { client - .sendFile(selectedFile, recipient) + .sendFile(selectedFile, selectedRecipient.transferAddress) .then(() => { - addToTransferHistory(`Sending ${selectedFile.name} to ${recipient}`); + addToTransferHistory( + `Sending ${selectedFile.name} to ${selectedRecipient.label}` + ); }) .catch((error) => { addToTransferHistory(`Error sending file: ${error.message}`); - }); - } - }; - - const handleAcceptTransfer = (sender: string, hash: string) => { - // Accepting an offer triggers FileTransferManager to handle the rest - client.acceptTransfer(sender, hash); + }); + } + }; + + const handleAcceptTransfer = (sender: string, hash: string) => { + // Accepting an offer triggers FileTransferManager to handle the rest + client.acceptTransfer(sender, hash); const offer = pendingOffers.find((o) => o.hash === hash); if (offer) { addToTransferHistory( - `Accepting file transfer: ${offer.filename} from ${sender}` + `Accepting file transfer: ${offer.filename} from ${getPeerLabel( + sender + )}` ); } - // Remove the offer from pendingOffers - setPendingOffers((prevOffers) => prevOffers.filter((o) => o.hash !== hash)); - }; - - const handleRejectTransfer = (sender: string, hash: string) => { - client.rejectTransfer(sender, hash); + // Remove the offer from pendingOffers + setPendingOffers((prevOffers) => prevOffers.filter((o) => o.hash !== hash)); + }; + + const handleRejectTransfer = (sender: string, hash: string) => { + client.rejectTransfer(sender, hash); const offer = pendingOffers.find((o) => o.hash === hash); if (offer) { addToTransferHistory( - `Rejected file transfer: ${offer.filename} from ${sender}` + `Rejected file transfer: ${offer.filename} from ${getPeerLabel( + sender + )}` ); } - setPendingOffers((prevOffers) => prevOffers.filter((o) => o.hash !== hash)); - }; - - const handleCancelTransfer = (hash: string) => { - client.cancelTransfer(hash); - setPendingOffers((prevOffers) => prevOffers.filter((o) => o.hash !== hash)); - }; - - return ( -
-

File Transfer

- + setPendingOffers((prevOffers) => prevOffers.filter((o) => o.hash !== hash)); + }; + + const handleCancelTransfer = (hash: string) => { + client.cancelTransfer(hash); + setPendingOffers((prevOffers) => prevOffers.filter((o) => o.hash !== hash)); + }; + + return ( +
+

File Transfer

+ - - {(sendProgress > 0 || receiveProgress > 0) && ( - 0 ? sendProgress : receiveProgress} - /> - )} - - {pendingOffers.map((offer) => ( + + {(sendProgress > 0 || receiveProgress > 0) && ( + 0 ? sendProgress : receiveProgress} + /> + )} + + {pendingOffers.map((offer) => ( - ))} - - -
- ); -}; - -export default FileTransferUI; + ))} + + +
+ ); +}; + +export default FileTransferUI; diff --git a/src/components/sidebar.tsx b/src/components/sidebar.tsx index b5ce2d97..0f8629cd 100644 --- a/src/components/sidebar.tsx +++ b/src/components/sidebar.tsx @@ -164,12 +164,16 @@ const Sidebar = React.forwardRef(({ client, collapsed, // condition: hasDefencesData, // }, { - id: "files-tab", - label: "Files", - content: ( - - ), - condition: true, // Always show Files tab + id: "files-tab", + label: "Files", + content: ( + + ), + condition: true, // Always show Files tab }, { id: "audio-tab", @@ -185,9 +189,9 @@ const Sidebar = React.forwardRef(({ client, collapsed, // Memoize visibleTabs to prevent unnecessary effect re-runs if conditions don't change // Note: If tab conditions become more dynamic, add them to the dependency array. - const visibleTabs = React.useMemo(() => { - return allTabs.filter((tab) => tab.condition ?? true); // Default condition to true - }, [hasRoomData, hasInventoryData, preferences.midi.enabled, preferences.haptics.enabled]); // Add dependencies based on actual conditions used + const visibleTabs = React.useMemo(() => { + return allTabs.filter((tab) => tab.condition ?? true); // Default condition to true + }, [hasRoomData, hasInventoryData, preferences.midi.enabled, preferences.haptics.enabled, users]); // Add dependencies based on actual conditions used // Expose switchToTab function via useImperativeHandle React.useImperativeHandle(ref, () => ({ diff --git a/src/fileTransferPeers.test.ts b/src/fileTransferPeers.test.ts new file mode 100644 index 00000000..8227a764 --- /dev/null +++ b/src/fileTransferPeers.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import type { UserlistPlayer } from "./mcp"; +import { + findTransferPeerByAddress, + userlistPlayersToTransferPeers, +} from "./fileTransferPeers"; + +const players: UserlistPlayer[] = [ + { + Object: "#100", + Name: "Quinn", + Icon: 1, + away: false, + idle: true, + }, + { + Object: "#200", + Name: "Riley", + Icon: 2, + away: true, + idle: false, + }, +]; + +describe("file transfer peers", () => { + it("maps connected userlist players into selectable transfer peers", () => { + expect(userlistPlayersToTransferPeers(players)).toEqual([ + { + id: "#100", + label: "Quinn", + transferAddress: "Quinn", + away: false, + idle: true, + }, + { + id: "#200", + label: "Riley", + transferAddress: "Riley", + away: true, + idle: false, + }, + ]); + }); + + it("resolves incoming transfer addresses by name or object id", () => { + const peers = userlistPlayersToTransferPeers(players); + + expect(findTransferPeerByAddress(peers, "quinn")?.label).toBe("Quinn"); + expect(findTransferPeerByAddress(peers, "#200")?.label).toBe("Riley"); + expect(findTransferPeerByAddress(peers, "unknown")).toBeNull(); + }); +}); diff --git a/src/fileTransferPeers.ts b/src/fileTransferPeers.ts new file mode 100644 index 00000000..0b76829d --- /dev/null +++ b/src/fileTransferPeers.ts @@ -0,0 +1,42 @@ +import type { UserlistPlayer } from "./mcp"; + +export interface TransferPeer { + id: string; + label: string; + transferAddress: string; + away: boolean; + idle: boolean; +} + +export function userlistPlayersToTransferPeers( + players: UserlistPlayer[] +): TransferPeer[] { + return players + .filter((player) => player.Name && player.Object) + .map((player) => ({ + id: String(player.Object), + label: player.Name, + transferAddress: player.Name, + away: player.away, + idle: player.idle, + })); +} + +export function findTransferPeerByAddress( + peers: TransferPeer[], + address: string +): TransferPeer | null { + const normalizedAddress = address.trim().toLowerCase(); + if (!normalizedAddress) { + return null; + } + + return ( + peers.find( + (peer) => + peer.transferAddress.toLowerCase() === normalizedAddress || + peer.id.toLowerCase() === normalizedAddress || + peer.label.toLowerCase() === normalizedAddress + ) ?? null + ); +}