feat: Add WebSocket-based location refresh for online friends

- Server sends request_location via WebSocket to connected clients
- Falls back to silent push for offline/background clients
- Client responds with current GPS location when requested
- Refresh button now works for online friends (no push subscription needed)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2025-12-29 01:51:09 +01:00
parent aa501b1778
commit d6a20f9500
5 changed files with 90 additions and 35 deletions

View File

@ -70,6 +70,7 @@ export default function RoomPage() {
addWaypoint,
removeWaypoint,
leave,
setLocationRequestCallback,
} = useRoom({
slug,
userName: currentUser?.name || '',
@ -175,6 +176,18 @@ export default function RoomPage() {
}
}, [shouldAutoStartSharing, isConnected, isSharing, startSharing]);
// Set up callback for when server requests location (via refresh button)
useEffect(() => {
if (isConnected) {
setLocationRequestCallback(() => {
console.log('Server requested location update');
if (isSharing) {
requestUpdate();
}
});
}
}, [isConnected, isSharing, requestUpdate, setLocationRequestCallback]);
// Handler for toggling location sharing - persists preference
const handleToggleSharing = useCallback(() => {
if (isSharing) {

View File

@ -41,8 +41,9 @@ export default function ParticipantList({
const data = await response.json();
if (data.success) {
if (data.sent > 0) {
setRefreshMessage(`Pinged ${data.sent} friend${data.sent > 1 ? 's' : ''}`);
const total = data.total || (data.websocket || 0) + (data.push || 0);
if (total > 0) {
setRefreshMessage(`Pinged ${total} friend${total > 1 ? 's' : ''}`);
} else {
setRefreshMessage('No friends to ping');
}

View File

@ -74,6 +74,7 @@ interface UseRoomReturn {
addWaypoint: (waypoint: Omit<Waypoint, 'id' | 'createdAt' | 'createdBy'>) => void;
removeWaypoint: (waypointId: string) => void;
leave: () => void;
setLocationRequestCallback: (callback: () => void) => void;
}
export function useRoom({ slug, userName, userEmoji }: UseRoomOptions): UseRoomReturn {
@ -225,6 +226,13 @@ export function useRoom({ slug, userName, userEmoji }: UseRoomOptions): UseRoomR
setIsConnected(false);
}, []);
// Set location request callback (called when server requests location update)
const setLocationRequestCallback = useCallback((callback: () => void) => {
if (syncRef.current) {
syncRef.current.setLocationRequestCallback(callback);
}
}, []);
return {
isConnected,
isLoading,
@ -240,5 +248,6 @@ export function useRoom({ slug, userName, userEmoji }: UseRoomOptions): UseRoomR
addWaypoint,
removeWaypoint,
leave,
setLocationRequestCallback,
};
}

View File

@ -76,10 +76,12 @@ export type SyncMessage =
| { type: 'waypoint_add'; waypoint: WaypointState }
| { type: 'waypoint_remove'; waypointId: string }
| { type: 'full_state'; state: RoomState }
| { type: 'request_state' };
| { type: 'request_state' }
| { type: 'request_location' };
type SyncCallback = (state: RoomState) => void;
type ConnectionCallback = (connected: boolean) => void;
type LocationRequestCallback = () => void;
// Validate that coordinates are reasonable (not 0,0 or out of bounds)
function isValidLocation(location: LocationState | undefined): boolean {
@ -101,6 +103,7 @@ export class RoomSync {
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private onStateChange: SyncCallback;
private onConnectionChange: ConnectionCallback;
private onLocationRequest: LocationRequestCallback | null = null;
private participantId: string;
private currentParticipant: ParticipantState | null = null;
@ -108,17 +111,23 @@ export class RoomSync {
slug: string,
participantId: string,
onStateChange: SyncCallback,
onConnectionChange: ConnectionCallback
onConnectionChange: ConnectionCallback,
onLocationRequest?: LocationRequestCallback
) {
this.slug = slug;
this.participantId = participantId;
this.onStateChange = onStateChange;
this.onConnectionChange = onConnectionChange;
this.onLocationRequest = onLocationRequest || null;
// Initialize or load state
this.state = this.loadState() || this.createInitialState();
}
setLocationRequestCallback(callback: LocationRequestCallback): void {
this.onLocationRequest = callback;
}
private createInitialState(): RoomState {
return {
id: crypto.randomUUID(),
@ -310,6 +319,14 @@ export class RoomSync {
(w) => w.id !== message.waypointId
);
break;
case 'request_location':
// Server is requesting a location update from us
console.log('[RoomSync] Received location request from server');
if (this.onLocationRequest) {
this.onLocationRequest();
}
return; // Don't notify state change for this message type
}
this.notifyStateChange();

View File

@ -471,6 +471,7 @@ const server = createServer(async (req, res) => {
}
} else if (pathname === '/push/request-location' && req.method === 'POST') {
// Manually trigger location request for a room
// Uses WebSocket for online clients, push for offline/background
try {
const { roomSlug } = await parseJsonBody(req);
@ -480,44 +481,58 @@ const server = createServer(async (req, res) => {
return;
}
let wsSent = 0;
let pushSent = 0;
let pushFailed = 0;
// First, send WebSocket message to all connected clients in the room
const locationRequestMsg = JSON.stringify({ type: 'request_location' });
for (const [ws, clientInfo] of clients.entries()) {
if (clientInfo.roomSlug === roomSlug && ws.readyState === 1) {
ws.send(locationRequestMsg);
wsSent++;
}
}
console.log(`[${roomSlug}] Location request via WebSocket: ${wsSent} clients`);
// Then, send push notifications to offline subscribers
const subs = pushSubscriptions.get(roomSlug);
if (!subs || subs.size === 0) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, sent: 0, message: 'No subscribers in room' }));
return;
}
let sent = 0;
let failed = 0;
const failedEndpoints = [];
for (const sub of subs) {
try {
await webpush.sendNotification(sub, JSON.stringify({
silent: true,
data: { type: 'location_request', roomSlug }
}));
sent++;
} catch (error) {
failed++;
if (error.statusCode === 404 || error.statusCode === 410) {
failedEndpoints.push(sub.endpoint);
}
}
}
// Clean up failed subscriptions
for (const endpoint of failedEndpoints) {
if (subs && subs.size > 0) {
const failedEndpoints = [];
for (const sub of subs) {
if (sub.endpoint === endpoint) {
subs.delete(sub);
try {
await webpush.sendNotification(sub, JSON.stringify({
silent: true,
data: { type: 'location_request', roomSlug }
}));
pushSent++;
} catch (error) {
pushFailed++;
if (error.statusCode === 404 || error.statusCode === 410) {
failedEndpoints.push(sub.endpoint);
}
}
}
// Clean up failed subscriptions
for (const endpoint of failedEndpoints) {
for (const sub of subs) {
if (sub.endpoint === endpoint) {
subs.delete(sub);
}
}
}
}
console.log(`[${roomSlug}] Manual location request: ${sent} sent, ${failed} failed`);
console.log(`[${roomSlug}] Manual location request: ${wsSent} WebSocket, ${pushSent} push sent, ${pushFailed} push failed`);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, sent, failed }));
res.end(JSON.stringify({
success: true,
websocket: wsSent,
push: pushSent,
pushFailed,
total: wsSent + pushSent
}));
} catch (error) {
console.error('Location request error:', error);
res.writeHead(500, { 'Content-Type': 'application/json' });