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:
Jannis Braun
2026-05-05 16:44:22 +02:00
parent d2bd7987c1
commit 1effb1c53f
6 changed files with 75 additions and 19 deletions
+23 -5
View File
@@ -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) => {