From ad1a0f71640891ef616a1a0017e22de0c2178161 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 5 May 2026 16:06:45 +0200 Subject: [PATCH] fix(presence): broadcast presence_update to friends + DM members + space members Six WS sites that previously broadcast presence_update to spaces only now use collectProfileBroadcastTargetIds (the same recipient set as user_updated): - ws/handler.ts finalizeDisconnect (offline) - ws/handler.ts auth path (online) - ws/events.ts handlePresenceUpdate (manual idle/dnd/online) - ws/events.ts handleActivityUpdate (rich activity changes) - routes/users.ts showActivity-toggle clear - routes/users.ts status PATCH Friends with no shared space + DM-only co-members now see each other's online/offline transitions live, matching user_updated semantics. Federated stub presence broadcasts (Task B3) use the same helper, so cross-instance recipients are uniform. Updates one assertion in social.federated.test.ts that asserted the old snowflake-style stub username (now realname-based per A1). --- .../src/routes/social.federated.test.ts | 7 ++-- packages/server/src/routes/users.ts | 23 +++++------- packages/server/src/ws/events.ts | 15 ++++---- packages/server/src/ws/handler.ts | 36 +++++++++---------- 4 files changed, 36 insertions(+), 45 deletions(-) diff --git a/packages/server/src/routes/social.federated.test.ts b/packages/server/src/routes/social.federated.test.ts index 82643963..2318fa0f 100644 --- a/packages/server/src/routes/social.federated.test.ts +++ b/packages/server/src/routes/social.federated.test.ts @@ -169,9 +169,12 @@ describe('POST /api/social/requests — federated branch (happy path)', () => { expect(sentEvent).toBeDefined(); expect(sentEvent![0]).toBe(CALLER_ID); expect(sentEvent![1].request.id).toBe(body.requestId); - // homeUserId identifies the target; username is the canonical stub form (@). + // homeUserId identifies the target; username is the realname-based stub form + // (@) since resolveOrCreateReplicatedUser now uses the + // username hint from the wire profile snapshot. Falls back to @ + // only when no hint is available. expect(sentEvent![1].request.user.homeUserId).toBe('remote-alice'); - expect(sentEvent![1].request.user.username).toBe('remote-alice@orbit.test'); + expect(sentEvent![1].request.user.username).toBe('alice@orbit.test'); }); }); diff --git a/packages/server/src/routes/users.ts b/packages/server/src/routes/users.ts index 654600a9..b0b9238b 100644 --- a/packages/server/src/routes/users.ts +++ b/packages/server/src/routes/users.ts @@ -436,16 +436,14 @@ export async function userRoutes(app: FastifyInstance): Promise { connectionManager.setUserShowActivity(request.userId, showActivity); if (!showActivity) { connectionManager.clearUserActivities(request.userId); - const userSpaces = connectionManager.getUserSpaces(request.userId); const clearPayload = { type: 'presence_update' as const, userId: request.userId, status: connectionManager.getUserStatus(request.userId), activities: [] as Activity[], }; - for (const spaceId of userSpaces) { - connectionManager.sendToSpace(spaceId, clearPayload, request.userId); - } + const clearTargets = collectProfileBroadcastTargetIds(request.userId); + for (const uid of clearTargets) connectionManager.sendToUser(uid, clearPayload); connectionManager.sendToUser(request.userId, clearPayload); // S2S: project the cleared-activities snapshot to all active peers. @@ -498,19 +496,14 @@ export async function userRoutes(app: FastifyInstance): Promise { // Broadcast presence update if status changed if (status !== undefined) { - const userSpaces = connectionManager.getUserSpaces(sanitized.id); - for (const spaceId of userSpaces) { - connectionManager.sendToSpace(spaceId, { - type: 'presence_update', - userId: sanitized.id, - status: status, - }, sanitized.id); - } - connectionManager.sendToUser(sanitized.id, { - type: 'presence_update', + const statusPayload = { + type: 'presence_update' as const, userId: sanitized.id, status: status, - }); + }; + const statusTargets = collectProfileBroadcastTargetIds(sanitized.id); + for (const uid of statusTargets) connectionManager.sendToUser(uid, statusPayload); + connectionManager.sendToUser(sanitized.id, statusPayload); } // Broadcast user_updated for profile field changes diff --git a/packages/server/src/ws/events.ts b/packages/server/src/ws/events.ts index 7fb191c2..8fb3ecad 100644 --- a/packages/server/src/ws/events.ts +++ b/packages/server/src/ws/events.ts @@ -11,6 +11,7 @@ import type { CallRelayResult, CallFanoutFailure } from '../utils/federationOutb import { mapCallReasonToEventReason } from '../utils/federationOutbox.js'; import { ACTIVITY_LIMITS } from '@backspace/shared/src/activities.js'; import { sanitizeUser } from '../utils/sanitize.js'; +import { collectProfileBroadcastTargetIds } from '../utils/userDeletion.js'; import { deleteAttachmentFiles } from '../utils/fileCleanup.js'; import { resolveEmbeds, reResolveEmbeds, embedRowToEmbed } from '../utils/embedResolver.js'; import { appendMutationLog, queueOutboxEvent, queueDmRelay, getGroupDmTargetOrigins, sendCallRelay, computeFederatedId, sendTypingRelay, queueReadStateRelay } from '../utils/federationOutbox.js'; @@ -497,17 +498,15 @@ function handlePresenceUpdate(event: Record, userId: string): v connectionManager.setUserStatus(userId, status); const activities = connectionManager.getUserActivities(userId); - // Broadcast to all spaces user is in - const userSpaces = connectionManager.getUserSpaces(userId); + // Broadcast to friends + DM co-members + space co-members. const payload = { type: 'presence_update' as const, userId, status, ...(activities.length > 0 ? { activities } : {}), }; - for (const spaceId of userSpaces) { - connectionManager.sendToSpace(spaceId, payload, userId); - } + const targets = collectProfileBroadcastTargetIds(userId); + for (const uid of targets) connectionManager.sendToUser(uid, payload); // Also send to self (other tabs) connectionManager.sendToUser(userId, payload); @@ -534,11 +533,9 @@ function handleActivityUpdate(event: Record, userId: string): v connectionManager.setUserActivities(userId, activities); const status = connectionManager.getUserStatus(userId); - const userSpaces = connectionManager.getUserSpaces(userId); const payload = { type: 'presence_update' as const, userId, status, activities }; - for (const spaceId of userSpaces) { - connectionManager.sendToSpace(spaceId, payload, userId); - } + const targets = collectProfileBroadcastTargetIds(userId); + for (const uid of targets) connectionManager.sendToUser(uid, payload); connectionManager.sendToUser(userId, payload); // S2S: project to all active peers (activities + current status). diff --git a/packages/server/src/ws/handler.ts b/packages/server/src/ws/handler.ts index 59618974..bd51a7c7 100644 --- a/packages/server/src/ws/handler.ts +++ b/packages/server/src/ws/handler.ts @@ -21,6 +21,7 @@ import type { Activity, } from '@backspace/shared'; import { sanitizeUser } from '../utils/sanitize.js'; +import { collectProfileBroadcastTargetIds } from '../utils/userDeletion.js'; // ─── Heartbeat State ────────────────────────────────────────────────────────── const wsIsAlive: WeakMap = new WeakMap(); @@ -296,16 +297,18 @@ class ConnectionManager { this.userStatuses.delete(userId); this.lastActivityUpdate.delete(userId); - // Broadcast offline to all spaces - const userSpaces = this.getUserSpaces(userId); - for (const spaceId of userSpaces) { - this.sendToSpace(spaceId, { - type: 'presence_update', - userId: userId, - status: 'offline', - activities: [] as Activity[], - }); - } + // Broadcast offline to friends + DM co-members + space co-members. + // Mirrors collectProfileBroadcastTargetIds (the recipient set used by + // user_updated). Two locally-friended users with no shared space now see + // each other's offline transitions live, instead of being space-only. + const offlinePayload = { + type: 'presence_update' as const, + userId, + status: 'offline' as const, + activities: [] as Activity[], + }; + const offlineTargets = collectProfileBroadcastTargetIds(userId); + for (const uid of offlineTargets) this.sendToUser(uid, offlinePayload); // S2S: project offline to all active peers (mirrors profile_update fanout). // Imported lazily to avoid circular import (federationPresence → db → ws/handler). @@ -1669,15 +1672,10 @@ export async function registerWebSocket(app: FastifyInstance): Promise { ...readyData, })); - // Broadcast presence update to all spaces - const userSpaces = connectionManager.getUserSpaces(userId); - for (const spaceId of userSpaces) { - connectionManager.sendToSpace(spaceId, { - type: 'presence_update', - userId, - status: 'online', - }, userId); - } + // Broadcast online to friends + DM co-members + space co-members. + const onlinePayload = { type: 'presence_update' as const, userId, status: 'online' as const }; + const onlineTargets = collectProfileBroadcastTargetIds(userId); + for (const uid of onlineTargets) connectionManager.sendToUser(uid, onlinePayload); // S2S: project online to all active peers (mirrors profile_update fanout). const _uid = userId;