fix: pairing flow + code review bugfixes + remove notifications
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 45s
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:
parent
b407e6ce95
commit
dd37b49ce4
@ -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 });
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]);
|
||||
|
||||
@ -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));
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -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, "/");
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -81,6 +81,7 @@ function ShareConnected() {
|
||||
}
|
||||
|
||||
setSent(true);
|
||||
setSelectedPeerId(null);
|
||||
},
|
||||
[shared, sent, sendFiles, sendText, setSelectedPeerId],
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user