diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index 3d3e9079..60357276 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -2330,6 +2330,7 @@ export async function federationRoutes(app: FastifyInstance): Promise { avatarColor: user.avatarColor, banner: user.banner, bio: user.bio, + status: user.status as 'online' | 'idle' | 'dnd' | 'offline' | null, }, }, }); @@ -2416,6 +2417,7 @@ export async function federationRoutes(app: FastifyInstance): Promise { displayName: user.displayName, avatar: user.avatar, avatarColor: user.avatarColor, + status: user.status as 'online' | 'idle' | 'dnd' | 'offline' | null, banner: user.banner, bio: user.bio, }, @@ -3253,7 +3255,7 @@ export function resolveOrCreateReplicatedUser( homeUserId: string, homeInstance: string, db: ReturnType, - hints?: { username?: string | null }, + hints?: { username?: string | null; status?: 'online' | 'idle' | 'dnd' | 'offline' | null }, ): typeof schema.users.$inferSelect | null { const existing = findFederatedUser(homeUserId, homeInstance, db, hints); if (existing) return backfillHomeUserId(existing, homeUserId, db); @@ -3299,12 +3301,18 @@ export function resolveOrCreateReplicatedUser( const userId = generateSnowflake(); const now = Date.now(); + // Seed status from the wire snapshot when available — without this, a + // freshly-created stub for an already-online remote sticks at 'offline' + // until the home next emits a presence transition (presence_update only + // fires on changes, not on stub creation). Falls back to 'offline'. + const initialStatus = hints?.status ?? 'offline'; + db.insert(schema.users).values({ id: userId, username, displayName: null, passwordHash: '!federation-replicated', // Cannot be used to log in (bcrypt never produces this) - status: 'offline', + status: initialStatus, isAdmin: 0, homeInstance: domain, // Normalized to bare domain homeUserId, @@ -3490,7 +3498,7 @@ async function processCreateEvent( }> = []; for (const p of event.participants) { - let localUser = resolveOrCreateReplicatedUser(p.homeUserId, p.homeInstance, db, { username: p.profile?.username }); + let localUser = resolveOrCreateReplicatedUser(p.homeUserId, p.homeInstance, db, { username: p.profile?.username, status: p.profile?.status }); // Skip deleted identities — don't include tombstoned users in the DM if (!localUser) continue; // Hydrate with profile data from the relay event (displayName, avatar, etc.) @@ -4085,7 +4093,7 @@ function processMemberAddEvent( // Resolve owner — create a replicated stub if unknown let ownerId: string | null = null; if (event.group.owner) { - const ownerLocal = resolveOrCreateReplicatedUser(event.group.owner.homeUserId, event.group.owner.homeInstance, db, { username: event.group.owner.profile?.username }); + const ownerLocal = resolveOrCreateReplicatedUser(event.group.owner.homeUserId, event.group.owner.homeInstance, db, { username: event.group.owner.profile?.username, status: event.group.owner.profile?.status }); ownerId = ownerLocal?.id ?? null; } @@ -4103,7 +4111,7 @@ function processMemberAddEvent( // Add all roster members — create replicated user stubs for any // participants from remote instances that haven't been seen before. for (const member of event.group.members) { - const rosterUser = resolveOrCreateReplicatedUser(member.homeUserId, member.homeInstance, db, { username: member.profile?.username }); + const rosterUser = resolveOrCreateReplicatedUser(member.homeUserId, member.homeInstance, db, { username: member.profile?.username, status: member.profile?.status }); // Skip deleted identities — tombstoned users can't be added to a DM if (!rosterUser) continue; const existing = db.select().from(schema.dmMembers) @@ -4157,7 +4165,7 @@ function processMemberAddEvent( event.membership.user.homeUserId, event.membership.user.homeInstance, db, - { username: event.membership.user.profile?.username }, + { username: event.membership.user.profile?.username, status: event.membership.user.profile?.status }, ); if (!localUser) { // The user's identity has been deleted — don't add a tombstoned user to the DM @@ -4196,7 +4204,7 @@ function processMemberAddEvent( // would otherwise find the channel already present and fall through to the incremental path, // creating spurious system messages (the exact bug this fixes). const actorUser = event.membership.addedBy - ? resolveOrCreateReplicatedUser(event.membership.addedBy.homeUserId, event.membership.addedBy.homeInstance, db, { username: event.membership.addedBy.profile?.username }) + ? resolveOrCreateReplicatedUser(event.membership.addedBy.homeUserId, event.membership.addedBy.homeInstance, db, { username: event.membership.addedBy.profile?.username, status: event.membership.addedBy.profile?.status }) : null; const actorId = actorUser?.id ?? localUser.id; const addBaseName = localUser.username?.includes('@') ? localUser.username.split('@')[0] : (localUser.username ?? 'Unknown'); @@ -4487,7 +4495,7 @@ function processOwnershipTransferEvent( event.ownership.newOwner.homeUserId, event.ownership.newOwner.homeInstance, db, - { username: event.ownership.newOwner.profile?.username }, + { username: event.ownership.newOwner.profile?.username, status: event.ownership.newOwner.profile?.status }, ); if (!newOwnerLocal) { rejected.push({ messageId: event.messageId, reason: 'participant_not_found' }); @@ -4646,7 +4654,7 @@ async function processFriendRequestCreateEvent( } // Resolve the sender (create stub if needed — they're on a remote instance) - const fromUserResolved = resolveOrCreateReplicatedUser(from.homeUserId, from.homeInstance, db, { username: event.friendship.fromProfile?.username }); + const fromUserResolved = resolveOrCreateReplicatedUser(from.homeUserId, from.homeInstance, db, { username: event.friendship.fromProfile?.username, status: event.friendship.fromProfile?.status }); if (!fromUserResolved) { // Sender's identity has been deleted — silently accept to drop the event accepted.push(event.messageId); @@ -4764,7 +4772,7 @@ function processFriendRequestUpdateEvent( } // Resolve the recipient (create stub if needed — they're on the remote instance) - const toUser = resolveOrCreateReplicatedUser(to.homeUserId, to.homeInstance, db, { username: event.friendship.toProfile?.username }); + const toUser = resolveOrCreateReplicatedUser(to.homeUserId, to.homeInstance, db, { username: event.friendship.toProfile?.username, status: event.friendship.toProfile?.status }); if (!toUser) { // Recipient's identity has been deleted — accept idempotently to drop the event accepted.push(event.messageId); @@ -4904,14 +4912,14 @@ async function processFriendAddEvent( } // Resolve both users (create stubs if needed) and hydrate with profile data - const fromUserResolved = resolveOrCreateReplicatedUser(from.homeUserId, from.homeInstance, db, { username: event.friendship.fromProfile?.username }); + const fromUserResolved = resolveOrCreateReplicatedUser(from.homeUserId, from.homeInstance, db, { username: event.friendship.fromProfile?.username, status: event.friendship.fromProfile?.status }); if (!fromUserResolved) { // One party's identity is deleted — accept idempotently to drop the event accepted.push(event.messageId); return; } let fromUser = await hydrateReplicatedUserProfile(fromUserResolved, event.friendship.fromProfile, db); - const toUserResolved = resolveOrCreateReplicatedUser(to.homeUserId, to.homeInstance, db, { username: event.friendship.toProfile?.username }); + const toUserResolved = resolveOrCreateReplicatedUser(to.homeUserId, to.homeInstance, db, { username: event.friendship.toProfile?.username, status: event.friendship.toProfile?.status }); if (!toUserResolved) { accepted.push(event.messageId); return; diff --git a/packages/server/src/routes/social.ts b/packages/server/src/routes/social.ts index 6244626d..39b07331 100644 --- a/packages/server/src/routes/social.ts +++ b/packages/server/src/routes/social.ts @@ -21,6 +21,11 @@ import type { import { sanitizeUser } from '../utils/sanitize.js'; function buildProfileSnapshot(user: typeof schema.users.$inferSelect): FederationRelayProfileSnapshot { + // Only meaningful for native users (us). Replicated stubs carry stale status + // their home owns — emitting it would flap remote UIs on relay receipt. + const status = !user.homeInstance && user.status + ? (user.status as 'online' | 'idle' | 'dnd' | 'offline') + : null; return { username: user.username ?? null, displayName: user.displayName ?? null, @@ -28,6 +33,7 @@ function buildProfileSnapshot(user: typeof schema.users.$inferSelect): Federatio avatarColor: user.avatarColor ?? null, banner: user.banner ?? null, bio: user.bio ?? null, + status, }; } @@ -237,7 +243,7 @@ async function handleFederatedFriendRequest( } // 5. Resolve / hydrate stub - const stub = resolveOrCreateReplicatedUser(lookup.homeUserId, targetDomain, db, { username: lookup.username }); + const stub = resolveOrCreateReplicatedUser(lookup.homeUserId, targetDomain, db, { username: lookup.username, status: lookup.profile.status }); if (!stub) { // Tombstoned identity — refuse to resurrect. return reply.code(404).send({ error: 'user_not_found', statusCode: 404, domain: targetDomain, handle: baseName }); @@ -308,6 +314,7 @@ async function handleFederatedFriendRequest( avatarColor: lookup.profile.avatarColor, banner: lookup.profile.banner, bio: lookup.profile.bio, + status: lookup.profile.status ?? null, }, status: 'pending', createdAt: now, diff --git a/packages/server/src/utils/federationOutbox.ts b/packages/server/src/utils/federationOutbox.ts index cac6f5cf..1b286b43 100644 --- a/packages/server/src/utils/federationOutbox.ts +++ b/packages/server/src/utils/federationOutbox.ts @@ -346,6 +346,7 @@ export function getDmParticipants(dmChannelId: string): FederationRelayParticipa displayName: schema.users.displayName, avatar: schema.users.avatar, avatarColor: schema.users.avatarColor, + status: schema.users.status, }) .from(schema.dmMembers) .innerJoin(schema.users, eq(schema.dmMembers.userId, schema.users.id)) @@ -362,6 +363,9 @@ export function getDmParticipants(dmChannelId: string): FederationRelayParticipa displayName: m.displayName ?? null, avatar: m.avatar ?? null, avatarColor: m.avatarColor ?? null, + // Only carry presence for native participants — replicated stubs hold + // stale status owned by their home; emitting it would flap remote UIs. + status: !m.homeInstance ? (m.status as 'online' | 'idle' | 'dnd' | 'offline' | null) : null, }, })); } diff --git a/packages/server/src/utils/federationStubBackfill.ts b/packages/server/src/utils/federationStubBackfill.ts index 288e864b..7d2c9466 100644 --- a/packages/server/src/utils/federationStubBackfill.ts +++ b/packages/server/src/utils/federationStubBackfill.ts @@ -74,10 +74,17 @@ export async function backfillStubUsernamesForPeer(peerOrigin: string): Promise< // Fill displayName from result.profile if the stub has none, mirroring the // displayName ?? username fallback applied at hydrate / profile_update time. - const updates: { username: string; displayName?: string } = { username: newUsername }; + const updates: { username: string; displayName?: string; status?: 'online' | 'idle' | 'dnd' | 'offline' } = { username: newUsername }; if (!stub.displayName) { updates.displayName = result.profile.displayName ?? result.username; } + // Heal status too — same root issue (stub was seeded offline at creation + // because the wire snapshot pre-dated the status field). Only overwrite + // when the lookup tells us something specific; keep the stub's current + // value otherwise. + if (result.profile.status && result.profile.status !== stub.status) { + updates.status = result.profile.status; + } db.update(schema.users) .set(updates) diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 00e4666a..bb35d68d 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -967,6 +967,15 @@ export interface FederationRelayProfileSnapshot { avatarColor?: string | null; banner?: string | null; bio?: string | null; + // Current presence at the moment the snapshot was built. Optional for + // backwards compatibility with peers that pre-date the field. Receivers use + // this to seed the stub's status at creation time, so a freshly-friended + // remote user shows their actual current state instead of defaulting to + // 'offline' until the next presence_update arrives. presence_update is + // ephemeral and fires only on transitions, so without this field an + // already-online remote stays stuck at 'offline' on the receiver until they + // next change status. + status?: 'online' | 'idle' | 'dnd' | 'offline' | null; } export interface FederationProfileUpdatePayload { @@ -1077,6 +1086,9 @@ export interface FederationUserLookupProfile { avatarColor: AvatarColor | null; banner: string | null; bio: string | null; + // Carried so the requester can seed the stub's status at creation time. + // Optional for backwards compat with peers that pre-date the field. + status?: 'online' | 'idle' | 'dnd' | 'offline' | null; } export type FederationUserLookupResponse = diff --git a/packages/web/src/stores/spaceStore.ts b/packages/web/src/stores/spaceStore.ts index cc66305d..2b6df0e2 100644 --- a/packages/web/src/stores/spaceStore.ts +++ b/packages/web/src/stores/spaceStore.ts @@ -568,11 +568,29 @@ export const useSpaceStore = create((set, get) => ({ }, updateMemberPresence: (userId: string, status: string) => { - set((state) => ({ - members: state.members.map(m => - m.userId === userId ? { ...m, user: { ...m.user, status: status as 'online' | 'idle' | 'dnd' | 'offline' } } : m - ), - })); + set((state) => { + const typedStatus = status as 'online' | 'idle' | 'dnd' | 'offline'; + // Mirror the status into the userViews cache so any component reading via + // useCanonicalUserView (e.g. the FriendItem avatar dot) re-renders with + // fresh status — not just spaceStore.members which only feeds space UIs. + // Match by user.id and user.homeUserId to catch both native rows and + // replicated stubs whose canonicalUserKey resolves to the canonical id. + let nextUserViews = state.userViews; + for (const [key, entry] of state.userViews) { + const u = entry.user; + if (u.id === userId || u.homeUserId === userId) { + if (nextUserViews === state.userViews) nextUserViews = new Map(state.userViews); + nextUserViews.set(key, { ...entry, user: { ...u, status: typedStatus } }); + } + } + + return { + members: state.members.map(m => + m.userId === userId ? { ...m, user: { ...m.user, status: typedStatus } } : m + ), + userViews: nextUserViews, + }; + }); }, updateUserEverywhere: (user: User) => {