mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
perf(desktop): virtualize unbounded lists and warm the emoji index (#1089)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
@@ -45,6 +45,7 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"@tanstack/react-router": "^1.168.10",
|
"@tanstack/react-router": "^1.168.10",
|
||||||
|
"@tanstack/react-virtual": "^3.14.2",
|
||||||
"@tauri-apps/api": "~2.11",
|
"@tauri-apps/api": "~2.11",
|
||||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||||
"@tauri-apps/plugin-opener": "~2.5",
|
"@tauri-apps/plugin-opener": "~2.5",
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export default defineConfig({
|
|||||||
"**/thread-unread-screenshots.spec.ts",
|
"**/thread-unread-screenshots.spec.ts",
|
||||||
"**/animated-avatar-screenshots.spec.ts",
|
"**/animated-avatar-screenshots.spec.ts",
|
||||||
"**/reminders-screenshots.spec.ts",
|
"**/reminders-screenshots.spec.ts",
|
||||||
|
"**/virtualization-screenshots.spec.ts",
|
||||||
],
|
],
|
||||||
use: {
|
use: {
|
||||||
...devices["Desktop Chrome"],
|
...devices["Desktop Chrome"],
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function AgentSessionTranscriptList({
|
|||||||
role="log"
|
role="log"
|
||||||
>
|
>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<div className="mt-4 first:mt-0" key={item.id}>
|
<div className="mt-4 first:mt-0 content-visibility-auto" key={item.id}>
|
||||||
<TranscriptItemView
|
<TranscriptItemView
|
||||||
agentName={agentName}
|
agentName={agentName}
|
||||||
item={item}
|
item={item}
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ export function ChannelCanvas({
|
|||||||
const [draft, setDraft] = React.useState("");
|
const [draft, setDraft] = React.useState("");
|
||||||
|
|
||||||
const canvasContent = canvasQuery.data?.content ?? null;
|
const canvasContent = canvasQuery.data?.content ?? null;
|
||||||
|
// Defer the single large Markdown parse so opening the canvas commits the
|
||||||
|
// surrounding chrome immediately and the heavy render reconciles after.
|
||||||
|
const deferredCanvasContent = React.useDeferredValue(canvasContent);
|
||||||
|
|
||||||
function handleStartEditing() {
|
function handleStartEditing() {
|
||||||
setDraft(canvasContent ?? "");
|
setDraft(canvasContent ?? "");
|
||||||
@@ -121,7 +124,10 @@ export function ChannelCanvas({
|
|||||||
className="rounded-2xl border border-border/70 bg-muted/20 px-4 py-3"
|
className="rounded-2xl border border-border/70 bg-muted/20 px-4 py-3"
|
||||||
data-testid="channel-canvas-content"
|
data-testid="channel-canvas-content"
|
||||||
>
|
>
|
||||||
<Markdown channelNames={channelNames} content={canvasContent} />
|
<Markdown
|
||||||
|
channelNames={channelNames}
|
||||||
|
content={deferredCanvasContent ?? ""}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -432,46 +432,47 @@ export function MembersSidebar({
|
|||||||
|
|
||||||
function renderMemberCard(member: ChannelMember, memberIsBot: boolean) {
|
function renderMemberCard(member: ChannelMember, memberIsBot: boolean) {
|
||||||
return (
|
return (
|
||||||
<MembersSidebarMemberCard
|
<div className="content-visibility-auto" key={member.pubkey}>
|
||||||
canChangeRole={canManageMembers && member.pubkey !== currentPubkey}
|
<MembersSidebarMemberCard
|
||||||
canRemoveMember={canRemoveMember(member)}
|
canChangeRole={canManageMembers && member.pubkey !== currentPubkey}
|
||||||
isActionPending={isActionPending || changeRoleMutation.isPending}
|
canRemoveMember={canRemoveMember(member)}
|
||||||
isArchived={isArchived}
|
isActionPending={isActionPending || changeRoleMutation.isPending}
|
||||||
key={member.pubkey}
|
isArchived={isArchived}
|
||||||
managedAgent={
|
managedAgent={
|
||||||
memberIsBot
|
memberIsBot
|
||||||
? managedAgentByPubkey.get(normalizePubkey(member.pubkey))
|
? managedAgentByPubkey.get(normalizePubkey(member.pubkey))
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
member={member}
|
member={member}
|
||||||
memberIsBot={memberIsBot}
|
memberIsBot={memberIsBot}
|
||||||
memberAvatarLabel={member.displayName ?? formatPubkey(member.pubkey)}
|
memberAvatarLabel={member.displayName ?? formatPubkey(member.pubkey)}
|
||||||
memberLabel={formatMemberName(member, currentPubkey)}
|
memberLabel={formatMemberName(member, currentPubkey)}
|
||||||
onChangeRole={(m, role) => {
|
onChangeRole={(m, role) => {
|
||||||
void changeRoleMutation.mutateAsync({ pubkey: m.pubkey, role });
|
void changeRoleMutation.mutateAsync({ pubkey: m.pubkey, role });
|
||||||
}}
|
}}
|
||||||
onEditRespondTo={memberIsBot ? setEditRespondToAgent : undefined}
|
onEditRespondTo={memberIsBot ? setEditRespondToAgent : undefined}
|
||||||
onManagedAgentAction={(agent) => {
|
onManagedAgentAction={(agent) => {
|
||||||
void handleAgentLifecycleAction(agent);
|
void handleAgentLifecycleAction(agent);
|
||||||
}}
|
}}
|
||||||
onOpenProfile={handleOpenProfile}
|
onOpenProfile={handleOpenProfile}
|
||||||
onRemoveMember={handleRemoveMember}
|
onRemoveMember={handleRemoveMember}
|
||||||
onViewActivity={
|
onViewActivity={
|
||||||
onViewActivity
|
onViewActivity
|
||||||
? (pubkey: string) => {
|
? (pubkey: string) => {
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
onViewActivity(pubkey);
|
onViewActivity(pubkey);
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
presenceStatus={
|
presenceStatus={
|
||||||
memberPresenceQuery.data?.[member.pubkey.toLowerCase()] ?? null
|
memberPresenceQuery.data?.[member.pubkey.toLowerCase()] ?? null
|
||||||
}
|
}
|
||||||
profileAvatarUrl={
|
profileAvatarUrl={
|
||||||
memberProfilesQuery.data?.profiles[member.pubkey.toLowerCase()]
|
memberProfilesQuery.data?.profiles[member.pubkey.toLowerCase()]
|
||||||
?.avatarUrl ?? null
|
?.avatarUrl ?? null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,33 @@
|
|||||||
import data from "@emoji-mart/data";
|
import data from "@emoji-mart/data";
|
||||||
import Picker from "@emoji-mart/react";
|
import Picker from "@emoji-mart/react";
|
||||||
|
import { init } from "emoji-mart";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import { buildCustomEmojiCategory } from "@/features/custom-emoji/emojiMartCategory";
|
import { buildCustomEmojiCategory } from "@/features/custom-emoji/emojiMartCategory";
|
||||||
import { useCustomEmoji } from "@/features/custom-emoji/hooks";
|
import { useCustomEmoji } from "@/features/custom-emoji/hooks";
|
||||||
|
|
||||||
|
// emoji-mart builds its searchable index synchronously inside `init`, which
|
||||||
|
// `<Picker>` calls on mount — so the first reaction popover open paid the full
|
||||||
|
// ~1.8k-emoji index build and froze the cursor. Warm `init({ data })` once at
|
||||||
|
// idle so the index is prebuilt; `init` is a no-op after the first call (its
|
||||||
|
// `Data` singleton guards the rebuild), so the Picker's mount-time `init` skips
|
||||||
|
// the heavy work. Search still reads the prebuilt index — no first-keystroke
|
||||||
|
// hitch. Module-level so it fires regardless of when a picker first mounts.
|
||||||
|
let warmStarted = false;
|
||||||
|
function warmEmojiIndex() {
|
||||||
|
if (warmStarted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
warmStarted = true;
|
||||||
|
const warm = () => void init({ data });
|
||||||
|
if (typeof window !== "undefined" && "requestIdleCallback" in window) {
|
||||||
|
window.requestIdleCallback(warm, { timeout: 1_500 });
|
||||||
|
} else {
|
||||||
|
globalThis.setTimeout(warm, 250);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
warmEmojiIndex();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The one emoji picker for the whole app. Every place that lets a user choose
|
* The one emoji picker for the whole app. Every place that lets a user choose
|
||||||
* an emoji — composing a message, reacting to a regular or system message,
|
* an emoji — composing a message, reacting to a regular or system message,
|
||||||
|
|||||||
@@ -75,7 +75,10 @@ function ReplyRow({
|
|||||||
const replyMentionNames = resolveMentionNames(reply.tags, profiles);
|
const replyMentionNames = resolveMentionNames(reply.tags, profiles);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group px-4 py-3" data-forum-event-id={reply.eventId}>
|
<div
|
||||||
|
className="group content-visibility-auto px-4 py-3"
|
||||||
|
data-forum-event-id={reply.eventId}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<UserProfilePopover pubkey={reply.pubkey}>
|
<UserProfilePopover pubkey={reply.pubkey}>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { Channel } from "@/shared/api/types";
|
|||||||
import { channelChrome } from "@/shared/layout/chromeLayout";
|
import { channelChrome } from "@/shared/layout/chromeLayout";
|
||||||
import { cn } from "@/shared/lib/cn";
|
import { cn } from "@/shared/lib/cn";
|
||||||
import { Skeleton } from "@/shared/ui/skeleton";
|
import { Skeleton } from "@/shared/ui/skeleton";
|
||||||
|
import { VirtualizedList } from "@/shared/ui/VirtualizedList";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
useCreateForumPostMutation,
|
useCreateForumPostMutation,
|
||||||
@@ -47,6 +48,7 @@ export function ForumView({
|
|||||||
targetReplyId,
|
targetReplyId,
|
||||||
}: ForumViewProps) {
|
}: ForumViewProps) {
|
||||||
const [isComposerOpen, setIsComposerOpen] = React.useState(false);
|
const [isComposerOpen, setIsComposerOpen] = React.useState(false);
|
||||||
|
const postsScrollRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const profileQuery = useProfileQuery();
|
const profileQuery = useProfileQuery();
|
||||||
const postsQuery = useForumPostsQuery(channel);
|
const postsQuery = useForumPostsQuery(channel);
|
||||||
@@ -185,6 +187,7 @@ export function ForumView({
|
|||||||
<div
|
<div
|
||||||
className="flex-1 overflow-y-auto"
|
className="flex-1 overflow-y-auto"
|
||||||
data-scroll-restoration-id={`forum-list:${channel.id}`}
|
data-scroll-restoration-id={`forum-list:${channel.id}`}
|
||||||
|
ref={postsScrollRef}
|
||||||
>
|
>
|
||||||
{postsQuery.isLoading ? (
|
{postsQuery.isLoading ? (
|
||||||
<div className="space-y-3 p-4">
|
<div className="space-y-3 p-4">
|
||||||
@@ -205,26 +208,32 @@ export function ForumView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3 p-4">
|
<VirtualizedList
|
||||||
{posts.map((post) => (
|
estimateSize={120}
|
||||||
<ForumPostCard
|
getItemKey={(post) => post.eventId}
|
||||||
canDelete={canDelete(post.pubkey, effectiveCurrentPubkey)}
|
innerClassName="p-4"
|
||||||
currentPubkey={effectiveCurrentPubkey}
|
items={posts}
|
||||||
isActive={selectedPostId === post.eventId}
|
renderItem={(post) => (
|
||||||
isDeleting={
|
<div className="pb-3">
|
||||||
deletePostMutation.isPending &&
|
<ForumPostCard
|
||||||
deletePostMutation.variables?.eventId === post.eventId
|
canDelete={canDelete(post.pubkey, effectiveCurrentPubkey)}
|
||||||
}
|
currentPubkey={effectiveCurrentPubkey}
|
||||||
key={post.eventId}
|
isActive={selectedPostId === post.eventId}
|
||||||
onClick={() => onSelectPost(post.eventId)}
|
isDeleting={
|
||||||
onDelete={(eventId) => {
|
deletePostMutation.isPending &&
|
||||||
deletePostMutation.mutate({ eventId });
|
deletePostMutation.variables?.eventId === post.eventId
|
||||||
}}
|
}
|
||||||
post={post}
|
onClick={() => onSelectPost(post.eventId)}
|
||||||
profiles={profiles}
|
onDelete={(eventId) => {
|
||||||
/>
|
deletePostMutation.mutate({ eventId });
|
||||||
))}
|
}}
|
||||||
</div>
|
post={post}
|
||||||
|
profiles={profiles}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
scrollRef={postsScrollRef}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ChevronDown, Inbox } from "lucide-react";
|
import { ChevronDown, Inbox } from "lucide-react";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
formatInboxTypeLabel,
|
formatInboxTypeLabel,
|
||||||
@@ -18,6 +19,7 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/shared/ui/dropdown-menu";
|
} from "@/shared/ui/dropdown-menu";
|
||||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||||
|
import { VirtualizedList } from "@/shared/ui/VirtualizedList";
|
||||||
|
|
||||||
const FILTER_OPTIONS: Array<{ label: string; value: InboxFilter }> = [
|
const FILTER_OPTIONS: Array<{ label: string; value: InboxFilter }> = [
|
||||||
{ value: "all", label: "All" },
|
{ value: "all", label: "All" },
|
||||||
@@ -47,6 +49,91 @@ export function InboxListPane({
|
|||||||
showRightDivider = false,
|
showRightDivider = false,
|
||||||
}: InboxListPaneProps) {
|
}: InboxListPaneProps) {
|
||||||
const activeFilter = FILTER_OPTIONS.find((option) => option.value === filter);
|
const activeFilter = FILTER_OPTIONS.find((option) => option.value === filter);
|
||||||
|
const scrollRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const renderItem = (item: InboxItem) => {
|
||||||
|
const isSelected = item.id === selectedId;
|
||||||
|
const isDone = doneSet.has(item.id);
|
||||||
|
const typeLabel = formatInboxTypeLabel(item);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-start gap-2.5 border-l px-5 py-2 text-left transition-colors",
|
||||||
|
isSelected
|
||||||
|
? "border-l-transparent bg-muted/30"
|
||||||
|
: "border-l-transparent hover:bg-muted/25 active:bg-muted/40",
|
||||||
|
)}
|
||||||
|
data-testid={`home-inbox-item-${item.id}`}
|
||||||
|
onClick={() => onSelect(item.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<UserAvatar
|
||||||
|
avatarUrl={item.avatarUrl}
|
||||||
|
className="h-8 w-8"
|
||||||
|
displayName={item.senderLabel}
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
{!isDone ? (
|
||||||
|
<span className="absolute -right-1 -top-1 h-2.5 w-2.5 rounded-full border-2 border-background bg-primary" />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="truncate text-sm font-semibold text-foreground">
|
||||||
|
{item.senderLabel}
|
||||||
|
</p>
|
||||||
|
{item.isActionRequired ? (
|
||||||
|
<span className="inline-flex shrink-0 items-center text-2xs font-semibold uppercase tracking-[0.14em] text-amber-600 dark:text-amber-300">
|
||||||
|
Needs action
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 text-xs text-muted-foreground",
|
||||||
|
isDone ? "font-normal" : "font-semibold",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.timestampLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mt-0.5 line-clamp-2 text-sm leading-5 **:inline [&_a]:font-medium [&_a]:text-current [&_br]:hidden [&_p]:inline",
|
||||||
|
isDone
|
||||||
|
? "font-normal text-muted-foreground"
|
||||||
|
: "font-semibold text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Markdown
|
||||||
|
className="inline max-w-full text-inherit"
|
||||||
|
content={item.preview}
|
||||||
|
interactive={false}
|
||||||
|
mentionNames={item.mentionNames}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-2xs text-muted-foreground",
|
||||||
|
isDone ? "font-normal" : "font-semibold",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{typeLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
@@ -101,6 +188,7 @@ export function InboxListPane({
|
|||||||
<div
|
<div
|
||||||
className="min-h-0 flex-1 overflow-y-auto overscroll-contain"
|
className="min-h-0 flex-1 overflow-y-auto overscroll-contain"
|
||||||
data-testid="home-inbox-list"
|
data-testid="home-inbox-list"
|
||||||
|
ref={scrollRef}
|
||||||
>
|
>
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<div className="flex h-full min-h-64 items-center justify-center px-6 text-center">
|
<div className="flex h-full min-h-64 items-center justify-center px-6 text-center">
|
||||||
@@ -114,92 +202,13 @@ export function InboxListPane({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<VirtualizedList
|
||||||
{items.map((item) => {
|
estimateSize={76}
|
||||||
const isSelected = item.id === selectedId;
|
getItemKey={(item) => item.id}
|
||||||
const isDone = doneSet.has(item.id);
|
items={items}
|
||||||
const typeLabel = formatInboxTypeLabel(item);
|
renderItem={renderItem}
|
||||||
|
scrollRef={scrollRef}
|
||||||
return (
|
/>
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
"flex w-full items-start gap-2.5 border-l px-5 py-2 text-left transition-colors",
|
|
||||||
isSelected
|
|
||||||
? "border-l-transparent bg-muted/30"
|
|
||||||
: "border-l-transparent hover:bg-muted/25 active:bg-muted/40",
|
|
||||||
)}
|
|
||||||
data-testid={`home-inbox-item-${item.id}`}
|
|
||||||
key={item.id}
|
|
||||||
onClick={() => onSelect(item.id)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<div className="relative">
|
|
||||||
<UserAvatar
|
|
||||||
avatarUrl={item.avatarUrl}
|
|
||||||
className="h-8 w-8"
|
|
||||||
displayName={item.senderLabel}
|
|
||||||
size="md"
|
|
||||||
/>
|
|
||||||
{!isDone ? (
|
|
||||||
<span className="absolute -right-1 -top-1 h-2.5 w-2.5 rounded-full border-2 border-background bg-primary" />
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<p className="truncate text-sm font-semibold text-foreground">
|
|
||||||
{item.senderLabel}
|
|
||||||
</p>
|
|
||||||
{item.isActionRequired ? (
|
|
||||||
<span className="inline-flex shrink-0 items-center text-2xs font-semibold uppercase tracking-[0.14em] text-amber-600 dark:text-amber-300">
|
|
||||||
Needs action
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"shrink-0 text-xs text-muted-foreground",
|
|
||||||
isDone ? "font-normal" : "font-semibold",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.timestampLabel}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"mt-0.5 line-clamp-2 text-sm leading-5 **:inline [&_a]:font-medium [&_a]:text-current [&_br]:hidden [&_p]:inline",
|
|
||||||
isDone
|
|
||||||
? "font-normal text-muted-foreground"
|
|
||||||
: "font-semibold text-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Markdown
|
|
||||||
className="inline max-w-full text-inherit"
|
|
||||||
content={item.preview}
|
|
||||||
interactive={false}
|
|
||||||
mentionNames={item.mentionNames}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"text-2xs text-muted-foreground",
|
|
||||||
isDone ? "font-normal" : "font-semibold",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{typeLabel}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import type { ChannelMember, UserProfileSummary } from "@/shared/api/types";
|
|||||||
import { Input } from "@/shared/ui/input";
|
import { Input } from "@/shared/ui/input";
|
||||||
import { Skeleton } from "@/shared/ui/skeleton";
|
import { Skeleton } from "@/shared/ui/skeleton";
|
||||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||||
|
import { VirtualizedList } from "@/shared/ui/VirtualizedList";
|
||||||
|
|
||||||
export type PulseTab =
|
export type PulseTab =
|
||||||
| "search"
|
| "search"
|
||||||
@@ -73,6 +74,7 @@ function TimelineSkeleton() {
|
|||||||
export function PulseView({ currentPubkey }: PulseViewProps) {
|
export function PulseView({ currentPubkey }: PulseViewProps) {
|
||||||
const [activeTab, setActiveTab] = React.useState<PulseTab>("everyone");
|
const [activeTab, setActiveTab] = React.useState<PulseTab>("everyone");
|
||||||
const [searchQuery, setSearchQuery] = React.useState("");
|
const [searchQuery, setSearchQuery] = React.useState("");
|
||||||
|
const scrollRef = React.useRef<HTMLDivElement>(null);
|
||||||
const contactListQuery = useContactListQuery(currentPubkey);
|
const contactListQuery = useContactListQuery(currentPubkey);
|
||||||
const contacts = contactListQuery.data?.contacts ?? [];
|
const contacts = contactListQuery.data?.contacts ?? [];
|
||||||
const contactPubkeys = React.useMemo(
|
const contactPubkeys = React.useMemo(
|
||||||
@@ -268,43 +270,57 @@ export function PulseView({ currentPubkey }: PulseViewProps) {
|
|||||||
return agentNoteGroups.length === 0 ? (
|
return agentNoteGroups.length === 0 ? (
|
||||||
<EmptyState message={emptyMessages.agents} />
|
<EmptyState message={emptyMessages.agents} />
|
||||||
) : (
|
) : (
|
||||||
agentNoteGroups.map((group) => (
|
<VirtualizedList
|
||||||
<AgentActivityCard
|
estimateSize={160}
|
||||||
agentStatus={agentStatusMap[group.pubkey]}
|
getItemKey={(group) => `${group.pubkey}-${group.latestAt}`}
|
||||||
group={group}
|
items={agentNoteGroups}
|
||||||
key={`${group.pubkey}-${group.latestAt}`}
|
renderItem={(group) => (
|
||||||
profile={profiles[group.pubkey.toLowerCase()] ?? null}
|
<div className="pb-4">
|
||||||
/>
|
<AgentActivityCard
|
||||||
))
|
agentStatus={agentStatusMap[group.pubkey]}
|
||||||
|
group={group}
|
||||||
|
profile={profiles[group.pubkey.toLowerCase()] ?? null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
scrollRef={scrollRef}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return visibleNotes.length === 0 ? (
|
return visibleNotes.length === 0 ? (
|
||||||
<EmptyState message={emptyMessages[activeTab]} />
|
<EmptyState message={emptyMessages[activeTab]} />
|
||||||
) : (
|
) : (
|
||||||
visibleNotes.map((note) => (
|
<VirtualizedList
|
||||||
<NoteCard
|
estimateSize={140}
|
||||||
actions={{
|
getItemKey={(note) => note.id}
|
||||||
reply: noteActions.reply,
|
items={visibleNotes}
|
||||||
share: noteActions.share,
|
renderItem={(note) => (
|
||||||
startDm: noteActions.startDm,
|
<div className="pb-4">
|
||||||
toggleUpvote: noteActions.toggleUpvote,
|
<NoteCard
|
||||||
}}
|
actions={{
|
||||||
composerProfiles={mentionProfiles}
|
reply: noteActions.reply,
|
||||||
currentUserDisplayName={currentDisplayName}
|
share: noteActions.share,
|
||||||
currentUserProfile={currentProfile}
|
startDm: noteActions.startDm,
|
||||||
isAgent={agentPubkeySet.has(note.pubkey)}
|
toggleUpvote: noteActions.toggleUpvote,
|
||||||
isOwnNote={note.pubkey === currentPubkey}
|
}}
|
||||||
isReplySending={noteActions.isReplySending}
|
composerProfiles={mentionProfiles}
|
||||||
isUpvotePending={noteActions.isUpvotePending(note.id)}
|
currentUserDisplayName={currentDisplayName}
|
||||||
isUpvoted={noteActions.isUpvoted(note.id)}
|
currentUserProfile={currentProfile}
|
||||||
reactionCount={noteActions.reactionCount(note.id)}
|
isAgent={agentPubkeySet.has(note.pubkey)}
|
||||||
key={note.id}
|
isOwnNote={note.pubkey === currentPubkey}
|
||||||
members={pulseMentionMembers}
|
isReplySending={noteActions.isReplySending}
|
||||||
note={note}
|
isUpvotePending={noteActions.isUpvotePending(note.id)}
|
||||||
profile={profiles[note.pubkey.toLowerCase()] ?? null}
|
isUpvoted={noteActions.isUpvoted(note.id)}
|
||||||
/>
|
reactionCount={noteActions.reactionCount(note.id)}
|
||||||
))
|
members={pulseMentionMembers}
|
||||||
|
note={note}
|
||||||
|
profile={profiles[note.pubkey.toLowerCase()] ?? null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
scrollRef={scrollRef}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,7 +334,7 @@ export function PulseView({ currentPubkey }: PulseViewProps) {
|
|||||||
relayAgents={relayAgents}
|
relayAgents={relayAgents}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-0 min-h-0 flex-1 overflow-y-auto">
|
<div className="mt-0 min-h-0 flex-1 overflow-y-auto" ref={scrollRef}>
|
||||||
<div
|
<div
|
||||||
aria-labelledby={pulseTabId(activeTab)}
|
aria-labelledby={pulseTabId(activeTab)}
|
||||||
className={`mx-auto flex w-full max-w-2xl flex-col px-4 pb-10 sm:px-6 ${
|
className={`mx-auto flex w-full max-w-2xl flex-col px-4 pb-10 sm:px-6 ${
|
||||||
@@ -399,9 +415,7 @@ export function PulseView({ currentPubkey }: PulseViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{activeTab !== "search" ? (
|
{activeTab !== "search" ? <div>{renderTimeline()}</div> : null}
|
||||||
<div className="space-y-4">{renderTimeline()}</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/shared/ui/dropdown-menu";
|
} from "@/shared/ui/dropdown-menu";
|
||||||
import { Input } from "@/shared/ui/input";
|
import { Input } from "@/shared/ui/input";
|
||||||
|
import { VirtualizedList } from "@/shared/ui/VirtualizedList";
|
||||||
|
|
||||||
type AssignableRelayRole = Exclude<RelayMemberRole, "owner">;
|
type AssignableRelayRole = Exclude<RelayMemberRole, "owner">;
|
||||||
|
|
||||||
@@ -486,17 +487,22 @@ export function RelayMembersSettingsCard({
|
|||||||
No members match your search.
|
No members match your search.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="max-h-[28rem] space-y-2 overflow-y-auto pr-1">
|
<VirtualizedList
|
||||||
{filteredMembers.map((member) => (
|
className="max-h-[28rem] pr-1"
|
||||||
<RelayMemberRow
|
estimateSize={56}
|
||||||
currentPubkey={currentPubkey}
|
getItemKey={(member) => member.pubkey}
|
||||||
currentRole={currentRole}
|
items={filteredMembers}
|
||||||
key={member.pubkey}
|
renderItem={(member) => (
|
||||||
member={member}
|
<div className="pb-2">
|
||||||
profile={profiles?.[normalizePubkey(member.pubkey)]}
|
<RelayMemberRow
|
||||||
/>
|
currentPubkey={currentPubkey}
|
||||||
))}
|
currentRole={currentRole}
|
||||||
</div>
|
member={member}
|
||||||
|
profile={profiles?.[normalizePubkey(member.pubkey)]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -363,7 +363,7 @@ export function ChannelGroupSection({
|
|||||||
{items.map((channel) => (
|
{items.map((channel) => (
|
||||||
<ContextMenu key={channel.id}>
|
<ContextMenu key={channel.id}>
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem className="content-visibility-auto-row">
|
||||||
{draggable ? (
|
{draggable ? (
|
||||||
<DraggableChannelRow channelId={channel.id}>
|
<DraggableChannelRow channelId={channel.id}>
|
||||||
<ChannelMenuButton
|
<ChannelMenuButton
|
||||||
|
|||||||
@@ -1768,6 +1768,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
|
/*
|
||||||
|
* Skips rendering (layout, paint) for offscreen rows while keeping them in
|
||||||
|
* the DOM — unlike windowing, in-DOM state (open `<details>`, drag-and-drop,
|
||||||
|
* deep-link `querySelector`) survives. The intrinsic size is a height
|
||||||
|
* placeholder so the scrollbar stays stable before a row is first rendered.
|
||||||
|
*/
|
||||||
|
.content-visibility-auto {
|
||||||
|
content-visibility: auto;
|
||||||
|
contain-intrinsic-size: auto 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Compact variant for short single-line rows (sidebar channels, etc). */
|
||||||
|
.content-visibility-auto-row {
|
||||||
|
content-visibility: auto;
|
||||||
|
contain-intrinsic-size: auto 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
.buzz-huddle-tooltip {
|
.buzz-huddle-tooltip {
|
||||||
--primary: var(
|
--primary: var(
|
||||||
--huddle-tooltip-surface,
|
--huddle-tooltip-surface,
|
||||||
|
|||||||
144
desktop/src/shared/ui/VirtualizedList.tsx
Normal file
144
desktop/src/shared/ui/VirtualizedList.tsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { type Virtualizer, useVirtualizer } from "@tanstack/react-virtual";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/cn";
|
||||||
|
|
||||||
|
export type ListVirtualizer = Virtualizer<HTMLElement, Element>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A headless virtualized list primitive using @tanstack/react-virtual.
|
||||||
|
*
|
||||||
|
* Migration contract:
|
||||||
|
* - Rows must tolerate unmount/remount (no DOM-resident state that can't be
|
||||||
|
* reconstructed from props/data). Surfaces with in-DOM row state (open
|
||||||
|
* `<details>`, drag-and-drop) should use `content-visibility` instead.
|
||||||
|
* - Rows may have variable height — the library's `measureElement` handles
|
||||||
|
* dynamic sizing automatically.
|
||||||
|
*
|
||||||
|
* Supports:
|
||||||
|
* (a) Optional non-virtualized sticky-header slot rendered above the virtual
|
||||||
|
* rows inside the scroll container (for PulseView's sticky composer, etc).
|
||||||
|
* (b) Optional externally-owned scroll container — pass `scrollRef` when the
|
||||||
|
* caller already owns the scrolling element (a surface that shares its
|
||||||
|
* scroll region with non-row siblings). When omitted, VirtualizedList
|
||||||
|
* renders its own `overflow-y-auto` container.
|
||||||
|
*/
|
||||||
|
type VirtualizedListProps<T> = {
|
||||||
|
/** The data items to virtualize. */
|
||||||
|
items: T[];
|
||||||
|
/** Stable key extractor for each item. */
|
||||||
|
getItemKey: (item: T, index: number) => string | number;
|
||||||
|
/** Render function for each row. Receives the item and its index. */
|
||||||
|
renderItem: (item: T, index: number) => React.ReactNode;
|
||||||
|
/** Estimated row height in px — used before measurement. */
|
||||||
|
estimateSize?: number;
|
||||||
|
/** Optional non-virtualized content rendered above the virtual rows (sticky headers, etc). */
|
||||||
|
stickyHeader?: React.ReactNode;
|
||||||
|
/**
|
||||||
|
* Externally-owned scroll container. When provided, no internal scroll
|
||||||
|
* container is rendered — the caller's element scrolls and is measured.
|
||||||
|
*/
|
||||||
|
scrollRef?: React.RefObject<HTMLElement | null>;
|
||||||
|
/** Class name for the internal scroll container (ignored when scrollRef is provided). */
|
||||||
|
className?: string;
|
||||||
|
/** Class name for the inner spacer div that holds the virtual rows. */
|
||||||
|
innerClassName?: string;
|
||||||
|
/** Overscan — number of items to render outside the visible area. */
|
||||||
|
overscan?: number;
|
||||||
|
/** Receives the virtualizer instance (for `scrollToIndex`, etc). */
|
||||||
|
onVirtualizer?: (virtualizer: ListVirtualizer) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function VirtualizedList<T>({
|
||||||
|
items,
|
||||||
|
getItemKey,
|
||||||
|
renderItem,
|
||||||
|
estimateSize = 80,
|
||||||
|
stickyHeader,
|
||||||
|
scrollRef,
|
||||||
|
className,
|
||||||
|
innerClassName,
|
||||||
|
overscan = 5,
|
||||||
|
onVirtualizer,
|
||||||
|
}: VirtualizedListProps<T>) {
|
||||||
|
const internalScrollRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const spacerRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const ownsScroll = scrollRef === undefined;
|
||||||
|
const resolvedScrollRef = scrollRef ?? internalScrollRef;
|
||||||
|
// Read the element lazily inside the callback so the virtualizer picks it up
|
||||||
|
// once the ref attaches — capturing `ref.current` at render time would freeze
|
||||||
|
// it at the first-render `null`.
|
||||||
|
const getScrollElement = React.useCallback(
|
||||||
|
() => resolvedScrollRef.current,
|
||||||
|
[resolvedScrollRef],
|
||||||
|
);
|
||||||
|
|
||||||
|
// When a sticky header (or any caller content) sits above the rows in the
|
||||||
|
// same scroll container, the row spacer no longer starts at scrollTop 0.
|
||||||
|
// Feed that offset to the virtualizer as `scrollMargin` so the visible-range
|
||||||
|
// math stays aligned; without it the wrong rows render near the top.
|
||||||
|
const [scrollMargin, setScrollMargin] = React.useState(0);
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
const scrollEl = resolvedScrollRef.current;
|
||||||
|
const spacer = spacerRef.current;
|
||||||
|
if (!scrollEl || !spacer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const offset =
|
||||||
|
spacer.getBoundingClientRect().top -
|
||||||
|
scrollEl.getBoundingClientRect().top +
|
||||||
|
scrollEl.scrollTop;
|
||||||
|
setScrollMargin((prev) => (prev === offset ? prev : offset));
|
||||||
|
});
|
||||||
|
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: items.length,
|
||||||
|
getScrollElement,
|
||||||
|
estimateSize: () => estimateSize,
|
||||||
|
getItemKey: (index) => getItemKey(items[index], index),
|
||||||
|
overscan,
|
||||||
|
scrollMargin,
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
onVirtualizer?.(virtualizer);
|
||||||
|
}, [onVirtualizer, virtualizer]);
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
{stickyHeader}
|
||||||
|
<div
|
||||||
|
className={cn("relative w-full", innerClassName)}
|
||||||
|
ref={spacerRef}
|
||||||
|
style={{ height: `${virtualizer.getTotalSize()}px` }}
|
||||||
|
>
|
||||||
|
{virtualizer.getVirtualItems().map((virtualRow) => (
|
||||||
|
<div
|
||||||
|
data-index={virtualRow.index}
|
||||||
|
key={virtualRow.key}
|
||||||
|
ref={virtualizer.measureElement}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: "100%",
|
||||||
|
transform: `translateY(${virtualRow.start - scrollMargin}px)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderItem(items[virtualRow.index], virtualRow.index)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (ownsScroll) {
|
||||||
|
return (
|
||||||
|
<div className={cn("overflow-y-auto", className)} ref={internalScrollRef}>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
@@ -2211,6 +2211,31 @@ function getMockMessageStore(channelId: string): RelayEvent[] {
|
|||||||
content: "Looks good to me. We should ship it.",
|
content: "Looks good to me. We should ship it.",
|
||||||
sig: "mocksig".repeat(20).slice(0, 128),
|
sig: "mocksig".repeat(20).slice(0, 128),
|
||||||
},
|
},
|
||||||
|
// Filler replies so the thread overflows the panel viewport — the
|
||||||
|
// deep-link target (mock-forum-release-deeplink) sits below the fold
|
||||||
|
// at open, proving scrollIntoView lands an offscreen content-
|
||||||
|
// visibility row. Named IDs above are untouched.
|
||||||
|
...Array.from({ length: 24 }, (_, index) => ({
|
||||||
|
id:
|
||||||
|
index === 23
|
||||||
|
? "mock-forum-release-deeplink"
|
||||||
|
: `mock-forum-release-filler-${index}`,
|
||||||
|
pubkey: ALICE_PUBKEY,
|
||||||
|
created_at: Math.floor(Date.now() / 1000) - (79 - index) * 60,
|
||||||
|
kind: 45003,
|
||||||
|
tags: buildReplyMessageTags(
|
||||||
|
channelId,
|
||||||
|
ALICE_PUBKEY,
|
||||||
|
"mock-forum-release-thread",
|
||||||
|
"mock-forum-release-thread",
|
||||||
|
undefined,
|
||||||
|
),
|
||||||
|
content:
|
||||||
|
index === 23
|
||||||
|
? "Deep-link target: confirmed the rollout plan end to end."
|
||||||
|
: `Follow-up note #${index + 1} on the release checklist.`,
|
||||||
|
sig: "mocksig".repeat(20).slice(0, 128),
|
||||||
|
})),
|
||||||
]
|
]
|
||||||
: channelId === "94a444a4-c0a3-5966-ab05-530c6ddc2301"
|
: channelId === "94a444a4-c0a3-5966-ab05-530c6ddc2301"
|
||||||
? [
|
? [
|
||||||
@@ -2522,7 +2547,9 @@ function getMockUserNotes(pubkey: string): RawUserNote[] {
|
|||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
if (pubkey === DEFAULT_MOCK_IDENTITY.pubkey) {
|
if (pubkey === DEFAULT_MOCK_IDENTITY.pubkey) {
|
||||||
return [
|
// Two named notes plus generated filler so the Pulse feed overflows the
|
||||||
|
// viewport — required to exercise windowed scroll + sticky-composer offset.
|
||||||
|
const named: RawUserNote[] = [
|
||||||
{
|
{
|
||||||
id: "mock-note-launch",
|
id: "mock-note-launch",
|
||||||
pubkey,
|
pubkey,
|
||||||
@@ -2538,6 +2565,14 @@ function getMockUserNotes(pubkey: string): RawUserNote[] {
|
|||||||
tags: [],
|
tags: [],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
const filler: RawUserNote[] = Array.from({ length: 28 }, (_, index) => ({
|
||||||
|
id: `mock-note-filler-${index}`,
|
||||||
|
pubkey,
|
||||||
|
created_at: now - (4 + index) * 60 * 60,
|
||||||
|
content: `Pulse update #${index + 1}: tracking virtualization rollout across desktop surfaces.`,
|
||||||
|
tags: [],
|
||||||
|
}));
|
||||||
|
return [...named, ...filler];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pubkey === ALICE_PUBKEY) {
|
if (pubkey === ALICE_PUBKEY) {
|
||||||
|
|||||||
199
desktop/tests/e2e/virtualization-screenshots.spec.ts
Normal file
199
desktop/tests/e2e/virtualization-screenshots.spec.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import type { Locator, Page } from "@playwright/test";
|
||||||
|
|
||||||
|
import { installMockBridge } from "../helpers/bridge";
|
||||||
|
|
||||||
|
// Screenshot capture for the desktop list-virtualization pass (PR #1089). Each
|
||||||
|
// shot is gated by an assertion so a geometry regression fails the run rather
|
||||||
|
// than silently producing a misleading image. The shots are the empirical gate
|
||||||
|
// that closes the correctness residual flagged in review: the absolute-position
|
||||||
|
// / scrollMargin geometry under live dynamic measurement, and the
|
||||||
|
// content-visibility "rows stay committed" claim on the dnd-coupled surface.
|
||||||
|
// Artifacts land in test-results/virtualization/.
|
||||||
|
const SHOTS = "test-results/virtualization";
|
||||||
|
|
||||||
|
const WATERCOOLER_CHANNEL_ID = "a27e1ee9-76a6-5bdf-a5d5-1d85610dad11";
|
||||||
|
const FORUM_THREAD_ID = "mock-forum-release-thread";
|
||||||
|
const FORUM_DEEPLINK_REPLY_ID = "mock-forum-release-deeplink";
|
||||||
|
|
||||||
|
// Mock-mode current-user pubkey (DEFAULT_MOCK_IDENTITY). Custom channel
|
||||||
|
// sections persist under buzz-channel-sections.v1:<pubkey>, so shot 6 seeds two
|
||||||
|
// sections for this key before the app boots.
|
||||||
|
const MOCK_PUBKEY = "deadbeef".repeat(8);
|
||||||
|
const SECTION_TOP = { id: "sec-top", name: "Priority", order: 0 };
|
||||||
|
const SECTION_BOTTOM = { id: "sec-bottom", name: "Archive", order: 1 };
|
||||||
|
|
||||||
|
async function seedChannelSections(page: Page) {
|
||||||
|
await page.addInitScript(
|
||||||
|
({ pubkey, sections }) => {
|
||||||
|
window.localStorage.setItem(
|
||||||
|
`buzz-channel-sections.v1:${pubkey}`,
|
||||||
|
JSON.stringify({ version: 1, sections, assignments: {} }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ pubkey: MOCK_PUBKEY, sections: [SECTION_TOP, SECTION_BOTTOM] },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// dnd-kit's PointerSensor activates only after the pointer travels past its
|
||||||
|
// 6px distance constraint, so a single move never starts a drag. This walks the
|
||||||
|
// pointer down, past the activation threshold, onto the target, then releases —
|
||||||
|
// the sequence dnd-kit needs to fire onDragEnd and commit the reorder.
|
||||||
|
async function dragOver(page: Page, source: Locator, target: Locator) {
|
||||||
|
const from = await source.boundingBox();
|
||||||
|
const to = await target.boundingBox();
|
||||||
|
if (!from || !to) throw new Error("drag handles not laid out");
|
||||||
|
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2 + 10);
|
||||||
|
await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2, {
|
||||||
|
steps: 10,
|
||||||
|
});
|
||||||
|
await page.mouse.up();
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("list virtualization screenshots", () => {
|
||||||
|
test("01 — Pulse windowed feed with sticky composer pinned mid-scroll", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await installMockBridge(page);
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByTestId("open-pulse-view").click();
|
||||||
|
|
||||||
|
// The seeded feed overflows the viewport (30 notes), so the windowed list
|
||||||
|
// renders a subset and the composer stays pinned. Wait for virtual rows.
|
||||||
|
const rows = page.locator("[data-index]");
|
||||||
|
await expect(rows.first()).toBeVisible();
|
||||||
|
const composer = page.locator(".pulse-composer");
|
||||||
|
await expect(composer).toBeVisible();
|
||||||
|
|
||||||
|
// Scroll the feed mid-list, then prove the sticky composer is still pinned
|
||||||
|
// at the top of its scroll container — this exercises the
|
||||||
|
// translateY(start - scrollMargin) offset under a non-zero scrollTop.
|
||||||
|
const scroller = composer.locator(
|
||||||
|
"xpath=ancestor::*[contains(@class,'overflow-y-auto')][1]",
|
||||||
|
);
|
||||||
|
await scroller.evaluate((el) => {
|
||||||
|
el.scrollTop = 600;
|
||||||
|
});
|
||||||
|
await expect
|
||||||
|
.poll(async () =>
|
||||||
|
composer.evaluate(
|
||||||
|
(el, scrollEl) => {
|
||||||
|
const composerTop = el.getBoundingClientRect().top;
|
||||||
|
const scrollTop = (scrollEl as HTMLElement).getBoundingClientRect()
|
||||||
|
.top;
|
||||||
|
return Math.abs(composerTop - scrollTop);
|
||||||
|
},
|
||||||
|
await scroller.elementHandle(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toBeLessThan(80);
|
||||||
|
|
||||||
|
await page.screenshot({ path: `${SHOTS}/01-pulse-sticky-composer.png` });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("02 — forum deep-link lands on an offscreen reply", async ({ page }) => {
|
||||||
|
await installMockBridge(page);
|
||||||
|
await page.goto(
|
||||||
|
`/#/channels/${WATERCOOLER_CHANNEL_ID}/posts/${FORUM_THREAD_ID}?replyId=${FORUM_DEEPLINK_REPLY_ID}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The deep-link target is the last of 25 replies — offscreen at open. Under
|
||||||
|
// content-visibility the row stays queryable, so scrollIntoView lands it.
|
||||||
|
const target = page.locator(
|
||||||
|
`[data-forum-event-id="${FORUM_DEEPLINK_REPLY_ID}"]`,
|
||||||
|
);
|
||||||
|
await expect(target).toBeVisible();
|
||||||
|
await expect(target).toContainText("Deep-link target");
|
||||||
|
// Assert the row sits within the viewport vertically — proves the scroll
|
||||||
|
// actually moved to it rather than leaving it below the fold.
|
||||||
|
await expect
|
||||||
|
.poll(async () =>
|
||||||
|
target.evaluate((el) => {
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
return rect.top >= 0 && rect.bottom <= window.innerHeight;
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
await page.screenshot({ path: `${SHOTS}/02-forum-deeplink-offscreen.png` });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("03 — members search shows both sticky titles under content-visibility", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await installMockBridge(page);
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByTestId("channel-general").click();
|
||||||
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||||
|
await page.getByTestId("channel-members-trigger").click();
|
||||||
|
await expect(page.getByTestId("members-sidebar")).toBeVisible();
|
||||||
|
|
||||||
|
// "a" matches member `alice` (Members section) and non-member `charlie`
|
||||||
|
// (Not in this channel section) — both heterogeneous lists + both sticky
|
||||||
|
// titles must stay alive under content-visibility.
|
||||||
|
await page.getByTestId("channel-management-search-users").fill("a");
|
||||||
|
await expect(page.getByText("Members", { exact: true })).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByText("Not in this channel", { exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
// A member row and an add-search (non-member) row both rendered.
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("members-sidebar-people").getByText("alice"),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.locator('[data-testid^="channel-user-search-result-"]').first(),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
await page.screenshot({
|
||||||
|
path: `${SHOTS}/03-members-both-sticky-titles.png`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("06 — custom-section dnd reorder commits under content-visibility", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await seedChannelSections(page);
|
||||||
|
await installMockBridge(page);
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByTestId("channel-general").click();
|
||||||
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||||
|
|
||||||
|
// dnd-kit marks each section's wrapping row with role="button" +
|
||||||
|
// aria-roledescription="sortable" and spreads the drag listeners there, so
|
||||||
|
// the row itself is the handle. Scoping to that attribute reads the live
|
||||||
|
// section order and excludes the inner disclosure button and the (hidden)
|
||||||
|
// assign-to-section context-menu items that reuse the same names.
|
||||||
|
const headers = page.locator('[aria-roledescription="sortable"]');
|
||||||
|
const topHeader = headers.filter({ hasText: "Priority" });
|
||||||
|
const bottomHeader = headers.filter({ hasText: "Archive" });
|
||||||
|
await expect(topHeader).toBeVisible();
|
||||||
|
await expect(bottomHeader).toBeVisible();
|
||||||
|
await expect(headers).toHaveCount(2);
|
||||||
|
|
||||||
|
const sectionOrder = async () =>
|
||||||
|
headers.evaluateAll((rows) =>
|
||||||
|
rows.map((row) =>
|
||||||
|
row.textContent?.trim().startsWith("Priority")
|
||||||
|
? "Priority"
|
||||||
|
: "Archive",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(await sectionOrder()).toEqual(["Priority", "Archive"]);
|
||||||
|
|
||||||
|
await page.screenshot({ path: `${SHOTS}/06a-sections-before-reorder.png` });
|
||||||
|
|
||||||
|
// Drag "Priority" past "Archive" — onDragEnd commits arrayMove and persists
|
||||||
|
// the new order. The drop must land for the order to flip.
|
||||||
|
await dragOver(page, topHeader, bottomHeader);
|
||||||
|
|
||||||
|
// The drop landed: order flipped. A no-op drag would leave it unchanged.
|
||||||
|
await expect.poll(sectionOrder).toEqual(["Archive", "Priority"]);
|
||||||
|
// Both section rows stayed committed in the DOM across the reorder — the
|
||||||
|
// content-visibility invariant the divergence rests on (no unmount).
|
||||||
|
await expect(headers).toHaveCount(2);
|
||||||
|
|
||||||
|
await page.screenshot({ path: `${SHOTS}/06b-sections-after-reorder.png` });
|
||||||
|
});
|
||||||
|
});
|
||||||
25
pnpm-lock.yaml
generated
25
pnpm-lock.yaml
generated
@@ -90,6 +90,9 @@ importers:
|
|||||||
'@tanstack/react-router':
|
'@tanstack/react-router':
|
||||||
specifier: ^1.168.10
|
specifier: ^1.168.10
|
||||||
version: 1.170.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
version: 1.170.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||||
|
'@tanstack/react-virtual':
|
||||||
|
specifier: ^3.14.2
|
||||||
|
version: 3.14.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||||
'@tauri-apps/api':
|
'@tauri-apps/api':
|
||||||
specifier: ~2.11
|
specifier: ~2.11
|
||||||
version: 2.11.0
|
version: 2.11.0
|
||||||
@@ -452,14 +455,12 @@ packages:
|
|||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@biomejs/cli-linux-x64@2.4.16':
|
'@biomejs/cli-linux-x64@2.4.16':
|
||||||
resolution: {integrity: sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ==}
|
resolution: {integrity: sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@biomejs/cli-win32-arm64@2.4.16':
|
'@biomejs/cli-win32-arm64@2.4.16':
|
||||||
resolution: {integrity: sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A==}
|
resolution: {integrity: sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A==}
|
||||||
@@ -1549,6 +1550,12 @@ packages:
|
|||||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
|
'@tanstack/react-virtual@3.14.2':
|
||||||
|
resolution: {integrity: sha512-IpWnmCLvuymRfeeLNVXIzNEYBFLpd3drVIS91sqV78VTZFyldlChkOocZRCPp1B+Wnk09bcLNme8WaMU/9/9bQ==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
'@tanstack/router-core@1.171.5':
|
'@tanstack/router-core@1.171.5':
|
||||||
resolution: {integrity: sha512-BfilbQqqWiQwJn68cD8wmk1ajEWIO3IlEA1zVuWslWbiVc23CDn+6ACO5tfPAcc96ED37hxela5ij3VBvAtusw==}
|
resolution: {integrity: sha512-BfilbQqqWiQwJn68cD8wmk1ajEWIO3IlEA1zVuWslWbiVc23CDn+6ACO5tfPAcc96ED37hxela5ij3VBvAtusw==}
|
||||||
engines: {node: '>=20.19'}
|
engines: {node: '>=20.19'}
|
||||||
@@ -1585,6 +1592,9 @@ packages:
|
|||||||
'@tanstack/store@0.9.3':
|
'@tanstack/store@0.9.3':
|
||||||
resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==}
|
resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==}
|
||||||
|
|
||||||
|
'@tanstack/virtual-core@3.17.0':
|
||||||
|
resolution: {integrity: sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ==}
|
||||||
|
|
||||||
'@tanstack/virtual-file-routes@1.162.0':
|
'@tanstack/virtual-file-routes@1.162.0':
|
||||||
resolution: {integrity: sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA==}
|
resolution: {integrity: sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA==}
|
||||||
engines: {node: '>=20.19'}
|
engines: {node: '>=20.19'}
|
||||||
@@ -1629,7 +1639,6 @@ packages:
|
|||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@tauri-apps/cli-linux-x64-gnu@2.11.2':
|
'@tauri-apps/cli-linux-x64-gnu@2.11.2':
|
||||||
resolution: {integrity: sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==}
|
resolution: {integrity: sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==}
|
||||||
@@ -3051,7 +3060,7 @@ packages:
|
|||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
wrappy@1.0.2:
|
wrappy@1.0.2:
|
||||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
resolution: {integrity: sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=}
|
||||||
|
|
||||||
yallist@3.1.1:
|
yallist@3.1.1:
|
||||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||||
@@ -4205,6 +4214,12 @@ snapshots:
|
|||||||
react-dom: 19.2.7(react@19.2.7)
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
use-sync-external-store: 1.6.0(react@19.2.7)
|
use-sync-external-store: 1.6.0(react@19.2.7)
|
||||||
|
|
||||||
|
'@tanstack/react-virtual@3.14.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||||
|
dependencies:
|
||||||
|
'@tanstack/virtual-core': 3.17.0
|
||||||
|
react: 19.2.7
|
||||||
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
|
|
||||||
'@tanstack/router-core@1.171.5':
|
'@tanstack/router-core@1.171.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tanstack/history': 1.162.0
|
'@tanstack/history': 1.162.0
|
||||||
@@ -4262,6 +4277,8 @@ snapshots:
|
|||||||
|
|
||||||
'@tanstack/store@0.9.3': {}
|
'@tanstack/store@0.9.3': {}
|
||||||
|
|
||||||
|
'@tanstack/virtual-core@3.17.0': {}
|
||||||
|
|
||||||
'@tanstack/virtual-file-routes@1.162.0': {}
|
'@tanstack/virtual-file-routes@1.162.0': {}
|
||||||
|
|
||||||
'@tauri-apps/api@2.11.0': {}
|
'@tauri-apps/api@2.11.0': {}
|
||||||
|
|||||||
Reference in New Issue
Block a user