mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
fix(desktop): stabilize members dialog scrolling (#6670)
## Summary - Keep virtualized member rows measurable by removing `content-visibility: auto` from the measured row subtree. - Use the member card's 60px baseline as the virtualizer estimate while retaining deferred rendering for eager search and archived-member lists. - Cover large rosters with a regression test that checks stable scroll extent across the list and verifies the final member remains reachable. ### Related issue None found. ### Testing - `just ci` - `pnpm -C desktop build:e2e` - `pnpm -C desktop exec playwright test tests/e2e/channels.spec.ts --grep 'members sidebar virtualizes large channel rosters' --repeat-each=5` #### Before https://github.com/user-attachments/assets/a5fcc040-6872-4200-bcc3-7b4197a4dd23 #### After https://github.com/user-attachments/assets/f2c97f2f-3719-4c2a-b17a-2450c6c70a55 Signed-off-by: Matt Toohey <contact@matttoohey.com>
This commit is contained in:
@@ -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;
|
||||
@@ -645,57 +646,62 @@ export function MembersSidebar({
|
||||
? managedAgentPairAction(managedAgentRuntime)
|
||||
: undefined;
|
||||
return (
|
||||
<div className="content-visibility-auto" key={member.pubkey}>
|
||||
<MembersSidebarMemberCard
|
||||
canChangeRole={canManageMembers && member.pubkey !== currentPubkey}
|
||||
canModerate={canModerate && member.pubkey !== currentPubkey}
|
||||
canRemoveMember={canRemoveMember(member)}
|
||||
isActionPending={
|
||||
isActionPending ||
|
||||
changeRoleMutation.isPending ||
|
||||
isModerationPending
|
||||
}
|
||||
isArchived={isArchived}
|
||||
managedAgent={managedAgent}
|
||||
managedAgentRuntime={managedAgentRuntime}
|
||||
member={member}
|
||||
memberIsBot={memberIsBot}
|
||||
memberAvatarLabel={
|
||||
member.displayName ?? truncatePubkey(member.pubkey)
|
||||
}
|
||||
memberLabel={formatMemberName(member, currentPubkey)}
|
||||
moderationState={moderationStateByPubkey.get(
|
||||
normalizePubkey(member.pubkey),
|
||||
)}
|
||||
onBan={onBan}
|
||||
onChangeRole={(m, role) => {
|
||||
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}
|
||||
/>
|
||||
<MembersSidebarMemberCard
|
||||
canChangeRole={canManageMembers && member.pubkey !== currentPubkey}
|
||||
canModerate={canModerate && member.pubkey !== currentPubkey}
|
||||
canRemoveMember={canRemoveMember(member)}
|
||||
isActionPending={
|
||||
isActionPending || changeRoleMutation.isPending || isModerationPending
|
||||
}
|
||||
isArchived={isArchived}
|
||||
managedAgent={managedAgent}
|
||||
managedAgentRuntime={managedAgentRuntime}
|
||||
member={member}
|
||||
memberIsBot={memberIsBot}
|
||||
memberAvatarLabel={member.displayName ?? truncatePubkey(member.pubkey)}
|
||||
memberLabel={formatMemberName(member, currentPubkey)}
|
||||
moderationState={moderationStateByPubkey.get(
|
||||
normalizePubkey(member.pubkey),
|
||||
)}
|
||||
onBan={onBan}
|
||||
onChangeRole={(m, role) => {
|
||||
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 (
|
||||
<div className="content-visibility-auto-member-row" key={member.pubkey}>
|
||||
{renderMemberCard(member, memberIsBot)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -780,7 +786,7 @@ export function MembersSidebar({
|
||||
{normalizedSearchQuery ? (
|
||||
<div>
|
||||
{filteredActiveMembers.map((member) =>
|
||||
renderMemberCard(member, isBot(member)),
|
||||
renderDeferredMemberCard(member, isBot(member)),
|
||||
)}
|
||||
{canAddMembers ? (
|
||||
<>
|
||||
@@ -824,6 +830,7 @@ export function MembersSidebar({
|
||||
) : filteredActiveMembers.length > 0 ? (
|
||||
<VirtualizedList
|
||||
className="h-[calc(100%_-_2.25rem)]"
|
||||
estimateSize={MEMBER_ROW_ESTIMATE_PX}
|
||||
getItemKey={(member) => member.pubkey}
|
||||
items={filteredActiveMembers}
|
||||
renderItem={(member) =>
|
||||
@@ -867,7 +874,7 @@ export function MembersSidebar({
|
||||
data-testid="members-sidebar-archived-list"
|
||||
>
|
||||
{filteredArchivedMembers.map((member) =>
|
||||
renderMemberCard(member, isBot(member)),
|
||||
renderDeferredMemberCard(member, isBot(member)),
|
||||
)}
|
||||
{filteredArchivedMembers.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
||||
@@ -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%))
|
||||
|
||||
@@ -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<void>((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"));
|
||||
|
||||
Reference in New Issue
Block a user