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:
@@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
status: status,
|
|
||||||
}, sanitized.id);
|
|
||||||
}
|
|
||||||
connectionManager.sendToUser(sanitized.id, {
|
|
||||||
type: 'presence_update',
|
|
||||||
userId: sanitized.id,
|
userId: sanitized.id,
|
||||||
status: status,
|
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
|
// Broadcast user_updated for profile field changes
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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,
|
||||||
activities: [] as Activity[],
|
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).
|
// 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;
|
||||||
|
|||||||
Reference in New Issue
Block a user