mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
perf(app): render member list rows in scroll chunks (#1895)
This commit is contained in:
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),
|
||||
|
||||
+8
-38
@@ -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"
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user