diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index 70c7bf10a8a..a2231dc9378 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -70,6 +70,7 @@ import { useMembersSidebarActions } from "./useMembersSidebarActions"; import { useMembersSidebarModeration } from "./useMembersSidebarModeration"; const MEMBER_ADD_RESULT_LIMIT = 50; const MEMBER_SEARCH_MIN_QUERY_LENGTH = 2; +const MEMBER_ROW_ESTIMATE_PX = 60; type AddMemberSearchCandidate = UserSearchResult & { isManagedAgent?: boolean; isMember?: boolean; @@ -636,57 +637,62 @@ export function MembersSidebar({ ? managedAgentPairAction(managedAgentRuntime) : undefined; return ( -
- { - void changeRoleMutation.mutateAsync({ pubkey: m.pubkey, role }); - }} - onEditRespondTo={memberIsBot ? setEditRespondToAgent : undefined} - onManagedAgentAction={(agent) => { - void handleAgentLifecycleAction(agent, managedAgentRuntime); - }} - onOpenProfile={handleOpenProfile} - onRemoveMember={handleRemoveMember} - onTimeout={onTimeout} - onUnban={onUnban} - onUntimeout={onUntimeout} - onViewActivity={ - onViewActivity - ? (pubkey: string) => { - onOpenChange(false); - onViewActivity(pubkey); - } - : undefined - } - pairAction={pairAction} - presenceStatus={ - memberPresenceQuery.data?.[member.pubkey.toLowerCase()] ?? null - } - profileAvatarUrl={memberProfile?.avatarUrl ?? null} - showOtherSetupMarker={showOtherSetupMarker} - viewerIsOwner={viewerIsOwner} - /> + { + void changeRoleMutation.mutateAsync({ pubkey: m.pubkey, role }); + }} + onEditRespondTo={memberIsBot ? setEditRespondToAgent : undefined} + onManagedAgentAction={(agent) => { + void handleAgentLifecycleAction(agent, managedAgentRuntime); + }} + onOpenProfile={handleOpenProfile} + onRemoveMember={handleRemoveMember} + onTimeout={onTimeout} + onUnban={onUnban} + onUntimeout={onUntimeout} + onViewActivity={ + onViewActivity + ? (pubkey: string) => { + onOpenChange(false); + onViewActivity(pubkey); + } + : undefined + } + pairAction={pairAction} + presenceStatus={ + memberPresenceQuery.data?.[member.pubkey.toLowerCase()] ?? null + } + profileAvatarUrl={memberProfile?.avatarUrl ?? null} + showOtherSetupMarker={showOtherSetupMarker} + viewerIsOwner={viewerIsOwner} + /> + ); + } + + function renderDeferredMemberCard( + member: ChannelMember, + memberIsBot: boolean, + ) { + return ( +
+ {renderMemberCard(member, memberIsBot)}
); } @@ -771,7 +777,7 @@ export function MembersSidebar({ {normalizedSearchQuery ? (
{filteredActiveMembers.map((member) => - renderMemberCard(member, isBot(member)), + renderDeferredMemberCard(member, isBot(member)), )} {canAddMembers ? ( <> @@ -815,6 +821,7 @@ export function MembersSidebar({ ) : filteredActiveMembers.length > 0 ? ( member.pubkey} items={filteredActiveMembers} renderItem={(member) => @@ -858,7 +865,7 @@ export function MembersSidebar({ data-testid="members-sidebar-archived-list" > {filteredArchivedMembers.map((member) => - renderMemberCard(member, isBot(member)), + renderDeferredMemberCard(member, isBot(member)), )} {filteredArchivedMembers.length === 0 ? (

diff --git a/desktop/src/shared/styles/globals/utilities.css b/desktop/src/shared/styles/globals/utilities.css index 0d5e0f453f4..2c858474a47 100644 --- a/desktop/src/shared/styles/globals/utilities.css +++ b/desktop/src/shared/styles/globals/utilities.css @@ -37,6 +37,15 @@ contain-intrinsic-size: auto 2rem; } + /* + * Member cards rendered eagerly can skip offscreen layout and paint. + * Virtualized member rows must not use this utility. + */ + .content-visibility-auto-member-row { + content-visibility: auto; + contain-intrinsic-size: auto 3.75rem; + } + .buzz-huddle-tooltip { background: hsl( var(--huddle-tooltip-surface, var(--huddle-control-surface, 0 0% 20%)) diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 52d91dea3ed..6849c3e9477 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -4007,7 +4007,40 @@ test("members sidebar virtualizes large channel rosters", async ({ page }) => { await expect(memberRows.first()).toBeVisible(); expect(await memberRows.count()).toBeLessThan(50); + const firstGeneratedRow = memberList.getByTestId( + `sidebar-member-${pubkeys[0]}`, + ); + await expect + .poll(() => + firstGeneratedRow.evaluate( + (row) => + getComputedStyle(row.parentElement as HTMLElement).contentVisibility, + ), + ) + .toBe("visible"); + const virtualizedList = memberList.locator(".overflow-y-auto"); + await page.evaluate(() => document.fonts.ready); + + const heights: number[] = []; + for (const ratio of [0, 0.1, 0.25, 0.5, 0.75, 1]) { + heights.push( + await virtualizedList.evaluate(async (element, ratio) => { + element.scrollTop = + (element.scrollHeight - element.clientHeight) * ratio; + element.dispatchEvent(new Event("scroll")); + + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ); + + return element.scrollHeight; + }, ratio), + ); + } + + expect(Math.max(...heights) - Math.min(...heights)).toBeLessThan(120); + await virtualizedList.evaluate((element) => { element.scrollTop = element.scrollHeight; element.dispatchEvent(new Event("scroll"));