Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 60 additions & 53 deletions desktop/src/features/channels/ui/MembersSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -636,57 +637,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>
);
}
Expand Down Expand Up @@ -771,7 +777,7 @@ export function MembersSidebar({
{normalizedSearchQuery ? (
<div>
{filteredActiveMembers.map((member) =>
renderMemberCard(member, isBot(member)),
renderDeferredMemberCard(member, isBot(member)),
)}
{canAddMembers ? (
<>
Expand Down Expand Up @@ -815,6 +821,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) =>
Expand Down Expand Up @@ -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 ? (
<p className="text-sm text-muted-foreground">
Expand Down
9 changes: 9 additions & 0 deletions desktop/src/shared/styles/globals/utilities.css
Original file line number Diff line number Diff line change
Expand Up @@ -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%))
Expand Down
33 changes: 33 additions & 0 deletions desktop/tests/e2e/channels.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down
Loading