mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Fix task route targets and scroll checks
This commit is contained in:
@@ -9,9 +9,14 @@ import {
|
||||
isBroadcastReply,
|
||||
} from "@/features/messages/lib/threading";
|
||||
import { useProfileQuery } from "@/features/profile/hooks";
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
import { getEventById } from "@/shared/api/tauri";
|
||||
import type { RelayEvent } from "@/shared/api/types";
|
||||
import {
|
||||
CHANNEL_TIMELINE_CONTENT_KINDS,
|
||||
CHANNEL_TIMELINE_STATE_KINDS,
|
||||
} from "@/shared/constants/kinds";
|
||||
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
|
||||
|
||||
type ChannelRouteScreenProps = {
|
||||
@@ -24,6 +29,7 @@ type ChannelRouteScreenProps = {
|
||||
};
|
||||
|
||||
const MAX_ROUTE_ANCESTOR_HOPS = 50;
|
||||
const MAX_ROUTE_TASK_EVENTS = 1000;
|
||||
|
||||
async function fetchRouteEvent(eventId: string): Promise<RelayEvent | null> {
|
||||
try {
|
||||
@@ -43,8 +49,10 @@ function getReplyParentId(event: RelayEvent): string | null {
|
||||
}
|
||||
|
||||
async function fetchRouteTargetEvents(
|
||||
channelId: string,
|
||||
eventIds: string[],
|
||||
targetMessageId: string | null,
|
||||
targetAgentConversationReplyId: string | null,
|
||||
targetThreadRootId: string | null,
|
||||
): Promise<RelayEvent[]> {
|
||||
const eventsById = new Map<string, RelayEvent>();
|
||||
@@ -73,6 +81,29 @@ async function fetchRouteTargetEvents(
|
||||
addEvent(await fetchRouteEvent(threadRootId));
|
||||
}
|
||||
|
||||
if (targetAgentConversationReplyId && threadRootId) {
|
||||
try {
|
||||
const taskEvents = await relayClient.fetchEvents({
|
||||
"#e": [threadRootId],
|
||||
"#h": [channelId],
|
||||
kinds: [
|
||||
...CHANNEL_TIMELINE_CONTENT_KINDS,
|
||||
...CHANNEL_TIMELINE_STATE_KINDS,
|
||||
],
|
||||
limit: MAX_ROUTE_TASK_EVENTS,
|
||||
});
|
||||
for (const event of taskEvents) {
|
||||
addEvent(event);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Failed to load route task conversation",
|
||||
targetAgentConversationReplyId,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let parentId = getReplyParentId(targetEvent);
|
||||
let guard = 0;
|
||||
while (
|
||||
@@ -171,8 +202,10 @@ export function ChannelRouteScreen({
|
||||
].filter((eventId): eventId is string => eventId !== null);
|
||||
|
||||
void fetchRouteTargetEvents(
|
||||
channelId,
|
||||
eventIds,
|
||||
targetAgentConversationReplyId ?? targetMessageId,
|
||||
targetAgentConversationReplyId,
|
||||
targetThreadRootId,
|
||||
).then((events) => {
|
||||
if (!isCancelled) {
|
||||
@@ -191,6 +224,7 @@ export function ChannelRouteScreen({
|
||||
};
|
||||
}, [
|
||||
selectedPostId,
|
||||
channelId,
|
||||
targetAgentConversationReplyId,
|
||||
targetMessageId,
|
||||
targetThreadRootId,
|
||||
|
||||
@@ -397,7 +397,6 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
|
||||
if (isThreadedTask) {
|
||||
onOpenThread(threadMessage);
|
||||
onSelectThreadReplyTarget(message);
|
||||
onThreadScrollTargetChange(message.id);
|
||||
return;
|
||||
}
|
||||
@@ -408,7 +407,6 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
[
|
||||
onCloseThread,
|
||||
onOpenThread,
|
||||
onSelectThreadReplyTarget,
|
||||
onSurfaceTabChange,
|
||||
onThreadScrollTargetChange,
|
||||
],
|
||||
|
||||
@@ -728,9 +728,10 @@ export function ChannelScreen({
|
||||
const handleThreadScrollTargetResolved = React.useCallback(() => {
|
||||
setThreadScrollTargetId(null);
|
||||
}, []);
|
||||
const handleTargetReached = React.useCallback(() => {
|
||||
clearMessageRouteTarget({ replace: true });
|
||||
}, [clearMessageRouteTarget]);
|
||||
const [
|
||||
pendingMainTimelineRouteTargetId,
|
||||
setPendingMainTimelineRouteTargetId,
|
||||
] = React.useState<string | null>(null);
|
||||
React.useEffect(() => {
|
||||
resetComposerTargets(activeChannelId);
|
||||
}, [activeChannelId, resetComposerTargets]);
|
||||
@@ -768,19 +769,55 @@ export function ChannelScreen({
|
||||
targetAgentConversationReplyId,
|
||||
timelineMessages,
|
||||
});
|
||||
const mainTimelineTargetMessageId = useChannelRouteTarget({
|
||||
activeChannel,
|
||||
activeChannelId,
|
||||
closeAgentSession: handleCloseAgentSession,
|
||||
setEditTargetId,
|
||||
setExpandedThreadReplyIds,
|
||||
setOpenThreadHeadId,
|
||||
setProfilePanelPubkey,
|
||||
setThreadReplyTargetId,
|
||||
setThreadScrollTargetId,
|
||||
targetMessageId,
|
||||
timelineMessages,
|
||||
});
|
||||
const { mainTimelineTargetMessageId, rootThreadHeadTargetId } =
|
||||
useChannelRouteTarget({
|
||||
activeChannel,
|
||||
activeChannelId,
|
||||
closeAgentSession: handleCloseAgentSession,
|
||||
setEditTargetId,
|
||||
setExpandedThreadReplyIds,
|
||||
setOpenThreadHeadId,
|
||||
setProfilePanelPubkey,
|
||||
setThreadReplyTargetId,
|
||||
setThreadScrollTargetId,
|
||||
targetMessageId,
|
||||
timelineMessages,
|
||||
});
|
||||
const handleTargetReached = React.useCallback(
|
||||
(messageId: string) => {
|
||||
setPendingMainTimelineRouteTargetId((current) =>
|
||||
current === messageId ? null : current,
|
||||
);
|
||||
if (rootThreadHeadTargetId === messageId) {
|
||||
handleCloseAgentSession();
|
||||
setProfilePanelPubkey(null, { replace: true });
|
||||
setEditTargetId(null);
|
||||
setOpenThreadHeadId(messageId, { replace: true });
|
||||
setThreadReplyTargetId(messageId);
|
||||
setThreadScrollTargetId(null);
|
||||
setExpandedThreadReplyIds(new Set());
|
||||
}
|
||||
clearMessageRouteTarget({ replace: true });
|
||||
},
|
||||
[
|
||||
clearMessageRouteTarget,
|
||||
handleCloseAgentSession,
|
||||
rootThreadHeadTargetId,
|
||||
setOpenThreadHeadId,
|
||||
setProfilePanelPubkey,
|
||||
],
|
||||
);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: activeChannelId is the reset trigger; the effect intentionally clears target state when the channel changes.
|
||||
React.useEffect(() => {
|
||||
setPendingMainTimelineRouteTargetId(null);
|
||||
}, [activeChannelId]);
|
||||
React.useEffect(() => {
|
||||
if (mainTimelineTargetMessageId) {
|
||||
setPendingMainTimelineRouteTargetId(mainTimelineTargetMessageId);
|
||||
}
|
||||
}, [mainTimelineTargetMessageId]);
|
||||
const effectiveMainTimelineTargetMessageId =
|
||||
pendingMainTimelineRouteTargetId ?? mainTimelineTargetMessageId;
|
||||
React.useEffect(() => {
|
||||
if (openThreadHeadId && !openThreadHeadMessage) {
|
||||
// While the timeline is still loading (e.g. a reload restoring the
|
||||
@@ -1063,7 +1100,7 @@ export function ChannelScreen({
|
||||
onSurfaceTabChange={handleSurfaceTabChange}
|
||||
firstUnreadMessageId={firstUnreadMessageId}
|
||||
unreadCount={unreadCount}
|
||||
targetMessageId={mainTimelineTargetMessageId}
|
||||
targetMessageId={effectiveMainTimelineTargetMessageId}
|
||||
threadHeadMessage={displayedThreadHeadMessage}
|
||||
threadMessages={displayedThreadMessages}
|
||||
threadPanelWidthPx={threadPanelWidthPx}
|
||||
|
||||
@@ -45,7 +45,11 @@ function getRouteMainTimelineTargetId(
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!targetMessage?.parentId || isBroadcastReply(targetMessage.tags ?? [])) {
|
||||
if (!targetMessage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!targetMessage.parentId || isBroadcastReply(targetMessage.tags ?? [])) {
|
||||
return targetMessageId;
|
||||
}
|
||||
|
||||
@@ -88,6 +92,10 @@ export function useChannelRouteTarget({
|
||||
targetMessageId,
|
||||
targetTimelineMessage,
|
||||
);
|
||||
const rootThreadHeadTargetId =
|
||||
targetTimelineMessage && !targetTimelineMessage.parentId
|
||||
? targetTimelineMessage.id
|
||||
: null;
|
||||
const handledThreadRouteTargetRef = React.useRef<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -115,18 +123,9 @@ export function useChannelRouteTarget({
|
||||
}
|
||||
|
||||
if (!targetMessage.parentId) {
|
||||
closeAgentSession();
|
||||
// Root message links should open the reply panel for that root. The
|
||||
// timeline scroll/highlight target alone is not enough: root links have
|
||||
// no parent/thread metadata, so the reply-only branch below cannot infer
|
||||
// a thread head.
|
||||
setProfilePanelPubkey(null, { replace: true });
|
||||
setEditTargetId(null);
|
||||
setOpenThreadHeadId(targetMessage.id, { replace: true });
|
||||
setThreadReplyTargetId(targetMessage.id);
|
||||
setThreadScrollTargetId(null);
|
||||
setExpandedThreadReplyIds(new Set());
|
||||
handledThreadRouteTargetRef.current = targetKey;
|
||||
// Root links still need to open the reply panel, but not until the main
|
||||
// timeline has centered the row. Opening the panel first changes the main
|
||||
// column layout mid-jump and can make the virtualized target abandon early.
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -166,5 +165,5 @@ export function useChannelRouteTarget({
|
||||
timelineMessageById,
|
||||
]);
|
||||
|
||||
return mainTimelineTargetMessageId;
|
||||
return { mainTimelineTargetMessageId, rootThreadHeadTargetId };
|
||||
}
|
||||
|
||||
@@ -6377,10 +6377,16 @@ async function handleGetEvent(
|
||||
},
|
||||
config: E2eConfig | undefined,
|
||||
) {
|
||||
const mockEvent = Array.from(mockMessages.values())
|
||||
.flat()
|
||||
.find((item) => item.id === args.eventId);
|
||||
if (mockEvent) {
|
||||
return JSON.stringify(mockEvent);
|
||||
}
|
||||
|
||||
const identity = getIdentity(config);
|
||||
if (!identity) {
|
||||
const knownEvents: RelayEvent[] = [
|
||||
...Array.from(mockMessages.values()).flat(),
|
||||
{
|
||||
id: "mock-engineering-shipped",
|
||||
pubkey:
|
||||
|
||||
@@ -167,7 +167,30 @@ async function scrollTimelineAwayFromBottom(page: Page, minDistance = 160) {
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Failed to scroll the timeline away from the bottom.");
|
||||
await timeline.evaluate((element, distance) => {
|
||||
const timeline = element as HTMLDivElement;
|
||||
timeline.dispatchEvent(
|
||||
new WheelEvent("wheel", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
deltaY: -800,
|
||||
}),
|
||||
);
|
||||
const maxScrollTop = Math.max(
|
||||
0,
|
||||
timeline.scrollHeight - timeline.clientHeight,
|
||||
);
|
||||
timeline.scrollTop = Math.max(0, maxScrollTop - distance - 80);
|
||||
timeline.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
}, minDistance);
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const metrics = await getTimelineMetrics(page);
|
||||
const scrollToLatestVisible = await isScrollToLatestVisible(page);
|
||||
return metrics.distanceFromBottom > minDistance && scrollToLatestVisible;
|
||||
})
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
test.beforeAll(async () => {
|
||||
|
||||
Reference in New Issue
Block a user