From 6c0ce9369bbab4f61f04e8eb8bb45df8db437c87 Mon Sep 17 00:00:00 2001 From: Hampus Date: Sun, 6 Sep 2026 22:38:31 +0200 Subject: [PATCH] fix(app): refresh mutual communities on membership change (#2559) --- .../src/features/guild/events/GuildCreate.ts | 2 + .../src/features/guild/events/GuildDelete.ts | 2 + .../features/guild/events/GuildMemberAdd.ts | 2 + .../guild/events/GuildMemberRemove.ts | 2 + .../src/features/user/state/UserProfile.ts | 84 ++++++++++++------- .../UserProfileCacheInvalidation.test.ts | 66 +++++++++++++++ 6 files changed, 126 insertions(+), 32 deletions(-) create mode 100644 fluxer_app/src/features/user/state/UserProfileCacheInvalidation.test.ts diff --git a/fluxer_app/src/features/guild/events/GuildCreate.ts b/fluxer_app/src/features/guild/events/GuildCreate.ts index 8c0d04b0f..1ddb83662 100644 --- a/fluxer_app/src/features/guild/events/GuildCreate.ts +++ b/fluxer_app/src/features/guild/events/GuildCreate.ts @@ -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); diff --git a/fluxer_app/src/features/guild/events/GuildDelete.ts b/fluxer_app/src/features/guild/events/GuildDelete.ts index 4113bd603..8b9e0254a 100644 --- a/fluxer_app/src/features/guild/events/GuildDelete.ts +++ b/fluxer_app/src/features/guild/events/GuildDelete.ts @@ -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(); } diff --git a/fluxer_app/src/features/guild/events/GuildMemberAdd.ts b/fluxer_app/src/features/guild/events/GuildMemberAdd.ts index 127d45228..f338d4a27 100644 --- a/fluxer_app/src/features/guild/events/GuildMemberAdd.ts +++ b/fluxer_app/src/features/guild/events/GuildMemberAdd.ts @@ -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); } diff --git a/fluxer_app/src/features/guild/events/GuildMemberRemove.ts b/fluxer_app/src/features/guild/events/GuildMemberRemove.ts index 921f6012b..216d35c8d 100644 --- a/fluxer_app/src/features/guild/events/GuildMemberRemove.ts +++ b/fluxer_app/src/features/guild/events/GuildMemberRemove.ts @@ -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); } diff --git a/fluxer_app/src/features/user/state/UserProfile.ts b/fluxer_app/src/features/user/state/UserProfile.ts index 75354d071..f76be05c4 100644 --- a/fluxer_app/src/features/user/state/UserProfile.ts +++ b/fluxer_app/src/features/user/state/UserProfile.ts @@ -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); diff --git a/fluxer_app/src/features/user/state/UserProfileCacheInvalidation.test.ts b/fluxer_app/src/features/user/state/UserProfileCacheInvalidation.test.ts new file mode 100644 index 000000000..d471d6743 --- /dev/null +++ b/fluxer_app/src/features/user/state/UserProfileCacheInvalidation.test.ts @@ -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(); + }); +});