feat: local device profile (name + photo) replaces animal naming
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 43s

- Users set their own device name and optional profile picture
- Profile persisted in localStorage (no account needed)
- Auto-detect device type from user agent (iPhone, Mac, Android...)
- Server uses client-provided profile instead of generating names
- PeerAvatar shows photo or device icon instead of animal emojis
- ProfileSetup modal on first visit + editable from header
This commit is contained in:
ordinarthur 2026-04-14 11:47:38 +02:00
parent 953f3cb8a1
commit 4dbdfedae0
14 changed files with 417 additions and 102 deletions

View File

@ -4,10 +4,10 @@ import { networkInterfaces } from "node:os";
import { WebSocketServer, WebSocket } from "ws"; import { WebSocketServer, WebSocket } from "ws";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { import {
generateDisplayName,
type ClientMessage, type ClientMessage,
type ServerMessage, type ServerMessage,
type PeerInfo, type PeerInfo,
type DeviceType,
} from "@anydrop/shared"; } from "@anydrop/shared";
import { RoomManager, type Client } from "./rooms.js"; import { RoomManager, type Client } from "./rooms.js";
@ -17,6 +17,7 @@ const BASE_URL = process.env.BASE_URL || "http://localhost:5173";
const RATE_LIMIT_CONNECTIONS_PER_IP = 10; const RATE_LIMIT_CONNECTIONS_PER_IP = 10;
const RATE_LIMIT_WINDOW_MS = 60_000; const RATE_LIMIT_WINDOW_MS = 60_000;
const RATE_LIMIT_MESSAGES = 100; const RATE_LIMIT_MESSAGES = 100;
const MAX_AVATAR_SIZE = 100_000; // 100KB max for avatar data URLs
// Track connection rate per IP // Track connection rate per IP
const connectionCounts = new Map<string, { count: number; windowStart: number }>(); const connectionCounts = new Map<string, { count: number; windowStart: number }>();
@ -62,14 +63,6 @@ function detectLanSubnet(): string | null {
const serverLanSubnet = detectLanSubnet(); const serverLanSubnet = detectLanSubnet();
console.log(`[init] detected LAN subnet: ${serverLanSubnet ?? "none"}`); console.log(`[init] detected LAN subnet: ${serverLanSubnet ?? "none"}`);
/**
* Extract the LAN grouping key from an IP.
* - Loopback (::1, 127.x): use the server's own LAN subnet so localhost
* clients end up in the same room as LAN clients.
* - Private IPs: use the /24 subnet (e.g. "192.168.1") so all devices
* on the same WiFi share one LAN room.
* - Public IPs: use the full IP (devices behind the same NAT share one).
*/
function getLanGroupKey(rawIP: string): string { function getLanGroupKey(rawIP: string): string {
const ip = normalizeIP(rawIP); const ip = normalizeIP(rawIP);
if (isLoopback(ip) || ip === "unknown") { if (isLoopback(ip) || ip === "unknown") {
@ -119,6 +112,25 @@ function checkMessageRate(client: Client): boolean {
return client.messageCount <= RATE_LIMIT_MESSAGES; return client.messageCount <= RATE_LIMIT_MESSAGES;
} }
/** Sanitize client-provided display name */
function sanitizeName(name: string): string {
return name.trim().slice(0, 30) || "Appareil";
}
/** Validate device type */
function validateDeviceType(dt: string): DeviceType {
const valid: DeviceType[] = ["phone", "tablet", "laptop", "desktop"];
return valid.includes(dt as DeviceType) ? (dt as DeviceType) : "laptop";
}
/** Validate avatar (must be a small data URL or undefined) */
function validateAvatar(avatar: unknown): string | undefined {
if (typeof avatar !== "string") return undefined;
if (!avatar.startsWith("data:image/")) return undefined;
if (avatar.length > MAX_AVATAR_SIZE) return undefined;
return avatar;
}
// ── HTTP server (health check) ── // ── HTTP server (health check) ──
const httpServer = createServer((req, res) => { const httpServer = createServer((req, res) => {
@ -149,13 +161,14 @@ wss.on("connection", (ws, req) => {
} }
const peerId = nanoid(8); const peerId = nanoid(8);
const displayName = generateDisplayName();
const lanRoomId = roomManager.getLanRoomId(hashIP(groupKey)); const lanRoomId = roomManager.getLanRoomId(hashIP(groupKey));
// Client object created with placeholder name — filled in on "hello"
const client: Client = { const client: Client = {
ws, ws,
peerId, peerId,
displayName, displayName: "Appareil",
deviceType: "laptop",
ip, ip,
lanRoomId, lanRoomId,
publicRoomId: null, publicRoomId: null,
@ -180,7 +193,7 @@ wss.on("connection", (ws, req) => {
switch (msg.type) { switch (msg.type) {
case "hello": case "hello":
handleHello(client, msg.joinCode); handleHello(client, msg);
break; break;
case "create-public-room": case "create-public-room":
handleCreatePublicRoom(client); handleCreatePublicRoom(client);
@ -200,7 +213,12 @@ wss.on("connection", (ws, req) => {
}); });
}); });
function handleHello(client: Client, joinCode?: string): void { function handleHello(client: Client, msg: ClientMessage & { type: "hello" }): void {
// Apply client-provided profile
client.displayName = sanitizeName(msg.deviceName);
client.deviceType = validateDeviceType(msg.deviceType);
client.avatar = validateAvatar(msg.avatar);
// Join LAN room // Join LAN room
const lanRoom = roomManager.getOrCreateLanRoom(hashIP(getLanGroupKey(client.ip))); const lanRoom = roomManager.getOrCreateLanRoom(hashIP(getLanGroupKey(client.ip)));
roomManager.addClientToRoom(lanRoom, client); roomManager.addClientToRoom(lanRoom, client);
@ -208,28 +226,49 @@ function handleHello(client: Client, joinCode?: string): void {
const peers: PeerInfo[] = []; const peers: PeerInfo[] = [];
for (const peer of lanRoom.clients.values()) { for (const peer of lanRoom.clients.values()) {
if (peer.peerId !== client.peerId) { if (peer.peerId !== client.peerId) {
peers.push({ peerId: peer.peerId, displayName: peer.displayName }); peers.push({
peerId: peer.peerId,
displayName: peer.displayName,
deviceType: peer.deviceType,
avatar: peer.avatar,
});
// Notify existing peers // Notify existing peers
send(peer.ws, { type: "peer-joined", peerId: client.peerId, displayName: client.displayName }); send(peer.ws, {
type: "peer-joined",
peerId: client.peerId,
displayName: client.displayName,
deviceType: client.deviceType,
avatar: client.avatar,
});
} }
} }
console.log(`[hello] peer=${client.peerId} (${client.displayName}) room=${lanRoom.id} peers_in_room=${lanRoom.clients.size}`); console.log(`[hello] peer=${client.peerId} (${client.displayName}) room=${lanRoom.id} peers_in_room=${lanRoom.clients.size}`);
// Join public room if code provided // Join public room if code provided
if (joinCode) { if (msg.joinCode) {
const pubRoom = roomManager.getPublicRoomByCode(joinCode); const pubRoom = roomManager.getPublicRoomByCode(msg.joinCode);
if (!pubRoom) { if (!pubRoom) {
send(client.ws, { type: "error", code: "room-not-found", message: `Room "${joinCode}" not found or expired` }); send(client.ws, { type: "error", code: "room-not-found", message: `Room "${msg.joinCode}" not found or expired` });
} else { } else {
client.publicRoomId = pubRoom.id; client.publicRoomId = pubRoom.id;
roomManager.addClientToRoom(pubRoom, client); roomManager.addClientToRoom(pubRoom, client);
for (const peer of pubRoom.clients.values()) { for (const peer of pubRoom.clients.values()) {
if (peer.peerId !== client.peerId) { if (peer.peerId !== client.peerId) {
// Avoid duplicate if peer is already in LAN peers
if (!peers.find((p) => p.peerId === peer.peerId)) { if (!peers.find((p) => p.peerId === peer.peerId)) {
peers.push({ peerId: peer.peerId, displayName: peer.displayName }); peers.push({
peerId: peer.peerId,
displayName: peer.displayName,
deviceType: peer.deviceType,
avatar: peer.avatar,
});
} }
send(peer.ws, { type: "peer-joined", peerId: client.peerId, displayName: client.displayName }); send(peer.ws, {
type: "peer-joined",
peerId: client.peerId,
displayName: client.displayName,
deviceType: client.deviceType,
avatar: client.avatar,
});
} }
} }
} }
@ -238,7 +277,6 @@ function handleHello(client: Client, joinCode?: string): void {
send(client.ws, { send(client.ws, {
type: "welcome", type: "welcome",
peerId: client.peerId, peerId: client.peerId,
displayName: client.displayName,
roomId: client.lanRoomId, roomId: client.lanRoomId,
peers, peers,
}); });
@ -270,14 +308,12 @@ function handleSignal(client: Client, to: string, data: unknown): void {
} }
function handleLeave(client: Client): void { function handleLeave(client: Client): void {
// Remove from LAN room
const lanRoom = roomManager.getOrCreateLanRoom(hashIP(getLanGroupKey(client.ip))); const lanRoom = roomManager.getOrCreateLanRoom(hashIP(getLanGroupKey(client.ip)));
roomManager.removeClientFromRoom(lanRoom, client.peerId); roomManager.removeClientFromRoom(lanRoom, client.peerId);
for (const peer of lanRoom.clients.values()) { for (const peer of lanRoom.clients.values()) {
send(peer.ws, { type: "peer-left", peerId: client.peerId }); send(peer.ws, { type: "peer-left", peerId: client.peerId });
} }
// Remove from public room
if (client.publicRoomId) { if (client.publicRoomId) {
const pubRoom = roomManager.getRoomById(client.publicRoomId); const pubRoom = roomManager.getRoomById(client.publicRoomId);
if (pubRoom) { if (pubRoom) {

View File

@ -1,5 +1,6 @@
import { WebSocket } from "ws"; import { WebSocket } from "ws";
import { customAlphabet } from "nanoid"; import { customAlphabet } from "nanoid";
import type { DeviceType } from "@anydrop/shared";
import { import {
SHORT_CODE_ALPHABET, SHORT_CODE_ALPHABET,
SHORT_CODE_LENGTH, SHORT_CODE_LENGTH,
@ -15,6 +16,8 @@ export interface Client {
ws: WebSocket; ws: WebSocket;
peerId: string; peerId: string;
displayName: string; displayName: string;
deviceType: DeviceType;
avatar?: string;
ip: string; ip: string;
lanRoomId: string; lanRoomId: string;
publicRoomId: string | null; publicRoomId: string | null;

View File

@ -1,21 +1,39 @@
const ADJECTIVES = [ /** Device types detected from user agent */
"Rouge", "Bleu", "Vert", "Doré", "Violet", export type DeviceType = "phone" | "tablet" | "laptop" | "desktop";
"Blanc", "Noir", "Rose", "Gris", "Orange",
"Rapide", "Calme", "Brave", "Agile", "Sage",
"Vif", "Fier", "Noble", "Grand", "Petit",
];
const ANIMALS = [ /** Detect device type from user agent string (client-side only) */
"Renard", "Tigre", "Ours", "Loup", "Aigle", export function detectDeviceType(ua: string): DeviceType {
"Dauphin", "Faucon", "Lynx", "Panda", "Lion", const lower = ua.toLowerCase();
"Chat", "Hibou", "Cerf", "Koala", "Phoque",
"Colibri", "Loutre", "Requin", "Corbeau", "Furet",
];
function pick<T>(arr: T[]): T { // Tablets first (before phone check since some tablets have "mobile")
return arr[Math.floor(Math.random() * arr.length)]; if (/ipad/.test(lower) || (/android/.test(lower) && !/mobile/.test(lower))) {
return "tablet";
}
// Phones
if (/iphone|android.*mobile|windows phone/.test(lower)) {
return "phone";
}
// Everything else is laptop/desktop — we can't really distinguish,
// so default to "laptop" (more common for file sharing use case)
return "laptop";
} }
export function generateDisplayName(): string { /** Default device name based on type */
return `${pick(ANIMALS)} ${pick(ADJECTIVES)}`; export function getDefaultDeviceName(ua: string): string {
const lower = ua.toLowerCase();
// Try to extract specific device/OS name
if (/iphone/.test(lower)) return "iPhone";
if (/ipad/.test(lower)) return "iPad";
if (/macintosh|mac os/.test(lower)) return "Mac";
if (/windows/.test(lower)) return "PC Windows";
if (/android/.test(lower)) {
return /mobile/.test(lower) ? "Android" : "Tablette Android";
}
if (/linux/.test(lower)) return "Linux";
if (/chromeos|cros/.test(lower)) return "Chromebook";
return "Appareil";
} }

View File

@ -1,7 +1,12 @@
import type { DeviceType } from "./names.js";
// ── Client → Server messages ── // ── Client → Server messages ──
export interface HelloMessage { export interface HelloMessage {
type: "hello"; type: "hello";
deviceName: string;
deviceType: DeviceType;
avatar?: string; // base64 data URL (small, <50KB)
joinCode?: string; joinCode?: string;
} }
@ -30,12 +35,13 @@ export type ClientMessage =
export interface PeerInfo { export interface PeerInfo {
peerId: string; peerId: string;
displayName: string; displayName: string;
deviceType: DeviceType;
avatar?: string;
} }
export interface WelcomeMessage { export interface WelcomeMessage {
type: "welcome"; type: "welcome";
peerId: string; peerId: string;
displayName: string;
roomId: string; roomId: string;
peers: PeerInfo[]; peers: PeerInfo[];
} }
@ -51,6 +57,8 @@ export interface PeerJoinedMessage {
type: "peer-joined"; type: "peer-joined";
peerId: string; peerId: string;
displayName: string; displayName: string;
deviceType: DeviceType;
avatar?: string;
} }
export interface PeerLeftMessage { export interface PeerLeftMessage {

File diff suppressed because one or more lines are too long

View File

@ -1,29 +1,30 @@
import type { DeviceType } from "@anydrop/shared";
interface PeerAvatarProps { interface PeerAvatarProps {
displayName: string; displayName: string;
deviceType: DeviceType;
avatar?: string;
onClick?: () => void; onClick?: () => void;
isSelected?: boolean; isSelected?: boolean;
size?: "sm" | "md" | "lg"; size?: "sm" | "md" | "lg";
} }
const ANIMAL_EMOJIS: Record<string, string> = { const DEVICE_ICONS: Record<DeviceType, string> = {
Renard: "🦊", Tigre: "🐯", Ours: "🐻", Loup: "🐺", Aigle: "🦅", phone: "📱",
Dauphin: "🐬", Faucon: "🦅", Lynx: "🐱", Panda: "🐼", Lion: "🦁", tablet: "📱",
Chat: "🐱", Hibou: "🦉", Cerf: "🦌", Koala: "🐨", Phoque: "🦭", laptop: "💻",
Colibri: "🐦", Loutre: "🦦", Requin: "🦈", Corbeau: "🐦‍⬛", Furet: "🐾", desktop: "🖥️",
}; };
function getEmoji(displayName: string): string {
const animal = displayName.split(" ")[0];
return ANIMAL_EMOJIS[animal] || "📱";
}
const sizeClasses = { const sizeClasses = {
sm: "w-12 h-12 text-xl", sm: { container: "w-12 h-12", icon: "text-xl", img: "w-12 h-12" },
md: "w-16 h-16 text-2xl", md: { container: "w-16 h-16", icon: "text-2xl", img: "w-16 h-16" },
lg: "w-20 h-20 text-3xl", lg: { container: "w-20 h-20", icon: "text-3xl", img: "w-20 h-20" },
}; };
export default function PeerAvatar({ displayName, onClick, isSelected, size = "md" }: PeerAvatarProps) { export default function PeerAvatar({ displayName, deviceType, avatar, onClick, isSelected, size = "md" }: PeerAvatarProps) {
const s = sizeClasses[size];
return ( return (
<button <button
onClick={onClick} onClick={onClick}
@ -34,16 +35,25 @@ export default function PeerAvatar({ displayName, onClick, isSelected, size = "m
> >
<div <div
className={` className={`
${sizeClasses[size]} ${s.container}
rounded-full flex items-center justify-center rounded-full flex items-center justify-center overflow-hidden
transition-all duration-200 transition-all duration-200
${isSelected ${isSelected
? "bg-brand-500 ring-2 ring-brand-400 ring-offset-2 ring-offset-slate-950" ? "ring-2 ring-brand-400 ring-offset-2 ring-offset-slate-950"
: "bg-slate-800 hover:bg-slate-700" : ""
} }
${avatar ? "" : isSelected ? "bg-brand-500" : "bg-slate-800 hover:bg-slate-700"}
`} `}
> >
{getEmoji(displayName)} {avatar ? (
<img
src={avatar}
alt={displayName}
className={`${s.img} rounded-full object-cover`}
/>
) : (
<span className={s.icon}>{DEVICE_ICONS[deviceType]}</span>
)}
</div> </div>
<span className="text-xs text-slate-300 group-hover:text-white transition-colors max-w-[80px] truncate"> <span className="text-xs text-slate-300 group-hover:text-white transition-colors max-w-[80px] truncate">
{displayName} {displayName}

View File

@ -28,6 +28,8 @@ export default function PeerList({ onPeerSelect }: PeerListProps) {
<PeerAvatar <PeerAvatar
key={peer.peerId} key={peer.peerId}
displayName={peer.displayName} displayName={peer.displayName}
deviceType={peer.deviceType}
avatar={peer.avatar}
isSelected={selectedPeerId === peer.peerId} isSelected={selectedPeerId === peer.peerId}
onClick={() => onPeerSelect(peer.peerId)} onClick={() => onPeerSelect(peer.peerId)}
/> />

View File

@ -0,0 +1,144 @@
import { useState, useRef } from "react";
import { useProfileStore } from "../stores/useProfileStore";
const MAX_AVATAR_SIZE = 80_000; // ~80KB after base64
function resizeImage(file: File, maxSize: number): Promise<string> {
return new Promise((resolve, reject) => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(url);
const canvas = document.createElement("canvas");
// Crop to square, max 128px
const side = Math.min(img.width, img.height);
const sx = (img.width - side) / 2;
const sy = (img.height - side) / 2;
let outSize = Math.min(side, 128);
canvas.width = outSize;
canvas.height = outSize;
const ctx = canvas.getContext("2d")!;
ctx.drawImage(img, sx, sy, side, side, 0, 0, outSize, outSize);
// Try JPEG at decreasing quality until small enough
let quality = 0.8;
let dataUrl = canvas.toDataURL("image/jpeg", quality);
while (dataUrl.length > maxSize && quality > 0.2) {
quality -= 0.1;
dataUrl = canvas.toDataURL("image/jpeg", quality);
}
if (dataUrl.length > maxSize) {
// Shrink further
outSize = 64;
canvas.width = outSize;
canvas.height = outSize;
ctx.drawImage(img, sx, sy, side, side, 0, 0, outSize, outSize);
dataUrl = canvas.toDataURL("image/jpeg", 0.6);
}
resolve(dataUrl);
};
img.onerror = reject;
img.src = url;
});
}
interface ProfileSetupProps {
onDone: () => void;
isEditing?: boolean;
}
export default function ProfileSetup({ onDone, isEditing }: ProfileSetupProps) {
const { deviceName, avatar, setDeviceName, setAvatar, setUp } = useProfileStore();
const [name, setName] = useState(deviceName);
const [preview, setPreview] = useState<string | null>(avatar);
const fileRef = useRef<HTMLInputElement>(null);
const handlePhoto = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const dataUrl = await resizeImage(file, MAX_AVATAR_SIZE);
setPreview(dataUrl);
};
const handleSubmit = () => {
setDeviceName(name);
setAvatar(preview);
setUp();
onDone();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4">
<div className="bg-slate-900 border border-slate-700 rounded-2xl p-6 w-full max-w-sm shadow-2xl">
<h2 className="text-lg font-semibold text-white text-center mb-6">
{isEditing ? "Modifier le profil" : "Votre appareil"}
</h2>
{/* Avatar */}
<div className="flex justify-center mb-6">
<button
onClick={() => fileRef.current?.click()}
className="relative w-24 h-24 rounded-full bg-slate-800 hover:bg-slate-700
flex items-center justify-center overflow-hidden
transition-colors border-2 border-dashed border-slate-600 hover:border-brand-400"
>
{preview ? (
<img src={preview} alt="Avatar" className="w-full h-full object-cover rounded-full" />
) : (
<div className="flex flex-col items-center text-slate-400">
<span className="text-2xl">📷</span>
<span className="text-[10px] mt-1">Photo</span>
</div>
)}
</button>
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={handlePhoto}
/>
</div>
{/* Remove photo */}
{preview && (
<button
onClick={() => setPreview(null)}
className="block mx-auto mb-4 text-xs text-slate-500 hover:text-red-400 transition-colors"
>
Supprimer la photo
</button>
)}
{/* Name */}
<div className="mb-6">
<label className="block text-xs text-slate-400 mb-1.5">Nom de l'appareil</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={30}
placeholder="ex: iPhone d'Arthur"
className="w-full px-3 py-2.5 bg-slate-800 border border-slate-700 rounded-xl
text-white text-sm placeholder:text-slate-500
focus:outline-none focus:border-brand-500 transition-colors"
autoFocus
onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
/>
</div>
{/* Submit */}
<button
onClick={handleSubmit}
className="w-full py-2.5 bg-brand-600 hover:bg-brand-500 text-white
rounded-xl text-sm font-medium transition-colors"
>
{isEditing ? "Enregistrer" : "C'est parti"}
</button>
</div>
</div>
);
}

View File

@ -9,6 +9,7 @@ import {
createFileReceiver, createFileReceiver,
} from "../lib/fileTransfer"; } from "../lib/fileTransfer";
import { useStore } from "../stores/useStore"; import { useStore } from "../stores/useStore";
import { useProfileStore } from "../stores/useProfileStore";
function downloadBlob(blob: Blob, fileName: string) { function downloadBlob(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@ -52,11 +53,8 @@ export function useSignaling(joinCode?: string) {
updateTransfer(fileId, { progress, status: "transferring" }); updateTransfer(fileId, { progress, status: "transferring" });
}, },
onTransferRequest: (msg: TransferRequestMessage) => { onTransferRequest: (msg: TransferRequestMessage) => {
// Find the peer who sent this
const store = useStore.getState();
const fromPeer = store.peers.find(() => true); // We'll set this from data handler
setIncomingRequest({ setIncomingRequest({
peerId: "", // Will be set from the data handler peerId: "",
displayName: "", displayName: "",
files: msg.files, files: msg.files,
text: msg.text, text: msg.text,
@ -64,10 +62,8 @@ export function useSignaling(joinCode?: string) {
}, },
onTransferResponse: (msg: TransferResponseMessage) => { onTransferResponse: (msg: TransferResponseMessage) => {
if (msg.accepted) { if (msg.accepted) {
// Start sending files
startSending(); startSending();
} else { } else {
// Clean up pending transfers
const store = useStore.getState(); const store = useStore.getState();
for (const transfer of store.transfers) { for (const transfer of store.transfers) {
if (transfer.direction === "send" && transfer.status === "pending") { if (transfer.direction === "send" && transfer.status === "pending") {
@ -77,8 +73,6 @@ export function useSignaling(joinCode?: string) {
} }
}, },
onText: (text) => { onText: (text) => {
// Show received text to user
const store = useStore.getState();
setIncomingRequest({ setIncomingRequest({
peerId: "", peerId: "",
displayName: "", displayName: "",
@ -118,11 +112,16 @@ export function useSignaling(joinCode?: string) {
const handleMessage = (msg: ServerMessage) => { const handleMessage = (msg: ServerMessage) => {
switch (msg.type) { switch (msg.type) {
case "welcome": case "welcome":
setConnection(msg.peerId, msg.displayName, msg.roomId); setConnection(msg.peerId, msg.roomId);
setPeers(msg.peers); setPeers(msg.peers);
break; break;
case "peer-joined": case "peer-joined":
addPeer({ peerId: msg.peerId, displayName: msg.displayName }); addPeer({
peerId: msg.peerId,
displayName: msg.displayName,
deviceType: msg.deviceType,
avatar: msg.avatar,
});
break; break;
case "peer-left": case "peer-left":
removePeer(msg.peerId); removePeer(msg.peerId);
@ -140,7 +139,18 @@ export function useSignaling(joinCode?: string) {
} }
}; };
const signaling = new SignalingClient(handleMessage, joinCode); // Get profile from store
const profile = useProfileStore.getState();
const signaling = new SignalingClient(
handleMessage,
{
deviceName: profile.deviceName,
deviceType: profile.deviceType,
avatar: profile.avatar || undefined,
},
joinCode,
);
signalingRef.current = signaling; signalingRef.current = signaling;
const peerManager = new PeerManager(signaling, { const peerManager = new PeerManager(signaling, {
@ -151,7 +161,6 @@ export function useSignaling(joinCode?: string) {
console.log(`P2P disconnected from ${peerId}`); console.log(`P2P disconnected from ${peerId}`);
}, },
onData: (peerId, data) => { onData: (peerId, data) => {
// Update incoming request with correct peerId
const store = useStore.getState(); const store = useStore.getState();
const peerInfo = store.peers.find((p) => p.peerId === peerId); const peerInfo = store.peers.find((p) => p.peerId === peerId);
@ -209,7 +218,6 @@ export function useSignaling(joinCode?: string) {
const signaling = signalingRef.current; const signaling = signalingRef.current;
if (!pm || !signaling) return; if (!pm || !signaling) return;
// Ensure P2P connection exists
let peer = pm.getPeer(peerId); let peer = pm.getPeer(peerId);
if (!peer) { if (!peer) {
peer = pm.createPeer(peerId, true); peer = pm.createPeer(peerId, true);
@ -217,9 +225,7 @@ export function useSignaling(joinCode?: string) {
const request = createTransferRequest(files); const request = createTransferRequest(files);
// Add transfers to store
for (const fileMeta of request.files) { for (const fileMeta of request.files) {
const file = files.find((f) => f.name === fileMeta.name)!;
addTransfer({ addTransfer({
id: fileMeta.id, id: fileMeta.id,
peerId, peerId,
@ -232,16 +238,13 @@ export function useSignaling(joinCode?: string) {
}); });
} }
// Store files for when accepted
pendingFilesRef.current.set(peerId, { files }); pendingFilesRef.current.set(peerId, { files });
// Wait for connection then send request
const sendRequest = () => { const sendRequest = () => {
const p = pm.getPeer(peerId); const p = pm.getPeer(peerId);
if (p && (p as any)._channel?.readyState === "open") { if (p && (p as any)._channel?.readyState === "open") {
p.send(JSON.stringify(request)); p.send(JSON.stringify(request));
} else { } else {
// Peer is connected but channel might not be ready
setTimeout(sendRequest, 100); setTimeout(sendRequest, 100);
} }
}; };
@ -283,7 +286,6 @@ export function useSignaling(joinCode?: string) {
const peer = pm.getPeer(peerId); const peer = pm.getPeer(peerId);
if (!peer) return; if (!peer) return;
// Add receive transfers
const store = useStore.getState(); const store = useStore.getState();
const request = store.incomingRequest; const request = store.incomingRequest;
if (request) { if (request) {

View File

@ -1,4 +1,10 @@
import type { ClientMessage, ServerMessage } from "@anydrop/shared"; import type { ClientMessage, ServerMessage, DeviceType } from "@anydrop/shared";
export interface ProfileData {
deviceName: string;
deviceType: DeviceType;
avatar?: string;
}
export type SignalingHandler = (msg: ServerMessage) => void; export type SignalingHandler = (msg: ServerMessage) => void;
@ -8,11 +14,13 @@ export class SignalingClient {
private ws: WebSocket | null = null; private ws: WebSocket | null = null;
private handler: SignalingHandler; private handler: SignalingHandler;
private joinCode?: string; private joinCode?: string;
private profile: ProfileData;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null; private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private intentionalClose = false; private intentionalClose = false;
constructor(handler: SignalingHandler, joinCode?: string) { constructor(handler: SignalingHandler, profile: ProfileData, joinCode?: string) {
this.handler = handler; this.handler = handler;
this.profile = profile;
this.joinCode = joinCode; this.joinCode = joinCode;
} }
@ -21,7 +29,13 @@ export class SignalingClient {
this.ws = new WebSocket(WS_URL); this.ws = new WebSocket(WS_URL);
this.ws.onopen = () => { this.ws.onopen = () => {
this.send({ type: "hello", joinCode: this.joinCode }); this.send({
type: "hello",
deviceName: this.profile.deviceName,
deviceType: this.profile.deviceType,
avatar: this.profile.avatar,
joinCode: this.joinCode,
});
}; };
this.ws.onmessage = (event) => { this.ws.onmessage = (event) => {

View File

@ -1,18 +1,19 @@
import { useCallback } from "react"; import { useCallback, useState } from "react";
import { useSignaling } from "../hooks/useSignaling"; import { useSignaling } from "../hooks/useSignaling";
import { useStore } from "../stores/useStore"; import { useStore } from "../stores/useStore";
import { useProfileStore } from "../stores/useProfileStore";
import PeerList from "../components/PeerList"; import PeerList from "../components/PeerList";
import DropZone from "../components/DropZone"; import DropZone from "../components/DropZone";
import TransferProgress from "../components/TransferProgress"; import TransferProgress from "../components/TransferProgress";
import TextShareModal from "../components/TextShareModal"; import TextShareModal from "../components/TextShareModal";
import ReceiveDialog from "../components/ReceiveDialog"; import ReceiveDialog from "../components/ReceiveDialog";
import PublicRoomPanel from "../components/PublicRoomPanel"; import PublicRoomPanel from "../components/PublicRoomPanel";
import ProfileSetup from "../components/ProfileSetup";
export default function Home() { export default function Home() {
const { sendFiles, sendText, acceptTransfer, rejectTransfer, createPublicRoom } = const { sendFiles, sendText, acceptTransfer, rejectTransfer, createPublicRoom } =
useSignaling(); useSignaling();
const displayName = useStore((s) => s.displayName);
const selectedPeerId = useStore((s) => s.selectedPeerId); const selectedPeerId = useStore((s) => s.selectedPeerId);
const showTextModal = useStore((s) => s.showTextModal); const showTextModal = useStore((s) => s.showTextModal);
const incomingRequest = useStore((s) => s.incomingRequest); const incomingRequest = useStore((s) => s.incomingRequest);
@ -21,6 +22,9 @@ export default function Home() {
const setShowTextModal = useStore((s) => s.setShowTextModal); const setShowTextModal = useStore((s) => s.setShowTextModal);
const setError = useStore((s) => s.setError); const setError = useStore((s) => s.setError);
const { deviceName, avatar, isSetUp } = useProfileStore();
const [showProfileEdit, setShowProfileEdit] = useState(false);
const handlePeerSelect = useCallback( const handlePeerSelect = useCallback(
(peerId: string) => { (peerId: string) => {
setSelectedPeerId(selectedPeerId === peerId ? null : peerId); setSelectedPeerId(selectedPeerId === peerId ? null : peerId);
@ -70,11 +74,23 @@ export default function Home() {
Any<span className="text-brand-400">Drop</span> Any<span className="text-brand-400">Drop</span>
</h1> </h1>
<p className="text-slate-500 text-sm">Partage instantané, sans compte</p> <p className="text-slate-500 text-sm">Partage instantané, sans compte</p>
{displayName && (
<p className="mt-3 text-sm text-slate-400"> {/* Profile badge — tap to edit */}
Vous êtes <span className="text-brand-300 font-medium">{displayName}</span> <button
</p> onClick={() => setShowProfileEdit(true)}
)} className="mt-3 inline-flex items-center gap-2 px-3 py-1.5 rounded-full
bg-slate-800/50 hover:bg-slate-800 transition-colors group"
>
{avatar ? (
<img src={avatar} alt="" className="w-5 h-5 rounded-full object-cover" />
) : (
<span className="text-sm">📱</span>
)}
<span className="text-sm text-slate-400 group-hover:text-white transition-colors">
{deviceName}
</span>
<span className="text-[10px] text-slate-600"></span>
</button>
</header> </header>
{/* Error banner */} {/* Error banner */}
@ -129,6 +145,14 @@ export default function Home() {
</footer> </footer>
</div> </div>
{/* Profile setup — first time or edit */}
{!isSetUp && (
<ProfileSetup onDone={() => {}} />
)}
{showProfileEdit && (
<ProfileSetup isEditing onDone={() => setShowProfileEdit(false)} />
)}
{/* Modals */} {/* Modals */}
{showTextModal && selectedPeerId && ( {showTextModal && selectedPeerId && (
<TextShareModal <TextShareModal

View File

@ -1,18 +1,21 @@
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { useCallback } from "react"; import { useCallback, useState } from "react";
import { useSignaling } from "../hooks/useSignaling"; import { useSignaling } from "../hooks/useSignaling";
import { useStore } from "../stores/useStore"; import { useStore } from "../stores/useStore";
import { useProfileStore } from "../stores/useProfileStore";
import PeerList from "../components/PeerList"; import PeerList from "../components/PeerList";
import DropZone from "../components/DropZone"; import DropZone from "../components/DropZone";
import TransferProgress from "../components/TransferProgress"; import TransferProgress from "../components/TransferProgress";
import TextShareModal from "../components/TextShareModal"; import TextShareModal from "../components/TextShareModal";
import ReceiveDialog from "../components/ReceiveDialog"; import ReceiveDialog from "../components/ReceiveDialog";
import ProfileSetup from "../components/ProfileSetup";
export default function JoinRoom() { export default function JoinRoom() {
const { code } = useParams<{ code: string }>(); const { code } = useParams<{ code: string }>();
const { sendFiles, sendText, acceptTransfer, rejectTransfer } = useSignaling(code); const { sendFiles, sendText, acceptTransfer, rejectTransfer } = useSignaling(code);
const displayName = useStore((s) => s.displayName); const { deviceName, avatar, isSetUp } = useProfileStore();
const [showProfileEdit, setShowProfileEdit] = useState(false);
const selectedPeerId = useStore((s) => s.selectedPeerId); const selectedPeerId = useStore((s) => s.selectedPeerId);
const showTextModal = useStore((s) => s.showTextModal); const showTextModal = useStore((s) => s.showTextModal);
const incomingRequest = useStore((s) => s.incomingRequest); const incomingRequest = useStore((s) => s.incomingRequest);
@ -72,11 +75,21 @@ export default function JoinRoom() {
<p className="text-slate-500 text-sm"> <p className="text-slate-500 text-sm">
Room <span className="text-brand-300 font-mono font-bold">{code?.toUpperCase()}</span> Room <span className="text-brand-300 font-mono font-bold">{code?.toUpperCase()}</span>
</p> </p>
{displayName && ( <button
<p className="mt-3 text-sm text-slate-400"> onClick={() => setShowProfileEdit(true)}
Vous êtes <span className="text-brand-300 font-medium">{displayName}</span> className="mt-3 inline-flex items-center gap-2 px-3 py-1.5 rounded-full
</p> bg-slate-800/50 hover:bg-slate-800 transition-colors group"
)} >
{avatar ? (
<img src={avatar} alt="" className="w-5 h-5 rounded-full object-cover" />
) : (
<span className="text-sm">📱</span>
)}
<span className="text-sm text-slate-400 group-hover:text-white transition-colors">
{deviceName}
</span>
<span className="text-[10px] text-slate-600"></span>
</button>
</header> </header>
{/* Error banner */} {/* Error banner */}
@ -133,6 +146,14 @@ export default function JoinRoom() {
</footer> </footer>
</div> </div>
{/* Profile setup — first time or edit */}
{!isSetUp && (
<ProfileSetup onDone={() => {}} />
)}
{showProfileEdit && (
<ProfileSetup isEditing onDone={() => setShowProfileEdit(false)} />
)}
{/* Modals */} {/* Modals */}
{showTextModal && selectedPeerId && ( {showTextModal && selectedPeerId && (
<TextShareModal <TextShareModal

View File

@ -0,0 +1,35 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { DeviceType } from "@anydrop/shared";
import { detectDeviceType, getDefaultDeviceName } from "@anydrop/shared";
interface ProfileState {
deviceName: string;
deviceType: DeviceType;
avatar: string | null; // base64 data URL
isSetUp: boolean; // true once user has confirmed their profile at least once
setDeviceName: (name: string) => void;
setAvatar: (avatar: string | null) => void;
setUp: () => void;
}
const ua = navigator.userAgent;
export const useProfileStore = create<ProfileState>()(
persist(
(set) => ({
deviceName: getDefaultDeviceName(ua),
deviceType: detectDeviceType(ua),
avatar: null,
isSetUp: false,
setDeviceName: (deviceName) => set({ deviceName: deviceName.trim() || getDefaultDeviceName(ua) }),
setAvatar: (avatar) => set({ avatar }),
setUp: () => set({ isSetUp: true }),
}),
{
name: "anydrop-profile",
},
),
);

View File

@ -23,7 +23,6 @@ interface AppState {
// Connection // Connection
connected: boolean; connected: boolean;
peerId: string | null; peerId: string | null;
displayName: string | null;
roomId: string | null; roomId: string | null;
// Peers // Peers
@ -44,7 +43,7 @@ interface AppState {
error: string | null; error: string | null;
// Actions // Actions
setConnection: (peerId: string, displayName: string, roomId: string) => void; setConnection: (peerId: string, roomId: string) => void;
setConnected: (connected: boolean) => void; setConnected: (connected: boolean) => void;
addPeer: (peer: PeerInfo) => void; addPeer: (peer: PeerInfo) => void;
removePeer: (peerId: string) => void; removePeer: (peerId: string) => void;
@ -64,7 +63,6 @@ interface AppState {
const initialState = { const initialState = {
connected: false, connected: false,
peerId: null, peerId: null,
displayName: null,
roomId: null, roomId: null,
peers: [], peers: [],
publicRoomCode: null, publicRoomCode: null,
@ -80,8 +78,8 @@ const initialState = {
export const useStore = create<AppState>((set) => ({ export const useStore = create<AppState>((set) => ({
...initialState, ...initialState,
setConnection: (peerId, displayName, roomId) => setConnection: (peerId, roomId) =>
set({ peerId, displayName, roomId, connected: true }), set({ peerId, roomId, connected: true }),
setConnected: (connected) => set({ connected }), setConnected: (connected) => set({ connected }),