diff --git a/__tests__/components/shared/WavesMessagesWrapper.test.tsx b/__tests__/components/shared/WavesMessagesWrapper.test.tsx new file mode 100644 index 0000000000..4a89749890 --- /dev/null +++ b/__tests__/components/shared/WavesMessagesWrapper.test.tsx @@ -0,0 +1,143 @@ +import WavesMessagesWrapper from "@/components/shared/WavesMessagesWrapper"; +import { render, screen } from "@testing-library/react"; +import React from "react"; + +let mockBreakpoint = "LG"; +let mockWaveId: string | null = null; + +const mockChildMounted = jest.fn(); +const mockCloseRightSidebar = jest.fn(); +const mockRouterReplace = jest.fn(); +const mockUseQuery = jest.fn(); + +jest.mock("react-use", () => ({ + createBreakpoint: () => () => mockBreakpoint, +})); + +jest.mock("next/navigation", () => ({ + usePathname: () => "/waves", + useRouter: () => ({ replace: mockRouterReplace }), + useSearchParams: () => new URLSearchParams(), +})); + +jest.mock("@/helpers/navigation.helpers", () => ({ + getActiveWaveIdFromUrl: () => mockWaveId, +})); + +jest.mock("@tanstack/react-query", () => ({ + keepPreviousData: (value: unknown) => value, + useQuery: (options: unknown) => mockUseQuery(options), +})); + +jest.mock("@/hooks/useSidebarState", () => ({ + useSidebarState: () => ({ + closeRightSidebar: mockCloseRightSidebar, + isRightSidebarOpen: false, + }), +})); + +jest.mock("@/hooks/useCreateModalState", () => ({ + __esModule: true, + default: () => ({ + close: jest.fn(), + isWaveModalOpen: false, + }), +})); + +jest.mock("@/hooks/useClosingDropId", () => ({ + useClosingDropId: () => ({ + beginClosingDrop: jest.fn(), + effectiveDropId: undefined, + }), +})); + +jest.mock("@/components/auth/Auth", () => ({ + useAuth: () => ({ connectedProfile: null }), +})); + +jest.mock("@/components/brain/my-stream/layout/LayoutContext", () => ({ + useLayout: () => ({ contentContainerStyle: {} }), +})); + +jest.mock("@/contexts/wave/WaveChatScrollContext", () => ({ + WaveChatScrollProvider: ({ + children, + }: { + readonly children: React.ReactNode; + }) => <>{children}, +})); + +jest.mock("@/components/brain/left-sidebar/web/WebLeftSidebar", () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock("@/components/brain/right-sidebar/BrainRightSidebar", () => ({ + __esModule: true, + SidebarTab: { ABOUT: "ABOUT" }, + default: () =>
, +})); + +jest.mock("@/components/brain/BrainDesktopDrop", () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock("@/components/waves/create-wave/CreateWaveModal", () => ({ + __esModule: true, + default: () =>
, +})); + +function MainContentProbe() { + mockChildMounted(); + return
Main content
; +} + +function renderWrapper() { + return render( + + + + ); +} + +describe("WavesMessagesWrapper", () => { + beforeEach(() => { + mockBreakpoint = "LG"; + mockWaveId = null; + jest.clearAllMocks(); + mockUseQuery.mockReturnValue({ data: undefined, error: null }); + }); + + it("renders no-wave main content on desktop", () => { + mockBreakpoint = "LG"; + mockWaveId = null; + + renderWrapper(); + + expect(screen.getByTestId("main-content")).toBeInTheDocument(); + expect(mockChildMounted).toHaveBeenCalledTimes(1); + }); + + it("does not mount no-wave main content on small screens", () => { + mockBreakpoint = "S"; + mockWaveId = null; + + renderWrapper(); + + expect(screen.queryByTestId("main-content")).not.toBeInTheDocument(); + expect(screen.getByTestId("left-sidebar")).toBeInTheDocument(); + expect(mockChildMounted).not.toHaveBeenCalled(); + }); + + it("renders selected wave main content on small screens", () => { + mockBreakpoint = "S"; + mockWaveId = "wave-1"; + + renderWrapper(); + + expect(screen.getByTestId("main-content")).toBeInTheDocument(); + expect(screen.queryByTestId("left-sidebar")).not.toBeInTheDocument(); + expect(mockChildMounted).toHaveBeenCalledTimes(1); + }); +}); diff --git a/__tests__/components/waves/WavesLayout.test.tsx b/__tests__/components/waves/WavesLayout.test.tsx index 5dd7f9a342..c1890481dd 100644 --- a/__tests__/components/waves/WavesLayout.test.tsx +++ b/__tests__/components/waves/WavesLayout.test.tsx @@ -4,9 +4,6 @@ import React from "react"; const mockUseAuthenticatedContent = jest.fn(); const mockUseDeviceInfo = jest.fn(); -const mockGetActiveWaveIdFromUrl = jest.fn(); -const mockUsePathname = jest.fn(); -const mockUseSearchParams = jest.fn(); jest.mock("../../../hooks/useAuthenticatedContent", () => ({ useAuthenticatedContent: () => mockUseAuthenticatedContent(), @@ -17,16 +14,6 @@ jest.mock("../../../hooks/useDeviceInfo", () => ({ default: () => mockUseDeviceInfo(), })); -jest.mock("next/navigation", () => ({ - usePathname: () => mockUsePathname(), - useSearchParams: () => mockUseSearchParams(), -})); - -jest.mock("../../../helpers/navigation.helpers", () => ({ - getActiveWaveIdFromUrl: (...args: unknown[]) => - mockGetActiveWaveIdFromUrl(...args), -})); - jest.mock("@/components/waves/WavesDesktop", () => ({ __esModule: true, default: ({ @@ -57,51 +44,20 @@ jest.mock("@/components/common/ConnectWallet", () => ({ default: () =>
Connect Wallet
, })); -jest.mock("@/components/header/user/HeaderUserConnect", () => ({ - __esModule: true, - default: ({ label }: { readonly label?: string }) => ( - - ), -})); - jest.mock("@/components/user/utils/set-up-profile/UserSetUpProfileCta", () => ({ __esModule: true, default: () =>
Set up profile
, })); -jest.mock("@/components/waves/WaveScreenMessage", () => ({ - __esModule: true, - default: ({ - action, - description, - title, - }: { - readonly action?: React.ReactNode; - readonly description?: string; - readonly title: string; - }) => ( -
-

{title}

- {description ?

{description}

: null} - {action} -
- ), -})); - describe("WavesLayout", () => { beforeEach(() => { mockUseAuthenticatedContent.mockReturnValue({ contentState: "not-authenticated", }); mockUseDeviceInfo.mockReturnValue({ isApp: false, isMobileDevice: false }); - mockUsePathname.mockReturnValue("/waves/test-wave"); - mockUseSearchParams.mockReturnValue(new URLSearchParams("wave=test-wave")); - mockGetActiveWaveIdFromUrl.mockReturnValue(null); }); it("renders the selected wave content for logged-out users", () => { - mockGetActiveWaveIdFromUrl.mockReturnValue("test-wave"); - render(
Real wave content
@@ -110,32 +66,19 @@ describe("WavesLayout", () => { expect(screen.getByTestId("wave-content")).toBeInTheDocument(); expect(screen.getByTestId("waves-desktop")).toHaveAttribute( - "data-allow-drop-overlay", - "false" + "data-show-left-sidebar", + "true" ); - expect(screen.getByTestId("waves-desktop")).toHaveAttribute( - "data-allow-right-sidebar", - "false" - ); - expect(screen.queryByTestId("wave-screen-message")).not.toBeInTheDocument(); }); - it("keeps the select-wave prompt when no wave is selected", () => { + it("renders the default waves content for logged-out web users when no wave is selected", () => { render(
Real wave content
); - expect(screen.getByText("Select a Wave")).toBeInTheDocument(); - expect( - screen.getByText( - "Connect your wallet to access waves and join the conversation." - ) - ).toBeInTheDocument(); - expect( - screen.getByRole("button", { name: "Connect Wallet" }) - ).toBeInTheDocument(); - expect(screen.queryByTestId("wave-content")).not.toBeInTheDocument(); + expect(screen.getByTestId("wave-content")).toBeInTheDocument(); + expect(screen.queryByTestId("connect-wallet")).not.toBeInTheDocument(); }); }); diff --git a/__tests__/hooks/useCommunityCurationsDrops.test.ts b/__tests__/hooks/useCommunityCurationsDrops.test.ts new file mode 100644 index 0000000000..81e653d57d --- /dev/null +++ b/__tests__/hooks/useCommunityCurationsDrops.test.ts @@ -0,0 +1,141 @@ +import { renderHook } from "@testing-library/react"; +import type { ApiCuratedProfileWaveDropsPage } from "@/generated/models/ApiCuratedProfileWaveDropsPage"; +import type { ApiDrop } from "@/generated/models/ApiDrop"; +import { useCommunityCurationsDrops } from "@/hooks/useCommunityCurationsDrops"; +import { commonApiFetch } from "@/services/api/common-api"; + +type InfiniteQueryOptions = { + readonly queryFn: (context: { + readonly pageParam: number; + }) => Promise; + readonly getNextPageParam: ( + lastPage: ApiCuratedProfileWaveDropsPage + ) => number | undefined; + readonly enabled?: boolean; + readonly initialPageParam?: number; + readonly queryKey?: unknown; +}; + +const mockUseInfiniteQuery = jest.fn(); + +jest.mock("@tanstack/react-query", () => ({ + useInfiniteQuery: (options: InfiniteQueryOptions) => + mockUseInfiniteQuery(options), +})); + +jest.mock("@/services/api/common-api", () => ({ + commonApiFetch: jest.fn(), +})); + +const commonApiFetchMock = commonApiFetch as jest.MockedFunction< + typeof commonApiFetch +>; + +const getDefaultQueryResult = ( + pages: ApiCuratedProfileWaveDropsPage[] | undefined = undefined +) => ({ + data: pages ? { pages } : undefined, + fetchNextPage: jest.fn(), + hasNextPage: false, + isError: false, + isFetchingNextPage: false, + isLoading: false, +}); + +const buildDrop = ({ + id, + mimeType, +}: { + readonly id: string; + readonly mimeType?: string | undefined; +}): ApiDrop => + ({ + id, + metadata: [], + nft_links: [], + parts: mimeType + ? [ + { + media: [{ mime_type: mimeType }], + }, + ] + : [], + }) as unknown as ApiDrop; + +describe("useCommunityCurationsDrops", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseInfiniteQuery.mockReturnValue(getDefaultQueryResult()); + }); + + it("fetches curated profile wave drops with page pagination", async () => { + let queryOptions: InfiniteQueryOptions | null = null; + mockUseInfiniteQuery.mockImplementation((options: InfiniteQueryOptions) => { + queryOptions = options; + return getDefaultQueryResult(); + }); + + renderHook(() => useCommunityCurationsDrops({ limit: 12 })); + + expect(queryOptions).not.toBeNull(); + expect(queryOptions?.initialPageParam).toBe(1); + + await queryOptions?.queryFn({ pageParam: 2 }); + + expect(commonApiFetchMock).toHaveBeenCalledWith({ + endpoint: "curated-profile-wave-drops", + params: { + page: "2", + page_size: "12", + }, + }); + expect( + queryOptions?.getNextPageParam({ + data: [], + page: 2, + next: true, + }) + ).toBe(3); + expect( + queryOptions?.getNextPageParam({ + data: [], + page: 2, + next: false, + }) + ).toBeUndefined(); + }); + + it("dedupes loaded drops and keeps existing media filtering", () => { + const imageDrop = buildDrop({ id: "image-drop", mimeType: "image/png" }); + const videoDrop = buildDrop({ id: "video-drop", mimeType: "video/mp4" }); + const duplicateVideoDrop = buildDrop({ + id: "video-drop", + mimeType: "video/mp4", + }); + + mockUseInfiniteQuery.mockReturnValue( + getDefaultQueryResult([ + { + data: [imageDrop, videoDrop], + page: 1, + next: true, + }, + { + data: [duplicateVideoDrop], + page: 2, + next: false, + }, + ]) + ); + + const { result } = renderHook(() => + useCommunityCurationsDrops({ limit: 12, mediaFilter: "video" }) + ); + + expect(result.current.allDrops.map((drop) => drop.id)).toEqual([ + "image-drop", + "video-drop", + ]); + expect(result.current.drops.map((drop) => drop.id)).toEqual(["video-drop"]); + }); +}); diff --git a/app/[user]/_lib/userTabPageFactory.tsx b/app/[user]/_lib/userTabPageFactory.tsx index 28624f5617..64c3f92fc0 100644 --- a/app/[user]/_lib/userTabPageFactory.tsx +++ b/app/[user]/_lib/userTabPageFactory.tsx @@ -1,7 +1,7 @@ import { TransferProvider } from "@/components/nft-transfer/TransferState"; import { getAppMetadata } from "@/components/providers/metadata"; import UserPageLayout from "@/components/user/layout/UserPageLayout"; -import type { ApiIdentity } from "@/generated/models/ObjectSerializer"; +import type { ApiIdentity } from "@/generated/models/ApiIdentity"; import { getMetadataForUserPage } from "@/helpers/Helpers"; import { getAppCommonHeaders } from "@/helpers/server.app.helpers"; import { diff --git a/components/brain/content/BrainContent.tsx b/components/brain/content/BrainContent.tsx index 6cc10c944c..48f5fe0072 100644 --- a/components/brain/content/BrainContent.tsx +++ b/components/brain/content/BrainContent.tsx @@ -59,18 +59,17 @@ const BrainContent: React.FC = ({ const shouldShowPinnedWaves = showPinnedWaves && breakpoint === "S" && isApp; return ( -
+
{showPinnedWaves && (
+ className="tw-sticky tw-top-0 tw-z-10 tw-bg-iron-950 tw-px-2 sm:tw-px-4 md:tw-px-6 lg:tw-hidden lg:tw-px-0" + > {shouldShowPinnedWaves && }
)} -
-
- {children} -
+
+
{children}
{activeDrop && (
diff --git a/components/brain/left-sidebar/waves/MemesWaveFooter.tsx b/components/brain/left-sidebar/waves/MemesWaveFooter.tsx index cdb0f079d3..14ced1e782 100644 --- a/components/brain/left-sidebar/waves/MemesWaveFooter.tsx +++ b/components/brain/left-sidebar/waves/MemesWaveFooter.tsx @@ -43,7 +43,7 @@ const MemesWaveFooter: React.FC = ({ leftThisRoundCount )}, ${formatMemesQuickVoteUnratedText(unratedCount)}` : "Quick vote"; - const buttonTitle = isReady ? "Uncast votes" : "Quick vote"; + const buttonTitle = isReady ? "Uncast Power" : "Quick vote"; const votingPowerLabel = votingLabel ? ` ${votingLabel}` : " votes"; const buttonValue = isReady && typeof uncastPower === "number" @@ -76,7 +76,7 @@ const MemesWaveFooter: React.FC = ({ transition={revealTransition} className={ collapsed - ? "tw-z-10 tw-flex tw-flex-shrink-0 tw-justify-center tw-px-2 tw-pb-2 tw-pt-1" + ? "tw-z-10 tw-flex tw-flex-shrink-0 tw-justify-center tw-gap-2 tw-px-2 tw-pb-2 tw-pt-1" : "tw-relative tw-z-20 tw-mt-auto tw-flex-shrink-0" } > @@ -89,46 +89,50 @@ const MemesWaveFooter: React.FC = ({ unratedCount={unratedCount} /> ) : ( -
- +
)} )} diff --git a/components/brain/my-stream/MyStreamWaveContent.tsx b/components/brain/my-stream/MyStreamWaveContent.tsx index c69a436714..6240a61939 100644 --- a/components/brain/my-stream/MyStreamWaveContent.tsx +++ b/components/brain/my-stream/MyStreamWaveContent.tsx @@ -181,7 +181,7 @@ const MyStreamWaveContent: React.FC = ({ waveId }) => { return (
{/* Always render tab container (hidden on app inside MyStreamWaveTabs) */} @@ -195,7 +195,7 @@ const MyStreamWaveContent: React.FC = ({ waveId }) => { />
[] = + [ + { key: "all", label: "All", value: "all" }, + { key: "image", label: "Images", value: "image" }, + { key: "video", label: "Video", value: "video" }, + ]; + +const COMMUNITY_CURATIONS_SKELETON_CARDS = [ + { id: "compact", mediaHeight: 210, lines: 2 }, + { id: "tall", mediaHeight: 320, lines: 4 }, + { id: "mid", mediaHeight: 250, lines: 3 }, + { id: "feature", mediaHeight: 390, lines: 2 }, + { id: "balanced", mediaHeight: 280, lines: 4 }, + { id: "short", mediaHeight: 220, lines: 3 }, + { id: "portrait", mediaHeight: 350, lines: 3 }, + { id: "wide", mediaHeight: 260, lines: 2 }, +] as const; + +const COMMUNITY_CURATIONS_SKELETON_LINE_IDS = [ + "headline", + "summary", + "detail", + "caption", +] as const; + +function CommunityCurationsSkeletonCard({ + lines, + mediaHeight, +}: { + readonly lines: number; + readonly mediaHeight: number; +}) { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+ {COMMUNITY_CURATIONS_SKELETON_LINE_IDS.slice(0, lines).map( + (lineId, index) => ( +
+ ) + )} +
+
+
+ ); +} + +function CommunityCurationsSkeletonGrid() { + return ( +
+ {COMMUNITY_CURATIONS_SKELETON_CARDS.map((card) => ( + + ))} +
+ ); +} + +function CommunityCurationsEmptyState({ + title, + description, +}: { + readonly title: string; + readonly description: string; +}) { + return ( +
+

+ {title} +

+

{description}

+
+ ); +} + +export default function CommunityCurations() { + const { waveViewStyle } = useLayout(); + const [scrollContainer, setScrollContainer] = useState( + null + ); + const [mediaFilter, setMediaFilter] = + useState("all"); + const { + allDrops, + drops, + fetchNextPage, + hasNextPage, + isError, + isFetchingNextPage, + isLoading, + } = useCommunityCurationsDrops({ + mediaFilter, + limit: COMMUNITY_CURATIONS_LIMIT, + }); + + const isInitialLoading = isLoading && allDrops.length === 0; + const hasMorePages = Boolean(hasNextPage) || isFetchingNextPage; + const shouldShowEmptyState = + !isInitialLoading && !isError && drops.length === 0 && !hasMorePages; + const shouldShowMasonry = + !isInitialLoading && !isError && (drops.length > 0 || hasMorePages); + const emptyStateTitle = + mediaFilter === "all" + ? "No curated drops yet" + : `No ${mediaFilter} drops found`; + const emptyStateDescription = + mediaFilter === "all" + ? "Community-curated drops will appear here when visible curations have activity." + : "Try All to see every community-curated drop."; + const handleFetchNextPage = useCallback(async () => { + await fetchNextPage(); + }, [fetchNextPage]); + + return ( +
+
+
+
+

+ Community Curations +

+

+ Community-curated drops from across 6529 Waves. +

+
+ +
+
+ +
+
+
+ +
+ {isInitialLoading && } + + {!isInitialLoading && isError && ( + + )} + + {shouldShowEmptyState && ( + + )} + + {shouldShowMasonry && ( + + )} +
+
+
+ ); +} diff --git a/components/community-curations/CommunityCurationsMasonry.tsx b/components/community-curations/CommunityCurationsMasonry.tsx new file mode 100644 index 0000000000..da8810bbff --- /dev/null +++ b/components/community-curations/CommunityCurationsMasonry.tsx @@ -0,0 +1,355 @@ +"use client"; + +import CircleLoader, { + CircleLoaderSize, +} from "@/components/distribution-plan-tool/common/CircleLoader"; +import { TweetPreviewModeProvider } from "@/components/tweets/TweetPreviewModeContext"; +import Drop, { DropLocation } from "@/components/waves/drops/Drop"; +import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; +import { useIntersectionObserver } from "@/hooks/scroll/useIntersectionObserver"; +import { + type RenderComponentProps, + useMasonry, + usePositioner, + useResizeObserver, +} from "masonic"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type Dispatch, + type ReactElement, + type SetStateAction, +} from "react"; + +const MASONRY_COLUMN_WIDTH = 300; +const MASONRY_GUTTER = 16; +const INFINITE_SCROLL_ROOT_MARGIN = "1200px 0px"; +const SCROLL_IDLE_DELAY_MS = 120; + +type PanelViewport = { + readonly height: number; + readonly isScrolling: boolean; + readonly scrollTop: number; +}; + +interface CommunityCurationsMasonryProps { + readonly drops: readonly ExtendedDrop[]; + readonly fetchNextPage: () => Promise; + readonly hasNextPage: boolean | undefined; + readonly isFetchingNextPage: boolean; + readonly scrollContainer: HTMLElement | null; +} + +const EMPTY_VIEWPORT: PanelViewport = { + height: 0, + isScrolling: false, + scrollTop: 0, +}; + +const noop = () => {}; + +const getDropKey = (drop: ExtendedDrop) => drop.stableKey; + +const getGridScrollTop = ( + scrollContainer: HTMLElement, + gridElement: HTMLElement | null +) => { + if (!gridElement) { + return 0; + } + + const scrollRect = scrollContainer.getBoundingClientRect(); + const gridRect = gridElement.getBoundingClientRect(); + const gridOffsetTop = + gridRect.top - scrollRect.top + scrollContainer.scrollTop; + + return Math.max(0, scrollContainer.scrollTop - gridOffsetTop); +}; + +const areViewportsEqual = (left: PanelViewport, right: PanelViewport) => + left.height === right.height && + left.isScrolling === right.isScrolling && + left.scrollTop === right.scrollTop; + +const readPanelViewport = ( + scrollContainer: HTMLElement, + gridElement: HTMLElement | null, + isScrolling: boolean +): PanelViewport => ({ + height: scrollContainer.clientHeight, + isScrolling, + scrollTop: getGridScrollTop(scrollContainer, gridElement), +}); + +const setPanelViewport = ( + setViewport: Dispatch>, + nextViewport: PanelViewport +) => { + setViewport((currentViewport) => + areViewportsEqual(currentViewport, nextViewport) + ? currentViewport + : nextViewport + ); +}; + +const schedulePanelViewportUpdate = ({ + frameId, + gridElement, + isScrolling, + scrollContainer, + setViewport, +}: { + readonly frameId: number | null; + readonly gridElement: HTMLElement | null; + readonly isScrolling: boolean; + readonly scrollContainer: HTMLElement; + readonly setViewport: Dispatch>; +}): number => { + if (frameId !== null) { + cancelAnimationFrame(frameId); + } + + return requestAnimationFrame(() => { + const nextViewport = readPanelViewport( + scrollContainer, + gridElement, + isScrolling + ); + setPanelViewport(setViewport, nextViewport); + }); +}; + +function useElementWidth(element: HTMLElement | null) { + const [width, setWidth] = useState(0); + + useEffect(() => { + if (!element || typeof ResizeObserver === "undefined") { + return; + } + + const observer = new ResizeObserver(([entry]) => { + const nextWidth = Math.floor(entry?.contentRect.width ?? 0); + setWidth((currentWidth) => + currentWidth === nextWidth ? currentWidth : nextWidth + ); + }); + + observer.observe(element); + return () => observer.disconnect(); + }, [element]); + + return { setWidth, width }; +} + +function usePanelViewport( + scrollContainer: HTMLElement | null, + gridElement: HTMLElement | null +) { + const [viewport, setViewport] = useState(EMPTY_VIEWPORT); + + useEffect(() => { + if (!scrollContainer) { + return; + } + + let idleTimeout: ReturnType | null = null; + let frameId: number | null = null; + + const scheduleViewportUpdate = (isScrolling: boolean) => { + frameId = schedulePanelViewportUpdate({ + frameId, + gridElement, + isScrolling, + scrollContainer, + setViewport, + }); + }; + + const onScroll = () => { + scheduleViewportUpdate(true); + + if (idleTimeout) { + clearTimeout(idleTimeout); + } + + idleTimeout = setTimeout( + () => scheduleViewportUpdate(false), + SCROLL_IDLE_DELAY_MS + ); + }; + + scheduleViewportUpdate(false); + scrollContainer.addEventListener("scroll", onScroll, { passive: true }); + + const observer = + typeof ResizeObserver === "undefined" + ? null + : new ResizeObserver(() => scheduleViewportUpdate(false)); + + observer?.observe(scrollContainer); + if (gridElement) { + observer?.observe(gridElement); + } + + return () => { + scrollContainer.removeEventListener("scroll", onScroll); + observer?.disconnect(); + if (idleTimeout) { + clearTimeout(idleTimeout); + } + if (frameId !== null) { + cancelAnimationFrame(frameId); + } + }; + }, [gridElement, scrollContainer]); + + return scrollContainer ? viewport : EMPTY_VIEWPORT; +} + +function CommunityCurationsInfiniteScrollTrigger({ + onIntersection, + scrollContainer, +}: { + readonly onIntersection: (isIntersecting: boolean) => void; + readonly scrollContainer: HTMLElement | null; +}) { + const triggerRef = useRef(null); + const handleIntersection = useCallback( + (entry: IntersectionObserverEntry) => onIntersection(entry.isIntersecting), + [onIntersection] + ); + + useIntersectionObserver( + triggerRef, + { + root: scrollContainer, + rootMargin: INFINITE_SCROLL_ROOT_MARGIN, + threshold: 0, + }, + handleIntersection, + Boolean(scrollContainer) + ); + + return