fix: live presence on freshly-friended remotes + green dot in same session
Two follow-on bugs from the initial S2S presence rollout:
(1) New friend stuck offline until they reload: presence_update fires only on
transitions, so a remote user already online when their stub is created
locally never receives a relay event seeding their actual status. The
stub defaulted to 'offline' at creation and stayed there until the next
transition. Fix: extend FederationRelayProfileSnapshot +
FederationUserLookupProfile with status. Sender-side buildProfileSnapshot,
getDmParticipants, and lookup endpoint responses populate it for native
users only (replicated stubs hold stale status owned elsewhere).
resolveOrCreateReplicatedUser uses hints.status to seed the new row's
status column. Threaded through every call site (DM participants, group
bootstrap, friend events, ownership transfer). Stub backfill worker also
heals existing rows whose status was stuck at 'offline' from creation.
(2) 'Online' text updates but green avatar dot stays grey on the same page:
spaceStore.updateMemberPresence patches members[] (which feeds space UIs)
but never patches userViews — the cache useCanonicalUserView reads from.
The Avatar in FriendItem reads canonical.status; the text reads
friend.status (socialStore). Two sources, one stale until full
user_updated arrives. Fix: updateMemberPresence now mirrors status into
matching userViews entries, so canonical-view consumers re-render with
fresh status the moment the WS event lands.
This commit is contained in:
@@ -2330,6 +2330,7 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
|
||||
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<void> {
|
||||
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<typeof getDb>,
|
||||
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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -568,11 +568,29 @@ export const useSpaceStore = create<SpaceState>((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) => {
|
||||
|
||||
Reference in New Issue
Block a user