0 ? Math.round((stage.loaded / stage.total) * 100) : 0}%`,
- }}
+ {stage.kind === "done" && (
+ <>
+
+
+
-
- {formatSize(stage.loaded)} / {formatSize(stage.total)}
-
- >
- )}
-
- {stage.kind === "done" && (
- <>
-
-
-
+
+
+ Ready to share
+
+
+ {stage.fileName}
+
+
+ Expires {new Date(stage.expiresAt).toLocaleDateString()} · one download
-
Ready to share
-
- {stage.fileName}
-
-
- {copied ? "Copied ✓" : "Copy link"}
-
-
- {stage.shareUrl}
-
-
- Expires {new Date(stage.expiresAt).toLocaleDateString()}. One download by default —
- anyone who has the link can fetch it once.
-
-
- Done
-
- >
- )}
+
- {stage.kind === "error" && (
- <>
-
Failed
-
- Could not complete the transfer
-
-
{stage.message}
-
setStage({ kind: "idle" })}
- className="mt-5 w-full py-2.5 bg-ink text-paper text-sm font-medium rounded-sm
- hover:bg-signal transition-colors duration-fast ease-crisp"
- >
- Try again
-
- >
- )}
-
+
copy(stage.shareUrl)}
+ className="w-full py-2.5 bg-ink text-paper text-sm font-medium rounded-sm
+ hover:bg-signal transition-colors duration-fast ease-crisp"
+ >
+ {copied ? "Copied ✓" : "Copy link"}
+
+
+
+ {stage.shareUrl}
+
+
+ {stage.password && (
+
+
+ Password (share separately)
+
+
+ {stage.password}
+ copy(stage.password!)}
+ className="text-xs text-ink-muted hover:text-ink transition-colors"
+ >
+ Copy
+
+
+
+ )}
+
+
+ Send another →
+
+ >
+ )}
+
+ {stage.kind === "error" && (
+ <>
+
Failed
+
+ Could not complete the transfer
+
+
{stage.message}
+
+ Try again
+
+ >
+ )}
);
}
+
+const inputCls =
+ "w-full px-3 py-2 bg-paper border border-paper-edge rounded-sm text-ink text-sm " +
+ "placeholder:text-ink-faint focus:outline-none focus:border-ink transition-colors " +
+ "duration-fast ease-crisp";
+
+function Field({ label, children }: { label: string; children: React.ReactNode }) {
+ return (
+
+
+ {label}
+
+ {children}
+
+ );
+}
diff --git a/web/src/components/Composer.tsx b/web/src/components/Composer.tsx
new file mode 100644
index 0000000..f61d0e1
--- /dev/null
+++ b/web/src/components/Composer.tsx
@@ -0,0 +1,105 @@
+import { useState } from "react";
+import DropZone from "./DropZone";
+
+interface ComposerProps {
+ recipientLabel: string;
+ onSendFiles: (files: File[]) => void;
+ onSendText: (text: string) => void;
+ onCancel: () => void;
+}
+
+function formatSize(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
+}
+
+export default function Composer({
+ recipientLabel,
+ onSendFiles,
+ onSendText,
+ onCancel,
+}: ComposerProps) {
+ const [files, setFiles] = useState
([]);
+ const [text, setText] = useState("");
+
+ const hasContent = files.length > 0 || text.trim().length > 0;
+
+ const handleSend = () => {
+ if (files.length > 0) onSendFiles(files);
+ if (text.trim().length > 0) onSendText(text.trim());
+ setFiles([]);
+ setText("");
+ };
+
+ return (
+
+
+
+
+ Send to
+
+
+ {recipientLabel}
+
+
+
+ Cancel
+
+
+
+ {files.length === 0 ? (
+
setFiles(f)} />
+ ) : (
+
+
+
+ {files.length} file{files.length > 1 ? "s" : ""}
+
+ setFiles([])}
+ className="text-xs text-ink-muted hover:text-ink transition-colors"
+ >
+ Clear
+
+
+
+ {files.map((f, i) => (
+
+ {f.name}
+
+ {formatSize(f.size)}
+
+
+ ))}
+
+
+ )}
+
+
+ );
+}
diff --git a/web/src/components/PeerList.tsx b/web/src/components/PeerList.tsx
index b70fa54..73aab93 100644
--- a/web/src/components/PeerList.tsx
+++ b/web/src/components/PeerList.tsx
@@ -11,13 +11,13 @@ export default function PeerList({ onPeerSelect }: PeerListProps) {
if (peers.length === 0) {
return (
-
+
Listening for devices
- Open AnyDrop on another device on this network, or pair a device below.
+ Open AnyDrop on another device on the same Wi-Fi. It shows up here automatically.
);
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts
index a42e02f..6aed66c 100644
--- a/web/src/lib/api.ts
+++ b/web/src/lib/api.ts
@@ -81,6 +81,7 @@ export interface TransferHead {
maxDownloads: number;
downloadCount: number;
expiresAt: string;
+ requiresPassword: boolean;
}
export interface InboxTransfer {
@@ -104,6 +105,7 @@ export async function createTransfer(input: {
maxDownloads?: number;
expiresInDays?: number;
deviceId?: string;
+ password?: string;
}): Promise
{
const res = await call("/api/transfers", {
method: "POST",
@@ -125,8 +127,14 @@ export async function getTransferHead(id: string): Promise {
return (await res.json()) as TransferHead;
}
-export async function consumeTransfer(id: string): Promise<{ downloadUrl: string }> {
- const res = await call(`/api/transfers/${encodeURIComponent(id)}/consume`, { method: "POST" });
+export async function consumeTransfer(
+ id: string,
+ password?: string,
+): Promise<{ downloadUrl: string }> {
+ const res = await call(`/api/transfers/${encodeURIComponent(id)}/consume`, {
+ method: "POST",
+ body: JSON.stringify(password ? { password } : {}),
+ });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error((body as { error?: string }).error ?? `consume failed: ${res.status}`);
diff --git a/web/src/lib/sendCloud.ts b/web/src/lib/sendCloud.ts
index c40b028..85c0dc1 100644
--- a/web/src/lib/sendCloud.ts
+++ b/web/src/lib/sendCloud.ts
@@ -18,6 +18,7 @@ export interface SendCloudOptions {
expiresInDays?: number;
maxDownloads?: number;
deviceId?: string;
+ password?: string;
onProgress?: (loaded: number, total: number) => void;
}
@@ -48,6 +49,7 @@ export async function sendCloud(
maxDownloads: options.maxDownloads,
expiresInDays: options.expiresInDays,
deviceId: options.deviceId,
+ password: options.password,
});
await uploadWithProgress(created.uploadUrl, encryptedBody, options.onProgress);
@@ -124,11 +126,14 @@ export async function receiveCloud(
transferId: string,
key: Uint8Array,
metadata: TransferMetadata,
- onProgress?: (loaded: number, total: number) => void,
+ options: {
+ password?: string;
+ onProgress?: (loaded: number, total: number) => void;
+ } = {},
): Promise {
- const { downloadUrl } = await consumeTransfer(transferId);
+ const { downloadUrl } = await consumeTransfer(transferId, options.password);
- const ciphertext = await downloadWithProgress(downloadUrl, onProgress);
+ const ciphertext = await downloadWithProgress(downloadUrl, options.onProgress);
return openFile(key, ciphertext, metadata);
}
diff --git a/web/src/pages/Home.tsx b/web/src/pages/Home.tsx
index fe641dc..88d5f06 100644
--- a/web/src/pages/Home.tsx
+++ b/web/src/pages/Home.tsx
@@ -4,56 +4,38 @@ import { useSignaling } from "../hooks/useSignaling";
import { useStore } from "../stores/useStore";
import { useProfileStore } from "../stores/useProfileStore";
import PeerList from "../components/PeerList";
-import DropZone from "../components/DropZone";
import TransferProgress from "../components/TransferProgress";
-import TextShareModal from "../components/TextShareModal";
import ReceiveDialog from "../components/ReceiveDialog";
-import PublicRoomPanel from "../components/PublicRoomPanel";
-import DevicePairingPanel from "../components/DevicePairingPanel";
import ProfileSetup from "../components/ProfileSetup";
import CloudSharePanel from "../components/CloudSharePanel";
+import Composer from "../components/Composer";
export default function Home() {
const isSetUp = useProfileStore((s) => s.isSetUp);
-
- if (!isSetUp) {
- return {}} />;
- }
-
+ if (!isSetUp) return {}} />;
return ;
}
function HomeConnected() {
- const {
- sendFiles,
- sendText,
- acceptTransfer,
- rejectTransfer,
- createPublicRoom,
- wakePeer,
- requestPairCode,
- resolvePairCode,
- } = useSignaling();
+ const { sendFiles, sendText, acceptTransfer, rejectTransfer, wakePeer } = useSignaling();
const peers = useStore((s) => s.peers);
const selectedPeerId = useStore((s) => s.selectedPeerId);
- const showTextModal = useStore((s) => s.showTextModal);
const incomingRequest = useStore((s) => s.incomingRequest);
const error = useStore((s) => s.error);
const setSelectedPeerId = useStore((s) => s.setSelectedPeerId);
- const setShowTextModal = useStore((s) => s.setShowTextModal);
const setError = useStore((s) => s.setError);
const { deviceName, avatar } = useProfileStore();
const [showProfileEdit, setShowProfileEdit] = useState(false);
- const [, setWakingDeviceId] = useState(null);
+
+ const selectedPeer = peers.find((p) => p.peerId === selectedPeerId) ?? null;
const handlePeerSelect = useCallback(
(peerId: string) => {
const peer = peers.find((p) => p.peerId === peerId);
if (peer && peer.online === false && peer.deviceId) {
setSelectedPeerId(peerId);
- setWakingDeviceId(peer.deviceId);
wakePeer(peer.deviceId);
return;
}
@@ -62,7 +44,7 @@ function HomeConnected() {
[selectedPeerId, setSelectedPeerId, peers, wakePeer],
);
- const handleFilesSelected = useCallback(
+ const handleSendFiles = useCallback(
(files: File[]) => {
if (!selectedPeerId) return;
sendFiles(selectedPeerId, files);
@@ -79,33 +61,30 @@ function HomeConnected() {
);
const handleAcceptTransfer = useCallback(() => {
- if (incomingRequest) {
- if (incomingRequest.text && incomingRequest.files.length === 0) {
- navigator.clipboard?.writeText(incomingRequest.text).catch(() => {});
- useStore.getState().setIncomingRequest(null);
- } else {
- acceptTransfer(incomingRequest.peerId);
- }
+ if (!incomingRequest) return;
+ if (incomingRequest.text && incomingRequest.files.length === 0) {
+ navigator.clipboard?.writeText(incomingRequest.text).catch(() => {});
+ useStore.getState().setIncomingRequest(null);
+ } else {
+ acceptTransfer(incomingRequest.peerId);
}
}, [incomingRequest, acceptTransfer]);
const handleRejectTransfer = useCallback(() => {
- if (incomingRequest) {
- rejectTransfer(incomingRequest.peerId);
- }
+ if (incomingRequest) rejectTransfer(incomingRequest.peerId);
}, [incomingRequest, rejectTransfer]);
return (