mirror of
https://github.com/block/buzz.git
synced 2026-11-12 04:12:21 +00:00
fix(desktop): restore virtual timeline prepend anchors
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
parent
2fb1da561a
commit
24f4383e07
@@ -13,6 +13,7 @@ import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
|
||||
import type { ChannelWindowThreadSummary } from "@/features/messages/lib/channelWindowStore";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import type { ChannelType } from "@/shared/api/types";
|
||||
import { useFeatureEnabled } from "@/shared/features";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { channelChrome } from "@/shared/layout/chromeLayout";
|
||||
import { Spinner } from "@/shared/ui/spinner";
|
||||
@@ -198,7 +199,7 @@ const MessageTimelineBase = React.forwardRef<
|
||||
React.useReducer((version: number) => version + 1, 0);
|
||||
const [timelineVirtualizerApi, setTimelineVirtualizerApi] =
|
||||
React.useState<TimelineVirtualizerApi | null>(null);
|
||||
const useTimelineVirtualizer = true;
|
||||
const useTimelineVirtualizer = useFeatureEnabled("virtuosoTimeline");
|
||||
const activeScrollContainerRef = React.useMemo(
|
||||
() => ({
|
||||
get current() {
|
||||
@@ -246,6 +247,7 @@ const MessageTimelineBase = React.forwardRef<
|
||||
// painted at a stale offset until the user's next scroll event forces layout.
|
||||
const scrollContainerDomKey = channelId ?? "none";
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: this effect is scoped to scroll DOM swaps; when the Experiments flag hydrates, the virtualizer child registers its own scroller/API via callbacks, and re-running this reset on the flag transition can briefly clear that API during navigation tests.
|
||||
React.useLayoutEffect(() => {
|
||||
// Re-read after `scrollContainerDomKey` swaps the keyed scroll DOM node.
|
||||
void scrollContainerDomKey;
|
||||
@@ -390,6 +392,18 @@ const MessageTimelineBase = React.forwardRef<
|
||||
React.useEffect(() => {
|
||||
const target = pendingSearchTargetRef.current;
|
||||
if (!target || showTimelineSkeleton) return;
|
||||
if (
|
||||
useTimelineVirtualizer &&
|
||||
!activeScrollContainerRef.current?.querySelector(
|
||||
`[data-message-id="${CSS.escape(target)}"]`,
|
||||
)
|
||||
) {
|
||||
// Phase 1: ask Virtuoso to realize the match's index. The retry effect
|
||||
// runs again on range change and the DOM-visible path does the actual
|
||||
// center + highlight once the row exists.
|
||||
void jumpToMessage(target, { behavior: "auto" });
|
||||
return;
|
||||
}
|
||||
if (jumpToMessage(target, { behavior: "auto" })) {
|
||||
pendingSearchTargetRef.current = null;
|
||||
}
|
||||
@@ -419,6 +433,11 @@ const MessageTimelineBase = React.forwardRef<
|
||||
messages: showTimelineSkeleton ? EMPTY_MESSAGES : deferredMessages,
|
||||
});
|
||||
|
||||
const handleVirtualizerRangeChanged = React.useCallback(() => {
|
||||
bumpVirtualizerRenderVersion();
|
||||
onScroll();
|
||||
}, [onScroll]);
|
||||
|
||||
const timelineList = showMessageList ? (
|
||||
<TimelineMessageList
|
||||
key={scrollContainerDomKey}
|
||||
@@ -448,7 +467,8 @@ const MessageTimelineBase = React.forwardRef<
|
||||
onStartReached={loadOlderViaVirtualizer}
|
||||
onToggleReaction={onToggleReaction}
|
||||
onVirtualizerApiChange={setTimelineVirtualizerApi}
|
||||
onVirtualizerRangeChanged={bumpVirtualizerRenderVersion}
|
||||
onVirtualizerRangeChanged={handleVirtualizerRangeChanged}
|
||||
onVirtualizerScroll={onScroll}
|
||||
onVirtualizerScrollerChange={setVirtualizerScrollParent}
|
||||
onAtBottomStateChange={onVirtualizerAtBottomStateChange}
|
||||
personaLookup={personaLookup}
|
||||
|
||||
@@ -103,6 +103,7 @@ type TimelineMessageListProps = {
|
||||
onAtBottomStateChange?: (atBottom: boolean) => void;
|
||||
onVirtualizerApiChange?: (api: TimelineVirtualizerApi | null) => void;
|
||||
onVirtualizerRangeChanged?: () => void;
|
||||
onVirtualizerScroll?: () => void;
|
||||
onVirtualizerScrollerChange?: (element: HTMLDivElement | null) => void;
|
||||
};
|
||||
|
||||
@@ -142,6 +143,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
onAtBottomStateChange,
|
||||
onVirtualizerApiChange,
|
||||
onVirtualizerRangeChanged,
|
||||
onVirtualizerScroll,
|
||||
onVirtualizerScrollerChange,
|
||||
}: TimelineMessageListProps) {
|
||||
const entries = React.useMemo(
|
||||
@@ -292,6 +294,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
onStartReached={onStartReached}
|
||||
onVirtualizerApiChange={onVirtualizerApiChange}
|
||||
onVirtualizerRangeChanged={onVirtualizerRangeChanged}
|
||||
onVirtualizerScroll={onVirtualizerScroll}
|
||||
onVirtualizerScrollerChange={onVirtualizerScrollerChange}
|
||||
renderItem={renderItem}
|
||||
/>
|
||||
@@ -331,12 +334,68 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
|
||||
const FIRST_ITEM_INDEX_BASE = 1_000_000;
|
||||
|
||||
function timelineItemMessageId(item: TimelineNonDayItem): string | null {
|
||||
return item.kind === "message" || item.kind === "system"
|
||||
? item.entry.message.id
|
||||
: null;
|
||||
}
|
||||
|
||||
function groupedInternalIndexForItem(
|
||||
groupCounts: readonly number[],
|
||||
itemIndex: number,
|
||||
): number {
|
||||
let itemCount = 0;
|
||||
|
||||
for (let groupIndex = 0; groupIndex < groupCounts.length; groupIndex += 1) {
|
||||
if (itemIndex < itemCount + groupCounts[groupIndex]) {
|
||||
return itemIndex + groupIndex + 1;
|
||||
}
|
||||
itemCount += groupCounts[groupIndex];
|
||||
}
|
||||
|
||||
return itemIndex + groupCounts.length;
|
||||
}
|
||||
|
||||
function buildGroupedInternalKeyByIndex(
|
||||
firstItemIndex: number,
|
||||
dayGroups: readonly TimelineDayGroup[],
|
||||
): Map<number, string> {
|
||||
const keyByIndex = new Map<number, string>();
|
||||
let internalIndex = 0;
|
||||
|
||||
for (const group of dayGroups) {
|
||||
keyByIndex.set(firstItemIndex + internalIndex, `day-${group.key}`);
|
||||
internalIndex += 1;
|
||||
|
||||
for (const item of group.items) {
|
||||
keyByIndex.set(firstItemIndex + internalIndex, getTimelineItemKey(item));
|
||||
internalIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return keyByIndex;
|
||||
}
|
||||
|
||||
function buildGroupedInternalData(
|
||||
dayGroups: readonly TimelineDayGroup[],
|
||||
): Array<TimelineNonDayItem | undefined> {
|
||||
const data: Array<TimelineNonDayItem | undefined> = [];
|
||||
|
||||
for (const group of dayGroups) {
|
||||
data.push(undefined);
|
||||
data.push(...group.items);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
type VirtualizedTimelineRowsProps = {
|
||||
dayGroups: TimelineDayGroup[];
|
||||
onAtBottomStateChange?: (atBottom: boolean) => void;
|
||||
onStartReached?: () => void;
|
||||
onVirtualizerApiChange?: (api: TimelineVirtualizerApi | null) => void;
|
||||
onVirtualizerRangeChanged?: () => void;
|
||||
onVirtualizerScroll?: () => void;
|
||||
onVirtualizerScrollerChange?: (element: HTMLDivElement | null) => void;
|
||||
renderItem: (item: TimelineNonDayItem) => React.ReactNode;
|
||||
};
|
||||
@@ -347,10 +406,13 @@ function VirtualizedTimelineRows({
|
||||
onStartReached,
|
||||
onVirtualizerApiChange,
|
||||
onVirtualizerRangeChanged,
|
||||
onVirtualizerScroll,
|
||||
onVirtualizerScrollerChange,
|
||||
renderItem,
|
||||
}: VirtualizedTimelineRowsProps) {
|
||||
const virtuosoRef = React.useRef<GroupedVirtuosoHandle>(null);
|
||||
const [scrollerElement, setScrollerElement] =
|
||||
React.useState<HTMLDivElement | null>(null);
|
||||
const firstItemIndexStateRef = React.useRef<StableFirstItemIndexState>({
|
||||
firstItemIndex: FIRST_ITEM_INDEX_BASE,
|
||||
keys: [],
|
||||
@@ -367,15 +429,56 @@ function VirtualizedTimelineRows({
|
||||
};
|
||||
}, [dayGroups]);
|
||||
const { firstItemIndex, flattenedItems, groupCounts } = timelineModel;
|
||||
const messageVirtualIndexById = React.useMemo(() => {
|
||||
const groupedInternalKeyByIndex = React.useMemo(
|
||||
() => buildGroupedInternalKeyByIndex(firstItemIndex, dayGroups),
|
||||
[dayGroups, firstItemIndex],
|
||||
);
|
||||
const groupedInternalData = React.useMemo(
|
||||
() => buildGroupedInternalData(dayGroups),
|
||||
[dayGroups],
|
||||
);
|
||||
const virtualizerModelRef = React.useRef<{
|
||||
firstItemIndex: number;
|
||||
flattenedCount: number;
|
||||
groupCounts: readonly number[];
|
||||
messageItemIndexById: Map<string, number>;
|
||||
}>({
|
||||
firstItemIndex,
|
||||
flattenedCount: flattenedItems.length,
|
||||
groupCounts,
|
||||
messageItemIndexById: new Map(),
|
||||
});
|
||||
const deferredScrollFrameRef = React.useRef<number | null>(null);
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
if (deferredScrollFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(deferredScrollFrameRef.current);
|
||||
deferredScrollFrameRef.current = null;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
const messageItemIndexById = React.useMemo(() => {
|
||||
const byId = new Map<string, number>();
|
||||
flattenedItems.forEach((item, index) => {
|
||||
if (item.kind === "message" || item.kind === "system") {
|
||||
byId.set(item.entry.message.id, index);
|
||||
}
|
||||
const messageId = timelineItemMessageId(item);
|
||||
if (messageId) byId.set(messageId, index);
|
||||
});
|
||||
return byId;
|
||||
}, [flattenedItems]);
|
||||
React.useLayoutEffect(() => {
|
||||
virtualizerModelRef.current = {
|
||||
firstItemIndex,
|
||||
flattenedCount: flattenedItems.length,
|
||||
groupCounts,
|
||||
messageItemIndexById,
|
||||
};
|
||||
}, [
|
||||
firstItemIndex,
|
||||
flattenedItems.length,
|
||||
groupCounts,
|
||||
messageItemIndexById,
|
||||
]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!onVirtualizerApiChange) return;
|
||||
@@ -388,27 +491,67 @@ function VirtualizedTimelineRows({
|
||||
});
|
||||
},
|
||||
scrollToMessage(messageId, options = {}) {
|
||||
const index = messageVirtualIndexById.get(messageId);
|
||||
if (index === undefined) return false;
|
||||
virtuosoRef.current?.scrollToIndex({
|
||||
align: "center",
|
||||
behavior: options.behavior === "smooth" ? "smooth" : "auto",
|
||||
index,
|
||||
const itemIndex = messageItemIndexById.get(messageId);
|
||||
if (itemIndex === undefined) return false;
|
||||
if (deferredScrollFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(deferredScrollFrameRef.current);
|
||||
}
|
||||
const requested = {
|
||||
count: flattenedItems.length,
|
||||
firstItemIndex,
|
||||
itemIndex,
|
||||
messageId,
|
||||
};
|
||||
deferredScrollFrameRef.current = window.requestAnimationFrame(() => {
|
||||
deferredScrollFrameRef.current = null;
|
||||
const current = virtualizerModelRef.current;
|
||||
const currentIndex = current.messageItemIndexById.get(messageId);
|
||||
if (
|
||||
currentIndex !== requested.itemIndex ||
|
||||
current.firstItemIndex !== requested.firstItemIndex ||
|
||||
current.flattenedCount !== requested.count
|
||||
) {
|
||||
return;
|
||||
}
|
||||
virtuosoRef.current?.scrollToIndex({
|
||||
align: "center",
|
||||
behavior: options.behavior === "smooth" ? "smooth" : "auto",
|
||||
index: groupedInternalIndexForItem(
|
||||
current.groupCounts,
|
||||
requested.itemIndex,
|
||||
),
|
||||
});
|
||||
});
|
||||
return true;
|
||||
},
|
||||
};
|
||||
onVirtualizerApiChange(api);
|
||||
return () => onVirtualizerApiChange(null);
|
||||
}, [messageVirtualIndexById, onVirtualizerApiChange]);
|
||||
}, [
|
||||
flattenedItems.length,
|
||||
firstItemIndex,
|
||||
messageItemIndexById,
|
||||
onVirtualizerApiChange,
|
||||
]);
|
||||
|
||||
const handleScrollerRef = React.useCallback(
|
||||
(ref: HTMLElement | Window | null) => {
|
||||
onVirtualizerScrollerChange?.(ref instanceof HTMLDivElement ? ref : null);
|
||||
const scroller = ref instanceof HTMLDivElement ? ref : null;
|
||||
onVirtualizerScrollerChange?.(scroller);
|
||||
setScrollerElement(scroller);
|
||||
},
|
||||
[onVirtualizerScrollerChange],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!scrollerElement || !onVirtualizerScroll) return;
|
||||
scrollerElement.addEventListener("scroll", onVirtualizerScroll, {
|
||||
passive: true,
|
||||
});
|
||||
return () =>
|
||||
scrollerElement.removeEventListener("scroll", onVirtualizerScroll);
|
||||
}, [onVirtualizerScroll, scrollerElement]);
|
||||
|
||||
const handleRangeChanged = React.useCallback(
|
||||
(_range: ListRange) => {
|
||||
onVirtualizerRangeChanged?.();
|
||||
@@ -417,16 +560,17 @@ function VirtualizedTimelineRows({
|
||||
);
|
||||
|
||||
return (
|
||||
<GroupedVirtuoso<TimelineNonDayItem>
|
||||
<GroupedVirtuoso<TimelineNonDayItem | undefined>
|
||||
ref={virtuosoRef}
|
||||
className="h-full min-h-0 w-full"
|
||||
components={virtuosoComponents}
|
||||
computeItemKey={(index, item) =>
|
||||
item === undefined
|
||||
? `timeline-missing-${index}`
|
||||
? (groupedInternalKeyByIndex.get(index) ??
|
||||
`timeline-missing-${index}`)
|
||||
: getTimelineItemKey(item)
|
||||
}
|
||||
data={flattenedItems}
|
||||
data={groupedInternalData}
|
||||
defaultItemHeight={72}
|
||||
firstItemIndex={firstItemIndex}
|
||||
followOutput="auto"
|
||||
@@ -455,9 +599,14 @@ function VirtualizedTimelineRows({
|
||||
);
|
||||
}}
|
||||
groupCounts={groupCounts}
|
||||
itemContent={(_index, _groupIndex, item) => (
|
||||
<TimelineRowShell item={item}>{renderItem(item)}</TimelineRowShell>
|
||||
)}
|
||||
itemContent={(_index, _groupIndex, item) => {
|
||||
if (!item) return null;
|
||||
return (
|
||||
<TimelineRowShell item={item} useContentVisibility={false}>
|
||||
{renderItem(item)}
|
||||
</TimelineRowShell>
|
||||
);
|
||||
}}
|
||||
rangeChanged={handleRangeChanged}
|
||||
scrollerRef={handleScrollerRef}
|
||||
startReached={onStartReached ? () => onStartReached() : undefined}
|
||||
@@ -465,7 +614,7 @@ function VirtualizedTimelineRows({
|
||||
);
|
||||
}
|
||||
|
||||
const virtuosoComponents: Components<TimelineNonDayItem> = {
|
||||
const virtuosoComponents: Components<TimelineNonDayItem | undefined> = {
|
||||
Scroller: React.forwardRef<
|
||||
HTMLDivElement,
|
||||
ScrollerProps & { className?: string }
|
||||
@@ -496,12 +645,17 @@ const virtuosoComponents: Components<TimelineNonDayItem> = {
|
||||
function TimelineRowShell({
|
||||
children,
|
||||
item,
|
||||
useContentVisibility = true,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
item: TimelineNonDayItem;
|
||||
useContentVisibility?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="timeline-row-cv" style={timelineRowReserveStyle(item)}>
|
||||
<div
|
||||
className={useContentVisibility ? "timeline-row-cv" : undefined}
|
||||
style={useContentVisibility ? timelineRowReserveStyle(item) : undefined}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -286,7 +286,10 @@ export function useAnchoredScroll({
|
||||
if (!el) {
|
||||
if (
|
||||
virtualScrollToMessage?.(messageId, {
|
||||
behavior: options.behavior ?? "auto",
|
||||
// Phase 1 only asks the virtualizer to realize an off-DOM row.
|
||||
// Keep this jump synchronous; the DOM-visible phase below applies
|
||||
// the caller's smooth/auto behavior once the target can be measured.
|
||||
behavior: "auto",
|
||||
})
|
||||
) {
|
||||
anchorRef.current = { kind: "message", messageId, topOffset: 0 };
|
||||
@@ -547,7 +550,7 @@ export function useAnchoredScroll({
|
||||
// *without* marking the target handled until its row actually exists — each
|
||||
// subsequent message commit re-runs the effect and retries the centering.
|
||||
// ---------------------------------------------------------------------------
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: `messages` is an intentional trigger, not a read — the effect reads the DOM (querySelector), and we need it to re-run each time the rendered row set changes so a target spliced into older history gets centered once its row commits.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: `messages` and `virtualizerRenderVersion` are intentional retry triggers, not values read by the effect body — the effect reads the DOM (querySelector), and we need it to re-run each time the message list or virtualized rendered range changes so a target spliced into older history gets centered once its row commits.
|
||||
React.useEffect(() => {
|
||||
if (!targetMessageId) {
|
||||
handledTargetIdRef.current = null;
|
||||
@@ -556,6 +559,7 @@ export function useAnchoredScroll({
|
||||
if (handledTargetIdRef.current === targetMessageId || isLoading) return;
|
||||
if (!hasInitializedRef.current) return; // initial-mount path will handle.
|
||||
|
||||
void virtualizerRenderVersion;
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
const el = container.querySelector<HTMLElement>(
|
||||
|
||||
@@ -5,41 +5,37 @@
|
||||
"id": "workflows",
|
||||
"name": "Workflows",
|
||||
"description": "YAML-defined automations with approval gates",
|
||||
"platforms": [
|
||||
"desktop"
|
||||
]
|
||||
"platforms": ["desktop"]
|
||||
},
|
||||
{
|
||||
"id": "projects",
|
||||
"name": "Projects",
|
||||
"description": "Git repository browser and collaboration",
|
||||
"platforms": [
|
||||
"desktop"
|
||||
]
|
||||
"platforms": ["desktop"]
|
||||
},
|
||||
{
|
||||
"id": "pulse",
|
||||
"name": "Pulse",
|
||||
"description": "Activity feed with notes, social posts, and agent activity",
|
||||
"platforms": [
|
||||
"desktop"
|
||||
]
|
||||
"platforms": ["desktop"]
|
||||
},
|
||||
{
|
||||
"id": "workspaceRail",
|
||||
"name": "Workspace Rail",
|
||||
"description": "Far-left rail for switching relays with cross-relay unread indicators",
|
||||
"platforms": [
|
||||
"desktop"
|
||||
]
|
||||
"platforms": ["desktop"]
|
||||
},
|
||||
{
|
||||
"id": "forum",
|
||||
"name": "Forum Channels",
|
||||
"description": "Forum-style threaded channels for long-form discussions",
|
||||
"platforms": [
|
||||
"desktop"
|
||||
]
|
||||
"platforms": ["desktop"]
|
||||
},
|
||||
{
|
||||
"id": "virtuosoTimeline",
|
||||
"name": "Virtuoso Timeline",
|
||||
"description": "Virtualized channel timeline using react-virtuoso",
|
||||
"platforms": ["desktop"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user