mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
fix(app): keep the member list in sync when switching channels (#1848)
This commit is contained in:
1 parent
7c5cab2144
commit
3e267b8104
8 files changed
+578
-561
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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<NormalizedMemberListRanges>(INITIAL_MEMBER_LIST_SUBSCRIPTION_RANGES);
|
||||
const pendingDesiredRangesRef = useRef<NormalizedMemberListRanges | null>(null);
|
||||
const settleTimerRef = useRef<number | null>(null);
|
||||
const controlRef = useRef<MemberListSubscriptionControl | null>(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();
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -22,6 +22,7 @@ interface MemberListHydrationItemIndexes {
|
||||
export interface MemberListHydrationInput {
|
||||
memberCount: number;
|
||||
groups: ReadonlyArray<MemberListGroupSnapshot>;
|
||||
layouts?: ReadonlyArray<MemberListGroupLayout>;
|
||||
itemIndexes: MemberListHydrationItemIndexes;
|
||||
}
|
||||
|
||||
@@ -61,6 +62,10 @@ function isHydratedMemberListRow(context: HydratedMemberListRowContext, rowIndex
|
||||
return input.itemIndexes.has(rowIndex);
|
||||
}
|
||||
|
||||
function resolveHydrationLayouts(input: MemberListHydrationInput): ReadonlyArray<MemberListGroupLayout> {
|
||||
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),
|
||||
);
|
||||
}
|
||||
@@ -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<Record<string, MemberListViewOverwrite>>;
|
||||
}
|
||||
|
||||
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<string> = [];
|
||||
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();
|
||||
}
|
||||
@@ -23,22 +23,22 @@ export function buildMemberListLayout(groups: ReadonlyArray<MemberListGroupSnaps
|
||||
for (const group of groups) {
|
||||
const effectiveCount = Math.max(0, group.count);
|
||||
if (effectiveCount === 0) {
|
||||
} else {
|
||||
const headerRowIndex = rowIndex;
|
||||
const memberStartIndex = memberIndex;
|
||||
const memberEndIndex = memberIndex + effectiveCount - 1;
|
||||
const rowEndIndex = headerRowIndex + effectiveCount;
|
||||
layouts.push({
|
||||
id: group.id,
|
||||
count: effectiveCount,
|
||||
headerRowIndex,
|
||||
memberStartIndex,
|
||||
memberEndIndex,
|
||||
rowEndIndex,
|
||||
});
|
||||
rowIndex = rowEndIndex + 1;
|
||||
memberIndex = memberEndIndex + 1;
|
||||
continue;
|
||||
}
|
||||
const headerRowIndex = rowIndex;
|
||||
const memberStartIndex = memberIndex;
|
||||
const memberEndIndex = memberIndex + effectiveCount - 1;
|
||||
const rowEndIndex = headerRowIndex + effectiveCount;
|
||||
layouts.push({
|
||||
id: group.id,
|
||||
count: effectiveCount,
|
||||
headerRowIndex,
|
||||
memberStartIndex,
|
||||
memberEndIndex,
|
||||
rowEndIndex,
|
||||
});
|
||||
rowIndex = rowEndIndex + 1;
|
||||
memberIndex = memberEndIndex + 1;
|
||||
}
|
||||
return layouts;
|
||||
}
|
||||
@@ -66,13 +66,33 @@ export function buildMemberListRowOffsets(
|
||||
if (safeTotalRows === 0) {
|
||||
return offsets;
|
||||
}
|
||||
const headerRows = new Set<number>();
|
||||
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<MemberListGroupLayout>,
|
||||
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)};
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user