fix(desktop): virtualize channel member lists (#4991)

## Summary

- virtualize the unfiltered channel member roster instead of eagerly
mounting every member card
- retain the existing member search/add flow and archived-member
behavior
- cover a 500-member roster, bounded mounted rows, and scrolling to the
final member in E2E

## Cause

The members sidebar rendered every active member card at once. On large
channels this mounted hundreds or thousands of avatars, profile/presence
consumers, menus, and DOM rows, blocking the renderer even though
fetching the roster itself is fast.

## Testing

- `pnpm typecheck`
- `pnpm exec biome check src/features/channels/ui/MembersSidebar.tsx
tests/e2e/channels.spec.ts`
- `pnpm build:e2e`
- `pnpm exec playwright test tests/e2e/channels.spec.ts --grep 'members
sidebar (virtualizes large channel rosters|can invite relay-authorized
agents|can invite and remove managed agents|collapses same-persona
managed agents)'` (4 passed)
- pre-push: `desktop-check`, full `desktop-test` (4,371 passed),
branch-skew

Implemented by Carl on Wes's behalf.

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
This commit is contained in:
Wes
2026-08-05 20:42:04 -06:00
committed by GitHub
parent 16cc3de6d6
commit e2796d4a89
2 changed files with 43 additions and 8 deletions

View File

@@ -30,6 +30,7 @@ import {
import { formatOwnerLabel } from "@/features/profile/lib/identity";
import { rankUserCandidatesBySearch } from "@/features/profile/lib/userCandidateSearch";
import { usePresenceQuery } from "@/features/presence/hooks";
import { VirtualizedList } from "@/shared/ui/VirtualizedList";
import { useIdentityQuery } from "@/shared/api/hooks";
import { changeChannelMemberRole } from "@/shared/api/tauri";
import type {
@@ -199,7 +200,6 @@ export function MembersSidebar({
),
[bots, currentPubkey, people],
);
const allMemberPubkeys = React.useMemo(
() => rawMembers.map((member) => member.pubkey),
[rawMembers],
@@ -217,9 +217,7 @@ export function MembersSidebar({
if (!normalizedSearchQuery) {
return activeMembers;
}
const profiles = memberProfilesQuery.data?.profiles ?? {};
return activeMembers.filter((member) => {
const normalizedPubkey = normalizePubkey(member.pubkey);
const profile = profiles[normalizedPubkey] ?? null;
@@ -816,11 +814,14 @@ export function MembersSidebar({
) : null}
</div>
) : filteredActiveMembers.length > 0 ? (
<div>
{filteredActiveMembers.map((member) =>
renderMemberCard(member, isBot(member)),
)}
</div>
<VirtualizedList
className="h-[calc(100%_-_2.25rem)]"
getItemKey={(member) => member.pubkey}
items={filteredActiveMembers}
renderItem={(member) =>
renderMemberCard(member, isBot(member))
}
/>
) : (
<p className="px-4 py-3 text-sm text-muted-foreground">
{membersQuery.isLoading

View File

@@ -3394,6 +3394,40 @@ test("home inbox manage affordance opens management without leaving home", async
await expect(page).not.toHaveURL(/#\/channels\//);
});
test("members sidebar virtualizes large channel rosters", async ({ page }) => {
await page.goto("/");
const channelId = await page
.getByTestId("channel-random")
.getAttribute("data-channel-id");
if (!channelId) {
throw new Error("Random channel id missing.");
}
const pubkeys = Array.from({ length: 500 }, (_, index) =>
(index + 1).toString(16).padStart(64, "0"),
);
await invokeMockCommand(page, "add_channel_members", {
channelId,
pubkeys,
role: "member",
});
await openMembersSidebar(page, "random");
const memberList = page.getByTestId("members-sidebar-people");
const memberRows = memberList.locator('[data-testid^="sidebar-member-"]');
await expect(memberRows.first()).toBeVisible();
expect(await memberRows.count()).toBeLessThan(50);
const virtualizedList = memberList.locator(".overflow-y-auto");
await virtualizedList.evaluate((element) => {
element.scrollTop = element.scrollHeight;
element.dispatchEvent(new Event("scroll"));
});
await expect(
memberList.getByTestId(`sidebar-member-${pubkeys.at(-1)}`),
).toBeVisible();
});
test("members sidebar can invite relay-authorized agents", async ({ page }) => {
await installMockBridge(page, {
relayAgents: [