mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +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({
|
validOps.push({
|
||||||
op: 'SYNC',
|
op: 'SYNC',
|
||||||
range: op.range,
|
range: op.range,
|
||||||
items: op.items.map((item: MemberListItem) => ({
|
items: op.items,
|
||||||
member: item.member,
|
|
||||||
group: item.group,
|
|
||||||
})),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (validOps.length === 0 && ops.length > 0) {
|
if (validOps.length === 0 && ops.length > 0) {
|
||||||
|
|||||||
@@ -28,13 +28,12 @@ interface MemberListSubscriptionControl {
|
|||||||
verifyHydration: () => void;
|
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 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_RESUBSCRIBE_DELAY_MS = 1000;
|
||||||
const MEMBER_LIST_MAX_RESUBSCRIBE_ATTEMPTS = 3;
|
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');
|
const logger = new Logger('useMemberListSubscription');
|
||||||
|
|
||||||
let nextMemberListSubscriptionOwnerId = 0;
|
let nextMemberListSubscriptionOwnerId = 0;
|
||||||
@@ -56,35 +55,81 @@ export function useMemberListSubscription({
|
|||||||
}: UseMemberListSubscriptionOptions): UseMemberListSubscriptionResult {
|
}: UseMemberListSubscriptionOptions): UseMemberListSubscriptionResult {
|
||||||
const [ownerId] = useState(createMemberListSubscriptionOwnerId);
|
const [ownerId] = useState(createMemberListSubscriptionOwnerId);
|
||||||
const desiredRangesRef = useRef<NormalizedMemberListRanges>(INITIAL_MEMBER_LIST_SUBSCRIPTION_RANGES);
|
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 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(
|
const subscribe = useCallback(
|
||||||
(ranges: MemberListRanges) => {
|
(ranges: MemberListRanges) => {
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const nextDesiredRanges = resolveDesiredRanges(ranges);
|
const nextDesiredRanges = resolveDesiredRanges(ranges);
|
||||||
const desiredRangesChanged = !areNormalizedMemberListRangesEqual(desiredRangesRef.current, nextDesiredRanges);
|
if (areNormalizedMemberListRangesEqual(desiredRangesRef.current, nextDesiredRanges)) {
|
||||||
desiredRangesRef.current = nextDesiredRanges;
|
pendingDesiredRangesRef.current = null;
|
||||||
const control = controlRef.current;
|
clearSettleTimer();
|
||||||
if (control != null) {
|
const control = controlRef.current;
|
||||||
control.handleDesiredRanges(desiredRangesChanged);
|
if (control != null) {
|
||||||
if (!desiredRangesChanged) {
|
control.handleDesiredRanges(false);
|
||||||
control.verifyHydration();
|
control.verifyHydration();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
sendWithoutControl(desiredRangesRef.current);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!MemberSidebar.isActiveMemberListSubscriptionOwner(guildId, channelId, ownerId)) {
|
pendingDesiredRangesRef.current = nextDesiredRanges;
|
||||||
MemberSidebar.claimMemberListSubscription(guildId, channelId, ownerId);
|
clearSettleTimer();
|
||||||
}
|
settleTimerRef.current = window.setTimeout(commitDesiredRanges, MEMBER_LIST_SUBSCRIPTION_SETTLE_MS);
|
||||||
MemberSidebar.subscribeToChannel(guildId, channelId, desiredRangesRef.current, ownerId);
|
|
||||||
},
|
},
|
||||||
[guildId, channelId, enabled, ownerId],
|
[enabled, clearSettleTimer, commitDesiredRanges, sendWithoutControl],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
desiredRangesRef.current = INITIAL_MEMBER_LIST_SUBSCRIPTION_RANGES;
|
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(() => {
|
useEffect(() => {
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
@@ -94,8 +139,6 @@ export function useMemberListSubscription({
|
|||||||
let reconciliationScheduled = false;
|
let reconciliationScheduled = false;
|
||||||
let retryTimer: number | null = null;
|
let retryTimer: number | null = null;
|
||||||
let hydrationGeneration = 0;
|
let hydrationGeneration = 0;
|
||||||
let freshSessionResetId: string | null = null;
|
|
||||||
let recoveryAttemptCount = 0;
|
|
||||||
let resubscribeAttemptCount = 0;
|
let resubscribeAttemptCount = 0;
|
||||||
let phase: MemberListSubscriptionPhase = 'offline';
|
let phase: MemberListSubscriptionPhase = 'offline';
|
||||||
|
|
||||||
@@ -122,13 +165,12 @@ export function useMemberListSubscription({
|
|||||||
function beginRecoveryDemand(): void {
|
function beginRecoveryDemand(): void {
|
||||||
clearRetryTimer();
|
clearRetryTimer();
|
||||||
hydrationGeneration += 1;
|
hydrationGeneration += 1;
|
||||||
recoveryAttemptCount = 0;
|
|
||||||
resubscribeAttemptCount = 0;
|
resubscribeAttemptCount = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resendStaleSubscription(): void {
|
function resendStaleSubscription(): void {
|
||||||
clearRetryTimer();
|
clearRetryTimer();
|
||||||
if (disposed || phase === 'freshSession' || !ownsSubscription()) {
|
if (disposed || !ownsSubscription()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!gatewayAvailable()) {
|
if (!gatewayAvailable()) {
|
||||||
@@ -141,11 +183,11 @@ export function useMemberListSubscription({
|
|||||||
channelId,
|
channelId,
|
||||||
attempt: resubscribeAttemptCount,
|
attempt: resubscribeAttemptCount,
|
||||||
});
|
});
|
||||||
sendDesiredRequest();
|
sendDesiredRequest(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleResubscribe(): void {
|
function scheduleResubscribe(): void {
|
||||||
if (retryTimer != null) {
|
if (retryTimer != null || resubscribeAttemptCount >= MEMBER_LIST_MAX_RESUBSCRIBE_ATTEMPTS) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const scheduledGeneration = hydrationGeneration;
|
const scheduledGeneration = hydrationGeneration;
|
||||||
@@ -158,76 +200,23 @@ export function useMemberListSubscription({
|
|||||||
}, MEMBER_LIST_RESUBSCRIBE_DELAY_MS);
|
}, 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 {
|
function verifyHydration(): void {
|
||||||
if (disposed || !ownsSubscription()) {
|
if (disposed || !ownsSubscription()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (phase === 'freshSession') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (hasHydratedDesiredRanges()) {
|
if (hasHydratedDesiredRanges()) {
|
||||||
clearRetryTimer();
|
clearRetryTimer();
|
||||||
recoveryAttemptCount = 0;
|
|
||||||
resubscribeAttemptCount = 0;
|
resubscribeAttemptCount = 0;
|
||||||
freshSessionResetId = null;
|
|
||||||
phase = 'settled';
|
phase = 'settled';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (phase === 'settled' || phase === 'hydrating') {
|
if (phase === 'settled' || phase === 'hydrating') {
|
||||||
phase = 'hydrating';
|
phase = 'hydrating';
|
||||||
scheduleHydrationRecovery();
|
scheduleResubscribe();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendDesiredRequest(): void {
|
function sendDesiredRequest(forceResend = false): void {
|
||||||
clearRetryTimer();
|
clearRetryTimer();
|
||||||
if (disposed || !gatewayAvailable()) {
|
if (disposed || !gatewayAvailable()) {
|
||||||
phase = 'offline';
|
phase = 'offline';
|
||||||
@@ -236,7 +225,14 @@ export function useMemberListSubscription({
|
|||||||
if (!ownsSubscription()) {
|
if (!ownsSubscription()) {
|
||||||
MemberSidebar.claimMemberListSubscription(guildId, channelId, ownerId);
|
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';
|
phase = 'offline';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -267,9 +263,6 @@ export function useMemberListSubscription({
|
|||||||
if (!changed && !reclaimedOwnership) {
|
if (!changed && !reclaimedOwnership) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (phase === 'freshSession') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
sendDesiredRequest();
|
sendDesiredRequest();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,14 +270,6 @@ export function useMemberListSubscription({
|
|||||||
if (disposed || !gatewayAvailable()) {
|
if (disposed || !gatewayAvailable()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
phase === 'freshSession' &&
|
|
||||||
freshSessionResetId != null &&
|
|
||||||
GatewayConnection.sessionId === freshSessionResetId
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
freshSessionResetId = null;
|
|
||||||
if (!ownsSubscription()) {
|
if (!ownsSubscription()) {
|
||||||
if (MemberSidebar.hasActiveMemberListSubscription()) {
|
if (MemberSidebar.hasActiveMemberListSubscription()) {
|
||||||
return;
|
return;
|
||||||
@@ -324,9 +309,7 @@ export function useMemberListSubscription({
|
|||||||
if (!isAvailable) {
|
if (!isAvailable) {
|
||||||
clearRetryTimer();
|
clearRetryTimer();
|
||||||
MemberSidebar.handleGatewayDisconnected();
|
MemberSidebar.handleGatewayDisconnected();
|
||||||
if (phase !== 'freshSession') {
|
phase = 'offline';
|
||||||
phase = 'offline';
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
scheduleReconciliation();
|
scheduleReconciliation();
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -22,6 +22,7 @@ interface MemberListHydrationItemIndexes {
|
|||||||
export interface MemberListHydrationInput {
|
export interface MemberListHydrationInput {
|
||||||
memberCount: number;
|
memberCount: number;
|
||||||
groups: ReadonlyArray<MemberListGroupSnapshot>;
|
groups: ReadonlyArray<MemberListGroupSnapshot>;
|
||||||
|
layouts?: ReadonlyArray<MemberListGroupLayout>;
|
||||||
itemIndexes: MemberListHydrationItemIndexes;
|
itemIndexes: MemberListHydrationItemIndexes;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +62,10 @@ function isHydratedMemberListRow(context: HydratedMemberListRowContext, rowIndex
|
|||||||
return input.itemIndexes.has(rowIndex);
|
return input.itemIndexes.has(rowIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveHydrationLayouts(input: MemberListHydrationInput): ReadonlyArray<MemberListGroupLayout> {
|
||||||
|
return input.layouts ?? buildMemberListLayout(input.groups);
|
||||||
|
}
|
||||||
|
|
||||||
function appendHydratedMemberListRange(request: AppendHydratedMemberListRangeRequest): void {
|
function appendHydratedMemberListRange(request: AppendHydratedMemberListRangeRequest): void {
|
||||||
const {target, range, totalRows} = request;
|
const {target, range, totalRows} = request;
|
||||||
const [rangeStart, rangeEnd] = range;
|
const [rangeStart, rangeEnd] = range;
|
||||||
@@ -87,15 +92,14 @@ function appendHydratedMemberListRange(request: AppendHydratedMemberListRangeReq
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getHydratedMemberListRanges(
|
export function getHydratedMemberListRangesFromNormalized(
|
||||||
input: MemberListHydrationInput,
|
input: MemberListHydrationInput,
|
||||||
ranges: MemberListRanges,
|
normalizedRanges: NormalizedMemberListRanges,
|
||||||
): NormalizedMemberListRanges {
|
): NormalizedMemberListRanges {
|
||||||
const normalizedRanges = normalizeMemberListRanges(ranges);
|
|
||||||
if (normalizedRanges.length === 0) {
|
if (normalizedRanges.length === 0) {
|
||||||
return normalizedRanges;
|
return normalizedRanges;
|
||||||
}
|
}
|
||||||
const layouts = buildMemberListLayout(input.groups);
|
const layouts = resolveHydrationLayouts(input);
|
||||||
const totalRows = getHydrationTotalRows(input, layouts);
|
const totalRows = getHydrationTotalRows(input, layouts);
|
||||||
if (totalRows === 0) {
|
if (totalRows === 0) {
|
||||||
return normalizedRanges;
|
return normalizedRanges;
|
||||||
@@ -111,7 +115,17 @@ export function getHydratedMemberListRanges(
|
|||||||
return normalizeMemberListRanges(hydratedRanges);
|
return normalizeMemberListRanges(hydratedRanges);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getHydratedMemberListRanges(
|
||||||
|
input: MemberListHydrationInput,
|
||||||
|
ranges: MemberListRanges,
|
||||||
|
): NormalizedMemberListRanges {
|
||||||
|
return getHydratedMemberListRangesFromNormalized(input, normalizeMemberListRanges(ranges));
|
||||||
|
}
|
||||||
|
|
||||||
export function isMemberListRangeHydrated(input: MemberListHydrationInput, ranges: MemberListRanges): boolean {
|
export function isMemberListRangeHydrated(input: MemberListHydrationInput, ranges: MemberListRanges): boolean {
|
||||||
const normalizedRanges = normalizeMemberListRanges(ranges);
|
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) {
|
for (const group of groups) {
|
||||||
const effectiveCount = Math.max(0, group.count);
|
const effectiveCount = Math.max(0, group.count);
|
||||||
if (effectiveCount === 0) {
|
if (effectiveCount === 0) {
|
||||||
} else {
|
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;
|
|
||||||
}
|
}
|
||||||
|
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;
|
return layouts;
|
||||||
}
|
}
|
||||||
@@ -66,13 +66,33 @@ export function buildMemberListRowOffsets(
|
|||||||
if (safeTotalRows === 0) {
|
if (safeTotalRows === 0) {
|
||||||
return offsets;
|
return offsets;
|
||||||
}
|
}
|
||||||
const headerRows = new Set<number>();
|
let rowIndex = 0;
|
||||||
|
let runningOffset = 0;
|
||||||
for (const layout of layouts) {
|
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) {
|
while (rowIndex < safeTotalRows) {
|
||||||
const rowHeight = headerRows.has(rowIndex) ? headerHeight : memberHeight;
|
runningOffset += memberHeight;
|
||||||
offsets[rowIndex + 1] = offsets[rowIndex]! + rowHeight;
|
rowIndex += 1;
|
||||||
|
offsets[rowIndex] = runningOffset;
|
||||||
}
|
}
|
||||||
return offsets;
|
return offsets;
|
||||||
}
|
}
|
||||||
@@ -113,15 +133,23 @@ export function getGroupLayoutForRow(
|
|||||||
layouts: ReadonlyArray<MemberListGroupLayout>,
|
layouts: ReadonlyArray<MemberListGroupLayout>,
|
||||||
rowIndex: number,
|
rowIndex: number,
|
||||||
): MemberListGroupLayout | null {
|
): MemberListGroupLayout | null {
|
||||||
for (const layout of layouts) {
|
let low = 0;
|
||||||
if (rowIndex < layout.headerRowIndex) {
|
let high = layouts.length - 1;
|
||||||
return null;
|
let candidate: MemberListGroupLayout | null = null;
|
||||||
}
|
while (low <= high) {
|
||||||
if (rowIndex <= layout.rowEndIndex) {
|
const mid = (low + high) >> 1;
|
||||||
return layout;
|
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(
|
export function getMemberIndexForRow(
|
||||||
@@ -166,15 +194,23 @@ export function getRowIndexForMemberIndex(
|
|||||||
if (layouts.length === 0) {
|
if (layouts.length === 0) {
|
||||||
return memberIndex;
|
return memberIndex;
|
||||||
}
|
}
|
||||||
for (const layout of layouts) {
|
let low = 0;
|
||||||
if (memberIndex < layout.memberStartIndex) {
|
let high = layouts.length - 1;
|
||||||
return null;
|
let candidate: MemberListGroupLayout | null = null;
|
||||||
}
|
while (low <= high) {
|
||||||
if (memberIndex <= layout.memberEndIndex) {
|
const mid = (low + high) >> 1;
|
||||||
return layout.headerRowIndex + 1 + (memberIndex - layout.memberStartIndex);
|
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(
|
export function getRowIndexRangeForMemberIndexRange(
|
||||||
@@ -189,3 +225,22 @@ export function getRowIndexRangeForMemberIndexRange(
|
|||||||
}
|
}
|
||||||
return [start, end];
|
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);
|
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(
|
expect(
|
||||||
buildMemberListRangeWindow({
|
buildMemberListRangeWindow({
|
||||||
scrollTop: 44 * 250,
|
scrollTop: 44 * 250,
|
||||||
@@ -52,13 +52,14 @@ describe('MemberListRangeUtils', () => {
|
|||||||
totalRows: 500,
|
totalRows: 500,
|
||||||
}),
|
}),
|
||||||
).toEqual([
|
).toEqual([
|
||||||
|
[0, 99],
|
||||||
[100, 199],
|
[100, 199],
|
||||||
[200, 299],
|
[200, 299],
|
||||||
[300, 399],
|
[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(
|
expect(
|
||||||
buildMemberListRangeWindow({
|
buildMemberListRangeWindow({
|
||||||
scrollTop: 44 * 490,
|
scrollTop: 44 * 490,
|
||||||
@@ -68,6 +69,7 @@ describe('MemberListRangeUtils', () => {
|
|||||||
totalRows: 500,
|
totalRows: 500,
|
||||||
}),
|
}),
|
||||||
).toEqual([
|
).toEqual([
|
||||||
|
[0, 99],
|
||||||
[300, 399],
|
[300, 399],
|
||||||
[400, 499],
|
[400, 499],
|
||||||
]);
|
]);
|
||||||
@@ -83,7 +85,41 @@ describe('MemberListRangeUtils', () => {
|
|||||||
overscanPages: 0,
|
overscanPages: 0,
|
||||||
totalRows: 500,
|
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', () => {
|
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);
|
lastPage = Math.min(lastPage, maxPage);
|
||||||
}
|
}
|
||||||
const ranges: MemberListRanges = [];
|
const ranges: MemberListRanges = [];
|
||||||
|
if (firstPage > 0) {
|
||||||
|
ranges.push([0, MEMBER_LIST_RANGE_MAX_SPAN]);
|
||||||
|
}
|
||||||
for (let page = firstPage; page <= lastPage; page += 1) {
|
for (let page = firstPage; page <= lastPage; page += 1) {
|
||||||
const pageStart = page * MEMBER_LIST_RANGE_PAGE_SIZE;
|
const pageStart = page * MEMBER_LIST_RANGE_PAGE_SIZE;
|
||||||
const maxPageEnd = pageStart + MEMBER_LIST_RANGE_MAX_SPAN;
|
ranges.push([pageStart, pageStart + MEMBER_LIST_RANGE_MAX_SPAN]);
|
||||||
const pageEnd = safeTotalRows != null ? Math.min(maxPageEnd, safeTotalRows - 1) : maxPageEnd;
|
|
||||||
ranges.push([pageStart, pageEnd]);
|
|
||||||
}
|
}
|
||||||
return normalizeMemberListRanges(ranges);
|
return normalizeMemberListRanges(ranges);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user