diff --git a/__tests__/app/tools/block-finder/page.client.test.tsx b/__tests__/app/tools/block-finder/page.client.test.tsx index 251c46de64..bda470c7ec 100644 --- a/__tests__/app/tools/block-finder/page.client.test.tsx +++ b/__tests__/app/tools/block-finder/page.client.test.tsx @@ -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 }), })); @@ -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(); +} + describe("tools/block-finder/page.client.tsx (client)", () => { beforeEach(() => { jest.useFakeTimers().setSystemTime(new Date("2025-09-26T12:00:00+03:00")); @@ -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 @@ -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); diff --git a/__tests__/components/brain/NotificationsWrapper.test.tsx b/__tests__/components/brain/NotificationsWrapper.test.tsx index cafa80b081..eaee070cc4 100644 --- a/__tests__/components/brain/NotificationsWrapper.test.tsx +++ b/__tests__/components/brain/NotificationsWrapper.test.tsx @@ -26,15 +26,25 @@ describe('NotificationsWrapper', () => { it('shows loading spinner and handles actions', () => { const setActive = jest.fn(); render( - + ); - expect(screen.getByText(/Loading notifications/, { selector: 'div' })).toBeInTheDocument(); + expect(screen.getByText(/Loading older notifications/i)).toBeInTheDocument(); }); it('delegates callbacks to router and state setter', () => { const setActive = jest.fn(); render( - + ); screen.getByTestId('items').click(); expect(setActive).toHaveBeenCalledTimes(2); diff --git a/__tests__/components/brain/feed/FeedScrollContainer.test.tsx b/__tests__/components/brain/feed/FeedScrollContainer.test.tsx index f993a55b89..824a198413 100644 --- a/__tests__/components/brain/feed/FeedScrollContainer.test.tsx +++ b/__tests__/components/brain/feed/FeedScrollContainer.test.tsx @@ -3,6 +3,34 @@ import { act } from 'react-dom/test-utils'; import React, { createRef } from 'react'; import { FeedScrollContainer } from '@/components/brain/feed/FeedScrollContainer'; +beforeAll(() => { + class IntersectionObserverMock implements IntersectionObserver { + readonly root: Element | Document | null = null; + readonly rootMargin = ''; + readonly thresholds = [] as ReadonlyArray; + + constructor(public callback: IntersectionObserverCallback) {} + + disconnect(): void {} + + observe(): void {} + + takeRecords(): IntersectionObserverEntry[] { + return []; + } + + unobserve(): void {} + } + + (globalThis as typeof globalThis & { + IntersectionObserver: typeof IntersectionObserver; + }).IntersectionObserver = IntersectionObserverMock as unknown as typeof IntersectionObserver; +}); + +afterAll(() => { + delete (globalThis as Record).IntersectionObserver; +}); + jest.useFakeTimers(); describe('FeedScrollContainer', () => { diff --git a/__tests__/components/brain/notifications/NotificationItems.test.tsx b/__tests__/components/brain/notifications/NotificationItems.test.tsx index f0009a2a9a..fa607f7df3 100644 --- a/__tests__/components/brain/notifications/NotificationItems.test.tsx +++ b/__tests__/components/brain/notifications/NotificationItems.test.tsx @@ -1,9 +1,7 @@ import { render } from '@testing-library/react'; const NotificationItem = jest.fn(() =>
); -const CommonChangeAnimation = jest.fn(({ children }) =>
{children}
); 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'; @@ -26,7 +24,6 @@ describe('NotificationItems', () => { /> ); - expect(CommonChangeAnimation).toHaveBeenCalledTimes(2); expect(NotificationItem).toHaveBeenCalledTimes(2); expect(NotificationItem.mock.calls[0][0]).toEqual( expect.objectContaining({ diff --git a/__tests__/components/brain/notifications/Notifications.test.tsx b/__tests__/components/brain/notifications/Notifications.test.tsx index 225096b1fa..20c70d76be 100644 --- a/__tests__/components/brain/notifications/Notifications.test.tsx +++ b/__tests__/components/brain/notifications/Notifications.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import React from 'react'; const mutateAsyncMock = jest.fn(); @@ -43,12 +43,6 @@ jest.mock('@/components/brain/notifications/NotificationsCauseFilter', () => ({ default: () =>
, })); -jest.mock('@/components/brain/feed/FeedScrollContainer', () => ({ - FeedScrollContainer: React.forwardRef((props: any, ref) => ( -
- )), -})); - jest.mock('@/components/brain/content/input/BrainContentInput', () => ({ __esModule: true, default: () =>
, @@ -94,11 +88,12 @@ import Notifications from '@/components/brain/notifications/Notifications'; describe('Notifications component', () => { beforeEach(() => { mutateAsyncMock.mockClear(); + mutateAsyncMock.mockResolvedValue(undefined); useNotificationsQueryMock.mockReset(); setTitleMock.mockClear(); }); - it('shows loader when fetching and no items', () => { + it('shows loader when fetching and no items', async () => { useNotificationsQueryMock.mockReturnValue({ items: [], isFetching: true, @@ -109,14 +104,16 @@ describe('Notifications component', () => { isInitialQueryDone: false, }); - render(); + render(); expect(screen.getByText('Loading notifications...', { selector: 'div' })).toBeInTheDocument(); - expect(mutateAsyncMock).toHaveBeenCalled(); + await waitFor(() => { + expect(mutateAsyncMock).toHaveBeenCalled(); + }); // Title is set via TitleContext hooks }); - it('renders wrapper with items', () => { + it('renders wrapper with items', async () => { useNotificationsQueryMock.mockReturnValue({ items: ['a'], isFetching: false, @@ -127,12 +124,15 @@ describe('Notifications component', () => { isInitialQueryDone: true, }); - render(); + render(); expect(screen.getByTestId('wrapper')).toBeInTheDocument(); + await waitFor(() => { + expect(mutateAsyncMock).toHaveBeenCalled(); + }); }); - it('shows no items component when query done but empty', () => { + it('shows no items component when query done but empty', async () => { useNotificationsQueryMock.mockReturnValue({ items: [], isFetching: false, @@ -143,8 +143,11 @@ describe('Notifications component', () => { isInitialQueryDone: true, }); - render(); + render(); expect(screen.getByTestId('no-items')).toBeInTheDocument(); + await waitFor(() => { + expect(mutateAsyncMock).toHaveBeenCalled(); + }); }); }); diff --git a/components/brain/constants.ts b/components/brain/constants.ts new file mode 100644 index 0000000000..055698bcde --- /dev/null +++ b/components/brain/constants.ts @@ -0,0 +1 @@ +export const NEAR_TOP_SCROLL_THRESHOLD_PX = 200; diff --git a/components/brain/feed/FeedScrollContainer.tsx b/components/brain/feed/FeedScrollContainer.tsx index 7e5a0d25dc..5e01804bd5 100644 --- a/components/brain/feed/FeedScrollContainer.tsx +++ b/components/brain/feed/FeedScrollContainer.tsx @@ -7,6 +7,7 @@ import React, { useCallback, useEffect, } from "react"; +import { NEAR_TOP_SCROLL_THRESHOLD_PX } from "../constants"; interface FeedScrollContainerProps { readonly children: React.ReactNode; @@ -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, @@ -36,6 +38,8 @@ export const FeedScrollContainer = forwardRef< const [lastScrollTop, setLastScrollTop] = useState(0); const throttleTimeoutRef = useRef(null); const previousHeightRef = useRef(0); + const outOfViewAboveCountRef = useRef(0); + const observedFeedItemsRef = useRef(new Map()); // Track height changes to maintain scroll position useEffect(() => { @@ -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) => { 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); }, [ diff --git a/components/brain/notifications/NotificationItem.tsx b/components/brain/notifications/NotificationItem.tsx index dc498ad47b..b4e55602b6 100644 --- a/components/brain/notifications/NotificationItem.tsx +++ b/components/brain/notifications/NotificationItem.tsx @@ -1,3 +1,4 @@ +import { memo } from "react"; import { ApiNotificationCause } from "@/generated/models/ApiNotificationCause"; import { assertUnreachable } from "@/helpers/AllowlistToolHelpers"; import { ExtendedDrop } from "@/helpers/waves/drop.helpers"; @@ -14,7 +15,7 @@ import NotificationAllDrops from "./all-drops/NotificationAllDrops"; import type { JSX } from "react"; import NotificationDropReacted from "./drop-reacted/NotificationDropReacted"; -export default function NotificationItem({ +function NotificationItemComponent({ notification, activeDrop, onReply, @@ -99,3 +100,9 @@ export default function NotificationItem({
); } + +const NotificationItem = memo(NotificationItemComponent); + +NotificationItem.displayName = "NotificationItem"; + +export default NotificationItem; diff --git a/components/brain/notifications/NotificationItems.tsx b/components/brain/notifications/NotificationItems.tsx index c2b3df146a..5381e952ea 100644 --- a/components/brain/notifications/NotificationItems.tsx +++ b/components/brain/notifications/NotificationItems.tsx @@ -1,9 +1,9 @@ +import { memo, useMemo } from "react"; import { TypedNotification } from "@/types/feed.types"; import NotificationItem from "./NotificationItem"; import { ActiveDropState } from "@/types/dropInteractionTypes"; import { DropInteractionParams } from "@/components/waves/drops/Drop"; import { ExtendedDrop } from "@/helpers/waves/drop.helpers"; -import CommonChangeAnimation from "@/components/utils/animation/CommonChangeAnimation"; interface NotificationItemsProps { readonly items: TypedNotification[]; @@ -13,30 +13,57 @@ interface NotificationItemsProps { readonly onDropContentClick?: (drop: ExtendedDrop) => void; } -export default function NotificationItems({ +function NotificationItemsComponent({ items, activeDrop, onReply, onQuote, onDropContentClick, }: NotificationItemsProps) { + const keyedNotifications = useMemo( + () => + items.map((notification, index) => { + const keySuffix = notification.id ?? `fallback-${index}`; + + return { + notification, + key: `notification-${keySuffix}`, + domId: `feed-item-${keySuffix}`, + }; + }), + [items] + ); + return (
- {items.map((notification, i) => ( -
- - - + {keyedNotifications.map(({ notification, key, domId }) => ( +
+
))}
); } + +const NotificationItems = memo( + NotificationItemsComponent, + (prevProps, nextProps) => { + return ( + prevProps.items === nextProps.items && + prevProps.activeDrop === nextProps.activeDrop && + prevProps.onReply === nextProps.onReply && + prevProps.onQuote === nextProps.onQuote && + prevProps.onDropContentClick === nextProps.onDropContentClick + ); + } +); + +NotificationItems.displayName = "NotificationItems"; + +export default NotificationItems; diff --git a/components/brain/notifications/Notifications.tsx b/components/brain/notifications/Notifications.tsx index 8e9eaf94ec..91e93fde03 100644 --- a/components/brain/notifications/Notifications.tsx +++ b/components/brain/notifications/Notifications.tsx @@ -1,6 +1,14 @@ "use client"; -import { useContext, useEffect, useRef, useState } from "react"; +import { + useCallback, + useContext, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; +import type { UIEventHandler } from "react"; import { useSetTitle } from "@/contexts/TitleContext"; import { AuthContext } from "@/components/auth/Auth"; import { ReactQueryWrapperContext } from "@/components/react-query-wrapper/ReactQueryWrapper"; @@ -10,7 +18,6 @@ import { useMutation } from "@tanstack/react-query"; import MyStreamNoItems from "../my-stream/layout/MyStreamNoItems"; import { useRouter, useSearchParams, usePathname } from "next/navigation"; import { ActiveDropState } from "@/types/dropInteractionTypes"; -import { FeedScrollContainer } from "../feed/FeedScrollContainer"; import { useNotificationsQuery } from "@/hooks/useNotificationsQuery"; import { useNotificationsContext } from "@/components/notifications/NotificationsContext"; import { useLayout } from "../my-stream/layout/LayoutContext"; @@ -18,6 +25,9 @@ import NotificationsCauseFilter, { NotificationFilter, } from "./NotificationsCauseFilter"; import SpinnerLoader from "@/components/common/SpinnerLoader"; +import { NEAR_TOP_SCROLL_THRESHOLD_PX } from "../constants"; + +const STICK_TO_BOTTOM_SCROLL_THRESHOLD_PX = 32; interface NotificationsProps { readonly activeDrop: ActiveDropState | null; @@ -27,7 +37,12 @@ interface NotificationsProps { export default function Notifications({ activeDrop, setActiveDrop }: NotificationsProps) { const { connectedProfile, activeProfileProxy, setToast } = useContext(AuthContext); - const scrollRef = useRef(null); + const scrollContainerRef = useRef(null); + const hasInitializedScrollRef = useRef(false); + const isPinnedToBottomRef = useRef(true); + const hasMarkedAllAsReadRef = useRef(false); + const isPrependingRef = useRef(false); + const previousScrollHeightRef = useRef(0); const { notificationsViewStyle } = useLayout(); const searchParams = useSearchParams(); @@ -43,25 +58,9 @@ export default function Notifications({ activeDrop, setActiveDrop }: Notificatio useSetTitle("Notifications | My Stream | Brain"); - useEffect(() => { - if (reload === "true") { - refetch() - .then(() => { - return markAllAsReadMutation.mutateAsync(); - }) - .catch((error) => { - console.error("Error during refetch:", error); - }); - const params = new URLSearchParams(searchParams?.toString() || ''); - params.delete('reload'); - const newUrl = params.toString() ? `${pathname}?${params.toString()}` : (pathname || '/my-stream/notifications'); - router.replace(newUrl, { scroll: false }); - } - }, [reload]); - const { invalidateNotifications } = useContext(ReactQueryWrapperContext); - const markAllAsReadMutation = useMutation({ + const { mutateAsync: markAllAsRead } = useMutation({ mutationFn: async () => await commonApiPostWithoutBodyAndResponse({ endpoint: `notifications/read`, @@ -72,15 +71,23 @@ export default function Notifications({ activeDrop, setActiveDrop }: Notificatio }, onError: (error) => { setToast({ - message: error as unknown as string, + message: + error instanceof Error ? error.message : String(error), type: "error", }); }, }); useEffect(() => { - markAllAsReadMutation.mutateAsync(); - }, []); + if (reload === "true" || hasMarkedAllAsReadRef.current) { + return; + } + + hasMarkedAllAsReadRef.current = true; + markAllAsRead().catch((error) => { + console.error("Failed to mark notifications as read:", error); + }); + }, [markAllAsRead, reload]); const { items, @@ -98,61 +105,210 @@ export default function Notifications({ activeDrop, setActiveDrop }: Notificatio cause: activeFilter?.cause, }); - const onBottomIntersection = (state: boolean) => { - if (!state) { - return; - } - if (isFetching) { - return; + useEffect(() => { + if (reload === "true") { + refetch() + .then(() => { + hasMarkedAllAsReadRef.current = true; + return markAllAsRead(); + }) + .catch((error) => { + console.error("Error during refetch:", error); + }); + const params = new URLSearchParams(searchParams?.toString() || ""); + params.delete("reload"); + const newUrl = params.toString() + ? `${pathname}?${params.toString()}` + : pathname || "/my-stream/notifications"; + router.replace(newUrl, { scroll: false }); } + }, [reload, refetch, markAllAsRead, searchParams, pathname, router]); + + const triggerFetchOlder = useCallback(() => { if (isFetchingNextPage) { return; } if (!hasNextPage) { return; } + const container = scrollContainerRef.current; + if (container) { + previousScrollHeightRef.current = container.scrollHeight; + } + isPrependingRef.current = true; fetchNextPage(); - }; + }, [isFetchingNextPage, hasNextPage, fetchNextPage]); - const handleScrollUpNearTop = () => { - onBottomIntersection(true); - }; + useLayoutEffect(() => { + const scrollElement = scrollContainerRef.current; + if (!scrollElement) { + return; + } + + if (items.length === 0) { + return; + } + + if (!hasInitializedScrollRef.current) { + scrollElement.scrollTop = scrollElement.scrollHeight; + hasInitializedScrollRef.current = true; + isPinnedToBottomRef.current = true; + } + }, [items]); + + useEffect(() => { + if (items.length === 0) { + hasInitializedScrollRef.current = false; + isPrependingRef.current = false; + previousScrollHeightRef.current = 0; + isPinnedToBottomRef.current = true; + } + }, [items.length]); + + useEffect(() => { + hasInitializedScrollRef.current = false; + isPinnedToBottomRef.current = true; + }, [activeFilter?.cause]); + + useLayoutEffect(() => { + if (!isPrependingRef.current) { + return; + } + + const container = scrollContainerRef.current; + if (!container) { + isPrependingRef.current = false; + return; + } + + const delta = container.scrollHeight - previousScrollHeightRef.current; + if (delta !== 0) { + container.scrollTop += delta; + } + + isPrependingRef.current = false; + }, [items]); const showLoader = (!isInitialQueryDone || isFetching) && items.length === 0; const showNoItems = isInitialQueryDone && !isFetching && items.length === 0; + const shouldEnableInfiniteScroll = !showLoader && !showNoItems; + + useLayoutEffect(() => { + const scrollElement = scrollContainerRef.current; + if (!scrollElement) { + return; + } + + if (typeof ResizeObserver === "undefined") { + return; + } + + const observationTarget = + (scrollElement.firstElementChild as HTMLElement | null) ?? scrollElement; - let mainContent: React.ReactNode; + let rafId: number | null = null; + const observer = new ResizeObserver(() => { + if (!hasInitializedScrollRef.current || !isPinnedToBottomRef.current) { + return; + } + + if (rafId !== null) { + cancelAnimationFrame(rafId); + } + + rafId = requestAnimationFrame(() => { + const container = scrollContainerRef.current; + if (!container) { + return; + } + + container.scrollTop = container.scrollHeight; + rafId = null; + }); + }); + + observer.observe(observationTarget); + + return () => { + if (rafId !== null) { + cancelAnimationFrame(rafId); + } + observer.disconnect(); + }; + }, [items, showLoader, showNoItems]); + + const handleScroll: UIEventHandler = useCallback( + (event) => { + const container = event.currentTarget; + const distanceFromBottom = + container.scrollHeight - container.scrollTop - container.clientHeight; + const isNearBottom = + distanceFromBottom <= STICK_TO_BOTTOM_SCROLL_THRESHOLD_PX; + + if (isNearBottom) { + isPinnedToBottomRef.current = true; + } else if (distanceFromBottom > STICK_TO_BOTTOM_SCROLL_THRESHOLD_PX) { + isPinnedToBottomRef.current = false; + } + + if (!shouldEnableInfiniteScroll) { + return; + } + + if (container.scrollTop <= NEAR_TOP_SCROLL_THRESHOLD_PX) { + if (!isFetchingNextPage && hasNextPage) { + triggerFetchOlder(); + } + } + }, + [ + shouldEnableInfiniteScroll, + isFetchingNextPage, + hasNextPage, + triggerFetchOlder, + ] + ); + + let notificationsContent = null; if (showLoader) { - mainContent = ; + notificationsContent = ( +
+ +
+ ); } else if (showNoItems) { - mainContent = ; + notificationsContent = ( +
+ +
+ ); } else { - mainContent = ( - - 0} - activeDrop={activeDrop} - setActiveDrop={setActiveDrop} - /> - + notificationsContent = ( + ); } return (
- {mainContent} +
+ {notificationsContent} +
); diff --git a/components/brain/notifications/NotificationsWrapper.tsx b/components/brain/notifications/NotificationsWrapper.tsx index 2275382a3c..48bf8e20ba 100644 --- a/components/brain/notifications/NotificationsWrapper.tsx +++ b/components/brain/notifications/NotificationsWrapper.tsx @@ -1,6 +1,6 @@ "use client"; -import React from "react"; +import React, { useCallback } from "react"; import { ExtendedDrop } from "@/helpers/waves/drop.helpers"; import { TypedNotification } from "@/types/feed.types"; import { @@ -13,71 +13,57 @@ import { useRouter } from "next/navigation"; interface NotificationsWrapperProps { readonly items: TypedNotification[]; - readonly loading: boolean; + readonly loadingOlder: boolean; readonly activeDrop: ActiveDropState | null; readonly setActiveDrop: (drop: ActiveDropState | null) => void; } export default function NotificationsWrapper({ items, - loading, + loadingOlder, activeDrop, setActiveDrop, }: NotificationsWrapperProps) { const router = useRouter(); - const onDropContentClick = (drop: ExtendedDrop) => { - router.push( - `/my-stream?wave=${drop.wave.id}&serialNo=${drop.serial_no}/` - ); - }; + const onDropContentClick = useCallback( + (drop: ExtendedDrop) => { + router.push( + `/my-stream?wave=${drop.wave.id}&serialNo=${drop.serial_no}/` + ); + }, + [router] + ); - const onReply = (param: DropInteractionParams) => { - setActiveDrop({ - action: ActiveDropAction.REPLY, - drop: param.drop, - partId: param.partId, - }); - }; + const onReply = useCallback( + (param: DropInteractionParams) => { + setActiveDrop({ + action: ActiveDropAction.REPLY, + drop: param.drop, + partId: param.partId, + }); + }, + [setActiveDrop] + ); - const onQuote = (param: DropInteractionParams) => { - setActiveDrop({ - action: ActiveDropAction.QUOTE, - drop: param.drop, - partId: param.partId, - }); - }; + const onQuote = useCallback( + (param: DropInteractionParams) => { + setActiveDrop({ + action: ActiveDropAction.QUOTE, + drop: param.drop, + partId: param.partId, + }); + }, + [setActiveDrop] + ); return ( -
- {loading && ( -
-
-
- -
-
- Loading notifications... -
+
+ {loadingOlder && ( +
+
+ + Loading older notifications...
)} diff --git a/package-lock.json b/package-lock.json index 89d82830d5..526f755912 100644 --- a/package-lock.json +++ b/package-lock.json @@ -299,7 +299,6 @@ "version": "7.28.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -933,7 +932,6 @@ "node_modules/@capacitor/core": { "version": "7.4.1", "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -1648,7 +1646,6 @@ "node_modules/@fortawesome/fontawesome-svg-core": { "version": "6.7.2", "license": "MIT", - "peer": true, "dependencies": { "@fortawesome/fontawesome-common-types": "6.7.2" }, @@ -2937,7 +2934,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.6.tgz", "integrity": "sha512-krKwLLcFmeuKDqngG2N/RuZHCs2ycsKcxWIDgcm7i1lf3sQ0iG03ci+DsP/r3FcT/eJDFsIHnKtNta2LIi7PzQ==", "license": "MIT", - "peer": true, "dependencies": { "file-type": "21.0.0", "iterare": "1.2.1", @@ -3158,7 +3154,6 @@ "node_modules/@noble/ciphers": { "version": "1.2.1", "license": "MIT", - "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -3403,7 +3398,6 @@ "version": "1.53.2", "devOptional": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "playwright": "1.53.2" }, @@ -3422,7 +3416,6 @@ "node_modules/@popperjs/core": { "version": "2.11.8", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/popperjs" @@ -4716,6 +4709,7 @@ "version": "2.1.1", "license": "BSD-3-Clause", "optional": true, + "peer": true, "dependencies": { "@lit-labs/ssr-dom-shim": "^1.4.0" } @@ -4980,10 +4974,24 @@ "benchmarks" ] }, + "node_modules/@reown/appkit/node_modules/lit": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.0.tgz", + "integrity": "sha512-DGVsqsOIHBww2DqnuZzW7QsuCdahp50ojuDaBPC7jUDRpYoH0z7kHBBYZewRzer75FwtrkmkKk7iOAwSaWdBmw==", + "license": "BSD-3-Clause", + "optional": true, + "peer": true, + "dependencies": { + "@lit/reactive-element": "^2.1.0", + "lit-element": "^4.2.0", + "lit-html": "^3.3.0" + } + }, "node_modules/@reown/appkit/node_modules/lit-element": { "version": "4.2.1", "license": "BSD-3-Clause", "optional": true, + "peer": true, "dependencies": { "@lit-labs/ssr-dom-shim": "^1.4.0", "@lit/reactive-element": "^2.1.0", @@ -4994,6 +5002,7 @@ "version": "3.3.1", "license": "BSD-3-Clause", "optional": true, + "peer": true, "dependencies": { "@types/trusted-types": "^2.0.2" } @@ -5491,7 +5500,6 @@ "node_modules/@tanstack/query-core": { "version": "5.82.0", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" @@ -5500,7 +5508,6 @@ "node_modules/@tanstack/react-query": { "version": "5.82.0", "license": "MIT", - "peer": true, "dependencies": { "@tanstack/query-core": "5.82.0" }, @@ -5691,7 +5698,8 @@ "node_modules/@types/aria-query": { "version": "5.0.4", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -5876,7 +5884,6 @@ "version": "20.19.6", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -5902,7 +5909,6 @@ "node_modules/@types/react": { "version": "19.1.4", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -5910,7 +5916,6 @@ "node_modules/@types/react-dom": { "version": "19.1.5", "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.0.0" } @@ -6032,7 +6037,6 @@ "integrity": "sha512-TGf22kon8KW+DeKaUmOibKWktRY8b2NSAZNdtWh798COm1NWx8+xJ6iFBtk3IvLdv6+LGLJLRlyhrhEDZWargQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.45.0", "@typescript-eslint/types": "8.45.0", @@ -7277,7 +7281,6 @@ "node_modules/@walletconnect/ethereum-provider/node_modules/ws": { "version": "8.18.0", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -7697,7 +7700,6 @@ "node_modules/@walletconnect/utils/node_modules/ws": { "version": "8.18.0", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -7771,7 +7773,6 @@ "version": "8.15.0", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -8195,7 +8196,6 @@ "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", "license": "MIT", - "peer": true, "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", @@ -8649,7 +8649,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", @@ -8755,7 +8754,6 @@ "version": "4.0.9", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "node-gyp-build": "^4.3.0" }, @@ -8945,7 +8943,6 @@ "node_modules/chart.js": { "version": "4.5.0", "license": "MIT", - "peer": true, "dependencies": { "@kurkle/color": "^0.3.0" }, @@ -9376,7 +9373,6 @@ "node_modules/cross-fetch": { "version": "4.1.0", "license": "MIT", - "peer": true, "dependencies": { "node-fetch": "^2.7.0" } @@ -9850,7 +9846,8 @@ "node_modules/dom-accessibility-api": { "version": "0.5.16", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dom-helpers": { "version": "5.2.1", @@ -10003,7 +10000,6 @@ "node_modules/eciesjs": { "version": "0.4.15", "license": "MIT", - "peer": true, "dependencies": { "@ecies/ciphers": "^0.2.3", "@noble/ciphers": "^1.3.0", @@ -10103,8 +10099,7 @@ }, "node_modules/emoji-mart": { "version": "5.6.0", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/emoji-regex": { "version": "9.2.2", @@ -10471,7 +10466,6 @@ "version": "8.57.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -10627,7 +10621,6 @@ "version": "2.32.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -11086,8 +11079,7 @@ }, "node_modules/eventemitter2": { "version": "6.4.9", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/eventemitter3": { "version": "5.0.1", @@ -12399,8 +12391,7 @@ }, "node_modules/idb-keyval": { "version": "6.2.2", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/identity-obj-proxy": { "version": "3.0.0", @@ -13129,6 +13120,7 @@ "node_modules/isomorphic.js": { "version": "0.2.5", "license": "MIT", + "peer": true, "funding": { "type": "GitHub Sponsors ❤", "url": "https://github.com/sponsors/dmonad" @@ -13265,7 +13257,6 @@ "version": "29.7.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -14495,6 +14486,7 @@ "node_modules/lib0": { "version": "0.2.109", "license": "MIT", + "peer": true, "dependencies": { "isomorphic.js": "^0.2.4" }, @@ -14659,6 +14651,7 @@ "version": "1.5.0", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -15709,7 +15702,6 @@ "resolved": "https://registry.npmjs.org/next/-/next-15.5.2.tgz", "integrity": "sha512-H8Otr7abj1glFhbGnvUt3gz++0AF1+QoCXEBmd/6aKbfdFwrn0LpA836Ed5+00va/7HQSDD+mOoVhn3tNy3e/Q==", "license": "MIT", - "peer": true, "dependencies": { "@next/env": "15.5.2", "@swc/helpers": "0.5.15", @@ -16847,7 +16839,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -16980,6 +16971,7 @@ "version": "27.5.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -16993,6 +16985,7 @@ "version": "5.2.0", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -17046,7 +17039,6 @@ "node_modules/prop-types": { "version": "15.8.1", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -17361,7 +17353,6 @@ "node_modules/react": { "version": "19.1.0", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -17406,7 +17397,6 @@ "node_modules/react-dom": { "version": "19.1.0", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -17427,7 +17417,8 @@ "node_modules/react-is": { "version": "17.0.2", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-lifecycles-compat": { "version": "3.0.4", @@ -17461,7 +17452,6 @@ "node_modules/react-redux": { "version": "9.2.0", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -17633,7 +17623,6 @@ "node_modules/readable-stream": { "version": "3.6.2", "license": "MIT", - "peer": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -17674,8 +17663,7 @@ }, "node_modules/redux": { "version": "5.0.1", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -17686,8 +17674,7 @@ }, "node_modules/reflect-metadata": { "version": "0.2.2", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", @@ -18223,7 +18210,6 @@ "node_modules/rxjs": { "version": "7.8.2", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -18308,7 +18294,6 @@ "node_modules/sass": { "version": "1.89.2", "license": "MIT", - "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -18706,7 +18691,6 @@ "node_modules/socket.io-client": { "version": "4.8.1", "license": "MIT", - "peer": true, "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.2", @@ -19378,7 +19362,6 @@ "node_modules/tailwindcss": { "version": "3.4.17", "license": "MIT", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -19505,8 +19488,7 @@ }, "node_modules/three": { "version": "0.163.0", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/throttle-debounce": { "version": "3.0.1", @@ -19553,7 +19535,6 @@ "version": "4.0.2", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -19805,8 +19786,7 @@ }, "node_modules/tslib": { "version": "2.8.1", - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tsx": { "version": "4.20.6", @@ -19943,7 +19923,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -20381,7 +20360,6 @@ "version": "5.0.10", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "node-gyp-build": "^4.3.0" }, @@ -20431,7 +20409,6 @@ "node_modules/valtio": { "version": "2.1.5", "license": "MIT", - "peer": true, "dependencies": { "proxy-compare": "^3.0.1" }, @@ -20506,7 +20483,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@noble/curves": "1.9.2", "@noble/hashes": "1.8.0", @@ -20616,7 +20592,6 @@ "node_modules/wagmi": { "version": "2.16.0", "license": "MIT", - "peer": true, "dependencies": { "@wagmi/connectors": "5.9.0", "@wagmi/core": "2.18.0", @@ -20783,7 +20758,6 @@ "node_modules/wagmi/node_modules/@wagmi/core": { "version": "2.18.0", "license": "MIT", - "peer": true, "dependencies": { "eventemitter3": "5.0.1", "mipd": "0.0.7", @@ -20865,7 +20839,6 @@ "node_modules/wagmi/node_modules/use-sync-external-store": { "version": "1.4.0", "license": "MIT", - "peer": true, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } @@ -21182,7 +21155,6 @@ "node_modules/ws": { "version": "7.5.10", "license": "MIT", - "peer": true, "engines": { "node": ">=8.3.0" }, @@ -21303,7 +21275,6 @@ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "devOptional": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" }