diff --git a/fluxer_app/src/components/channel/ChannelMembers.tsx b/fluxer_app/src/components/channel/ChannelMembers.tsx index e67b5df7..ab71ed68 100644 --- a/fluxer_app/src/components/channel/ChannelMembers.tsx +++ b/fluxer_app/src/components/channel/ChannelMembers.tsx @@ -20,6 +20,7 @@ import styles from '@app/components/channel/ChannelMembers.module.css'; import {MemberListContainer} from '@app/components/channel/MemberListContainer'; import {MemberListItem} from '@app/components/channel/MemberListItem'; +import {MemberListUnavailableFallback} from '@app/components/channel/shared/MemberListUnavailableFallback'; import {OutlineFrame} from '@app/components/layout/OutlineFrame'; import {resolveMemberListPresence} from '@app/hooks/useMemberListPresence'; import {useMemberListSubscription} from '@app/hooks/useMemberListSubscription'; @@ -34,6 +35,7 @@ import type {GroupDMMemberGroup} from '@app/utils/MemberListUtils'; import * as MemberListUtils from '@app/utils/MemberListUtils'; import * as NicknameUtils from '@app/utils/NicknameUtils'; import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; +import {GuildOperations} from '@fluxer/constants/src/GuildConstants'; import {isOfflineStatus} from '@fluxer/constants/src/StatusConstants'; import {useLingui} from '@lingui/react/macro'; import clsx from 'clsx'; @@ -152,11 +154,12 @@ interface LazyMemberListProps { const LazyMemberList = observer(function LazyMemberList({guild, channel}: LazyMemberListProps) { const [subscribedRange, setSubscribedRange] = useState<[number, number]>(INITIAL_MEMBER_RANGE); + const memberListUpdatesDisabled = (guild.disabledOperations & GuildOperations.MEMBER_LIST_UPDATES) !== 0; const {subscribe} = useMemberListSubscription({ guildId: guild.id, channelId: channel.id, - enabled: true, + enabled: !memberListUpdatesDisabled, allowInitialUnfocusedLoad: true, }); @@ -181,6 +184,14 @@ const LazyMemberList = observer(function LazyMemberList({guild, channel}: LazyMe [subscribedRange, subscribe], ); + if (memberListUpdatesDisabled) { + return ( + + + + ); + } + if (isLoading) { return ( diff --git a/fluxer_app/src/stores/MemberSidebarStore.test.tsx b/fluxer_app/src/stores/MemberSidebarStore.test.tsx index 9969e513..4a4eabae 100644 --- a/fluxer_app/src/stores/MemberSidebarStore.test.tsx +++ b/fluxer_app/src/stores/MemberSidebarStore.test.tsx @@ -17,10 +17,14 @@ * along with Fluxer. If not, see . */ +import {GuildRecord} from '@app/records/GuildRecord'; import GuildMemberStore from '@app/stores/GuildMemberStore'; +import GuildStore from '@app/stores/GuildStore'; import MemberSidebarStore from '@app/stores/MemberSidebarStore'; import {buildMemberListLayout} from '@app/utils/MemberListLayout'; +import {GuildOperations} from '@fluxer/constants/src/GuildConstants'; import type {GuildMemberData} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas'; +import type {Guild} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas'; import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas'; import {beforeEach, describe, expect, test} from 'vitest'; @@ -60,10 +64,25 @@ function seedMembers(guildId: string, members: Array<{id: string; name: string}> } } +function createGuild(guildId: string, disabledOperations = 0): GuildRecord { + const guild: Guild = { + id: guildId, + name: `Guild ${guildId}`, + icon: null, + vanity_url_code: null, + owner_id: 'owner-1', + system_channel_id: null, + features: [], + disabled_operations: disabledOperations, + }; + return new GuildRecord(guild); +} + describe('MemberSidebarStore', () => { beforeEach(() => { MemberSidebarStore.handleSessionInvalidated(); GuildMemberStore.handleConnectionOpen([]); + GuildStore.guilds = {}; }); test('stores members by member index when sync includes group entries', () => { @@ -357,4 +376,39 @@ describe('MemberSidebarStore', () => { expect(Array.from(listState?.rows.keys() ?? [])).toEqual([0, 1, 2]); expect(Array.from(listState?.items.values() ?? []).map((item) => item.data.user.id)).toEqual(['u-1', 'u-2']); }); + + test('ignores list updates when member list updates are disabled for the guild', () => { + const guildId = 'guild-disabled-updates'; + const listId = 'list-disabled-updates'; + GuildStore.guilds[guildId] = createGuild(guildId, GuildOperations.MEMBER_LIST_UPDATES); + seedMembers(guildId, [{id: 'u-1', name: 'Alpha'}]); + + MemberSidebarStore.handleListUpdate({ + guildId, + listId, + memberCount: 1, + onlineCount: 1, + groups: [{id: 'online', count: 1}], + ops: [ + { + op: 'SYNC', + range: [0, 1], + items: [{group: {id: 'online', count: 1}}, {member: {user: {id: 'u-1'}}}], + }, + ], + }); + + expect(MemberSidebarStore.getList(guildId, listId)).toBeUndefined(); + }); + + test('treats subscribe attempts as no-op when member list updates are disabled for the guild', () => { + const guildId = 'guild-disabled-subscribe'; + const channelId = 'channel-disabled-subscribe'; + GuildStore.guilds[guildId] = createGuild(guildId, GuildOperations.MEMBER_LIST_UPDATES); + + MemberSidebarStore.subscribeToChannel(guildId, channelId, [[0, 99]]); + + expect(MemberSidebarStore.getList(guildId, channelId)).toBeUndefined(); + expect(MemberSidebarStore.getSubscribedRanges(guildId, channelId)).toEqual([]); + }); }); diff --git a/fluxer_app/src/stores/MemberSidebarStore.tsx b/fluxer_app/src/stores/MemberSidebarStore.tsx index 0ca17204..b1139034 100644 --- a/fluxer_app/src/stores/MemberSidebarStore.tsx +++ b/fluxer_app/src/stores/MemberSidebarStore.tsx @@ -23,6 +23,7 @@ import {CustomStatusEmitter} from '@app/lib/CustomStatusEmitter'; import {Logger} from '@app/lib/Logger'; import type {GuildMemberRecord} from '@app/records/GuildMemberRecord'; import GuildMemberStore from '@app/stores/GuildMemberStore'; +import GuildStore from '@app/stores/GuildStore'; import GatewayConnectionStore from '@app/stores/gateway/GatewayConnectionStore'; import WindowStore from '@app/stores/WindowStore'; import { @@ -31,6 +32,7 @@ import { getTotalMemberCount, getTotalRowsFromLayout, } from '@app/utils/MemberListLayout'; +import {GuildOperations} from '@fluxer/constants/src/GuildConstants'; import type {StatusType} from '@fluxer/constants/src/StatusConstants'; import {StatusTypes} from '@fluxer/constants/src/StatusConstants'; import {makeAutoObservable} from 'mobx'; @@ -165,6 +167,10 @@ class MemberSidebarStore { ops: Array; }): void { const {guildId, listId, channelId, memberCount, onlineCount, groups, ops} = params; + if (this.isMemberListUpdatesDisabled(guildId)) { + return; + } + const storageKey = listId; const existingGuildLists = this.lists[guildId] ?? {}; const guildLists: Record = {...existingGuildLists}; @@ -445,6 +451,10 @@ class MemberSidebarStore { } subscribeToChannel(guildId: string, channelId: string, ranges: Array<[number, number]>): void { + if (this.isMemberListUpdatesDisabled(guildId)) { + return; + } + const storageKey = this.resolveListKey(guildId, channelId); const socket = GatewayConnectionStore.socket; @@ -564,6 +574,14 @@ class MemberSidebarStore { return listState.customStatuses.get(userId) ?? null; } + private isMemberListUpdatesDisabled(guildId: string): boolean { + const guild = GuildStore.getGuild(guildId); + if (!guild) { + return false; + } + return (guild.disabledOperations & GuildOperations.MEMBER_LIST_UPDATES) !== 0; + } + private touchList(guildId: string, listId: string): void { const now = Date.now(); if (!this.lastAccess[guildId]) { diff --git a/fluxer_gateway/src/guild/guild_dispatch.erl b/fluxer_gateway/src/guild/guild_dispatch.erl index 2dbe672a..aced1d91 100644 --- a/fluxer_gateway/src/guild/guild_dispatch.erl +++ b/fluxer_gateway/src/guild/guild_dispatch.erl @@ -23,7 +23,8 @@ decorate_member_data/3, extract_member_for_event/3, collect_and_send_push_notifications/3, - normalize_event/1 + normalize_event/1, + is_member_list_updates_enabled/1 ]). -type guild_state() :: map(). diff --git a/fluxer_gateway/src/guild/guild_subscription_handler.erl b/fluxer_gateway/src/guild/guild_subscription_handler.erl index 15df38d2..9c7e93bc 100644 --- a/fluxer_gateway/src/guild/guild_subscription_handler.erl +++ b/fluxer_gateway/src/guild/guild_subscription_handler.erl @@ -22,6 +22,10 @@ handle_cast/2 ]). +-ifdef(TEST). +-include_lib("eunit/include/eunit.hrl"). +-endif. + -type guild_state() :: map(). -type user_id() :: integer(). -type session_id() :: binary(). @@ -43,30 +47,36 @@ handle_cast({very_large_guild_member_list_deliver, Deliveries}, State) when is_l -spec handle_lazy_subscribe(map(), guild_state()) -> {reply, ok, guild_state()}. handle_lazy_subscribe(Request, State) -> - case maps:get(disable_member_list_updates, State, false) of + #{session_id := SessionId, channel_id := ChannelId, ranges := Ranges} = Request, + case should_ignore_member_list_subscribe(Ranges, State) of true -> {reply, ok, State}; false -> - #{session_id := SessionId, channel_id := ChannelId, ranges := Ranges} = Request, - Sessions0 = maps:get(sessions, State, #{}), - SessionUserId = get_session_user_id(SessionId, Sessions0), - case - is_integer(SessionUserId) andalso - guild_permissions:can_view_channel(SessionUserId, ChannelId, undefined, State) - of - true -> - GuildId = maps:get(id, State), - ListId = guild_member_list:calculate_list_id(ChannelId, State), - {NewState, ShouldSendSync, NormalizedRanges} = - guild_member_list:subscribe_ranges(SessionId, ListId, Ranges, State), - handle_lazy_subscribe_sync( - ShouldSendSync, NormalizedRanges, GuildId, ListId, ChannelId, SessionId, NewState - ); - false -> - {reply, ok, State} - end + Sessions0 = maps:get(sessions, State, #{}), + SessionUserId = get_session_user_id(SessionId, Sessions0), + case + is_integer(SessionUserId) andalso + guild_permissions:can_view_channel(SessionUserId, ChannelId, undefined, State) + of + true -> + GuildId = maps:get(id, State), + ListId = guild_member_list:calculate_list_id(ChannelId, State), + {NewState, ShouldSendSync, NormalizedRanges} = + guild_member_list:subscribe_ranges(SessionId, ListId, Ranges, State), + handle_lazy_subscribe_sync( + ShouldSendSync, NormalizedRanges, GuildId, ListId, ChannelId, SessionId, NewState + ); + false -> + {reply, ok, State} + end end. +-spec should_ignore_member_list_subscribe(list(), guild_state()) -> boolean(). +should_ignore_member_list_subscribe([], _State) -> + false; +should_ignore_member_list_subscribe(_Ranges, State) -> + not guild_dispatch:is_member_list_updates_enabled(State). + -spec handle_lazy_subscribe_sync( boolean(), list(), integer(), term(), channel_id(), session_id(), guild_state() ) -> @@ -325,3 +335,29 @@ can_session_view_channel(SessionData, ChannelId, State) -> _ -> false end. + +-ifdef(TEST). + +-spec disabled_operations_state(integer() | binary()) -> guild_state(). +disabled_operations_state(Value) -> + #{data => #{<<"guild">> => #{<<"disabled_operations">> => Value}}}. + +should_ignore_member_list_subscribe_ignores_non_empty_ranges_when_disabled_test() -> + ?assertEqual( + true, + should_ignore_member_list_subscribe( + [{0, 99}], + disabled_operations_state(1 bsl 6) + ) + ). + +should_ignore_member_list_subscribe_allows_empty_ranges_when_disabled_test() -> + ?assertEqual( + false, + should_ignore_member_list_subscribe( + [], + disabled_operations_state(1 bsl 6) + ) + ). + +-endif.