feat: offline peers visible + push wake + iOS share sheet + no self-notification
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 44s

- Offline push-subscribed devices appear dimmed in peer list
- Tap offline peer to send wake push notification
- Skip self-notification (own deviceId excluded)
- iOS/Android share sheet via Web Share Target API
- Online/offline indicator dot on peer avatars
This commit is contained in:
ordinarthur 2026-04-14 12:12:08 +02:00
parent 0034e91672
commit d1c4b3d196
8 changed files with 180 additions and 45 deletions

View File

@ -15,6 +15,8 @@ import {
getVapidPublicKey,
storeSubscription,
notifyOfflineDevices,
getOfflineSubscribers,
wakeDevice,
} from "./push.js";
const PORT = parseInt(process.env.PORT || "3001", 10);
@ -207,6 +209,9 @@ wss.on("connection", (ws, req) => {
case "subscribe-push":
handleSubscribePush(client, msg);
break;
case "wake-peer":
handleWakePeer(client, msg);
break;
case "leave":
handleLeave(client);
break;
@ -279,6 +284,24 @@ function handleHello(client: Client, msg: ClientMessage & { type: "hello" }): vo
}
}
// Collect online device IDs
const onlineDeviceIds = new Set<string>();
for (const peer of lanRoom.clients.values()) {
if (peer.deviceId) onlineDeviceIds.add(peer.deviceId);
}
// Add offline push-subscribed peers to the list (excluding self)
const offlineSubs = getOfflineSubscribers(lanGroupKey, onlineDeviceIds, client.deviceId ?? undefined);
for (const sub of offlineSubs) {
peers.push({
peerId: `offline:${sub.deviceId}`,
displayName: sub.displayName,
deviceType: sub.deviceType,
online: false,
deviceId: sub.deviceId,
});
}
send(client.ws, {
type: "welcome",
peerId: client.peerId,
@ -287,18 +310,20 @@ function handleHello(client: Client, msg: ClientMessage & { type: "hello" }): vo
vapidPublicKey: getVapidPublicKey(),
});
// Notify offline devices that have push subscriptions for this LAN group
const onlineDeviceIds = new Set<string>();
for (const peer of lanRoom.clients.values()) {
if (peer.deviceId) onlineDeviceIds.add(peer.deviceId);
}
notifyOfflineDevices(lanGroupKey, onlineDeviceIds, client.displayName, client.deviceType);
// Notify offline devices via push (skip self)
notifyOfflineDevices(lanGroupKey, onlineDeviceIds, client.deviceId, client.displayName, client.deviceType);
}
function handleSubscribePush(client: Client, msg: ClientMessage & { type: "subscribe-push" }): void {
if (!msg.deviceId || !msg.subscription) return;
const lanGroupKey = getLanGroupKey(client.ip);
storeSubscription(msg.deviceId, client.displayName, msg.subscription as any, lanGroupKey);
storeSubscription(msg.deviceId, client.displayName, client.deviceType, msg.subscription as any, lanGroupKey);
}
function handleWakePeer(client: Client, msg: ClientMessage & { type: "wake-peer" }): void {
if (!msg.deviceId) return;
const lanGroupKey = getLanGroupKey(client.ip);
wakeDevice(lanGroupKey, msg.deviceId, client.displayName, client.deviceType);
}
function handleCreatePublicRoom(client: Client): void {

View File

@ -1,16 +1,16 @@
import webpush from "web-push";
import type { PushPayload } from "@anydrop/shared";
import type { PushPayload, DeviceType } from "@anydrop/shared";
const VAPID_PUBLIC_KEY = process.env.VAPID_PUBLIC_KEY || "";
const VAPID_PRIVATE_KEY = process.env.VAPID_PRIVATE_KEY || "";
const VAPID_SUBJECT = process.env.VAPID_SUBJECT || "mailto:contact@arthurbarre.fr";
// Subscription TTL: 24h (auto-clean stale entries)
const SUBSCRIPTION_TTL_MS = 24 * 60 * 60 * 1000;
export interface StoredSubscription {
deviceId: string;
displayName: string;
deviceType: DeviceType;
subscription: webpush.PushSubscription;
lanGroupKey: string;
storedAt: number;
@ -29,10 +29,7 @@ export function initPush(): boolean {
webpush.setVapidDetails(VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY);
configured = true;
console.log("[push] Web Push configured");
// Clean stale subscriptions every 10 min
setInterval(cleanStale, 10 * 60 * 1000);
return true;
}
@ -43,6 +40,7 @@ export function getVapidPublicKey(): string | undefined {
export function storeSubscription(
deviceId: string,
displayName: string,
deviceType: DeviceType,
subscription: webpush.PushSubscription,
lanGroupKey: string,
): void {
@ -56,6 +54,7 @@ export function storeSubscription(
group.set(deviceId, {
deviceId,
displayName,
deviceType,
subscription,
lanGroupKey,
storedAt: Date.now(),
@ -67,13 +66,44 @@ export function removeSubscription(deviceId: string, lanGroupKey: string): void
subscriptions.get(lanGroupKey)?.delete(deviceId);
}
/**
* Get all offline push-subscribed devices for a LAN group.
* Excludes devices that are currently online and the requesting device itself.
*/
export function getOfflineSubscribers(
lanGroupKey: string,
onlineDeviceIds: Set<string>,
excludeDeviceId?: string,
): StoredSubscription[] {
if (!configured) return [];
const group = subscriptions.get(lanGroupKey);
if (!group) return [];
const result: StoredSubscription[] = [];
const now = Date.now();
for (const [deviceId, stored] of group) {
if (onlineDeviceIds.has(deviceId)) continue;
if (excludeDeviceId && deviceId === excludeDeviceId) continue;
if (now - stored.storedAt > SUBSCRIPTION_TTL_MS) {
group.delete(deviceId);
continue;
}
result.push(stored);
}
return result;
}
/**
* Notify offline devices in a LAN group that a peer is nearby.
* `onlineDeviceIds` = set of deviceIds currently connected in this room.
* Skips the connecting device's own deviceId.
*/
export async function notifyOfflineDevices(
lanGroupKey: string,
onlineDeviceIds: Set<string>,
senderDeviceId: string | null,
peerDisplayName: string,
peerDeviceType: string,
): Promise<void> {
@ -90,10 +120,9 @@ export async function notifyOfflineDevices(
const body = JSON.stringify(payload);
for (const [deviceId, stored] of group) {
// Skip devices that are currently online
if (onlineDeviceIds.has(deviceId)) continue;
// Skip stale subscriptions
// Never notify the sender's own device
if (senderDeviceId && deviceId === senderDeviceId) continue;
if (Date.now() - stored.storedAt > SUBSCRIPTION_TTL_MS) {
group.delete(deviceId);
continue;
@ -103,7 +132,6 @@ export async function notifyOfflineDevices(
await webpush.sendNotification(stored.subscription, body, { TTL: 60 });
console.log(`[push] notified ${stored.displayName} (${deviceId})`);
} catch (err: any) {
// 404/410 = subscription expired/invalid
if (err.statusCode === 404 || err.statusCode === 410) {
group.delete(deviceId);
console.log(`[push] removed expired subscription ${deviceId}`);
@ -114,6 +142,45 @@ export async function notifyOfflineDevices(
}
}
/**
* Send a push notification to a specific device to wake it up.
*/
export async function wakeDevice(
lanGroupKey: string,
targetDeviceId: string,
senderDisplayName: string,
senderDeviceType: DeviceType,
): Promise<boolean> {
if (!configured) return false;
const group = subscriptions.get(lanGroupKey);
if (!group) return false;
const stored = group.get(targetDeviceId);
if (!stored) return false;
if (Date.now() - stored.storedAt > SUBSCRIPTION_TTL_MS) {
group.delete(targetDeviceId);
return false;
}
const payload: PushPayload = {
type: "peer-nearby",
displayName: senderDisplayName,
deviceType: senderDeviceType,
};
try {
await webpush.sendNotification(stored.subscription, JSON.stringify(payload), { TTL: 60 });
console.log(`[push] woke ${stored.displayName} (${targetDeviceId})`);
return true;
} catch (err: any) {
if (err.statusCode === 404 || err.statusCode === 410) {
group.delete(targetDeviceId);
}
return false;
}
}
function cleanStale(): void {
const now = Date.now();
for (const [key, group] of subscriptions) {

View File

@ -32,12 +32,19 @@ export interface SubscribePushMessage {
subscription: PushSubscriptionJSON;
}
/** Client asks the server to send a push notification to wake a specific offline device */
export interface WakePeerMessage {
type: "wake-peer";
deviceId: string;
}
export type ClientMessage =
| HelloMessage
| CreatePublicRoomMessage
| SignalMessage
| LeaveMessage
| SubscribePushMessage;
| SubscribePushMessage
| WakePeerMessage;
// ── Server → Client messages ──
@ -46,6 +53,8 @@ export interface PeerInfo {
displayName: string;
deviceType: DeviceType;
avatar?: string;
online?: boolean; // default true; false = offline but reachable via push
deviceId?: string; // for offline peers (used to wake them)
}
export interface WelcomeMessage {

File diff suppressed because one or more lines are too long

View File

@ -4,6 +4,7 @@ interface PeerAvatarProps {
displayName: string;
deviceType: DeviceType;
avatar?: string;
online?: boolean;
onClick?: () => void;
isSelected?: boolean;
size?: "sm" | "md" | "lg";
@ -22,8 +23,9 @@ const sizeClasses = {
lg: { container: "w-20 h-20", icon: "text-3xl", img: "w-20 h-20" },
};
export default function PeerAvatar({ displayName, deviceType, avatar, onClick, isSelected, size = "md" }: PeerAvatarProps) {
export default function PeerAvatar({ displayName, deviceType, avatar, online = true, onClick, isSelected, size = "md" }: PeerAvatarProps) {
const s = sizeClasses[size];
const isOffline = !online;
return (
<button
@ -31,31 +33,41 @@ export default function PeerAvatar({ displayName, deviceType, avatar, onClick, i
className={`
flex flex-col items-center gap-2 group cursor-pointer
transition-transform duration-200 hover:scale-105
${isOffline ? "opacity-50" : ""}
`}
>
<div
className={`
${s.container}
rounded-full flex items-center justify-center overflow-hidden
transition-all duration-200
${isSelected
? "ring-2 ring-brand-400 ring-offset-2 ring-offset-slate-950"
: ""
}
${avatar ? "" : isSelected ? "bg-brand-500" : "bg-slate-800 hover:bg-slate-700"}
`}
>
{avatar ? (
<img
src={avatar}
alt={displayName}
className={`${s.img} rounded-full object-cover`}
/>
) : (
<span className={s.icon}>{DEVICE_ICONS[deviceType]}</span>
)}
<div className="relative">
<div
className={`
${s.container}
rounded-full flex items-center justify-center overflow-hidden
transition-all duration-200
${isSelected
? "ring-2 ring-brand-400 ring-offset-2 ring-offset-slate-950"
: ""
}
${avatar ? "" : isSelected ? "bg-brand-500" : "bg-slate-800 hover:bg-slate-700"}
`}
>
{avatar ? (
<img
src={avatar}
alt={displayName}
className={`${s.img} rounded-full object-cover ${isOffline ? "grayscale" : ""}`}
/>
) : (
<span className={s.icon}>{DEVICE_ICONS[deviceType]}</span>
)}
</div>
{/* Online/offline indicator */}
<div
className={`
absolute -bottom-0.5 -right-0.5 w-3.5 h-3.5 rounded-full border-2 border-slate-950
${online ? "bg-green-500" : "bg-slate-500"}
`}
/>
</div>
<span className="text-xs text-slate-300 group-hover:text-white transition-colors max-w-[80px] truncate">
<span className={`text-xs transition-colors max-w-[80px] truncate ${isOffline ? "text-slate-500" : "text-slate-300 group-hover:text-white"}`}>
{displayName}
</span>
</button>

View File

@ -22,14 +22,22 @@ export default function PeerList({ onPeerSelect }: PeerListProps) {
);
}
// Sort: online first, then offline
const sorted = [...peers].sort((a, b) => {
const aOnline = a.online !== false ? 1 : 0;
const bOnline = b.online !== false ? 1 : 0;
return bOnline - aOnline;
});
return (
<div className="flex flex-wrap justify-center gap-6 py-8">
{peers.map((peer) => (
{sorted.map((peer) => (
<PeerAvatar
key={peer.peerId}
displayName={peer.displayName}
deviceType={peer.deviceType}
avatar={peer.avatar}
online={peer.online !== false}
isSelected={selectedPeerId === peer.peerId}
onClick={() => onPeerSelect(peer.peerId)}
/>

View File

@ -340,11 +340,16 @@ export function useSignaling(joinCode?: string) {
signalingRef.current?.send({ type: "create-public-room" });
}, []);
const wakePeer = useCallback((deviceId: string) => {
signalingRef.current?.send({ type: "wake-peer", deviceId });
}, []);
return {
sendFiles,
sendText,
acceptTransfer,
rejectTransfer,
createPublicRoom,
wakePeer,
};
}

View File

@ -21,9 +21,10 @@ export default function Home() {
}
function HomeConnected() {
const { sendFiles, sendText, acceptTransfer, rejectTransfer, createPublicRoom } =
const { sendFiles, sendText, acceptTransfer, rejectTransfer, createPublicRoom, 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);
@ -34,12 +35,20 @@ function HomeConnected() {
const { deviceName, avatar } = useProfileStore();
const [showProfileEdit, setShowProfileEdit] = useState(false);
const [wakingDeviceId, setWakingDeviceId] = useState<string | null>(null);
const handlePeerSelect = useCallback(
(peerId: string) => {
// 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);
setWakingDeviceId(peer.deviceId);
return;
}
setSelectedPeerId(selectedPeerId === peerId ? null : peerId);
},
[selectedPeerId, setSelectedPeerId],
[selectedPeerId, setSelectedPeerId, peers, wakePeer],
);
const handleFilesSelected = useCallback(