fix: pairing flow + code review bugfixes + remove notifications
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 45s

Pairing:
- Always request fresh code when opening "Mon code" tab
- Clear stale pairCode on modal close/tab switch
- Show errors inline for invalid codes (don't close modal)
- Delete pair code after first use (server)
- Validate code length server-side

Notifications removed:
- Remove all showLocalNotification calls (peer-joined, transfer, text)
- Push setup now runs only once per session (no memory leak)

Other fixes:
- Clear pendingFilesRef on disconnect
- Set transfer status to "transferring" immediately on send start
- Select offline peer when tapping to wake
- Fix file receiver blob restoration race condition (await arrayBuffer)
- Clear selectedPeerId after share-target send
- Add returnValue to beforeunload handler

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-04-14 13:22:33 +02:00
parent b407e6ce95
commit dd37b49ce4
7 changed files with 75 additions and 73 deletions

View File

@ -451,12 +451,16 @@ function handleCreatePairCode(client: Client, msg: ClientMessage & { type: "crea
}
function handleResolvePairCode(client: Client, msg: ClientMessage & { type: "resolve-pair-code" }): void {
if (typeof msg.code !== "string") return;
if (typeof msg.code !== "string" || msg.code.length !== 6) {
send(client.ws, { type: "error", code: "pair-code-not-found", message: "Code d'appairage invalide" });
return;
}
const entry = pairCodes.get(msg.code.toUpperCase());
if (!entry || Date.now() > entry.expiresAt) {
send(client.ws, { type: "error", code: "pair-code-not-found", message: "Code d'appairage invalide ou expiré" });
return;
}
pairCodes.delete(msg.code.toUpperCase());
console.log(`[pair] resolved code ${msg.code} → group ${entry.groupId.slice(0, 8)}...`);
send(client.ws, { type: "pair-code-resolved", groupId: entry.groupId });
}

View File

@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { useProfileStore } from "../stores/useProfileStore";
import { useStore } from "../stores/useStore";
@ -13,38 +13,60 @@ export default function DevicePairingPanel({
}: DevicePairingPanelProps) {
const { groupId, setGroupId } = useProfileStore();
const pairCode = useStore((s) => s.pairCode);
const error = useStore((s) => s.error);
const [showModal, setShowModal] = useState(false);
const [mode, setMode] = useState<"show" | "enter">("show");
const [inputCode, setInputCode] = useState("");
const [pairError, setPairError] = useState<string | null>(null);
const handleShow = () => {
let gid = groupId;
if (!gid) {
gid = crypto.randomUUID();
setGroupId(gid);
// When modal opens in "show" mode, always request a fresh code
useEffect(() => {
if (showModal && mode === "show") {
let gid = groupId;
if (!gid) {
gid = crypto.randomUUID();
setGroupId(gid);
}
useStore.getState().setPairCode(null);
onRequestCode(gid);
}
onRequestCode(gid);
setMode("show");
setShowModal(true);
};
}, [showModal, mode]);
const handleEnter = () => {
setMode("enter");
// Detect pair-code-not-found errors
useEffect(() => {
if (error && error.includes("appairage")) {
setPairError(error);
useStore.getState().setError(null);
}
}, [error]);
const handleClose = () => {
setShowModal(false);
useStore.getState().setPairCode(null);
setPairError(null);
setInputCode("");
setShowModal(true);
};
const handleSubmitCode = () => {
if (inputCode.length >= 6) {
setPairError(null);
onResolveCode(inputCode);
setShowModal(false);
// Don't close — wait for response or error
}
};
// Auto-close on successful pairing (groupId changed after resolve)
const currentGroupId = useProfileStore((s) => s.groupId);
useEffect(() => {
if (mode === "enter" && showModal && currentGroupId && currentGroupId !== groupId) {
handleClose();
}
}, [currentGroupId]);
return (
<>
<button
onClick={handleShow}
onClick={() => { setMode("show"); setShowModal(true); }}
className="flex-1 flex flex-col items-center justify-center gap-1.5 px-3 py-3
border border-slate-700 hover:border-brand-500 rounded-xl
text-slate-300 hover:text-white transition-all
@ -57,7 +79,7 @@ export default function DevicePairingPanel({
{showModal && (
<div
className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-6"
onClick={() => setShowModal(false)}
onClick={handleClose}
>
<div
className="bg-slate-900 border border-slate-700 rounded-2xl p-6 max-w-sm w-full
@ -67,14 +89,14 @@ export default function DevicePairingPanel({
{/* Tab switcher */}
<div className="flex gap-2 bg-slate-800 rounded-xl p-1">
<button
onClick={() => { setMode("show"); if (!pairCode && groupId) onRequestCode(groupId); }}
onClick={() => setMode("show")}
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-colors
${mode === "show" ? "bg-brand-500 text-white" : "text-slate-400 hover:text-white"}`}
>
Mon code
</button>
<button
onClick={() => setMode("enter")}
onClick={() => { setMode("enter"); setPairError(null); }}
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-colors
${mode === "enter" ? "bg-brand-500 text-white" : "text-slate-400 hover:text-white"}`}
>
@ -93,7 +115,9 @@ export default function DevicePairingPanel({
{pairCode}
</p>
) : (
<p className="text-slate-500 text-sm">Génération du code...</p>
<div className="flex justify-center py-2">
<div className="w-6 h-6 border-2 border-brand-400 border-t-transparent rounded-full animate-spin" />
</div>
)}
<p className="text-xs text-slate-500">
@ -110,7 +134,10 @@ export default function DevicePairingPanel({
<input
type="text"
value={inputCode}
onChange={(e) => setInputCode(e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, ""))}
onChange={(e) => {
setInputCode(e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, ""));
setPairError(null);
}}
placeholder="ABC123"
maxLength={6}
autoFocus
@ -122,6 +149,10 @@ export default function DevicePairingPanel({
}}
/>
{pairError && (
<p className="text-sm text-red-400">{pairError}</p>
)}
<button
onClick={handleSubmitCode}
disabled={inputCode.length < 6}
@ -135,7 +166,7 @@ export default function DevicePairingPanel({
)}
<button
onClick={() => setShowModal(false)}
onClick={handleClose}
className="text-sm text-slate-500 hover:text-slate-300 transition-colors"
>
Fermer

View File

@ -13,7 +13,7 @@ import {
createTransferResponse,
createFileReceiver,
} from "../lib/fileTransfer";
import { setupPushNotifications, showLocalNotification } from "../lib/notifications";
import { setupPushNotifications } from "../lib/notifications";
import { detectLocalIP } from "../lib/localIP";
import {
saveTransferMeta,
@ -68,6 +68,7 @@ export function useSignaling(joinCode?: string) {
);
if (active) {
e.preventDefault();
e.returnValue = "Des transferts sont en cours";
}
};
window.addEventListener("beforeunload", handler);
@ -184,6 +185,7 @@ export function useSignaling(joinCode?: string) {
t.status === "pending",
);
if (transfer) {
updateTransfer(transfer.id, { status: "transferring" });
sendFile(peer, file, transfer.id, (progress) => {
updateTransfer(transfer.id, {
progress,
@ -227,10 +229,6 @@ export function useSignaling(joinCode?: string) {
deviceType: msg.deviceType,
avatar: msg.avatar,
});
showLocalNotification(
"AnyDrop",
`${msg.displayName} est à proximité`,
);
break;
case "peer-left":
removePeer(msg.peerId);
@ -355,15 +353,6 @@ export function useSignaling(joinCode?: string) {
files: msg.files,
text: msg.text,
});
const fileCount = msg.files?.length || 0;
const label =
fileCount > 1
? `${fileCount} fichiers`
: msg.files?.[0]?.name || "un fichier";
showLocalNotification(
"Transfert entrant",
`${peerInfo?.displayName || "Quelqu'un"} veut vous envoyer ${label}`,
);
return;
}
if (msg.type === "transfer-response") {
@ -383,10 +372,6 @@ export function useSignaling(joinCode?: string) {
files: [],
text: msg.content,
});
showLocalNotification(
"Texte reçu",
`${peerInfo?.displayName || "Quelqu'un"} vous a envoyé du texte`,
);
return;
}
} catch {
@ -411,6 +396,7 @@ export function useSignaling(joinCode?: string) {
cancelled = true;
signalingRef.current?.disconnect();
peerManagerRef.current?.closeAll();
pendingFilesRef.current.clear();
setConnected(false);
};
}, [joinCode]);

View File

@ -175,7 +175,7 @@ export function createFileReceiver(callbacks: FileReceiver) {
},
/** Restore a partially received file (from IndexedDB after refresh) */
restoreReceiving(
async restoreReceiving(
id: string,
name: string,
size: number,
@ -183,19 +183,14 @@ export function createFileReceiver(callbacks: FileReceiver) {
receivedBytes: number,
existingBlob: Blob,
) {
const buf = await existingBlob.arrayBuffer();
receiving.set(id, {
name,
size,
mime,
chunks: [new Uint8Array(0)], // placeholder, actual data is in existingBlob
chunks: [new Uint8Array(buf)],
received: receivedBytes,
});
// Replace chunks array with the existing blob as first entry
const entry = receiving.get(id)!;
entry.chunks = [];
existingBlob.arrayBuffer().then((buf) => {
entry.chunks.push(new Uint8Array(buf));
});
},
};
}

View File

@ -1,35 +1,35 @@
import type { SignalingClient } from "./signaling";
let pushSetupDone = false;
/**
* Request notification permission and subscribe to Web Push.
* Sends the push subscription to the signaling server.
* Only runs once per session.
*/
export async function setupPushNotifications(
vapidPublicKey: string,
deviceId: string,
signaling: SignalingClient,
): Promise<void> {
// Check support
if (pushSetupDone) return;
if (!("Notification" in window) || !("serviceWorker" in navigator) || !("PushManager" in window)) {
console.log("[push] Push notifications not supported");
return;
}
// Request permission
const permission = await Notification.requestPermission();
if (permission !== "granted") {
console.log("[push] Notification permission denied");
return;
}
pushSetupDone = true;
try {
const registration = await navigator.serviceWorker.ready;
// Check for existing subscription
let subscription = await registration.pushManager.getSubscription();
if (!subscription) {
// Convert VAPID key from base64url to Uint8Array
const applicationServerKey = urlBase64ToUint8Array(vapidPublicKey);
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
@ -37,33 +37,17 @@ export async function setupPushNotifications(
});
}
// Send subscription to server
signaling.send({
type: "subscribe-push",
deviceId,
subscription: subscription.toJSON() as any,
});
console.log("[push] Push subscription registered");
} catch (err) {
console.error("[push] Failed to subscribe:", err);
pushSetupDone = false;
}
}
/**
* Show a local notification (for when tab is in background but page is still loaded).
*/
export function showLocalNotification(title: string, body: string): void {
if (!("Notification" in window) || Notification.permission !== "granted") return;
if (document.visibilityState === "visible") return; // Don't notify if tab is focused
new Notification(title, {
body,
icon: "/icon-192.png",
tag: "anydrop-transfer",
});
}
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");

View File

@ -43,8 +43,9 @@ function HomeConnected() {
// Check if this is an offline peer
const peer = peers.find((p) => p.peerId === peerId);
if (peer && peer.online === false && peer.deviceId) {
wakePeer(peer.deviceId);
setSelectedPeerId(peerId);
setWakingDeviceId(peer.deviceId);
wakePeer(peer.deviceId);
return;
}
setSelectedPeerId(selectedPeerId === peerId ? null : peerId);

View File

@ -81,6 +81,7 @@ function ShareConnected() {
}
setSent(true);
setSelectedPeerId(null);
},
[shared, sent, sendFiles, sendText, setSelectedPeerId],
);