diff --git a/fluxer_app/src/features/guild/events/GuildMemberListUpdate.ts b/fluxer_app/src/features/guild/events/GuildMemberListUpdate.ts index 31d8633dd..b7e1b5023 100644 --- a/fluxer_app/src/features/guild/events/GuildMemberListUpdate.ts +++ b/fluxer_app/src/features/guild/events/GuildMemberListUpdate.ts @@ -81,10 +81,7 @@ export function handleGuildMemberListUpdate(data: GuildMemberListUpdatePayload, validOps.push({ op: 'SYNC', range: op.range, - items: op.items.map((item: MemberListItem) => ({ - member: item.member, - group: item.group, - })), + items: op.items, }); } if (validOps.length === 0 && ops.length > 0) { diff --git a/fluxer_app/src/features/member/hooks/useMemberListSubscription.ts b/fluxer_app/src/features/member/hooks/useMemberListSubscription.ts index 54519ce9f..42a446422 100644 --- a/fluxer_app/src/features/member/hooks/useMemberListSubscription.ts +++ b/fluxer_app/src/features/member/hooks/useMemberListSubscription.ts @@ -28,13 +28,12 @@ interface MemberListSubscriptionControl { verifyHydration: () => void; } -type MemberListSubscriptionPhase = 'offline' | 'hydrating' | 'settled' | 'freshSession'; +type MemberListSubscriptionPhase = 'offline' | 'hydrating' | 'settled'; const INITIAL_MEMBER_LIST_SUBSCRIPTION_RANGES = normalizeMemberListRanges([[0, MEMBER_LIST_RANGE_MAX_SPAN]]); +const MEMBER_LIST_SUBSCRIPTION_SETTLE_MS = 50; const MEMBER_LIST_RESUBSCRIBE_DELAY_MS = 1000; const MEMBER_LIST_MAX_RESUBSCRIBE_ATTEMPTS = 3; -const MEMBER_LIST_RECOVERY_DELAY_MS = 3000; -const MEMBER_LIST_MAX_RECOVERY_ATTEMPTS = 2; const logger = new Logger('useMemberListSubscription'); let nextMemberListSubscriptionOwnerId = 0; @@ -56,35 +55,81 @@ export function useMemberListSubscription({ }: UseMemberListSubscriptionOptions): UseMemberListSubscriptionResult { const [ownerId] = useState(createMemberListSubscriptionOwnerId); const desiredRangesRef = useRef(INITIAL_MEMBER_LIST_SUBSCRIPTION_RANGES); + const pendingDesiredRangesRef = useRef(null); + const settleTimerRef = useRef(null); const controlRef = useRef(null); + const clearSettleTimer = useCallback(() => { + if (settleTimerRef.current == null) { + return; + } + window.clearTimeout(settleTimerRef.current); + settleTimerRef.current = null; + }, []); + + const sendWithoutControl = useCallback( + (nextDesiredRanges: NormalizedMemberListRanges) => { + if (!MemberSidebar.isActiveMemberListSubscriptionOwner(guildId, channelId, ownerId)) { + MemberSidebar.claimMemberListSubscription(guildId, channelId, ownerId); + } + MemberSidebar.subscribeToChannel(guildId, channelId, nextDesiredRanges, ownerId); + }, + [guildId, channelId, ownerId], + ); + + const commitDesiredRanges = useCallback(() => { + settleTimerRef.current = null; + const nextDesiredRanges = pendingDesiredRangesRef.current; + pendingDesiredRangesRef.current = null; + if (nextDesiredRanges == null) { + return; + } + desiredRangesRef.current = nextDesiredRanges; + const control = controlRef.current; + if (control != null) { + control.handleDesiredRanges(true); + return; + } + sendWithoutControl(nextDesiredRanges); + }, [sendWithoutControl]); + const subscribe = useCallback( (ranges: MemberListRanges) => { if (!enabled) { return; } const nextDesiredRanges = resolveDesiredRanges(ranges); - const desiredRangesChanged = !areNormalizedMemberListRangesEqual(desiredRangesRef.current, nextDesiredRanges); - desiredRangesRef.current = nextDesiredRanges; - const control = controlRef.current; - if (control != null) { - control.handleDesiredRanges(desiredRangesChanged); - if (!desiredRangesChanged) { + if (areNormalizedMemberListRangesEqual(desiredRangesRef.current, nextDesiredRanges)) { + pendingDesiredRangesRef.current = null; + clearSettleTimer(); + const control = controlRef.current; + if (control != null) { + control.handleDesiredRanges(false); control.verifyHydration(); + return; } + sendWithoutControl(desiredRangesRef.current); return; } - if (!MemberSidebar.isActiveMemberListSubscriptionOwner(guildId, channelId, ownerId)) { - MemberSidebar.claimMemberListSubscription(guildId, channelId, ownerId); - } - MemberSidebar.subscribeToChannel(guildId, channelId, desiredRangesRef.current, ownerId); + pendingDesiredRangesRef.current = nextDesiredRanges; + clearSettleTimer(); + settleTimerRef.current = window.setTimeout(commitDesiredRanges, MEMBER_LIST_SUBSCRIPTION_SETTLE_MS); }, - [guildId, channelId, enabled, ownerId], + [enabled, clearSettleTimer, commitDesiredRanges, sendWithoutControl], ); useEffect(() => { desiredRangesRef.current = INITIAL_MEMBER_LIST_SUBSCRIPTION_RANGES; - }, [guildId, channelId]); + pendingDesiredRangesRef.current = null; + clearSettleTimer(); + }, [guildId, channelId, enabled, clearSettleTimer]); + + useEffect(() => { + return () => { + pendingDesiredRangesRef.current = null; + clearSettleTimer(); + }; + }, [clearSettleTimer]); useEffect(() => { if (!enabled) { @@ -94,8 +139,6 @@ export function useMemberListSubscription({ let reconciliationScheduled = false; let retryTimer: number | null = null; let hydrationGeneration = 0; - let freshSessionResetId: string | null = null; - let recoveryAttemptCount = 0; let resubscribeAttemptCount = 0; let phase: MemberListSubscriptionPhase = 'offline'; @@ -122,13 +165,12 @@ export function useMemberListSubscription({ function beginRecoveryDemand(): void { clearRetryTimer(); hydrationGeneration += 1; - recoveryAttemptCount = 0; resubscribeAttemptCount = 0; } function resendStaleSubscription(): void { clearRetryTimer(); - if (disposed || phase === 'freshSession' || !ownsSubscription()) { + if (disposed || !ownsSubscription()) { return; } if (!gatewayAvailable()) { @@ -141,11 +183,11 @@ export function useMemberListSubscription({ channelId, attempt: resubscribeAttemptCount, }); - sendDesiredRequest(); + sendDesiredRequest(true); } function scheduleResubscribe(): void { - if (retryTimer != null) { + if (retryTimer != null || resubscribeAttemptCount >= MEMBER_LIST_MAX_RESUBSCRIBE_ATTEMPTS) { return; } const scheduledGeneration = hydrationGeneration; @@ -158,76 +200,23 @@ export function useMemberListSubscription({ }, MEMBER_LIST_RESUBSCRIBE_DELAY_MS); } - function scheduleHydrationRecovery(): void { - if (resubscribeAttemptCount < MEMBER_LIST_MAX_RESUBSCRIBE_ATTEMPTS) { - scheduleResubscribe(); - return; - } - scheduleSessionReplacement(); - } - - function replaceStaleSession(): void { - clearRetryTimer(); - if ( - disposed || - recoveryAttemptCount >= MEMBER_LIST_MAX_RECOVERY_ATTEMPTS || - phase === 'freshSession' || - !ownsSubscription() - ) { - return; - } - const socket = GatewayConnection.socket; - if (!gatewayAvailable() || socket == null) { - phase = 'offline'; - return; - } - freshSessionResetId = GatewayConnection.sessionId; - if (freshSessionResetId == null) { - phase = 'offline'; - return; - } - recoveryAttemptCount += 1; - phase = 'freshSession'; - logger.warn('Member list hydration stalled; replacing the stale Gateway session', {guildId, channelId}); - socket.reset(true); - } - - function scheduleSessionReplacement(): void { - if (recoveryAttemptCount >= MEMBER_LIST_MAX_RECOVERY_ATTEMPTS || retryTimer != null) { - return; - } - const scheduledGeneration = hydrationGeneration; - retryTimer = window.setTimeout(() => { - retryTimer = null; - if (scheduledGeneration !== hydrationGeneration) { - return; - } - replaceStaleSession(); - }, MEMBER_LIST_RECOVERY_DELAY_MS); - } - function verifyHydration(): void { if (disposed || !ownsSubscription()) { return; } - if (phase === 'freshSession') { - return; - } if (hasHydratedDesiredRanges()) { clearRetryTimer(); - recoveryAttemptCount = 0; resubscribeAttemptCount = 0; - freshSessionResetId = null; phase = 'settled'; return; } if (phase === 'settled' || phase === 'hydrating') { phase = 'hydrating'; - scheduleHydrationRecovery(); + scheduleResubscribe(); } } - function sendDesiredRequest(): void { + function sendDesiredRequest(forceResend = false): void { clearRetryTimer(); if (disposed || !gatewayAvailable()) { phase = 'offline'; @@ -236,7 +225,14 @@ export function useMemberListSubscription({ if (!ownsSubscription()) { MemberSidebar.claimMemberListSubscription(guildId, channelId, ownerId); } - if (!MemberSidebar.retryChannelSubscription(guildId, channelId, desiredRangesRef.current, ownerId)) { + const reachedWire = MemberSidebar.retryChannelSubscription( + guildId, + channelId, + desiredRangesRef.current, + ownerId, + forceResend, + ); + if (!reachedWire && !ownsSubscription()) { phase = 'offline'; return; } @@ -267,9 +263,6 @@ export function useMemberListSubscription({ if (!changed && !reclaimedOwnership) { return; } - if (phase === 'freshSession') { - return; - } sendDesiredRequest(); } @@ -277,14 +270,6 @@ export function useMemberListSubscription({ if (disposed || !gatewayAvailable()) { return; } - if ( - phase === 'freshSession' && - freshSessionResetId != null && - GatewayConnection.sessionId === freshSessionResetId - ) { - return; - } - freshSessionResetId = null; if (!ownsSubscription()) { if (MemberSidebar.hasActiveMemberListSubscription()) { return; @@ -324,9 +309,7 @@ export function useMemberListSubscription({ if (!isAvailable) { clearRetryTimer(); MemberSidebar.handleGatewayDisconnected(); - if (phase !== 'freshSession') { - phase = 'offline'; - } + phase = 'offline'; return; } scheduleReconciliation(); diff --git a/fluxer_app/src/features/member/state/MemberSidebar.ts b/fluxer_app/src/features/member/state/MemberSidebar.ts index b43626b83..29f84758c 100644 --- a/fluxer_app/src/features/member/state/MemberSidebar.ts +++ b/fluxer_app/src/features/member/state/MemberSidebar.ts @@ -5,12 +5,13 @@ import Channels from '@app/features/channel/state/Channels'; import GatewayConnection from '@app/features/gateway/transport/GatewayConnection'; import Guilds from '@app/features/guild/state/Guilds'; import {GuildMember} from '@app/features/member/models/GuildMember'; -import {getHydratedMemberListRanges} from '@app/features/member/utils/MemberListHydration'; +import {getHydratedMemberListRangesFromNormalized} from '@app/features/member/utils/MemberListHydration'; +import {deriveMemberListIdentity} from '@app/features/member/utils/MemberListIdentity'; import { buildMemberListLayout, - getGroupLayoutForRow, getTotalMemberCount, getTotalRowsFromLayout, + type MemberListGroupLayout, } from '@app/features/member/utils/MemberListLayout'; import { areNormalizedMemberListRangesCovered, @@ -23,12 +24,11 @@ import {Logger} from '@app/features/platform/utils/AppLogger'; import type {CustomStatus, GatewayCustomStatusPayload} from '@app/features/user/state/CustomStatus'; import {fromGatewayCustomStatus} from '@app/features/user/state/CustomStatus'; import {CustomStatusEmitter} from '@app/features/user/state/CustomStatusEmitter'; -import Window from '@app/features/window/state/Window'; -import {Permissions} from '@fluxer/constants/src/ChannelConstants'; import {GuildOperations} from '@fluxer/constants/src/GuildConstants'; import type {StatusType} from '@fluxer/constants/src/StatusConstants'; import {StatusTypes} from '@fluxer/constants/src/StatusConstants'; import type {GuildMemberData} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas'; +import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas'; import {makeAutoObservable, observable} from 'mobx'; interface MemberListGroup { @@ -38,7 +38,7 @@ interface MemberListGroup { interface MemberListMember { userId: string; - member: MemberListOperationMember; + member: GuildMemberData; } interface MemberListItem { @@ -53,6 +53,7 @@ interface MemberListState { groups: Array; rows: Map; items: Map; + membersByUserId: Map; requestedRanges: NormalizedMemberListRanges; subscribedRanges: NormalizedMemberListRanges; presences: Map; @@ -64,7 +65,7 @@ interface MemberListRow { type: 'group' | 'member'; group?: MemberListGroup; userId?: string; - member?: MemberListOperationMember; + member?: GuildMemberData; presence?: { status?: string; custom_status?: GatewayCustomStatusPayload | null; @@ -114,9 +115,6 @@ interface MemberListUpdateDemand { ownerId: string | null; } -const MEMBER_LIST_TTL_MS = 60 * 1000; -const MEMBER_LIST_CACHE_MAX_LISTS = 8; -const MEMBER_LIST_PRUNE_INTERVAL_MS = 30 * 1000; const MEMBER_LIST_UPDATE_BATCH_MS = 1000; const EMPTY_MEMBER_LIST_RANGES = normalizeMemberListRanges([]); @@ -142,6 +140,73 @@ function areCustomStatusesEqual( ); } +function areMemberUsersEqual(left: UserPartialResponse, right: UserPartialResponse): boolean { + return ( + left === right || + (left.id === right.id && + left.username === right.username && + left.discriminator === right.discriminator && + left.global_name === right.global_name && + left.avatar === right.avatar && + left.avatar_color === right.avatar_color && + left.bot === right.bot && + left.system === right.system && + left.flags === right.flags && + left.mention_flags === right.mention_flags) + ); +} + +function areMemberRolesEqual(left: ReadonlyArray, right: ReadonlyArray): boolean { + if (left === right) { + return true; + } + if (left.length !== right.length) { + return false; + } + for (let index = 0; index < left.length; index += 1) { + if (left[index] !== right[index]) { + return false; + } + } + return true; +} + +function areMemberIdentitiesEqual(left: GuildMemberData, right: GuildMemberData): boolean { + return ( + left === right || + (left.nick === right.nick && + left.avatar === right.avatar && + left.banner === right.banner && + left.accent_color === right.accent_color && + left.joined_at === right.joined_at && + left.mute === right.mute && + left.deaf === right.deaf && + left.communication_disabled_until === right.communication_disabled_until && + left.profile_flags === right.profile_flags && + left.mention_flags === right.mention_flags && + areMemberRolesEqual(left.roles, right.roles) && + areMemberUsersEqual(left.user, right.user)) + ); +} + +function areMemberListGroupsEqual( + left: ReadonlyArray, + right: ReadonlyArray, +): boolean { + if (left === right) { + return true; + } + if (left.length !== right.length) { + return false; + } + for (let index = 0; index < left.length; index += 1) { + if (left[index].id !== right[index].id || left[index].count !== right[index].count) { + return false; + } + } + return true; +} + function areRangesEqual(left?: Array<[number, number]>, right?: Array<[number, number]>): boolean { const leftRanges = left ?? []; const rightRanges = right ?? []; @@ -161,8 +226,7 @@ function areRangesEqual(left?: Array<[number, number]>, right?: Array<[number, n class MemberSidebar { private logger = new Logger('MemberSidebar'); lists: Record> = {}; - channelListIds: Record> = {}; - private permissionListIds: Record> = {}; + private wireListChannelIds: Record> = {}; private listSubscribedChannelIds: Record> = {}; private syncedMemberListGuildIds = new Set(); private sentMemberListGuildId: string | null = null; @@ -170,17 +234,16 @@ class MemberSidebar { private sentMemberListRanges: NormalizedMemberListRanges = EMPTY_MEMBER_LIST_RANGES; private gatewaySessionId: string | null = null; private activeMemberListSubscription: MemberListSubscription | null = null; - lastAccess: Record> = {}; - pruneIntervalId: number | null = null; sessionVersion = 0; memberListSubscriptionGeneration = 0; private materializedMemberCache = new WeakMap(); + private memberListItemCache = new WeakMap(); private pendingListUpdateBatches = new Map(); constructor() { makeAutoObservable< this, - | 'permissionListIds' + | 'wireListChannelIds' | 'listSubscribedChannelIds' | 'syncedMemberListGuildIds' | 'sentMemberListGuildId' @@ -188,15 +251,14 @@ class MemberSidebar { | 'sentMemberListRanges' | 'gatewaySessionId' | 'activeMemberListSubscription' - | 'lastAccess' - | 'pruneIntervalId' | 'materializedMemberCache' + | 'memberListItemCache' | 'pendingListUpdateBatches' >( this, { lists: observable.ref, - permissionListIds: false, + wireListChannelIds: false, listSubscribedChannelIds: false, syncedMemberListGuildIds: false, sentMemberListGuildId: false, @@ -204,14 +266,12 @@ class MemberSidebar { sentMemberListRanges: false, gatewaySessionId: false, activeMemberListSubscription: false, - lastAccess: false, - pruneIntervalId: false, materializedMemberCache: false, + memberListItemCache: false, pendingListUpdateBatches: false, }, {autoBind: true}, ); - this.startPruneInterval(); } synchronizeGatewaySession(sessionId: string | null): void { @@ -228,13 +288,11 @@ class MemberSidebar { handleSessionInvalidated(): void { this.clearPendingListUpdateBatches(); this.lists = {}; - this.channelListIds = {}; - this.permissionListIds = {}; + this.wireListChannelIds = {}; this.listSubscribedChannelIds = {}; this.syncedMemberListGuildIds.clear(); this.clearSentMemberListSubscription(); this.setActiveMemberListSubscription(null); - this.lastAccess = {}; this.sessionVersion += 1; } @@ -244,13 +302,9 @@ class MemberSidebar { const {[guildId]: _, ...remainingLists} = this.lists; this.lists = remainingLists; } - if (this.channelListIds[guildId]) { - const {[guildId]: _, ...remainingMappings} = this.channelListIds; - this.channelListIds = remainingMappings; - } - if (this.permissionListIds[guildId]) { - const {[guildId]: _, ...remainingPermissionMappings} = this.permissionListIds; - this.permissionListIds = remainingPermissionMappings; + if (this.wireListChannelIds[guildId]) { + const {[guildId]: _, ...remainingWireMappings} = this.wireListChannelIds; + this.wireListChannelIds = remainingWireMappings; } if (this.listSubscribedChannelIds[guildId]) { const {[guildId]: _, ...remainingSubscribedChannels} = this.listSubscribedChannelIds; @@ -263,10 +317,6 @@ class MemberSidebar { if (this.activeMemberListSubscription?.guildId === guildId) { this.setActiveMemberListSubscription(null); } - if (this.lastAccess[guildId]) { - const {[guildId]: _, ...remainingAccess} = this.lastAccess; - this.lastAccess = remainingAccess; - } } handleGuildCreate(guildId: string): void { @@ -275,13 +325,9 @@ class MemberSidebar { const {[guildId]: _, ...remainingLists} = this.lists; this.lists = remainingLists; } - if (this.channelListIds[guildId]) { - const {[guildId]: _, ...remainingMappings} = this.channelListIds; - this.channelListIds = remainingMappings; - } - if (this.permissionListIds[guildId]) { - const {[guildId]: _, ...remainingPermissionMappings} = this.permissionListIds; - this.permissionListIds = remainingPermissionMappings; + if (this.wireListChannelIds[guildId]) { + const {[guildId]: _, ...remainingWireMappings} = this.wireListChannelIds; + this.wireListChannelIds = remainingWireMappings; } if (this.listSubscribedChannelIds[guildId]) { const {[guildId]: _, ...remainingSubscribedChannels} = this.listSubscribedChannelIds; @@ -294,14 +340,10 @@ class MemberSidebar { if (this.activeMemberListSubscription?.guildId === guildId) { this.setActiveMemberListSubscription(null); } - if (this.lastAccess[guildId]) { - const {[guildId]: _, ...remainingAccess} = this.lastAccess; - this.lastAccess = remainingAccess; - } } handleListUpdate(params: MemberListUpdateParams): void { - const {guildId, listId, channelId} = params; + const {guildId} = params; if (this.isMemberListUpdatesDisabled(guildId)) { return; } @@ -309,18 +351,14 @@ class MemberSidebar { if (demand == null) { return; } - const existingGuildLists = this.lists[guildId] ?? {}; - const localStorageKey = channelId ? this.getLocalChannelListKey(guildId, channelId) : undefined; - const existingList = - existingGuildLists[listId] ?? - (localStorageKey ? existingGuildLists[localStorageKey] : undefined) ?? - (channelId ? existingGuildLists[channelId] : undefined); + const storageKey = this.resolveStorageKey(guildId, demand.channelId); + const existingList = this.lists[guildId]?.[storageKey]; if (!existingList?.hasReceivedInitialPayload || typeof window === 'undefined') { this.applyListUpdate(params, demand); return; } if (this.shouldBypassListUpdateBatchForCurrentUserPresence(params, existingList)) { - this.flushPendingListUpdateBatch(this.getListUpdateBatchKey(guildId, listId)); + this.flushPendingListUpdateBatch(this.getListUpdateBatchKey(guildId, storageKey)); this.applyListUpdate(params, demand); return; } @@ -331,8 +369,9 @@ class MemberSidebar { if (!this.isMemberListUpdateAccepted(demand, params)) { return; } - const {guildId, listId} = params; - const batchKey = this.getListUpdateBatchKey(guildId, listId); + const {guildId} = params; + const storageKey = this.resolveStorageKey(guildId, demand.channelId); + const batchKey = this.getListUpdateBatchKey(guildId, storageKey); let batch = this.pendingListUpdateBatches.get(batchKey); if ( batch != null && @@ -346,7 +385,7 @@ class MemberSidebar { if (!batch) { batch = { guildId, - listId, + listId: storageKey, timeoutId: window.setTimeout(() => this.flushPendingListUpdateBatch(batchKey), MEMBER_LIST_UPDATE_BATCH_MS), demand, updates: [], @@ -358,7 +397,6 @@ class MemberSidebar { } else { batch.updates[0] = this.mergeQueuedListUpdate(batch.updates[0], params); } - this.touchList(guildId, listId); } private flushPendingListUpdateBatch(batchKey: string): void { @@ -383,21 +421,14 @@ class MemberSidebar { } } - private clearPendingListUpdateBatch(guildId: string, listId: string): void { - const candidateListIds = new Set([listId, this.resolveListKey(guildId, listId)]); - const localStorageKey = this.getLocalChannelListKey(guildId, listId); - if (localStorageKey) { - candidateListIds.add(localStorageKey); - } - for (const candidateListId of candidateListIds) { - const batchKey = this.getListUpdateBatchKey(guildId, candidateListId); - const batch = this.pendingListUpdateBatches.get(batchKey); - if (!batch) { - continue; - } - window.clearTimeout(batch.timeoutId); - this.pendingListUpdateBatches.delete(batchKey); + private clearPendingListUpdateBatch(guildId: string, storageKey: string): void { + const batchKey = this.getListUpdateBatchKey(guildId, storageKey); + const batch = this.pendingListUpdateBatches.get(batchKey); + if (!batch) { + return; } + window.clearTimeout(batch.timeoutId); + this.pendingListUpdateBatches.delete(batchKey); } private getListUpdateBatchKey(guildId: string, listId: string): string { @@ -541,67 +572,46 @@ class MemberSidebar { if (this.isMemberListUpdatesDisabled(guildId)) { return; } - const storageKey = listId; + if (channelId != null) { + this.registerWireListChannel(guildId, listId, channelId); + } + const storageKey = this.resolveStorageKey(guildId, demand.channelId); const existingGuildLists = this.lists[guildId] ?? {}; const guildLists: Record = {...existingGuildLists}; - const localStorageKey = channelId ? this.getLocalChannelListKey(guildId, channelId) : undefined; - const preRegistrationStorageKey = channelId ? this.resolveListKey(guildId, channelId) : undefined; - if (channelId) { - this.registerChannelListId(guildId, channelId, listId); - for (const aliasKey of [preRegistrationStorageKey, localStorageKey, channelId]) { - if (!aliasKey || aliasKey === storageKey) { - continue; - } - if (guildLists[aliasKey]) { - if (!guildLists[storageKey]) { - guildLists[storageKey] = guildLists[aliasKey]; - } - delete guildLists[aliasKey]; - this.moveListAccess(guildId, aliasKey, storageKey); - } - this.moveListSubscribedChannel(guildId, aliasKey, storageKey); - } - } if (!guildLists[storageKey]) { guildLists[storageKey] = this.createEmptyListState(); } const listState = guildLists[storageKey]; - const newRows = new Map(listState.rows); - const changedPresenceUserIds = new Set(); + const retainedMembers = listState.membersByUserId; + const opRows = new Map(); const changedCustomStatusUserIds = new Set(); - this.touchList(guildId, storageKey); for (const op of ops) { const [start, end] = op.range; for (let i = start; i <= end; i++) { - newRows.delete(i); + opRows.set(i, null); } let nextIndex = start; for (const rawItem of op.items) { - const row = this.convertRow(rawItem); + const row = this.convertRow(rawItem, retainedMembers); if (row) { - newRows.set(nextIndex, row); + opRows.set(nextIndex, row); nextIndex += 1; } else if (!this.isEmptyGroupItem(rawItem)) { nextIndex += 1; } } } - const visibleGroups = this.visibleGroups(groups); + const visibleGroups = this.visibleGroups(listState.groups, groups); const groupLayouts = buildMemberListLayout(visibleGroups); const totalMembers = Math.max(memberCount, getTotalMemberCount(visibleGroups)); const totalRows = groupLayouts.length > 0 ? getTotalRowsFromLayout(groupLayouts) : totalMembers; - const boundedRows = new Map(); - for (const [index, row] of newRows) { - if (index < 0 || index >= totalRows) { - continue; - } - boundedRows.set(index, row); - } - const requestedRanges = normalizeMemberListRanges(listState.requestedRanges); - const prunedRows = requestedRanges.length > 0 ? this.pruneRowsToRanges(boundedRows, requestedRanges) : boundedRows; + const requestedRanges = listState.requestedRanges; + const previousRows = listState.rows; + const newRows = new Map(); const newItems = new Map(); const newPresences = new Map(); const newCustomStatuses = new Map(); + const newMembersByUserId = new Map(); const nextKnownCustomStatuses = new Map(listState.knownCustomStatuses); const userIdRowCounts = new Map(); const recordCustomStatus = (userId: string, customStatus: CustomStatus | null) => { @@ -621,38 +631,73 @@ class MemberSidebar { } recordCustomStatus(userId, null); }; - this.visitRowsInIndexOrder(prunedRows, requestedRanges, (rowIndex, row) => { - if (row.type !== 'member' || !row.userId) { + let layoutCursor = 0; + const visitRow = (rowIndex: number, row: MemberListRow) => { + newRows.set(rowIndex, row); + const {userId, member} = row; + if (row.type !== 'member' || !userId || !member) { return; } let rowGroupId: string | null = null; if (groupLayouts.length > 0) { - const layout = getGroupLayoutForRow(groupLayouts, rowIndex); - if (!layout || rowIndex === layout.headerRowIndex) { + while (layoutCursor < groupLayouts.length && groupLayouts[layoutCursor]!.rowEndIndex < rowIndex) { + layoutCursor += 1; + } + const layout: MemberListGroupLayout | undefined = groupLayouts[layoutCursor]; + if (layout == null || rowIndex <= layout.headerRowIndex) { return; } rowGroupId = layout.id; } - userIdRowCounts.set(row.userId, (userIdRowCounts.get(row.userId) ?? 0) + 1); + userIdRowCounts.set(userId, (userIdRowCounts.get(userId) ?? 0) + 1); + newMembersByUserId.set(userId, member); const memberItem = this.convertItem(guildId, row); if (memberItem) { newItems.set(rowIndex, memberItem); } const presenceStatus = this.extractPresenceFromRow(row); if (presenceStatus) { - const previousPresenceStatus = listState.presences.get(row.userId); - if (previousPresenceStatus !== undefined && previousPresenceStatus !== presenceStatus) { - changedPresenceUserIds.add(row.userId); - } - newPresences.set(row.userId, presenceStatus); + newPresences.set(userId, presenceStatus); } if (row.presence && Object.hasOwn(row.presence, 'custom_status')) { const customStatus = fromGatewayCustomStatus(row.presence.custom_status ?? null); - recordCustomStatus(row.userId, customStatus); + recordCustomStatus(userId, customStatus); } else if (presenceStatus === StatusTypes.OFFLINE || (row.presence == null && rowGroupId === 'offline')) { - clearKnownCustomStatus(row.userId); + clearKnownCustomStatus(userId); } - }); + }; + const resolveRow = (rowIndex: number): MemberListRow | null => { + if (rowIndex < 0 || rowIndex >= totalRows) { + return null; + } + const patchedRow = opRows.get(rowIndex); + if (patchedRow !== undefined) { + return patchedRow; + } + return previousRows.get(rowIndex) ?? null; + }; + if (requestedRanges.length > 0) { + for (const [start, end] of requestedRanges) { + const lastRowIndex = Math.min(end, totalRows - 1); + for (let rowIndex = Math.max(0, start); rowIndex <= lastRowIndex; rowIndex += 1) { + const row = resolveRow(rowIndex); + if (row) { + visitRow(rowIndex, row); + } + } + } + } else { + const candidateRowIndexes = new Set(previousRows.keys()); + for (const rowIndex of opRows.keys()) { + candidateRowIndexes.add(rowIndex); + } + for (const rowIndex of Array.from(candidateRowIndexes).sort((left, right) => left - right)) { + const row = resolveRow(rowIndex); + if (row) { + visitRow(rowIndex, row); + } + } + } const duplicateUserIds: Array = []; for (const [userId, count] of userIdRowCounts) { if (count > 1) { @@ -662,16 +707,17 @@ class MemberSidebar { listState.memberCount = memberCount; listState.onlineCount = onlineCount; listState.groups = visibleGroups; - listState.rows = prunedRows; + listState.rows = newRows; listState.items = newItems; + listState.membersByUserId = newMembersByUserId; listState.presences = newPresences; listState.customStatuses = newCustomStatuses; listState.knownCustomStatuses = nextKnownCustomStatuses; - listState.requestedRanges = requestedRanges; - listState.subscribedRanges = getHydratedMemberListRanges( + listState.subscribedRanges = getHydratedMemberListRangesFromNormalized( { memberCount: totalMembers, groups: visibleGroups, + layouts: groupLayouts, itemIndexes: newItems, }, requestedRanges, @@ -694,11 +740,6 @@ class MemberSidebar { } }); } - this.evictCachedListsForUsers( - guildId, - new Set([...changedPresenceUserIds, ...changedCustomStatusUserIds]), - storageKey, - ); } private getMemberListUpdateDemand(params: MemberListUpdateParams): MemberListUpdateDemand | null { @@ -731,7 +772,7 @@ class MemberSidebar { ) { return false; } - const storageKey = this.resolveListKey(demand.guildId, demand.channelId); + const storageKey = this.resolveStorageKey(demand.guildId, demand.channelId); const guildLists = this.lists[demand.guildId]; const listState = guildLists == null ? undefined : guildLists[storageKey]; return listState != null && listState.requestedRanges.length > 0; @@ -741,11 +782,14 @@ class MemberSidebar { if (!this.isMemberListUpdateDemandCurrent(demand) || params.guildId !== demand.guildId) { return false; } + return this.resolveUpdateChannelId(demand.guildId, params) === demand.channelId; + } + + private resolveUpdateChannelId(guildId: string, params: MemberListUpdateParams): string | undefined { if (params.channelId != null) { - return params.channelId === demand.channelId; + return params.channelId; } - const storageKey = this.resolveListKey(demand.guildId, demand.channelId); - return params.listId === storageKey; + return this.wireListChannelIds[guildId]?.[params.listId]; } private areMemberListUpdateDemandsEqual(left: MemberListUpdateDemand, right: MemberListUpdateDemand): boolean { @@ -763,7 +807,10 @@ class MemberSidebar { this.clearSentMemberListSubscription(); } - private convertRow(rawItem: MemberListOperationItem): MemberListRow | null { + private convertRow( + rawItem: MemberListOperationItem, + retainedMembers: Map, + ): MemberListRow | null { if (rawItem.group) { if (Math.max(0, rawItem.group.count) === 0) { return null; @@ -773,19 +820,25 @@ class MemberSidebar { group: rawItem.group, }; } - if (!rawItem.member?.user?.id) { + const member = rawItem.member; + if (!member?.user?.id) { return null; } + const retained = retainedMembers.get(member.user.id); return { type: 'member', - userId: rawItem.member.user.id, - member: rawItem.member, - presence: rawItem.member.presence ?? null, + userId: member.user.id, + member: retained != null && areMemberIdentitiesEqual(retained, member) ? retained : member, + presence: member.presence ?? null, }; } - private visibleGroups(groups: ReadonlyArray): Array { - return groups.filter((group) => Math.max(0, group.count) > 0); + private visibleGroups( + previousGroups: Array, + groups: ReadonlyArray, + ): Array { + const nextGroups = groups.filter((group) => Math.max(0, group.count) > 0); + return areMemberListGroupsEqual(previousGroups, nextGroups) ? previousGroups : nextGroups; } private isEmptyGroupItem(rawItem: MemberListOperationItem): boolean { @@ -793,17 +846,18 @@ class MemberSidebar { } private convertItem(guildId: string, row: MemberListRow): MemberListItem | null { - if (row.member && row.userId) { - return { - type: 'member', - data: { - userId: row.userId, - member: row.member, - }, - }; + const {member, userId} = row; + if (!member || !userId) { + this.logger.warn('Member not found in store:', {guildId, userId: row.userId}); + return null; } - this.logger.warn('Member not found in store:', {guildId, userId: row.userId}); - return null; + const cachedItem = this.memberListItemCache.get(member); + if (cachedItem != null && cachedItem.data.userId === userId) { + return cachedItem; + } + const item: MemberListItem = {type: 'member', data: {userId, member}}; + this.memberListItemCache.set(member, item); + return item; } materializeItemMember(guildId: string, item: MemberListItem | null | undefined): GuildMember | null { @@ -862,11 +916,12 @@ class MemberSidebar { channelId: string, ranges: Array<[number, number]>, ownerId: string, + forceResend = false, ): boolean { if (!this.isActiveMemberListSubscriptionOwner(guildId, channelId, ownerId)) { return false; } - return this.applyChannelSubscription(guildId, channelId, ranges); + return this.applyChannelSubscription(guildId, channelId, ranges, forceResend); } updateChannelSubscriptionRangesLocally( @@ -882,20 +937,25 @@ class MemberSidebar { return true; } - private applyChannelSubscription(guildId: string, channelId: string, ranges: Array<[number, number]>): boolean { + private applyChannelSubscription( + guildId: string, + channelId: string, + ranges: Array<[number, number]>, + forceResend = false, + ): boolean { const normalizedRanges = normalizeMemberListRanges(ranges); - this.pruneMemberListCache(); - const storageKey = this.resolveListKey(guildId, channelId); + const storageKey = this.resolveStorageKey(guildId, channelId); const socket = GatewayConnection.socket; const guildSubscriptions = this.listSubscribedChannelIds[guildId]; const currentSubscribedChannelId = guildSubscriptions == null ? undefined : guildSubscriptions[storageKey]; const shouldSendUpdate = + forceResend || this.sentMemberListGuildId !== guildId || this.sentMemberListChannelId !== channelId || currentSubscribedChannelId !== channelId || !areRangesEqual(this.sentMemberListRanges, normalizedRanges); const shouldBootstrapGuildSync = !this.syncedMemberListGuildIds.has(guildId); - let delivered = !shouldSendUpdate; + let delivered = false; if (shouldSendUpdate) { if (socket != null && socket.isConnected() === true) { delivered = true; @@ -924,8 +984,7 @@ class MemberSidebar { channelId: string, normalizedRanges: NormalizedMemberListRanges, ): void { - this.pruneMemberListCache(); - const storageKey = this.resolveListKey(guildId, channelId); + const storageKey = this.resolveStorageKey(guildId, channelId); const existingGuildLists = this.lists[guildId] ?? {}; const guildLists: Record = {...existingGuildLists}; const existingList = guildLists[storageKey]; @@ -937,9 +996,7 @@ class MemberSidebar { requestedRanges: normalizedRanges, }); } - this.touchList(guildId, storageKey); this.lists = {...this.lists, [guildId]: guildLists}; - this.pruneMemberListCache(); } unsubscribeFromChannel( @@ -991,7 +1048,7 @@ class MemberSidebar { ) { this.setActiveMemberListSubscription(null); } - const storageKey = this.resolveListKey(guildId, channelId); + const storageKey = this.resolveStorageKey(guildId, channelId); const currentSubscribedChannelId = this.listSubscribedChannelIds[guildId]?.[storageKey]; if (currentSubscribedChannelId && currentSubscribedChannelId !== channelId) { return; @@ -1019,9 +1076,7 @@ class MemberSidebar { requestedRanges: EMPTY_MEMBER_LIST_RANGES, subscribedRanges: EMPTY_MEMBER_LIST_RANGES, }; - this.touchList(guildId, storageKey); this.lists = {...this.lists, [guildId]: guildLists}; - this.pruneMemberListCache(); } } @@ -1089,17 +1144,12 @@ class MemberSidebar { return items; } - getList(guildId: string, listId: string): MemberListState | undefined { - const storageKey = this.resolveListKey(guildId, listId); - const listState = this.lists[guildId]?.[storageKey]; - if (!listState || this.isInactiveListExpired(guildId, storageKey, Date.now(), listState)) { - return undefined; - } - return listState; + getList(guildId: string, channelId: string): MemberListState | undefined { + return this.lists[guildId]?.[this.resolveStorageKey(guildId, channelId)]; } getListIdentityKey(guildId: string, channelId: string): string { - return this.getLocalChannelListKey(guildId, channelId) ?? this.resolveListKey(guildId, channelId); + return `${guildId}:${this.resolveStorageKey(guildId, channelId)}`; } hasHydratedRanges(guildId: string, channelId: string, ranges: MemberListRanges): boolean { @@ -1203,6 +1253,7 @@ class MemberSidebar { subscribedRanges: EMPTY_MEMBER_LIST_RANGES, rows: new Map(), items: new Map(), + membersByUserId: new Map(), presences: new Map(), customStatuses: new Map(), }; @@ -1210,8 +1261,10 @@ class MemberSidebar { const prunedRows = this.pruneRowsToRanges(listState.rows, normalizedRequestedRanges); const prunedItems = this.pruneItemsToRanges(listState.items, normalizedRequestedRanges); const retainedUserIds = new Set(); + const prunedMembersByUserId = new Map(); for (const item of prunedItems.values()) { retainedUserIds.add(item.data.userId); + prunedMembersByUserId.set(item.data.userId, item.data.member); } const prunedPresences = new Map(); const prunedCustomStatuses = new Map(); @@ -1229,7 +1282,7 @@ class MemberSidebar { ...listState, requestedRanges: normalizedRequestedRanges, subscribedRanges: listState.hasReceivedInitialPayload - ? getHydratedMemberListRanges( + ? getHydratedMemberListRangesFromNormalized( { memberCount: listState.memberCount, groups: listState.groups, @@ -1240,6 +1293,7 @@ class MemberSidebar { : EMPTY_MEMBER_LIST_RANGES, rows: prunedRows, items: prunedItems, + membersByUserId: prunedMembersByUserId, presences: prunedPresences, customStatuses: prunedCustomStatuses, }; @@ -1261,28 +1315,6 @@ class MemberSidebar { return prunedRows; } - private visitRowsInIndexOrder( - rows: Map, - subscribedRanges: NormalizedMemberListRanges, - visit: (rowIndex: number, row: MemberListRow) => void, - ): void { - if (subscribedRanges.length === 0) { - const sortedRows = Array.from(rows.entries()).sort(([left], [right]) => left - right); - for (const [rowIndex, row] of sortedRows) { - visit(rowIndex, row); - } - return; - } - for (const [start, end] of subscribedRanges) { - for (let rowIndex = start; rowIndex <= end; rowIndex += 1) { - const row = rows.get(rowIndex); - if (row) { - visit(rowIndex, row); - } - } - } - } - private pruneItemsToRanges( items: Map, subscribedRanges: Array<[number, number]>, @@ -1307,6 +1339,7 @@ class MemberSidebar { groups: [], rows: new Map(), items: new Map(), + membersByUserId: new Map(), requestedRanges: normalizeMemberListRanges(requestedRanges), subscribedRanges: EMPTY_MEMBER_LIST_RANGES, presences: new Map(), @@ -1323,92 +1356,23 @@ class MemberSidebar { return (guild.disabledOperations & GuildOperations.MEMBER_LIST_UPDATES) !== 0; } - private touchList(guildId: string, listId: string): void { - const now = Date.now(); - if (!this.lastAccess[guildId]) { - this.lastAccess[guildId] = {}; - } - this.lastAccess[guildId][listId] = now; - } - - private resolveListKey(guildId: string, listIdOrChannelId: string): string { - const guildMappings = this.channelListIds[guildId]; - const mappedListId = guildMappings?.[listIdOrChannelId]; - if (mappedListId) { - return mappedListId; - } - const localStorageKey = this.getLocalChannelListKey(guildId, listIdOrChannelId); - if (!localStorageKey) { - return listIdOrChannelId; - } - return this.permissionListIds[guildId]?.[localStorageKey] ?? localStorageKey; - } - - private registerChannelListId(guildId: string, channelId: string, listId: string): void { - const guildMappings = this.channelListIds[guildId] ?? {}; - if (guildMappings[channelId] !== listId) { - this.channelListIds = { - ...this.channelListIds, - [guildId]: {...guildMappings, [channelId]: listId}, - }; - } else if (!this.channelListIds[guildId]) { - this.channelListIds = {...this.channelListIds, [guildId]: guildMappings}; - } - const localStorageKey = this.getLocalChannelListKey(guildId, channelId); - if (!localStorageKey) { - return; - } - const guildPermissionMappings = this.permissionListIds[guildId] ?? {}; - if (guildPermissionMappings[localStorageKey] === listId) { - if (!this.permissionListIds[guildId]) { - this.permissionListIds = {...this.permissionListIds, [guildId]: guildPermissionMappings}; - } - return; - } - this.permissionListIds = { - ...this.permissionListIds, - [guildId]: {...guildPermissionMappings, [localStorageKey]: listId}, - }; - } - - private getLocalChannelListKey(guildId: string, channelId: string): string | undefined { + private resolveStorageKey(guildId: string, channelId: string): string { const channel = Channels.getChannel(channelId); - if (!channel || channel.guildId !== guildId || channel.isPrivate()) { - return undefined; + if (channel != null && (channel.guildId !== guildId || channel.isPrivate())) { + return channelId; } - const overwrites = Object.values(channel.permissionOverwrites) - .map((overwrite) => { - const allowViewChannel = (overwrite.allow & Permissions.VIEW_CHANNEL) === Permissions.VIEW_CHANNEL; - const denyViewChannel = - !allowViewChannel && (overwrite.deny & Permissions.VIEW_CHANNEL) === Permissions.VIEW_CHANNEL; - if (!allowViewChannel && !denyViewChannel) { - return null; - } - return { - id: overwrite.id, - type: overwrite.type, - view: allowViewChannel ? 'allow' : 'deny', - }; - }) - .filter((overwrite): overwrite is {id: string; type: number; view: 'allow' | 'deny'} => overwrite != null) - .sort((left, right) => left.id.localeCompare(right.id) || left.type - right.type) - .map((overwrite) => [overwrite.id, overwrite.type.toString(), overwrite.view].join(':')) - .join(','); - return `permission:${guildId}:${overwrites}`; + return deriveMemberListIdentity(Guilds.getGuild(guildId), channel); } - private moveListAccess(guildId: string, fromListId: string, toListId: string): void { - if (fromListId === toListId) { + private registerWireListChannel(guildId: string, wireListId: string, channelId: string): void { + const guildMappings = this.wireListChannelIds[guildId]; + if (guildMappings?.[wireListId] === channelId) { return; } - const guildAccess = this.lastAccess[guildId]; - if (!guildAccess || guildAccess[fromListId] == null) { - return; - } - const nextGuildAccess = {...guildAccess}; - nextGuildAccess[toListId] = Math.max(nextGuildAccess[toListId] ?? 0, nextGuildAccess[fromListId]); - delete nextGuildAccess[fromListId]; - this.lastAccess = {...this.lastAccess, [guildId]: nextGuildAccess}; + this.wireListChannelIds = { + ...this.wireListChannelIds, + [guildId]: {...guildMappings, [wireListId]: channelId}, + }; } private setListSubscribedChannel(guildId: string, listId: string, channelId: string): void { @@ -1444,19 +1408,6 @@ class MemberSidebar { } } - private moveListSubscribedChannel(guildId: string, fromListId: string, toListId: string): void { - if (fromListId === toListId) { - return; - } - const guildSubscriptions = this.listSubscribedChannelIds[guildId]; - if (guildSubscriptions != null && guildSubscriptions[fromListId] != null) { - const nextGuildSubscriptions = {...guildSubscriptions}; - nextGuildSubscriptions[toListId] = nextGuildSubscriptions[fromListId]; - delete nextGuildSubscriptions[fromListId]; - this.listSubscribedChannelIds = {...this.listSubscribedChannelIds, [guildId]: nextGuildSubscriptions}; - } - } - private unsubscribeKnownMemberListSubscriptionsExcept(target: {guildId: string; channelId: string} | null): void { const subscriptions = new Map(); for (const [guildId, guildSubscriptions] of Object.entries(this.listSubscribedChannelIds)) { @@ -1477,66 +1428,6 @@ class MemberSidebar { } } - private isInactiveListExpired(guildId: string, listId: string, now: number, listState: MemberListState): boolean { - if (listState.requestedRanges.length > 0) { - return false; - } - const lastSeen = this.lastAccess[guildId]?.[listId] ?? 0; - return lastSeen > 0 && now - lastSeen > MEMBER_LIST_TTL_MS; - } - - private pruneMemberListCache(now = Date.now()): void { - for (const [guildId, guildLists] of Object.entries(this.lists)) { - for (const [listId, listState] of Object.entries(guildLists)) { - if (this.isInactiveListExpired(guildId, listId, now, listState)) { - this.evictList(guildId, listId); - } - } - } - const inactiveLists: Array<{guildId: string; listId: string; lastSeen: number}> = []; - for (const [guildId, guildLists] of Object.entries(this.lists)) { - for (const [listId, listState] of Object.entries(guildLists)) { - if (listState.requestedRanges.length > 0) { - continue; - } - inactiveLists.push({ - guildId, - listId, - lastSeen: this.lastAccess[guildId]?.[listId] ?? 0, - }); - } - } - if (inactiveLists.length <= MEMBER_LIST_CACHE_MAX_LISTS) { - return; - } - inactiveLists.sort((left, right) => left.lastSeen - right.lastSeen); - for (let index = 0; index < inactiveLists.length - MEMBER_LIST_CACHE_MAX_LISTS; index += 1) { - const {guildId, listId} = inactiveLists[index]; - this.evictList(guildId, listId); - } - } - - private evictCachedListsForUsers(guildId: string, userIds: Set, sourceListId: string): void { - if (userIds.size === 0) { - return; - } - const guildLists = this.lists[guildId]; - if (!guildLists) { - return; - } - for (const [listId, listState] of Object.entries(guildLists)) { - if (listId === sourceListId || listState.requestedRanges.length > 0) { - continue; - } - for (const userId of userIds) { - if (this.listStateContainsUser(listState, userId)) { - this.evictList(guildId, listId); - break; - } - } - } - } - private listStateContainsUser(listState: MemberListState, userId: string): boolean { for (const item of listState.items.values()) { if (item.data.userId === userId) { @@ -1554,83 +1445,6 @@ class MemberSidebar { listState.knownCustomStatuses.has(userId) ); } - - private evictList(guildId: string, listId: string): void { - this.clearPendingListUpdateBatch(guildId, listId); - const existingGuildLists = this.lists[guildId] ?? {}; - if (existingGuildLists[listId]) { - const {[listId]: _, ...remainingGuildLists} = existingGuildLists; - if (Object.keys(remainingGuildLists).length === 0) { - const {[guildId]: __, ...remainingLists} = this.lists; - this.lists = remainingLists; - } else { - this.lists = {...this.lists, [guildId]: remainingGuildLists}; - } - } - const existingAccess = this.lastAccess[guildId] ?? {}; - if (existingAccess[listId] != null) { - const {[listId]: _, ...remainingAccess} = existingAccess; - if (Object.keys(remainingAccess).length === 0) { - const {[guildId]: __, ...allAccess} = this.lastAccess; - this.lastAccess = allAccess; - } else { - this.lastAccess = {...this.lastAccess, [guildId]: remainingAccess}; - } - } - this.clearListSubscribedChannel(guildId, listId); - this.removeListAliases(guildId, listId); - } - - private removeListAliases(guildId: string, listId: string): void { - const guildMappings = this.channelListIds[guildId]; - if (guildMappings) { - const nextGuildMappings: Record = {}; - for (const [channelId, mappedListId] of Object.entries(guildMappings)) { - if (mappedListId !== listId && channelId !== listId) { - nextGuildMappings[channelId] = mappedListId; - } - } - if (Object.keys(nextGuildMappings).length === 0) { - const {[guildId]: _, ...remainingMappings} = this.channelListIds; - this.channelListIds = remainingMappings; - } else { - this.channelListIds = {...this.channelListIds, [guildId]: nextGuildMappings}; - } - } - const permissionMappings = this.permissionListIds[guildId]; - if (!permissionMappings) { - return; - } - const nextPermissionMappings: Record = {}; - for (const [permissionKey, mappedListId] of Object.entries(permissionMappings)) { - if (mappedListId !== listId && permissionKey !== listId) { - nextPermissionMappings[permissionKey] = mappedListId; - } - } - if (Object.keys(nextPermissionMappings).length === 0) { - const {[guildId]: _, ...remainingPermissionMappings} = this.permissionListIds; - this.permissionListIds = remainingPermissionMappings; - } else { - this.permissionListIds = {...this.permissionListIds, [guildId]: nextPermissionMappings}; - } - } - - private startPruneInterval(): void { - if (this.pruneIntervalId != null) { - return; - } - if (typeof window === 'undefined') { - return; - } - this.pruneIntervalId = window.setInterval(() => this.pruneExpiredLists(), MEMBER_LIST_PRUNE_INTERVAL_MS); - } - - private pruneExpiredLists(): void { - if (!Window.focused) { - return; - } - this.pruneMemberListCache(); - } } export default new MemberSidebar(); diff --git a/fluxer_app/src/features/member/utils/MemberListHydration.ts b/fluxer_app/src/features/member/utils/MemberListHydration.ts index eb2f22f2b..8839f4c14 100644 --- a/fluxer_app/src/features/member/utils/MemberListHydration.ts +++ b/fluxer_app/src/features/member/utils/MemberListHydration.ts @@ -22,6 +22,7 @@ interface MemberListHydrationItemIndexes { export interface MemberListHydrationInput { memberCount: number; groups: ReadonlyArray; + layouts?: ReadonlyArray; itemIndexes: MemberListHydrationItemIndexes; } @@ -61,6 +62,10 @@ function isHydratedMemberListRow(context: HydratedMemberListRowContext, rowIndex return input.itemIndexes.has(rowIndex); } +function resolveHydrationLayouts(input: MemberListHydrationInput): ReadonlyArray { + return input.layouts ?? buildMemberListLayout(input.groups); +} + function appendHydratedMemberListRange(request: AppendHydratedMemberListRangeRequest): void { const {target, range, totalRows} = request; const [rangeStart, rangeEnd] = range; @@ -87,15 +92,14 @@ function appendHydratedMemberListRange(request: AppendHydratedMemberListRangeReq } } -export function getHydratedMemberListRanges( +export function getHydratedMemberListRangesFromNormalized( input: MemberListHydrationInput, - ranges: MemberListRanges, + normalizedRanges: NormalizedMemberListRanges, ): NormalizedMemberListRanges { - const normalizedRanges = normalizeMemberListRanges(ranges); if (normalizedRanges.length === 0) { return normalizedRanges; } - const layouts = buildMemberListLayout(input.groups); + const layouts = resolveHydrationLayouts(input); const totalRows = getHydrationTotalRows(input, layouts); if (totalRows === 0) { return normalizedRanges; @@ -111,7 +115,17 @@ export function getHydratedMemberListRanges( return normalizeMemberListRanges(hydratedRanges); } +export function getHydratedMemberListRanges( + input: MemberListHydrationInput, + ranges: MemberListRanges, +): NormalizedMemberListRanges { + return getHydratedMemberListRangesFromNormalized(input, normalizeMemberListRanges(ranges)); +} + export function isMemberListRangeHydrated(input: MemberListHydrationInput, ranges: MemberListRanges): boolean { const normalizedRanges = normalizeMemberListRanges(ranges); - return areNormalizedMemberListRangesCovered(normalizedRanges, getHydratedMemberListRanges(input, normalizedRanges)); + return areNormalizedMemberListRangesCovered( + normalizedRanges, + getHydratedMemberListRangesFromNormalized(input, normalizedRanges), + ); } diff --git a/fluxer_app/src/features/member/utils/MemberListIdentity.ts b/fluxer_app/src/features/member/utils/MemberListIdentity.ts new file mode 100644 index 000000000..9d776ffbe --- /dev/null +++ b/fluxer_app/src/features/member/utils/MemberListIdentity.ts @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {Permissions} from '@fluxer/constants/src/ChannelConstants'; + +export interface MemberListViewOverwrite { + readonly id: string; + readonly allow: bigint; + readonly deny: bigint; +} + +export interface MemberListIdentityChannel { + readonly permissionOverwrites: Readonly>; +} + +export interface MemberListIdentityGuild { + readonly id: string; + getRole(roleId: string): {readonly permissions: bigint} | undefined; +} + +export const OPEN_MEMBER_LIST_IDENTITY = 'everyone'; + +const IDENTITY_TOKEN_SEPARATOR = ','; +const SCRAMBLE_A = 0xcc9e2d51; +const SCRAMBLE_B = 0x1b873593; +const BLEND_A = 0x85ebca6b; +const BLEND_B = 0xc2b2ae35; +const CHAIN_ADDEND = 0xe6546b64; +const identityTokenEncoder = new TextEncoder(); + +function rotateLeft32(value: number, shift: number): number { + return (value << shift) | (value >>> (32 - shift)); +} + +function scramble(value: number): number { + return Math.imul(rotateLeft32(Math.imul(value, SCRAMBLE_A), 15), SCRAMBLE_B); +} + +export function fingerprintIdentityTokens(tokens: string): number { + const bytes = identityTokenEncoder.encode(tokens); + const blockCount = bytes.length >>> 2; + let accumulator = 0; + for (let block = 0; block < blockCount; block += 1) { + const offset = block << 2; + const chunk = bytes[offset]! | (bytes[offset + 1]! << 8) | (bytes[offset + 2]! << 16) | (bytes[offset + 3]! << 24); + accumulator ^= scramble(chunk); + accumulator = rotateLeft32(accumulator, 13); + accumulator = (Math.imul(accumulator, 5) + CHAIN_ADDEND) | 0; + } + const remainderStart = blockCount << 2; + const remainderLength = bytes.length & 3; + if (remainderLength > 0) { + let remainder = 0; + if (remainderLength === 3) { + remainder ^= bytes[remainderStart + 2]! << 16; + } + if (remainderLength >= 2) { + remainder ^= bytes[remainderStart + 1]! << 8; + } + remainder ^= bytes[remainderStart]!; + accumulator ^= scramble(remainder); + } + accumulator ^= bytes.length; + accumulator ^= accumulator >>> 16; + accumulator = Math.imul(accumulator, BLEND_A); + accumulator ^= accumulator >>> 13; + accumulator = Math.imul(accumulator, BLEND_B); + accumulator ^= accumulator >>> 16; + return accumulator >>> 0; +} + +function isViewableWithoutOverrides( + guild: MemberListIdentityGuild | undefined, + channel: MemberListIdentityChannel, +): boolean { + if (guild == null) { + return false; + } + const baselineRole = guild.getRole(guild.id); + if (baselineRole == null) { + return false; + } + if ((baselineRole.permissions & Permissions.VIEW_CHANNEL) !== Permissions.VIEW_CHANNEL) { + return false; + } + for (const overwrite of Object.values(channel.permissionOverwrites)) { + if ((overwrite.deny & Permissions.VIEW_CHANNEL) === Permissions.VIEW_CHANNEL) { + return false; + } + } + return true; +} + +export function buildIdentityTokens(channel: MemberListIdentityChannel): string { + const tokens: Array = []; + for (const overwrite of Object.values(channel.permissionOverwrites)) { + if ((overwrite.allow & Permissions.VIEW_CHANNEL) === Permissions.VIEW_CHANNEL) { + tokens.push(`allow:${overwrite.id}`); + } else if ((overwrite.deny & Permissions.VIEW_CHANNEL) === Permissions.VIEW_CHANNEL) { + tokens.push(`deny:${overwrite.id}`); + } + } + tokens.sort(); + return tokens.join(IDENTITY_TOKEN_SEPARATOR); +} + +export function deriveMemberListIdentity( + guild: MemberListIdentityGuild | undefined, + channel: MemberListIdentityChannel | undefined, +): string { + if (channel == null) { + return OPEN_MEMBER_LIST_IDENTITY; + } + if (isViewableWithoutOverrides(guild, channel)) { + return OPEN_MEMBER_LIST_IDENTITY; + } + return fingerprintIdentityTokens(buildIdentityTokens(channel)).toString(); +} diff --git a/fluxer_app/src/features/member/utils/MemberListLayout.ts b/fluxer_app/src/features/member/utils/MemberListLayout.ts index b352a53eb..d5a2bf544 100644 --- a/fluxer_app/src/features/member/utils/MemberListLayout.ts +++ b/fluxer_app/src/features/member/utils/MemberListLayout.ts @@ -23,22 +23,22 @@ export function buildMemberListLayout(groups: ReadonlyArray(); + let rowIndex = 0; + let runningOffset = 0; for (const layout of layouts) { - headerRows.add(layout.headerRowIndex); + if (rowIndex >= safeTotalRows) { + break; + } + while (rowIndex < layout.headerRowIndex && rowIndex < safeTotalRows) { + runningOffset += memberHeight; + rowIndex += 1; + offsets[rowIndex] = runningOffset; + } + if (rowIndex === layout.headerRowIndex && rowIndex < safeTotalRows) { + runningOffset += headerHeight; + rowIndex += 1; + offsets[rowIndex] = runningOffset; + } + const lastMemberRow = Math.min(layout.rowEndIndex, safeTotalRows - 1); + while (rowIndex <= lastMemberRow) { + runningOffset += memberHeight; + rowIndex += 1; + offsets[rowIndex] = runningOffset; + } } - for (let rowIndex = 0; rowIndex < safeTotalRows; rowIndex += 1) { - const rowHeight = headerRows.has(rowIndex) ? headerHeight : memberHeight; - offsets[rowIndex + 1] = offsets[rowIndex]! + rowHeight; + while (rowIndex < safeTotalRows) { + runningOffset += memberHeight; + rowIndex += 1; + offsets[rowIndex] = runningOffset; } return offsets; } @@ -113,15 +133,23 @@ export function getGroupLayoutForRow( layouts: ReadonlyArray, rowIndex: number, ): MemberListGroupLayout | null { - for (const layout of layouts) { - if (rowIndex < layout.headerRowIndex) { - return null; - } - if (rowIndex <= layout.rowEndIndex) { - return layout; + let low = 0; + let high = layouts.length - 1; + let candidate: MemberListGroupLayout | null = null; + while (low <= high) { + const mid = (low + high) >> 1; + const layout = layouts[mid]!; + if (layout.headerRowIndex <= rowIndex) { + candidate = layout; + low = mid + 1; + } else { + high = mid - 1; } } - return null; + if (candidate == null || rowIndex > candidate.rowEndIndex) { + return null; + } + return candidate; } export function getMemberIndexForRow( @@ -166,15 +194,23 @@ export function getRowIndexForMemberIndex( if (layouts.length === 0) { return memberIndex; } - for (const layout of layouts) { - if (memberIndex < layout.memberStartIndex) { - return null; - } - if (memberIndex <= layout.memberEndIndex) { - return layout.headerRowIndex + 1 + (memberIndex - layout.memberStartIndex); + let low = 0; + let high = layouts.length - 1; + let candidate: MemberListGroupLayout | null = null; + while (low <= high) { + const mid = (low + high) >> 1; + const layout = layouts[mid]!; + if (layout.memberStartIndex <= memberIndex) { + candidate = layout; + low = mid + 1; + } else { + high = mid - 1; } } - return null; + if (candidate == null || memberIndex > candidate.memberEndIndex) { + return null; + } + return candidate.headerRowIndex + 1 + (memberIndex - candidate.memberStartIndex); } export function getRowIndexRangeForMemberIndexRange( @@ -189,3 +225,22 @@ export function getRowIndexRangeForMemberIndexRange( } return [start, end]; } + +export interface MemberListScrollSpan { + top: number; + height: number; +} + +export function quantizeMemberListScrollSpan( + scrollTop: number, + viewportHeight: number, + chunkPx: number, +): MemberListScrollSpan { + const safeChunkPx = Number.isFinite(chunkPx) && chunkPx > 0 ? chunkPx : 1; + const safeScrollTop = Number.isFinite(scrollTop) ? Math.max(0, scrollTop) : 0; + const safeViewportHeight = Number.isFinite(viewportHeight) ? Math.max(0, viewportHeight) : 0; + const firstChunk = Math.floor(safeScrollTop / safeChunkPx) - 1; + const lastChunk = Math.ceil((safeScrollTop + safeViewportHeight) / safeChunkPx) + 1; + const top = Math.max(0, firstChunk * safeChunkPx); + return {top, height: Math.max(0, lastChunk * safeChunkPx - top)}; +} diff --git a/fluxer_app/src/features/member/utils/MemberListRangeUtils.test.ts b/fluxer_app/src/features/member/utils/MemberListRangeUtils.test.ts index 1492e19f8..b5c03dd91 100644 --- a/fluxer_app/src/features/member/utils/MemberListRangeUtils.test.ts +++ b/fluxer_app/src/features/member/utils/MemberListRangeUtils.test.ts @@ -42,7 +42,7 @@ describe('MemberListRangeUtils', () => { expect(areNormalizedMemberListRangesCovered(requested, subscribed)).toBe(true); }); - it('builds a bounded, overscanned page window from scroll metrics', () => { + it('builds a bounded, overscanned page window from scroll metrics and keeps the head page subscribed', () => { expect( buildMemberListRangeWindow({ scrollTop: 44 * 250, @@ -52,13 +52,14 @@ describe('MemberListRangeUtils', () => { totalRows: 500, }), ).toEqual([ + [0, 99], [100, 199], [200, 299], [300, 399], ]); }); - it('keeps scrollbar-dragged bottom windows clamped to the known row count', () => { + it('keeps scrollbar-dragged bottom windows clamped to the last known page', () => { expect( buildMemberListRangeWindow({ scrollTop: 44 * 490, @@ -68,6 +69,7 @@ describe('MemberListRangeUtils', () => { totalRows: 500, }), ).toEqual([ + [0, 99], [300, 399], [400, 499], ]); @@ -83,7 +85,41 @@ describe('MemberListRangeUtils', () => { overscanPages: 0, totalRows: 500, }), - ).toEqual([[200, 299]]); + ).toEqual([ + [0, 99], + [200, 299], + ]); + }); + + it('omits a duplicate head page when the window already starts at the top of the list', () => { + expect( + buildMemberListRangeWindow({ + scrollTop: 0, + clientHeight: 44 * 10, + rowHeight: 44, + bufferRows: 12, + overscanPages: 0, + totalRows: 500, + }), + ).toEqual([[0, 99]]); + }); + + it('keeps page ends aligned so a changing member count does not resubscribe', () => { + const options = { + scrollTop: 44 * 250, + clientHeight: 44 * 10, + rowHeight: 44, + bufferRows: 12, + overscanPages: 0, + }; + expect(buildMemberListRangeWindow({...options, totalRows: 450})).toEqual([ + [0, 99], + [200, 299], + ]); + expect(buildMemberListRangeWindow({...options, totalRows: 451})).toEqual([ + [0, 99], + [200, 299], + ]); }); it('builds a tight render window instead of rendering whole subscription pages', () => { diff --git a/fluxer_app/src/features/member/utils/MemberListRangeUtils.ts b/fluxer_app/src/features/member/utils/MemberListRangeUtils.ts index 89f4ff5ae..e10869970 100644 --- a/fluxer_app/src/features/member/utils/MemberListRangeUtils.ts +++ b/fluxer_app/src/features/member/utils/MemberListRangeUtils.ts @@ -188,11 +188,12 @@ export function buildMemberListRangeWindow(options: MemberListRangeWindowOptions lastPage = Math.min(lastPage, maxPage); } const ranges: MemberListRanges = []; + if (firstPage > 0) { + ranges.push([0, MEMBER_LIST_RANGE_MAX_SPAN]); + } for (let page = firstPage; page <= lastPage; page += 1) { const pageStart = page * MEMBER_LIST_RANGE_PAGE_SIZE; - const maxPageEnd = pageStart + MEMBER_LIST_RANGE_MAX_SPAN; - const pageEnd = safeTotalRows != null ? Math.min(maxPageEnd, safeTotalRows - 1) : maxPageEnd; - ranges.push([pageStart, pageEnd]); + ranges.push([pageStart, pageStart + MEMBER_LIST_RANGE_MAX_SPAN]); } return normalizeMemberListRanges(ranges); }