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).
This commit is contained in:
Jannis Braun
2026-05-05 16:06:45 +02:00
parent 53fe7d2b53
commit ad1a0f7164
4 changed files with 36 additions and 45 deletions
@@ -169,9 +169,12 @@ describe('POST /api/social/requests — federated branch (happy path)', () => {
expect(sentEvent).toBeDefined(); expect(sentEvent).toBeDefined();
expect(sentEvent![0]).toBe(CALLER_ID); expect(sentEvent![0]).toBe(CALLER_ID);
expect(sentEvent![1].request.id).toBe(body.requestId); expect(sentEvent![1].request.id).toBe(body.requestId);
// homeUserId identifies the target; username is the canonical stub form (<homeUserId>@<host>). // homeUserId identifies the target; username is the realname-based stub form
// (<lookup.username>@<host>) since resolveOrCreateReplicatedUser now uses the
// username hint from the wire profile snapshot. Falls back to <homeUserId>@<host>
// only when no hint is available.
expect(sentEvent![1].request.user.homeUserId).toBe('remote-alice'); 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');
}); });
}); });
+8 -15
View File
@@ -436,16 +436,14 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
connectionManager.setUserShowActivity(request.userId, showActivity); connectionManager.setUserShowActivity(request.userId, showActivity);
if (!showActivity) { if (!showActivity) {
connectionManager.clearUserActivities(request.userId); connectionManager.clearUserActivities(request.userId);
const userSpaces = connectionManager.getUserSpaces(request.userId);
const clearPayload = { const clearPayload = {
type: 'presence_update' as const, type: 'presence_update' as const,
userId: request.userId, userId: request.userId,
status: connectionManager.getUserStatus(request.userId), status: connectionManager.getUserStatus(request.userId),
activities: [] as Activity[], activities: [] as Activity[],
}; };
for (const spaceId of userSpaces) { const clearTargets = collectProfileBroadcastTargetIds(request.userId);
connectionManager.sendToSpace(spaceId, clearPayload, request.userId); for (const uid of clearTargets) connectionManager.sendToUser(uid, clearPayload);
}
connectionManager.sendToUser(request.userId, clearPayload); connectionManager.sendToUser(request.userId, clearPayload);
// S2S: project the cleared-activities snapshot to all active peers. // S2S: project the cleared-activities snapshot to all active peers.
@@ -498,19 +496,14 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
// Broadcast presence update if status changed // Broadcast presence update if status changed
if (status !== undefined) { if (status !== undefined) {
const userSpaces = connectionManager.getUserSpaces(sanitized.id); const statusPayload = {
for (const spaceId of userSpaces) { type: 'presence_update' as const,
connectionManager.sendToSpace(spaceId, {
type: 'presence_update',
userId: sanitized.id, userId: sanitized.id,
status: status, status: status,
}, sanitized.id); };
} const statusTargets = collectProfileBroadcastTargetIds(sanitized.id);
connectionManager.sendToUser(sanitized.id, { for (const uid of statusTargets) connectionManager.sendToUser(uid, statusPayload);
type: 'presence_update', connectionManager.sendToUser(sanitized.id, statusPayload);
userId: sanitized.id,
status: status,
});
} }
// Broadcast user_updated for profile field changes // Broadcast user_updated for profile field changes
+6 -9
View File
@@ -11,6 +11,7 @@ import type { CallRelayResult, CallFanoutFailure } from '../utils/federationOutb
import { mapCallReasonToEventReason } from '../utils/federationOutbox.js'; import { mapCallReasonToEventReason } from '../utils/federationOutbox.js';
import { ACTIVITY_LIMITS } from '@backspace/shared/src/activities.js'; import { ACTIVITY_LIMITS } from '@backspace/shared/src/activities.js';
import { sanitizeUser } from '../utils/sanitize.js'; import { sanitizeUser } from '../utils/sanitize.js';
import { collectProfileBroadcastTargetIds } from '../utils/userDeletion.js';
import { deleteAttachmentFiles } from '../utils/fileCleanup.js'; import { deleteAttachmentFiles } from '../utils/fileCleanup.js';
import { resolveEmbeds, reResolveEmbeds, embedRowToEmbed } from '../utils/embedResolver.js'; import { resolveEmbeds, reResolveEmbeds, embedRowToEmbed } from '../utils/embedResolver.js';
import { appendMutationLog, queueOutboxEvent, queueDmRelay, getGroupDmTargetOrigins, sendCallRelay, computeFederatedId, sendTypingRelay, queueReadStateRelay } from '../utils/federationOutbox.js'; import { appendMutationLog, queueOutboxEvent, queueDmRelay, getGroupDmTargetOrigins, sendCallRelay, computeFederatedId, sendTypingRelay, queueReadStateRelay } from '../utils/federationOutbox.js';
@@ -497,17 +498,15 @@ function handlePresenceUpdate(event: Record<string, unknown>, userId: string): v
connectionManager.setUserStatus(userId, status); connectionManager.setUserStatus(userId, status);
const activities = connectionManager.getUserActivities(userId); const activities = connectionManager.getUserActivities(userId);
// Broadcast to all spaces user is in // Broadcast to friends + DM co-members + space co-members.
const userSpaces = connectionManager.getUserSpaces(userId);
const payload = { const payload = {
type: 'presence_update' as const, type: 'presence_update' as const,
userId, userId,
status, status,
...(activities.length > 0 ? { activities } : {}), ...(activities.length > 0 ? { activities } : {}),
}; };
for (const spaceId of userSpaces) { const targets = collectProfileBroadcastTargetIds(userId);
connectionManager.sendToSpace(spaceId, payload, userId); for (const uid of targets) connectionManager.sendToUser(uid, payload);
}
// Also send to self (other tabs) // Also send to self (other tabs)
connectionManager.sendToUser(userId, payload); connectionManager.sendToUser(userId, payload);
@@ -534,11 +533,9 @@ function handleActivityUpdate(event: Record<string, unknown>, userId: string): v
connectionManager.setUserActivities(userId, activities); connectionManager.setUserActivities(userId, activities);
const status = connectionManager.getUserStatus(userId); const status = connectionManager.getUserStatus(userId);
const userSpaces = connectionManager.getUserSpaces(userId);
const payload = { type: 'presence_update' as const, userId, status, activities }; const payload = { type: 'presence_update' as const, userId, status, activities };
for (const spaceId of userSpaces) { const targets = collectProfileBroadcastTargetIds(userId);
connectionManager.sendToSpace(spaceId, payload, userId); for (const uid of targets) connectionManager.sendToUser(uid, payload);
}
connectionManager.sendToUser(userId, payload); connectionManager.sendToUser(userId, payload);
// S2S: project to all active peers (activities + current status). // S2S: project to all active peers (activities + current status).
+16 -18
View File
@@ -21,6 +21,7 @@ import type {
Activity, Activity,
} from '@backspace/shared'; } from '@backspace/shared';
import { sanitizeUser } from '../utils/sanitize.js'; import { sanitizeUser } from '../utils/sanitize.js';
import { collectProfileBroadcastTargetIds } from '../utils/userDeletion.js';
// ─── Heartbeat State ────────────────────────────────────────────────────────── // ─── Heartbeat State ──────────────────────────────────────────────────────────
const wsIsAlive: WeakMap<WebSocket, boolean> = new WeakMap(); const wsIsAlive: WeakMap<WebSocket, boolean> = new WeakMap();
@@ -296,16 +297,18 @@ class ConnectionManager {
this.userStatuses.delete(userId); this.userStatuses.delete(userId);
this.lastActivityUpdate.delete(userId); this.lastActivityUpdate.delete(userId);
// Broadcast offline to all spaces // Broadcast offline to friends + DM co-members + space co-members.
const userSpaces = this.getUserSpaces(userId); // Mirrors collectProfileBroadcastTargetIds (the recipient set used by
for (const spaceId of userSpaces) { // user_updated). Two locally-friended users with no shared space now see
this.sendToSpace(spaceId, { // each other's offline transitions live, instead of being space-only.
type: 'presence_update', const offlinePayload = {
userId: userId, type: 'presence_update' as const,
status: 'offline', userId,
status: 'offline' as const,
activities: [] as Activity[], 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). // S2S: project offline to all active peers (mirrors profile_update fanout).
// Imported lazily to avoid circular import (federationPresence → db → ws/handler). // Imported lazily to avoid circular import (federationPresence → db → ws/handler).
@@ -1669,15 +1672,10 @@ export async function registerWebSocket(app: FastifyInstance): Promise<void> {
...readyData, ...readyData,
})); }));
// Broadcast presence update to all spaces // Broadcast online to friends + DM co-members + space co-members.
const userSpaces = connectionManager.getUserSpaces(userId); const onlinePayload = { type: 'presence_update' as const, userId, status: 'online' as const };
for (const spaceId of userSpaces) { const onlineTargets = collectProfileBroadcastTargetIds(userId);
connectionManager.sendToSpace(spaceId, { for (const uid of onlineTargets) connectionManager.sendToUser(uid, onlinePayload);
type: 'presence_update',
userId,
status: 'online',
}, userId);
}
// S2S: project online to all active peers (mirrors profile_update fanout). // S2S: project online to all active peers (mirrors profile_update fanout).
const _uid = userId; const _uid = userId;