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

@@ -23,6 +23,7 @@ import ReadStates from '@app/features/read_state/state/ReadStates';
import QuickSwitcher from '@app/features/search/state/QuickSwitcher';
import Nagbar from '@app/features/ui/state/Nagbar';
import UserGuildSettings from '@app/features/user/state/UserGuildSettings';
import UserProfile from '@app/features/user/state/UserProfile';
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
import {FAVORITES_GUILD_ID} from '@fluxer/constants/src/AppConstants';
@@ -51,6 +52,7 @@ export function handleGuildCreate(data: GuildReadyData, _context: GatewayHandler
GuildCount.handleGuildCreate(data);
if (!isSync) {
MemberSidebar.handleGuildCreate(data.id);
UserProfile.handleGuildCreate();
}
if (!data.unavailable) {
Channels.handleGuildCreate(data);
@@ -20,6 +20,7 @@ import MentionFeed from '@app/features/notification/state/MentionFeed';
import Permission from '@app/features/permissions/state/Permission';
import Presence from '@app/features/presence/state/Presence';
import QuickSwitcher from '@app/features/search/state/QuickSwitcher';
import UserProfile from '@app/features/user/state/UserProfile';
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
import Webhooks from '@app/features/webhook/state/Webhooks';
import type {Guild} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
@@ -52,5 +53,6 @@ export function handleGuildDelete(data: GuildDeletePayload, _context: GatewayHan
Messages.handleGuildUnavailable(data.id, data.unavailable ?? false);
Messages.handleCleanup();
MentionFeed.handleGuildDelete(data.id);
UserProfile.handleGuildDelete(data.unavailable);
QuickSwitcher.recomputeIfOpen();
}
@@ -6,6 +6,7 @@ import GuildMembers from '@app/features/member/state/GuildMembers';
import MemberSearch from '@app/features/member/state/MemberSearch';
import Permission from '@app/features/permissions/state/Permission';
import Presence from '@app/features/presence/state/Presence';
import UserProfile from '@app/features/user/state/UserProfile';
import Users from '@app/features/user/state/Users';
import type {GuildMemberData} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
import type {User} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
@@ -21,4 +22,5 @@ export function handleGuildMemberAdd(data: GuildMemberAddPayload, _context: Gate
GuildVerification.handleGuildMemberUpdate(data.guild_id);
Presence.handleGuildMemberAdd(data.guild_id, data.user.id);
MemberSearch.handleMemberAdd(data.guild_id, data.user.id);
UserProfile.handleGuildMemberAdd(data.user.id);
}
@@ -5,6 +5,7 @@ import GuildMembers from '@app/features/member/state/GuildMembers';
import MemberSearch from '@app/features/member/state/MemberSearch';
import Permission from '@app/features/permissions/state/Permission';
import Presence from '@app/features/presence/state/Presence';
import UserProfile from '@app/features/user/state/UserProfile';
import Users from '@app/features/user/state/Users';
import type {UserPartial} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
@@ -19,4 +20,5 @@ export function handleGuildMemberRemove(data: GuildMemberRemovePayload, _context
GuildMembers.handleMemberRemove(data.guild_id, data.user.id);
Permission.handleGuildMemberUpdate(data.user.id);
Presence.handleGuildMemberRemove(data.guild_id, data.user.id);
UserProfile.handleGuildMemberRemove(data.user.id);
}
@@ -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();
});
});