Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
23 changes: 18 additions & 5 deletions __tests__/app/tools/block-finder/page.client.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
const mockSetToast = jest.fn();
const mockSetTitle = jest.fn();

const DEFAULT_DATE = "2025-09-26";
const DEFAULT_TIME = "12:00";

jest.mock("@/contexts/TitleContext", () => ({
useTitle: () => ({ setTitle: mockSetTitle }),
}));
Expand Down Expand Up @@ -112,13 +115,22 @@ jest.mock("@/components/block-picker/result/BlockPickerResult", () => ({
/** Utilities */
function setDateAndTime() {
fireEvent.change(screen.getByTestId("date-input"), {
target: { value: "2025-09-26" },
target: { value: DEFAULT_DATE },
});
fireEvent.change(screen.getByTestId("time-input"), {
target: { value: "12:00" },
target: { value: DEFAULT_TIME },
});
}

// Mirrors the component's timestamp calculation so expectations stay timezone agnostic.
function getTimestamp(date: string, time: string) {
const dateObj = new Date(date);
const [hours, minutes] = time.split(":");
const startDate = new Date(dateObj);
startDate.setHours(parseInt(hours, 10), parseInt(minutes, 10), 0, 0);
return startDate.getTime();
}
Comment thread
simo6529 marked this conversation as resolved.

describe("tools/block-finder/page.client.tsx (client)", () => {
beforeEach(() => {
jest.useFakeTimers().setSystemTime(new Date("2025-09-26T12:00:00+03:00"));
Expand Down Expand Up @@ -202,9 +214,10 @@ describe("tools/block-finder/page.client.tsx (client)", () => {
expect(init.method).toBe("POST");

const body = JSON.parse(init.body as string);
const expectedTimestamp = getTimestamp(DEFAULT_DATE, DEFAULT_TIME);

expect(body).toEqual({
// timestamp equals 2025-09-26 date with time 12:00 local -> client code uses Date(date)+time
timestamp: new Date("2025-09-26T12:00:00.000+03:00").getTime(),
timestamp: expectedTimestamp,
});

// Result rendered with returned block number
Expand Down Expand Up @@ -252,7 +265,7 @@ describe("tools/block-finder/page.client.tsx (client)", () => {
expect(init.method).toBe("POST");

const parsed = JSON.parse(init.body as string);
const min = new Date("2025-09-26T12:00:00.000+03:00").getTime();
const min = getTimestamp(DEFAULT_DATE, DEFAULT_TIME);
const max = min + 60_000; // ONE_MINUTE

expect(parsed.minTimestamp).toBe(min);
Expand Down
16 changes: 13 additions & 3 deletions __tests__/components/brain/NotificationsWrapper.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,25 @@ describe('NotificationsWrapper', () => {
it('shows loading spinner and handles actions', () => {
const setActive = jest.fn();
render(
<NotificationsWrapper items={[]} loading={true} activeDrop={null} setActiveDrop={setActive} />
<NotificationsWrapper
items={[]}
loadingOlder={true}
activeDrop={null}
setActiveDrop={setActive}
/>
);
expect(screen.getByText(/Loading notifications/, { selector: 'div' })).toBeInTheDocument();
expect(screen.getByText(/Loading older notifications/, { selector: 'div' })).toBeInTheDocument();
});

it('delegates callbacks to router and state setter', () => {
const setActive = jest.fn();
render(
<NotificationsWrapper items={[]} loading={false} activeDrop={null} setActiveDrop={setActive} />
<NotificationsWrapper
items={[]}
loadingOlder={false}
activeDrop={null}
setActiveDrop={setActive}
/>
);
screen.getByTestId('items').click();
expect(setActive).toHaveBeenCalledTimes(2);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import { render } from '@testing-library/react';
const NotificationItem = jest.fn(() => <div data-testid="item" />);
const CommonChangeAnimation = jest.fn(({ children }) => <div data-testid="anim">{children}</div>);

jest.mock('@/components/brain/notifications/NotificationItem', () => ({ __esModule: true, default: NotificationItem }));
jest.mock('@/components/utils/animation/CommonChangeAnimation', () => ({ __esModule: true, default: CommonChangeAnimation }));

import NotificationItems from '@/components/brain/notifications/NotificationItems';
import React from 'react';
Expand All @@ -26,7 +24,6 @@ describe('NotificationItems', () => {
/>
);

expect(CommonChangeAnimation).toHaveBeenCalledTimes(2);
expect(NotificationItem).toHaveBeenCalledTimes(2);
expect(NotificationItem.mock.calls[0][0]).toEqual(
expect.objectContaining({
Expand Down
12 changes: 3 additions & 9 deletions __tests__/components/brain/notifications/Notifications.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,6 @@ jest.mock('@/components/brain/notifications/NotificationsCauseFilter', () => ({
default: () => <div data-testid="filter" />,
}));

jest.mock('@/components/brain/feed/FeedScrollContainer', () => ({
FeedScrollContainer: React.forwardRef((props: any, ref) => (
<div data-testid="scroll" ref={ref} {...props} />
)),
}));

jest.mock('@/components/brain/content/input/BrainContentInput', () => ({
__esModule: true,
default: () => <div data-testid="input" />,
Expand Down Expand Up @@ -109,7 +103,7 @@ describe('Notifications component', () => {
isInitialQueryDone: false,
});

render(<Notifications />);
render(<Notifications activeDrop={null} setActiveDrop={jest.fn()} />);

expect(screen.getByText('Loading notifications...', { selector: 'div' })).toBeInTheDocument();
expect(mutateAsyncMock).toHaveBeenCalled();
Expand All @@ -127,7 +121,7 @@ describe('Notifications component', () => {
isInitialQueryDone: true,
});

render(<Notifications />);
render(<Notifications activeDrop={null} setActiveDrop={jest.fn()} />);

expect(screen.getByTestId('wrapper')).toBeInTheDocument();
});
Expand All @@ -143,7 +137,7 @@ describe('Notifications component', () => {
isInitialQueryDone: true,
});

render(<Notifications />);
render(<Notifications activeDrop={null} setActiveDrop={jest.fn()} />);

expect(screen.getByTestId('no-items')).toBeInTheDocument();
});
Expand Down
1 change: 1 addition & 0 deletions components/brain/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const NEAR_TOP_SCROLL_THRESHOLD_PX = 200;
209 changes: 180 additions & 29 deletions components/brain/feed/FeedScrollContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import React, {
useCallback,
useEffect,
} from "react";
import { NEAR_TOP_SCROLL_THRESHOLD_PX } from "../constants";

interface FeedScrollContainerProps {
readonly children: React.ReactNode;
Expand All @@ -17,6 +18,7 @@ interface FeedScrollContainerProps {
}

const MIN_OUT_OF_VIEW_COUNT = 30;
const FEED_ITEM_SELECTOR = "[id^='feed-item-']";

export const FeedScrollContainer = forwardRef<
HTMLDivElement,
Expand All @@ -36,6 +38,8 @@ export const FeedScrollContainer = forwardRef<
const [lastScrollTop, setLastScrollTop] = useState(0);
const throttleTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const previousHeightRef = useRef<number>(0);
const outOfViewAboveCountRef = useRef(0);
const observedFeedItemsRef = useRef(new Map<Element, boolean>());

// Track height changes to maintain scroll position
useEffect(() => {
Expand Down Expand Up @@ -74,51 +78,198 @@ export const FeedScrollContainer = forwardRef<
}
}, []);

useEffect(() => {
if (
!contentRef.current ||
!ref ||
typeof ref === "function" ||
!("current" in ref) ||
!ref.current
) {
return;
}

const scrollContainer = ref.current;
if (!scrollContainer) {
return;
}

const updateOutOfViewCount = (element: Element, isAbove: boolean) => {
const previous = observedFeedItemsRef.current.get(element) ?? false;

if (previous === isAbove) {
return;
}

observedFeedItemsRef.current.set(element, isAbove);
outOfViewAboveCountRef.current += isAbove ? 1 : -1;

if (outOfViewAboveCountRef.current < 0) {
outOfViewAboveCountRef.current = 0;
}
};

const intersectionObserver = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
const rootTop = entry.rootBounds?.top ?? scrollContainer.getBoundingClientRect().top;
const isAbove =
!entry.isIntersecting && entry.boundingClientRect.bottom <= rootTop;

updateOutOfViewCount(entry.target, isAbove);
}
},
{
root: scrollContainer,
threshold: 0,
}
);

const observeElement = (element: Element) => {
if (observedFeedItemsRef.current.has(element)) {
return;
}

observedFeedItemsRef.current.set(element, false);
intersectionObserver.observe(element);
};

const unobserveElement = (element: Element) => {
if (!observedFeedItemsRef.current.has(element)) {
return;
}

const wasAbove = observedFeedItemsRef.current.get(element) ?? false;
if (wasAbove) {
outOfViewAboveCountRef.current = Math.max(
0,
outOfViewAboveCountRef.current - 1
);
}

observedFeedItemsRef.current.delete(element);
intersectionObserver.unobserve(element);
};

const collectFeedItems = (node: Node): Element[] => {
const feedItems: Element[] = [];

if (node instanceof Element) {
if (node.matches(FEED_ITEM_SELECTOR)) {
feedItems.push(node);
}

for (const child of Array.from(
node.querySelectorAll(FEED_ITEM_SELECTOR)
)) {
feedItems.push(child);
}
} else if (node instanceof DocumentFragment) {
for (const child of Array.from(
node.querySelectorAll(FEED_ITEM_SELECTOR)
)) {
feedItems.push(child);
}
}

return feedItems;
};

const initializeFeedItems = () => {
observedFeedItemsRef.current.clear();
outOfViewAboveCountRef.current = 0;

const initialElements = contentRef.current?.querySelectorAll(
FEED_ITEM_SELECTOR
);

if (!initialElements) {
return;
}

for (const element of Array.from(initialElements)) {
// Ensure we observe each existing feed item exactly once
observeElement(element);
}
};

initializeFeedItems();

const feedItemsMutationObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of Array.from(mutation.addedNodes)) {
for (const item of collectFeedItems(node)) {
observeElement(item);
}
}

for (const node of Array.from(mutation.removedNodes)) {
for (const item of collectFeedItems(node)) {
unobserveElement(item);
}
}
}
});

feedItemsMutationObserver.observe(contentRef.current, {
childList: true,
subtree: true,
});

return () => {
feedItemsMutationObserver.disconnect();
intersectionObserver.disconnect();
observedFeedItemsRef.current.clear();
outOfViewAboveCountRef.current = 0;
};
}, [ref]);

const handleScroll = useCallback(
(event: React.UIEvent<HTMLDivElement>) => {
if (isFetchingNextPage || throttleTimeoutRef.current) return;

const currentTarget = event.currentTarget;
const currentScrollTop = currentTarget.scrollTop;

throttleTimeoutRef.current = setTimeout(() => {
const direction = currentScrollTop > lastScrollTop ? "down" : "up";
setLastScrollTop(currentScrollTop);

if (direction === "up" && onScrollUpNearTop) {
const dropElements =
contentRef.current?.querySelectorAll("[id^='feed-item-']");
if (!dropElements) {
throttleTimeoutRef.current = null;
return;
}
const clearThrottle = () => {
throttleTimeoutRef.current = null;
};

const containerRect = currentTarget.getBoundingClientRect();
let outOfViewCount = 0;
const latestScrollTop = currentTarget.scrollTop;
const isNearTop =
latestScrollTop <= NEAR_TOP_SCROLL_THRESHOLD_PX;

dropElements.forEach((el) => {
const rect = el.getBoundingClientRect();
if (rect.bottom < containerRect.top) {
outOfViewCount++;
}
});
const direction = latestScrollTop > lastScrollTop ? "down" : "up";
setLastScrollTop(latestScrollTop);

if (outOfViewCount <= MIN_OUT_OF_VIEW_COUNT) {
onScrollUpNearTop();
}
if (isNearTop) {
onScrollUpNearTop();
clearThrottle();
return;
}

if (direction === "down" && onScrollDownNearBottom) {
const { scrollHeight, scrollTop, clientHeight } = currentTarget;
const scrolledToBottom =
scrollHeight - scrollTop - clientHeight < 100;
if (direction === "down") {
if (onScrollDownNearBottom) {
const { scrollHeight, scrollTop, clientHeight } = currentTarget;
const scrolledToBottom =
scrollHeight - scrollTop - clientHeight < 100;

if (scrolledToBottom) {
onScrollDownNearBottom();
if (scrolledToBottom) {
onScrollDownNearBottom();
}
}

clearThrottle();
return;
}

const outOfViewCount = outOfViewAboveCountRef.current;

if (outOfViewCount <= MIN_OUT_OF_VIEW_COUNT) {
onScrollUpNearTop();
}

throttleTimeoutRef.current = null;
clearThrottle();
}, 100);
},
[
Expand Down
Loading