fix(app): refresh mutual communities on membership change (#2559)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-06 22:38:31 +02:00
1 parent 00c1b19809
commit 6c0ce9369b
6 files changed
+126 -32

No files matched your search

@@ -24,26 +24,30 @@ class UserProfile {
}
handleGatewayReady(): void {
Object.values(this.profileTimeouts).forEach(clearTimeout);
this.profiles = {};
this.profileTimeouts = {};
this.clearAllProfiles();
}
handleProfileInvalidate(userId: string, guildId?: string): void {
const targetGuildId = guildId ?? ME;
this.clearProfileTimeout(userId, targetGuildId);
const userProfiles = this.profiles[userId];
if (!userProfiles) return;
const {[targetGuildId]: _, ...remainingGuildProfiles} = userProfiles;
if (Object.keys(remainingGuildProfiles).length === 0) {
const {[userId]: __, ...remainingProfiles} = this.profiles;
this.profiles = remainingProfiles;
} else {
this.profiles = {
...this.profiles,
[userId]: remainingGuildProfiles,
};
}
this.removeProfile(userId, targetGuildId);
}
handleGuildMemberAdd(userId: string): void {
this.clearUserProfiles(userId);
}
handleGuildMemberRemove(userId: string): void {
this.clearUserProfiles(userId);
}
handleGuildCreate(): void {
this.clearAllProfiles();
}
handleGuildDelete(unavailable?: boolean): void {
if (unavailable) return;
this.clearAllProfiles();
}
handleProfileCreate(profile: Profile): void {
@@ -82,6 +86,37 @@ class UserProfile {
this.profileTimeouts = updatedTimeouts;
}
private clearAllProfiles(): void {
Object.values(this.profileTimeouts).forEach(clearTimeout);
this.profiles = {};
this.profileTimeouts = {};
}
private clearUserProfiles(userId: string): void {
const userProfiles = this.profiles[userId];
if (!userProfiles) return;
for (const guildId of Object.keys(userProfiles)) {
this.clearProfileTimeout(userId, guildId);
}
const {[userId]: _, ...remainingProfiles} = this.profiles;
this.profiles = remainingProfiles;
}
private removeProfile(userId: string, guildId: string): void {
const userProfiles = this.profiles[userId];
if (!userProfiles) return;
const {[guildId]: _, ...remainingGuildProfiles} = userProfiles;
if (Object.keys(remainingGuildProfiles).length === 0) {
const {[userId]: __, ...remainingProfiles} = this.profiles;
this.profiles = remainingProfiles;
} else {
this.profiles = {
...this.profiles,
[userId]: remainingGuildProfiles,
};
}
}
private createTimeoutKey(userId: string, guildId: string): string {
return `${userId}:${guildId}`;
}
@@ -101,23 +136,8 @@ class UserProfile {
this.clearProfileTimeout(userId, guildId);
const timeout = setTimeout(() => {
runInAction(() => {
const userProfiles = this.profiles[userId];
if (!userProfiles) {
const {[timeoutKey]: _, ...remainingTimeouts} = this.profileTimeouts;
this.profileTimeouts = remainingTimeouts;
return;
}
const {[guildId]: _, ...remainingGuildProfiles} = userProfiles;
if (Object.keys(remainingGuildProfiles).length === 0) {
const {[userId]: __, ...remainingProfiles} = this.profiles;
this.profiles = remainingProfiles;
} else {
this.profiles = {
...this.profiles,
[userId]: remainingGuildProfiles,
};
}
const {[timeoutKey]: ___, ...remainingTimeouts} = this.profileTimeouts;
this.removeProfile(userId, guildId);
const {[timeoutKey]: _, ...remainingTimeouts} = this.profileTimeouts;
this.profileTimeouts = remainingTimeouts;
});
}, PROFILE_TIMEOUT_MS);
@@ -0,0 +1,66 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {Profile} from '@app/features/user/models/Profile';
import {afterEach, describe, expect, it, vi} from 'vitest';
vi.mock('@app/features/auth/state/Authentication', () => ({default: {currentUserId: null}}));
const {default: UserProfile} = await import('@app/features/user/state/UserProfile');
const GUILD_ID = '1000';
const TARGET_ID = '2000';
function cache(userId: string = TARGET_ID, guildId?: string): void {
UserProfile.handleProfileCreate({
userId,
guildId: guildId ?? null,
mutualGuilds: [{id: GUILD_ID, nick: null}],
} as unknown as Profile);
}
describe('UserProfile cache invalidation', () => {
afterEach(() => {
UserProfile.handleGatewayReady();
});
it('drops every cached scope for a member removed from a community', () => {
cache();
cache(TARGET_ID, GUILD_ID);
expect(UserProfile.getProfile(TARGET_ID)?.mutualGuilds).toHaveLength(1);
UserProfile.handleGuildMemberRemove(TARGET_ID);
expect(UserProfile.getProfile(TARGET_ID)).toBeNull();
expect(UserProfile.getProfile(TARGET_ID, GUILD_ID)).toBeNull();
});
it('drops every cached scope for a member who joined a community', () => {
cache();
UserProfile.handleGuildMemberAdd(TARGET_ID);
expect(UserProfile.getProfile(TARGET_ID)).toBeNull();
});
it('keeps other users cached when one member is removed', () => {
cache();
cache('3000');
UserProfile.handleGuildMemberRemove(TARGET_ID);
expect(UserProfile.getProfile(TARGET_ID)).toBeNull();
expect(UserProfile.getProfile('3000')).not.toBeNull();
});
it('drops cached profiles when the viewer leaves a community', () => {
cache();
UserProfile.handleGuildDelete(false);
expect(UserProfile.getProfile(TARGET_ID)).toBeNull();
});
it('keeps cached profiles when a community only goes unavailable', () => {
cache();
UserProfile.handleGuildDelete(true);
expect(UserProfile.getProfile(TARGET_ID)).not.toBeNull();
});
it('drops cached profiles when the viewer joins a community', () => {
cache();
UserProfile.handleGuildCreate();
expect(UserProfile.getProfile(TARGET_ID)).toBeNull();
});
});