perf(app): render member list rows in scroll chunks (#1895)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-24 12:41:14 +02:00
1 parent b81bfc80fd
commit c54d7bcc88
5 files changed
+146 -228

No files matched your search

@@ -18,6 +18,8 @@ import {
MEMBER_LIST_GROUP_HEADER_HEIGHT_PX,
MEMBER_LIST_ITEM_HEIGHT_PX,
MEMBER_LIST_METRICS_STYLE,
MEMBER_LIST_SCROLL_CHUNK_PX,
MEMBER_LIST_SUBSCRIPTION_VIEWPORT_RATIO,
} from '@app/features/channel/components/MemberListMetrics';
import {
MemberListSkeleton,
@@ -28,8 +30,6 @@ import {MemberListUnavailableFallback} from '@app/features/channel/components/sh
import type {Channel} from '@app/features/channel/models/Channel';
import type {Guild} from '@app/features/guild/models/Guild';
import {OFFLINE_DESCRIPTOR, ONLINE_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {resolveMemberListCustomStatus} from '@app/features/member/hooks/useMemberListCustomStatus';
import {resolveMemberListPresence} from '@app/features/member/hooks/useMemberListPresence';
import {useMemberListSubscription} from '@app/features/member/hooks/useMemberListSubscription';
import {resolveMemberListViewportModel} from '@app/features/member/state/MemberListViewportStateMachine';
import MemberSidebar from '@app/features/member/state/MemberSidebar';
@@ -38,6 +38,7 @@ import {
buildMemberListRowOffsets,
getGroupLayoutForRow,
getTotalRowsFromLayout,
quantizeMemberListScrollSpan,
} from '@app/features/member/utils/MemberListLayout';
import {
areNormalizedMemberListRangesEqual,
@@ -60,6 +61,7 @@ import * as NicknameUtils from '@app/features/user/utils/NicknameUtils';
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
import {MEMBER_LIST_RANGE_MAX_SPAN} from '@fluxer/constants/src/GatewayConstants';
import {GuildOperations} from '@fluxer/constants/src/GuildConstants';
import {MEDIA_PROXY_AVATAR_SIZE_DEFAULT} from '@fluxer/constants/src/MediaProxyAssetSizes';
import {useLingui as useLinguiRuntime} from '@lingui/react';
import {useLingui} from '@lingui/react/macro';
import clsx from 'clsx';
@@ -72,11 +74,10 @@ const INITIAL_RENDER_RANGE: [number, number] = [0, 64];
const INITIAL_SUBSCRIPTION_RANGES = normalizeMemberListRanges([INITIAL_SUBSCRIPTION_RANGE]);
const INITIAL_RENDER_RANGES = normalizeMemberListRanges([INITIAL_RENDER_RANGE]);
const EMPTY_MEMBER_LIST_RANGES = normalizeMemberListRanges([]);
const SUBSCRIPTION_BUFFER_ROWS = 12;
const SUBSCRIPTION_OVERSCAN_PAGES = 0;
const RENDER_BUFFER_ROWS = 6;
const AVATAR_DEFER_AFTER_SCROLL_IDLE_MS = 180;
const MEMBER_LIST_AVATAR_MEDIA_SIZE = 64;
const MIN_SUBSCRIPTION_BUFFER_ROWS = 1;
const RESIDENT_SUBSCRIPTION_RANGE: [number, number] = [0, MEMBER_LIST_RANGE_MAX_SPAN];
const EMPTY_MEMBER_GROUPS: NonNullable<ReturnType<typeof MemberSidebar.getList>>['groups'] = [];
function measureMemberGroupHeadingWidthPx(element: HTMLElement | null): number {
if (element == null) {
@@ -95,36 +96,41 @@ function measureMemberGroupHeadingWidthPx(element: HTMLElement | null): number {
return Math.round((right - left) / getRemScaleForDocument(element.ownerDocument));
}
function createGroupHeadingRegistrar(groupHeadingWidths: Map<string, number>): (node: HTMLDivElement | null) => void {
return (node) => {
if (node == null) {
return;
}
const groupId = node.dataset.memberGroupId;
if (groupId == null) {
return;
}
const widthPx = measureMemberGroupHeadingWidthPx(node);
if (widthPx !== SKELETON_UNMEASURED_WIDTH_PX) {
groupHeadingWidths.set(groupId, widthPx);
}
};
}
interface GroupHeadingWidthTracking {
contentKey: string;
groupHeadingWidths: Map<string, number>;
registerGroupHeading: (node: HTMLDivElement | null) => void;
captureGroupHeadingWidths: () => void;
}
function useGroupHeadingWidthTracking(contentKey: string): GroupHeadingWidthTracking {
function useGroupHeadingWidthTracking(): GroupHeadingWidthTracking {
const trackingRef = useRef<GroupHeadingWidthTracking | null>(null);
if (trackingRef.current == null || trackingRef.current.contentKey !== contentKey) {
const groupHeadingWidths = trackingRef.current?.groupHeadingWidths ?? new Map<string, number>();
if (trackingRef.current == null) {
const groupHeadingWidths = new Map<string, number>();
const groupHeadingNodes = new Map<string, HTMLDivElement>();
trackingRef.current = {
contentKey,
groupHeadingWidths,
registerGroupHeading: createGroupHeadingRegistrar(groupHeadingWidths),
registerGroupHeading: (node) => {
if (node == null) {
return;
}
const groupId = node.dataset.memberGroupId;
if (groupId == null) {
return;
}
groupHeadingNodes.set(groupId, node);
},
captureGroupHeadingWidths: () => {
for (const [groupId, node] of groupHeadingNodes) {
if (!node.isConnected) {
groupHeadingNodes.delete(groupId);
continue;
}
const widthPx = measureMemberGroupHeadingWidthPx(node);
if (widthPx !== SKELETON_UNMEASURED_WIDTH_PX) {
groupHeadingWidths.set(groupId, widthPx);
}
}
},
};
}
return trackingRef.current;
@@ -211,12 +217,10 @@ const LazyMemberList = observer(function LazyMemberList({guild, channel}: LazyMe
const subscriptionRangesRef = useRef<NormalizedMemberListRanges>(INITIAL_SUBSCRIPTION_RANGES);
const renderRangesRef = useRef<NormalizedMemberListRanges>(INITIAL_RENDER_RANGES);
const scrollFrameRef = useRef<number | null>(null);
const avatarDeferTimerRef = useRef<number | null>(null);
const avatarDeferDeadlineRef = useRef(0);
const pendingScrollMetricsRef = useRef<{scrollTop: number; clientHeight: number} | null>(null);
const scrollerRef = useRef<ScrollerHandle | null>(null);
const viewportHeightRef = useRef<number | null>(null);
const [renderWindowRanges, setRenderWindowRanges] = useState<NormalizedMemberListRanges>(INITIAL_RENDER_RANGES);
const [deferAvatarLoad, setDeferAvatarLoad] = useState(false);
const memberListIdentityKey = MemberSidebar.getListIdentityKey(guild.id, channel.id);
const memberListUpdatesDisabled = (guild.disabledOperations & GuildOperations.MEMBER_LIST_UPDATES) !== 0;
const currentUserId = Authentication.currentUserId;
@@ -229,10 +233,10 @@ const LazyMemberList = observer(function LazyMemberList({guild, channel}: LazyMe
});
const memberListState = MemberSidebar.getList(guild.id, channel.id);
const memberCount = memberListState?.memberCount ?? 0;
const groups = memberListState?.groups ?? [];
const groups = memberListState?.groups ?? EMPTY_MEMBER_GROUPS;
const populatedGroups = useMemo(() => groups.filter((group) => group.count > 0), [groups]);
const memberGroupCountsKey = populatedGroups.map((group) => `${group.id}:${group.count}`).join(',');
const {groupHeadingWidths, registerGroupHeading} = useGroupHeadingWidthTracking(memberGroupCountsKey);
const {groupHeadingWidths, registerGroupHeading, captureGroupHeadingWidths} = useGroupHeadingWidthTracking();
const memberSurfaceKind =
channel.type === ChannelTypes.GUILD_VOICE ? SkeletonMemberSurfaceKind.GUILD_VOICE : SkeletonMemberSurfaceKind.GUILD;
const rememberedMemberGroups = getRememberedSkeletonMemberGroups(channel.id, memberSurfaceKind);
@@ -248,6 +252,7 @@ const LazyMemberList = observer(function LazyMemberList({guild, channel}: LazyMe
const remScale = getAppRemScale();
const scaledMemberItemHeight = MEMBER_LIST_ITEM_HEIGHT_PX * remScale;
const scaledGroupHeaderHeight = MEMBER_LIST_GROUP_HEADER_HEIGHT_PX * remScale;
const scaledScrollChunk = MEMBER_LIST_SCROLL_CHUNK_PX * remScale;
const rowOffsets = useMemo(
() =>
layouts.length > 0
@@ -280,6 +285,7 @@ const LazyMemberList = observer(function LazyMemberList({guild, channel}: LazyMe
if (memberListUpdatesDisabled || lacksMemberViewPermission || !memberListState?.hasReceivedInitialPayload) {
return;
}
captureGroupHeadingWidths();
reportSkeletonMemberLayout(
channel.id,
memberSurfaceKind,
@@ -305,21 +311,29 @@ const LazyMemberList = observer(function LazyMemberList({guild, channel}: LazyMe
);
const commitRangeUpdate = useCallback(
(scrollTop: number, clientHeight: number) => {
const nextSubscriptionRanges = buildMemberListRangeWindow({
scrollTop,
clientHeight,
rowHeight: scaledMemberItemHeight,
rowOffsets,
bufferRows: SUBSCRIPTION_BUFFER_ROWS,
overscanPages: SUBSCRIPTION_OVERSCAN_PAGES,
totalRows: totalRows > 0 ? totalRows : undefined,
});
const subscriptionBufferRows = Math.max(
MIN_SUBSCRIPTION_BUFFER_ROWS,
Math.ceil((clientHeight * MEMBER_LIST_SUBSCRIPTION_VIEWPORT_RATIO) / scaledMemberItemHeight),
);
const nextSubscriptionRanges = normalizeMemberListRanges([
RESIDENT_SUBSCRIPTION_RANGE,
...buildMemberListRangeWindow({
scrollTop,
clientHeight,
rowHeight: scaledMemberItemHeight,
rowOffsets,
bufferRows: subscriptionBufferRows,
overscanPages: SUBSCRIPTION_OVERSCAN_PAGES,
totalRows: totalRows > 0 ? totalRows : undefined,
}),
]);
const renderSpan = quantizeMemberListScrollSpan(scrollTop, clientHeight, scaledScrollChunk);
const nextRenderRanges = buildMemberListRenderWindow({
scrollTop,
clientHeight,
scrollTop: renderSpan.top,
clientHeight: renderSpan.height,
rowHeight: scaledMemberItemHeight,
rowOffsets,
bufferRows: RENDER_BUFFER_ROWS,
bufferRows: 0,
totalRows: totalRows > 0 ? totalRows : undefined,
});
if (!areNormalizedMemberListRangesEqual(nextRenderRanges, renderRangesRef.current)) {
@@ -331,29 +345,8 @@ const LazyMemberList = observer(function LazyMemberList({guild, channel}: LazyMe
subscribe(nextSubscriptionRanges);
}
},
[subscribe, totalRows, rowOffsets, scaledMemberItemHeight, zoomLevel],
[subscribe, totalRows, rowOffsets, scaledMemberItemHeight, scaledScrollChunk, zoomLevel],
);
const finishAvatarLoadingDeferralAfterIdle = useCallback(() => {
avatarDeferTimerRef.current = null;
const remainingMs = avatarDeferDeadlineRef.current - performance.now();
if (remainingMs > 0) {
avatarDeferTimerRef.current = window.setTimeout(finishAvatarLoadingDeferralAfterIdle, remainingMs);
return;
}
setDeferAvatarLoad(false);
}, []);
const markAvatarLoadingDeferred = useCallback(() => {
if (!deferAvatarLoad) {
setDeferAvatarLoad(true);
}
avatarDeferDeadlineRef.current = performance.now() + AVATAR_DEFER_AFTER_SCROLL_IDLE_MS;
if (avatarDeferTimerRef.current == null) {
avatarDeferTimerRef.current = window.setTimeout(
finishAvatarLoadingDeferralAfterIdle,
AVATAR_DEFER_AFTER_SCROLL_IDLE_MS,
);
}
}, [deferAvatarLoad, finishAvatarLoadingDeferralAfterIdle]);
const flushScrollRangeUpdate = useCallback(() => {
scrollFrameRef.current = null;
const metrics = pendingScrollMetricsRef.current;
@@ -383,20 +376,25 @@ const LazyMemberList = observer(function LazyMemberList({guild, channel}: LazyMe
const handleScroll = useCallback(
(event: UIEvent<HTMLDivElement>) => {
const target = event.currentTarget;
markAvatarLoadingDeferred();
scheduleRangeUpdate(target.scrollTop, target.clientHeight);
let viewportHeight = viewportHeightRef.current;
if (viewportHeight == null) {
viewportHeight = target.clientHeight;
viewportHeightRef.current = viewportHeight;
}
scheduleRangeUpdate(target.scrollTop, viewportHeight);
},
[markAvatarLoadingDeferred, scheduleRangeUpdate],
[scheduleRangeUpdate],
);
const handleResize = useCallback(() => {
viewportHeightRef.current = null;
scheduleRangeUpdateFromScroller();
}, [scheduleRangeUpdateFromScroller]);
useEffect(() => {
setDeferAvatarLoad(false);
const initialSubscriptionRanges = INITIAL_SUBSCRIPTION_RANGES;
const initialRenderRanges = INITIAL_RENDER_RANGES;
subscriptionRangesRef.current = initialSubscriptionRanges;
renderRangesRef.current = initialRenderRanges;
viewportHeightRef.current = null;
setRenderWindowRanges(initialRenderRanges);
}, [memberListIdentityKey, guild.id]);
useEffect(() => {
@@ -408,11 +406,6 @@ const LazyMemberList = observer(function LazyMemberList({guild, channel}: LazyMe
window.cancelAnimationFrame(scrollFrameRef.current);
scrollFrameRef.current = null;
}
if (avatarDeferTimerRef.current != null) {
window.clearTimeout(avatarDeferTimerRef.current);
avatarDeferTimerRef.current = null;
}
avatarDeferDeadlineRef.current = 0;
pendingScrollMetricsRef.current = null;
};
}, [memberListIdentityKey, guild.id]);
@@ -457,6 +450,7 @@ const LazyMemberList = observer(function LazyMemberList({guild, channel}: LazyMe
);
}
const virtualRows: Array<ReactNode> = [];
const renderedMemberIds = new Set<string>();
for (const [rangeStart, rangeEnd] of renderRanges) {
const firstRow = Math.max(0, rangeStart);
const lastRow = totalRows > 0 ? Math.min(rangeEnd, totalRows - 1) : -1;
@@ -487,19 +481,17 @@ const LazyMemberList = observer(function LazyMemberList({guild, channel}: LazyMe
continue;
}
const user = member.user;
if (renderedMemberIds.has(user.id)) {
continue;
}
renderedMemberIds.add(user.id);
const displayName = member.nick
? NicknameUtils.formatNicknameForStreamerMode(member.nick)
: NicknameUtils.getNickname(user, guild.id);
const status = resolveMemberListPresence({guildId: guild.id, channelId: channel.id, userId: user.id});
const customStatus = resolveMemberListCustomStatus({
guildId: guild.id,
channelId: channel.id,
userId: user.id,
});
const roleColor = member.getColorString?.() ?? undefined;
virtualRows.push(
<div
key={`member-${rowIndex}-${user.id}`}
key={`member-${user.id}`}
className={clsx(styles.virtualRow, styles.virtualMemberRow)}
style={rowStyle}
data-flx="channel.channel-members.lazy-member-list.virtual-row.member"
@@ -509,15 +501,11 @@ const LazyMemberList = observer(function LazyMemberList({guild, channel}: LazyMe
channelId={channel.id}
guildId={guild.id}
guildMember={member}
status={status}
customStatus={customStatus}
isOwner={guild.isOwner(user.id)}
roleColor={roleColor}
displayName={displayName}
disableBackdrop={true}
deferAvatarLoad={deferAvatarLoad}
deferCustomStatusMedia={deferAvatarLoad}
avatarMediaSize={MEMBER_LIST_AVATAR_MEDIA_SIZE}
avatarMediaSize={MEDIA_PROXY_AVATAR_SIZE_DEFAULT}
data-flx="channel.channel-members.lazy-member-list.virtual-row.member-list-item"
/>
</div>,
@@ -542,7 +530,7 @@ const LazyMemberList = observer(function LazyMemberList({guild, channel}: LazyMe
if (role) {
virtualRows.push(
<div
key={`group-${rowIndex}-${group.id}`}
key={`group-${group.id}`}
ref={registerGroupHeading}
data-member-group-id={group.id}
className={clsx(styles.virtualRow, styles.virtualGroupRow)}
@@ -565,7 +553,7 @@ const LazyMemberList = observer(function LazyMemberList({guild, channel}: LazyMe
} else {
virtualRows.push(
<div
key={`group-${rowIndex}-${group.id}`}
key={`group-${group.id}`}
ref={registerGroupHeading}
data-member-group-id={group.id}
className={clsx(styles.virtualRow, styles.virtualGroupRow)}
@@ -601,19 +589,17 @@ const LazyMemberList = observer(function LazyMemberList({guild, channel}: LazyMe
continue;
}
const user = member.user;
if (renderedMemberIds.has(user.id)) {
continue;
}
renderedMemberIds.add(user.id);
const displayName = member.nick
? NicknameUtils.formatNicknameForStreamerMode(member.nick)
: NicknameUtils.getNickname(user, guild.id);
const status = resolveMemberListPresence({guildId: guild.id, channelId: channel.id, userId: user.id});
const customStatus = resolveMemberListCustomStatus({
guildId: guild.id,
channelId: channel.id,
userId: user.id,
});
const roleColor = member.getColorString?.() ?? undefined;
virtualRows.push(
<div
key={`member-${rowIndex}-${user.id}`}
key={`member-${user.id}`}
className={clsx(styles.virtualRow, styles.virtualMemberRow)}
style={rowStyle}
data-flx="channel.channel-members.lazy-member-list.virtual-row.member--2"
@@ -623,15 +609,11 @@ const LazyMemberList = observer(function LazyMemberList({guild, channel}: LazyMe
channelId={channel.id}
guildId={guild.id}
guildMember={member}
status={status}
customStatus={customStatus}
isOwner={guild.isOwner(user.id)}
roleColor={roleColor}
displayName={displayName}
disableBackdrop={true}
deferAvatarLoad={deferAvatarLoad}
deferCustomStatusMedia={deferAvatarLoad}
avatarMediaSize={MEMBER_LIST_AVATAR_MEDIA_SIZE}
avatarMediaSize={MEDIA_PROXY_AVATAR_SIZE_DEFAULT}
data-flx="channel.channel-members.lazy-member-list.virtual-row.member-list-item--2"
/>
</div>,
@@ -672,8 +654,9 @@ const GroupDMChannelMembers = observer(function GroupDMChannelMembers({channel}:
const memberGroupContentKey = memberGroups
.map((group) => `${group.id}:${group.displayName}:${group.count}`)
.join(',');
const {groupHeadingWidths, registerGroupHeading} = useGroupHeadingWidthTracking(memberGroupContentKey);
const {groupHeadingWidths, registerGroupHeading, captureGroupHeadingWidths} = useGroupHeadingWidthTracking();
useSkeletonLayoutReport(() => {
captureGroupHeadingWidths();
reportSkeletonMemberLayout(
channel.id,
SkeletonMemberSurfaceKind.GROUP_DM,
@@ -1,13 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import sharedStyles from '@app/features/app/components/shared/custom_status_display/CustomStatusDisplay.module.css';
import {buildCustomEmojiURL} from '@app/features/expressions/utils/CustomEmojiImageUrl';
import {getEmojiURL as getUnicodeEmojiURL} from '@app/features/expressions/utils/EmojiUtils';
import {usePresenceCustomStatus} from '@app/features/presence/hooks/usePresenceCustomStatus';
import {useTextOverflow} from '@app/features/ui/hooks/useTextOverflow';
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
import type {CustomStatus} from '@app/features/user/state/CustomStatus';
import {getCustomStatusText, isCustomStatusExpired, normalizeCustomStatus} from '@app/features/user/state/CustomStatus';
import * as AvatarUtils from '@app/features/user/utils/AvatarUtils';
import clsx from 'clsx';
import {type ReactNode, useEffect, useMemo, useRef, useState} from 'react';
@@ -16,7 +16,6 @@ interface CompactMemberCustomStatusProps {
customStatus?: CustomStatus | null;
userId?: string;
showText?: boolean;
deferMediaLoad?: boolean;
}
function sanitizeText(text: string): string {
@@ -45,33 +44,14 @@ export function hasVisibleCompactMemberCustomStatus(status: CustomStatus | null
return sanitizeText(normalized.text ?? '').length > 0;
}
const loadedStatusMediaUrls = new Set<string>();
function markStatusMediaLoaded(url: string): void {
loadedStatusMediaUrls.add(url);
}
function renderEmoji(status: CustomStatus, deferMediaLoad: boolean): ReactNode {
function renderEmoji(status: CustomStatus): ReactNode {
if (status.emojiId) {
const emojiUrl = AvatarUtils.getEmojiURL({id: status.emojiId, animated: false});
if (deferMediaLoad && !loadedStatusMediaUrls.has(emojiUrl)) {
return (
<span
className={sharedStyles.statusEmojiWrapper}
aria-hidden={true}
data-flx="channel.compact-member-custom-status.emoji-placeholder"
/>
);
}
return (
<img
src={emojiUrl}
src={buildCustomEmojiURL({id: status.emojiId, animated: false})}
alt={status.emojiName ?? undefined}
draggable={false}
className={sharedStyles.statusEmoji}
loading="lazy"
decoding="async"
onLoad={() => markStatusMediaLoaded(emojiUrl)}
data-flx="channel.compact-member-custom-status.emoji"
/>
);
@@ -83,24 +63,12 @@ function renderEmoji(status: CustomStatus, deferMediaLoad: boolean): ReactNode {
if (!twemojiUrl) {
return null;
}
if (deferMediaLoad && !loadedStatusMediaUrls.has(twemojiUrl)) {
return (
<span
className={sharedStyles.statusEmojiWrapper}
aria-hidden={true}
data-flx="channel.compact-member-custom-status.unicode-emoji-placeholder"
/>
);
}
return (
<img
src={twemojiUrl}
alt={status.emojiName}
draggable={false}
className={sharedStyles.statusEmoji}
loading="lazy"
decoding="async"
onLoad={() => markStatusMediaLoaded(twemojiUrl)}
data-flx="channel.compact-member-custom-status.unicode-emoji"
/>
);
@@ -111,7 +79,6 @@ export function CompactMemberCustomStatus({
customStatus,
userId,
showText = true,
deferMediaLoad = false,
}: CompactMemberCustomStatusProps) {
const containerRef = useRef<HTMLDivElement>(null);
const shouldFetchFromPresence = customStatus === undefined && userId !== undefined;
@@ -143,7 +110,7 @@ export function CompactMemberCustomStatus({
return null;
}
const text = normalized.text ? sanitizeText(normalized.text) : null;
const emoji = renderEmoji(normalized, deferMediaLoad);
const emoji = renderEmoji(normalized);
if (!emoji && (!showText || !text)) {
return null;
}
@@ -16,7 +16,6 @@ import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuComma
import {FocusRingWrapper} from '@app/features/ui/components/FocusRingWrapper';
import {ListStatusAwareAvatar} from '@app/features/ui/components/StatusAwareAvatar';
import {useTextOverflow} from '@app/features/ui/hooks/useTextOverflow';
import ContextMenu from '@app/features/ui/state/ContextMenu';
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
import type {User} from '@app/features/user/models/User';
import type {CustomStatus} from '@app/features/user/state/CustomStatus';
@@ -30,10 +29,9 @@ import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {CrownIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {autorun} from 'mobx';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {useCallback, useMemo, useRef, useState} from 'react';
const COMMUNITY_OWNER_DESCRIPTOR = msg({
message: 'Community owner',
@@ -57,8 +55,6 @@ interface MemberListItemProps {
roleColor?: string;
displayName?: string;
disableBackdrop?: boolean;
deferAvatarLoad?: boolean;
deferCustomStatusMedia?: boolean;
avatarMediaSize?: MediaProxyImageSize;
}
@@ -75,7 +71,6 @@ export const MemberListItem: React.FC<MemberListItemProps> = observer((props) =>
roleColor,
displayName,
disableBackdrop = false,
deferCustomStatusMedia = false,
avatarMediaSize,
} = props;
const itemRef = useRef<HTMLButtonElement>(null);
@@ -83,9 +78,9 @@ export const MemberListItem: React.FC<MemberListItemProps> = observer((props) =>
guildId: guildId ?? '',
channelId,
userId: user.id,
enabled: guildId !== undefined && providedStatus === undefined,
enabled: providedStatus === undefined,
});
const status = providedStatus ?? hookStatus;
const status = providedStatus !== undefined ? providedStatus : hookStatus;
const hookCustomStatus = useMemberListCustomStatus({
guildId: guildId ?? '',
channelId,
@@ -94,53 +89,53 @@ export const MemberListItem: React.FC<MemberListItemProps> = observer((props) =>
});
const memberListCustomStatus = providedCustomStatus !== undefined ? providedCustomStatus : hookCustomStatus;
const [contextMenuOpen, setContextMenuOpen] = useState(false);
const contextMenuTicketRef = useRef(0);
const isCurrentUser = user.id === Authentication.currentUserId;
const isTyping = TypingIndicator.isMemberListTyping(channelId, user.id, Authentication.currentUserId);
useEffect(() => {
const disposer = autorun(() => {
const contextMenu = ContextMenu.contextMenu;
const targetElement = contextMenu?.target.target;
const isNodeTarget = typeof Node !== 'undefined' && targetElement instanceof Node;
const isOpen = Boolean(contextMenu && isNodeTarget && itemRef.current?.contains(targetElement));
setContextMenuOpen(isOpen);
});
return () => {
disposer();
};
}, []);
const handleContextMenu = useCallback(
(event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
ContextMenuCommands.openFromEvent(event, ({onClose}) => (
<>
{guildId ? (
<GuildMemberContextMenu
user={user}
onClose={onClose}
guildId={guildId}
channelId={channelId}
member={guildMember}
data-flx="channel.member-list-item.handle-context-menu.guild-member-context-menu"
/>
) : (
<GroupDMMemberContextMenu
userId={user.id}
channelId={channelId}
onClose={onClose}
data-flx="channel.member-list-item.handle-context-menu.group-dm-member-context-menu"
/>
)}
</>
));
const ticket = contextMenuTicketRef.current + 1;
contextMenuTicketRef.current = ticket;
setContextMenuOpen(true);
ContextMenuCommands.openFromEvent(
event,
({onClose}) => (
<>
{guildId ? (
<GuildMemberContextMenu
user={user}
onClose={onClose}
guildId={guildId}
channelId={channelId}
member={guildMember}
data-flx="channel.member-list-item.handle-context-menu.guild-member-context-menu"
/>
) : (
<GroupDMMemberContextMenu
userId={user.id}
channelId={channelId}
onClose={onClose}
data-flx="channel.member-list-item.handle-context-menu.group-dm-member-context-menu"
/>
)}
</>
),
{
onClose: () => {
if (contextMenuTicketRef.current === ticket) {
setContextMenuOpen(false);
}
},
},
);
},
[user, guildId, channelId, guildMember],
);
const ownerTitle = guildId ? i18n._(COMMUNITY_OWNER_DESCRIPTOR) : i18n._(GROUP_OWNER_DESCRIPTOR);
const hideOwnerCrown = guildId
? (Guilds.getGuild(guildId)?.features.has(GuildFeatures.HIDE_OWNER_CROWN) ?? false)
: false;
const showOwnerCrown = isOwner && !hideOwnerCrown;
const showOwnerCrown =
isOwner &&
!(guildId !== undefined && (Guilds.getGuild(guildId)?.features.has(GuildFeatures.HIDE_OWNER_CROWN) ?? false));
const nickname = displayName || NicknameUtils.getNickname(user, guildId, channelId);
const nameRef = useRef<HTMLSpanElement>(null);
const isNameOverflowing = useTextOverflow(nameRef, {content: nickname, measureTextRange: true});
@@ -213,7 +208,7 @@ export const MemberListItem: React.FC<MemberListItemProps> = observer((props) =>
avatarUrl={memberAvatarUrl}
hoverAvatarUrl={memberHoverAvatarUrl}
mediaSize={avatarMediaSize}
data-flx="channel.member-list-item.status-aware-avatar"
data-flx="channel.member-list-item.status-aware-avatar"
/>
</div>
<div className={styles.userInfoContainer} data-flx="channel.member-list-item.user-info-container">
@@ -227,7 +222,10 @@ export const MemberListItem: React.FC<MemberListItemProps> = observer((props) =>
)}
{showOwnerCrown && (
<div className={styles.ownerIcon} data-flx="channel.member-list-item.owner-icon">
<Tooltip text={ownerTitle} data-flx="channel.member-list-item.tooltip">
<Tooltip
text={i18n._(guildId ? COMMUNITY_OWNER_DESCRIPTOR : GROUP_OWNER_DESCRIPTOR)}
data-flx="channel.member-list-item.tooltip"
>
<CrownIcon className={styles.crownIcon} data-flx="channel.member-list-item.crown-icon" />
</Tooltip>
</div>
@@ -244,7 +242,6 @@ export const MemberListItem: React.FC<MemberListItemProps> = observer((props) =>
customStatus={memberListCustomStatus}
userId={user.id}
className={styles.memberCustomStatus}
deferMediaLoad={deferCustomStatusMedia}
data-flx="channel.member-list-item.member-custom-status"
/>
</div>
@@ -261,7 +258,6 @@ export const MemberListItem: React.FC<MemberListItemProps> = observer((props) =>
guildId={guildId}
guildMember={guildMember}
channelId={channelId}
key={user.id}
disableContextMenu
disableBackdrop={disableBackdrop}
profilePopoutAnimationType="profile-slide-inverted"
@@ -7,6 +7,8 @@ import type {CSSProperties} from 'react';
export const MEMBER_LIST_ITEM_HEIGHT_PX = 44;
export const MEMBER_LIST_GROUP_DM_ITEM_HEIGHT_PX = 40;
export const MEMBER_LIST_GROUP_HEADER_HEIGHT_PX = 32;
export const MEMBER_LIST_SCROLL_CHUNK_PX = 256;
export const MEMBER_LIST_SUBSCRIPTION_VIEWPORT_RATIO = 0.5;
const MEMBER_LIST_METRICS = {
'--member-list-item-height': remFromPx(MEMBER_LIST_ITEM_HEIGHT_PX),
@@ -31,12 +31,13 @@ import * as NicknameUtils from '@app/features/user/utils/NicknameUtils';
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
import {MEMBER_LIST_RANGE_MAX_SPAN} from '@fluxer/constants/src/GatewayConstants';
import {GuildFeatures, GuildOperations} from '@fluxer/constants/src/GuildConstants';
import {MEDIA_PROXY_AVATAR_SIZE_DEFAULT} from '@fluxer/constants/src/MediaProxyAssetSizes';
import {isOfflineStatus} from '@fluxer/constants/src/StatusConstants';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import clsx from 'clsx';
import {observer} from 'mobx-react-lite';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import React, {useCallback, useEffect, useMemo, useRef} from 'react';
const COMMUNITY_OWNER_DESCRIPTOR = msg({
message: 'Community owner',
@@ -46,8 +47,6 @@ const MEMBER_ITEM_HEIGHT = 56;
const INITIAL_MEMBER_RANGE: [number, number] = [0, MEMBER_LIST_RANGE_MAX_SPAN];
const SCROLL_BUFFER_ROWS = 10;
const SUBSCRIPTION_OVERSCAN_PAGES = 0;
const AVATAR_DEFER_AFTER_SCROLL_IDLE_MS = 180;
const MEMBER_LIST_AVATAR_MEDIA_SIZE = 80;
function isScrollableOverflow(value: string): boolean {
return value === 'auto' || value === 'scroll' || value === 'overlay';
@@ -109,13 +108,11 @@ export const MobileMemberListItem = observer(
guild,
channelId,
member,
deferAvatarLoad = false,
onLongPress,
}: {
guild: Guild;
channelId: string;
member: GuildMember;
deferAvatarLoad?: boolean;
onLongPress?: (member: GuildMember) => void;
}) => {
const {i18n} = useLingui();
@@ -140,7 +137,7 @@ export const MobileMemberListItem = observer(
memberAvatar: member.avatar,
avatarUnset: member.isAvatarUnset(),
animated: false,
size: MEMBER_LIST_AVATAR_MEDIA_SIZE,
size: MEDIA_PROXY_AVATAR_SIZE_DEFAULT,
}),
[guild.id, member],
);
@@ -152,7 +149,7 @@ export const MobileMemberListItem = observer(
memberAvatar: member.avatar,
avatarUnset: member.isAvatarUnset(),
animated: true,
size: MEMBER_LIST_AVATAR_MEDIA_SIZE,
size: MEDIA_PROXY_AVATAR_SIZE_DEFAULT,
}),
[guild.id, member],
);
@@ -180,8 +177,8 @@ export const MobileMemberListItem = observer(
status={status}
avatarUrl={avatarUrl}
hoverAvatarUrl={hoverAvatarUrl}
mediaSize={MEMBER_LIST_AVATAR_MEDIA_SIZE}
data-flx="channel.channel-details-bottom-sheet-member-list.mobile-member-list-item.status-aware-avatar"
mediaSize={MEDIA_PROXY_AVATAR_SIZE_DEFAULT}
data-flx="channel.channel-details-bottom-sheet-member-list.mobile-member-list-item.status-aware-avatar"
/>
<div
className={styles.memberContent}
@@ -228,7 +225,6 @@ export const MobileMemberListItem = observer(
userId={member.user.id}
className={styles.memberCustomStatus}
showText={true}
deferMediaLoad={deferAvatarLoad}
data-flx="channel.channel-details-bottom-sheet-member-list.mobile-member-list-item.member-custom-status"
/>
)}
@@ -256,12 +252,11 @@ interface LazyMemberListGroupProps {
group: {id: string; count: number};
channelId: string;
members: Array<GuildMember>;
deferAvatarLoad?: boolean;
onMemberLongPress?: (member: GuildMember) => void;
}
const LazyMemberListGroup = observer(
({guild, group, channelId, members, deferAvatarLoad = false, onMemberLongPress}: LazyMemberListGroupProps) => {
({guild, group, channelId, members, onMemberLongPress}: LazyMemberListGroupProps) => {
const {i18n} = useLingui();
const groupName = (() => {
switch (group.id) {
@@ -296,7 +291,6 @@ const LazyMemberListGroup = observer(
guild={guild}
channelId={channelId}
member={member}
deferAvatarLoad={deferAvatarLoad}
onLongPress={onMemberLongPress}
data-flx="channel.channel-details-bottom-sheet-member-list.lazy-member-list-group.mobile-member-list-item"
/>
@@ -328,8 +322,6 @@ const LazyGuildMemberList = observer(
const subscribedRangesRef = useRef<Array<[number, number]>>([INITIAL_MEMBER_RANGE]);
const listContainerRef = useRef<HTMLDivElement | null>(null);
const scrollAnimationFrameRef = useRef<number | null>(null);
const avatarDeferTimerRef = useRef<number | null>(null);
const [deferAvatarLoad, setDeferAvatarLoad] = useState(false);
const memberListUpdatesDisabled = (guild.disabledOperations & GuildOperations.MEMBER_LIST_UPDATES) !== 0;
const currentUserId = Authentication.currentUserId;
const lacksMemberViewPermission =
@@ -366,16 +358,6 @@ const LazyGuildMemberList = observer(
},
[subscribe, totalRows],
);
const markAvatarLoadingDeferred = useCallback(() => {
setDeferAvatarLoad(true);
if (avatarDeferTimerRef.current != null) {
window.clearTimeout(avatarDeferTimerRef.current);
}
avatarDeferTimerRef.current = window.setTimeout(() => {
avatarDeferTimerRef.current = null;
setDeferAvatarLoad(false);
}, AVATAR_DEFER_AFTER_SCROLL_IDLE_MS);
}, []);
useEffect(() => {
if (!enabled || memberListUpdatesDisabled || lacksMemberViewPermission) {
return;
@@ -393,7 +375,6 @@ const LazyGuildMemberList = observer(
updateSubscribedRange(scrollParent.scrollTop, scrollParent.clientHeight);
};
const handleScroll = () => {
markAvatarLoadingDeferred();
if (scrollAnimationFrameRef.current !== null) {
return;
}
@@ -417,18 +398,8 @@ const LazyGuildMemberList = observer(
window.cancelAnimationFrame(scrollAnimationFrameRef.current);
scrollAnimationFrameRef.current = null;
}
if (avatarDeferTimerRef.current !== null) {
window.clearTimeout(avatarDeferTimerRef.current);
avatarDeferTimerRef.current = null;
}
};
}, [
enabled,
memberListUpdatesDisabled,
lacksMemberViewPermission,
updateSubscribedRange,
markAvatarLoadingDeferred,
]);
}, [enabled, memberListUpdatesDisabled, lacksMemberViewPermission, updateSubscribedRange]);
if (lacksMemberViewPermission) {
return (
<div
@@ -547,7 +518,6 @@ const LazyGuildMemberList = observer(
group={group}
channelId={channel.id}
members={members}
deferAvatarLoad={deferAvatarLoad}
onMemberLongPress={onMemberLongPress}
data-flx="channel.channel-details-bottom-sheet-member-list.lazy-guild-member-list.lazy-member-list-group"
/>