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
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:
parent
0034e91672
commit
d1c4b3d196
@ -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 {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
@ -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,8 +33,10 @@ 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="relative">
|
||||
<div
|
||||
className={`
|
||||
${s.container}
|
||||
@ -49,13 +53,21 @@ export default function PeerAvatar({ displayName, deviceType, avatar, onClick, i
|
||||
<img
|
||||
src={avatar}
|
||||
alt={displayName}
|
||||
className={`${s.img} rounded-full object-cover`}
|
||||
className={`${s.img} rounded-full object-cover ${isOffline ? "grayscale" : ""}`}
|
||||
/>
|
||||
) : (
|
||||
<span className={s.icon}>{DEVICE_ICONS[deviceType]}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-slate-300 group-hover:text-white transition-colors max-w-[80px] truncate">
|
||||
{/* 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 transition-colors max-w-[80px] truncate ${isOffline ? "text-slate-500" : "text-slate-300 group-hover:text-white"}`}>
|
||||
{displayName}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@ -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)}
|
||||
/>
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user