diff --git a/apps/desktop/src/components/AccountDrawer/AccountDrawerDisplay.test.tsx b/apps/desktop/src/components/AccountDrawer/AccountDrawerDisplay.test.tsx index b6a26271d6..75a4d8f0d1 100644 --- a/apps/desktop/src/components/AccountDrawer/AccountDrawerDisplay.test.tsx +++ b/apps/desktop/src/components/AccountDrawer/AccountDrawerDisplay.test.tsx @@ -51,6 +51,7 @@ beforeEach(() => { isFirstLoad: false, isLoading: false, loadMore: jest.fn(), + triggerRef: { current: null }, }); store.dispatch(networksActions.setCurrent(MAINNET)); addTestAccount(store, selectedAccount); @@ -300,6 +301,7 @@ describe("", () => { isFirstLoad: false, isLoading: false, loadMore: jest.fn(), + triggerRef: { current: null }, }); multisigsFixture.forEach(account => addTestAccount(store, account)); }); diff --git a/apps/desktop/src/setupTests.tsx b/apps/desktop/src/setupTests.tsx index ba167fbc8b..0471f70d91 100644 --- a/apps/desktop/src/setupTests.tsx +++ b/apps/desktop/src/setupTests.tsx @@ -24,6 +24,22 @@ import { MockDate.set("2023-03-27T14:15:09.760Z"); +const mockIntersectionObserver = class MockIntersectionObserver { + callback: jest.Mock; + options: jest.Mock; + observe: jest.Mock; + unobserve: jest.Mock; + disconnect: jest.Mock; + + constructor(callback: jest.Mock, options: jest.Mock) { + this.callback = callback; + this.options = options; + this.observe = jest.fn(); + this.unobserve = jest.fn(); + this.disconnect = jest.fn(); + } +}; + jest.mock("./env", () => ({ IS_DEV: false })); beforeEach(() => { @@ -32,6 +48,7 @@ beforeEach(() => { crypto: { value: webcrypto, writable: true }, TextDecoder: { value: TextDecoder, writable: true }, TextEncoder: { value: TextEncoder, writable: true }, + IntersectionObserver: { value: mockIntersectionObserver, writable: true, configurable: true }, scrollTo: { value: jest.fn(), writable: true }, // taken from https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom diff --git a/apps/desktop/src/views/operations/OperationsView.tsx b/apps/desktop/src/views/operations/OperationsView.tsx index 9306bebbe6..f718125f66 100644 --- a/apps/desktop/src/views/operations/OperationsView.tsx +++ b/apps/desktop/src/views/operations/OperationsView.tsx @@ -1,6 +1,5 @@ import { Box, Center, Divider, Flex, Image } from "@chakra-ui/react"; import { useGetOperations } from "@umami/data-polling"; -import { type UIEvent, useRef } from "react"; import { NoOperations } from "../../components/NoItems"; import { OperationTile, OperationTileContext } from "../../components/OperationTile"; @@ -10,26 +9,7 @@ import colors from "../../style/colors"; export const OperationsView = () => { const { accountsFilter, selectedAccounts } = useAccountsFilter(); - const { operations, loadMore, hasMore, isLoading, isFirstLoad } = - useGetOperations(selectedAccounts); - // used to run loadMore only once when the user scrolls to the bottom - // otherwise it might be called multiple times which would trigger multiple fetches - const skipLoadMore = useRef(false); - - const onScroll = (e: UIEvent) => { - if (skipLoadMore.current || !hasMore || isLoading) { - return; - } - const element = e.target as HTMLDivElement; - - // start loading earlier than we reached the end of the list - if (element.scrollHeight - element.scrollTop - element.clientHeight < 100) { - skipLoadMore.current = true; - return loadMore().finally(() => { - skipLoadMore.current = false; - }); - } - }; + const { operations, isLoading, isFirstLoad, triggerRef } = useGetOperations(selectedAccounts); return ( @@ -52,7 +32,6 @@ export const OperationsView = () => { marginBottom="20px" background={colors.gray[900]} borderRadius="8px" - onScroll={onScroll} paddingX="20px" > @@ -75,6 +54,7 @@ export const OperationsView = () => { ); })} +
diff --git a/apps/web/src/setupTests.ts b/apps/web/src/setupTests.ts index 564d727959..d5fffcd127 100644 --- a/apps/web/src/setupTests.ts +++ b/apps/web/src/setupTests.ts @@ -6,10 +6,21 @@ import { mockToast } from "@umami/state"; import { mockLocalStorage } from "@umami/test-utils"; import { setupJestCanvasMock } from "jest-canvas-mock"; -const intersectionObserverMock = () => ({ - observe: jest.fn(), - unobserve: jest.fn(), -}); +const mockIntersectionObserver = class MockIntersectionObserver { + callback: jest.Mock; + options: jest.Mock; + observe: jest.Mock; + unobserve: jest.Mock; + disconnect: jest.Mock; + + constructor(callback: jest.Mock, options: jest.Mock) { + this.callback = callback; + this.options = options; + this.observe = jest.fn(); + this.unobserve = jest.fn(); + this.disconnect = jest.fn(); + } +}; jest.mock("./env", () => ({ IS_DEV: false })); @@ -23,7 +34,7 @@ Object.defineProperties(global, { crypto: { value: webcrypto, writable: true }, TextDecoder: { value: TextDecoder, writable: true }, TextEncoder: { value: TextEncoder, writable: true }, - IntersectionObserver: { value: intersectionObserverMock, writable: true, configurable: true }, + IntersectionObserver: { value: mockIntersectionObserver, writable: true, configurable: true }, }); jest.mock("./utils/persistor", () => ({ diff --git a/apps/web/src/views/Activity/Activity.tsx b/apps/web/src/views/Activity/Activity.tsx index a0193ba092..ba6824010d 100644 --- a/apps/web/src/views/Activity/Activity.tsx +++ b/apps/web/src/views/Activity/Activity.tsx @@ -2,7 +2,6 @@ import { Box, Center, Divider, Flex, Image, Spinner } from "@chakra-ui/react"; import { type Account } from "@umami/core"; import { useGetOperations } from "@umami/data-polling"; import { useCurrentAccount } from "@umami/state"; -import { type UIEvent, useRef } from "react"; import loadingDots from "../../assets/loading-dots.gif"; import { EmptyMessage } from "../../components/EmptyMessage"; @@ -17,34 +16,15 @@ export const Activity = () => { const color = useColor(); const currentAccount = useCurrentAccount(); - const { operations, loadMore, hasMore, isLoading, isFirstLoad } = useGetOperations( + const { operations, isLoading, isFirstLoad, triggerRef } = useGetOperations( [currentAccount ?? ({} as Account)], isVerified ); const buyTezUrl = `https://widget.wert.io/default/widget/?commodity=XTZ&address=${currentAccount?.address.pkh}&network=tezos&commodity_id=xtz.simple.tezos`; - // used to run loadMore only once when the user scrolls to the bottom - // otherwise it might be called multiple times which would trigger multiple fetches - const skipLoadMore = useRef(false); - const isEmpty = operations.length === 0 && !isLoading; - const onScroll = (e: UIEvent) => { - if (skipLoadMore.current || !hasMore || isLoading) { - return; - } - const element = e.target as HTMLDivElement; - - // start loading earlier than we reached the end of the list - if (element.scrollHeight - element.scrollTop - element.clientHeight < 100) { - skipLoadMore.current = true; - return loadMore().finally(() => { - skipLoadMore.current = false; - }); - } - }; - return ( <> @@ -68,7 +48,7 @@ export const Activity = () => { )} {operations.length > 0 && ( - + {operations.map((operation, i) => { const isFirst = i === 0; const isLast = i === operations.length - 1; @@ -84,6 +64,8 @@ export const Activity = () => { /> ); })} + {/* trigger for loading more operations */} +
diff --git a/packages/data-polling/src/useGetOperations.tsx b/packages/data-polling/src/useGetOperations.tsx index bfc070ef9d..eac71f07bd 100644 --- a/packages/data-polling/src/useGetOperations.tsx +++ b/packages/data-polling/src/useGetOperations.tsx @@ -16,7 +16,7 @@ import { getRelatedTokenTransfers, } from "@umami/tzkt"; import { maxBy } from "lodash"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { useReactQueryErrorHandler } from "./useReactQueryErrorHandler"; import { useRefetchTrigger } from "./useRefetchTrigger"; @@ -34,6 +34,7 @@ type UseGetOperationsResult = { isFirstLoad: boolean; isLoading: boolean; hasMore: boolean; + triggerRef: React.RefObject; loadMore: InfiniteQueryObserverBaseResult["fetchNextPage"]; }; @@ -63,6 +64,8 @@ export const useGetOperations = (accounts: Account[], isEnabled = true): UseGetO const refetchTrigger = useRefetchTrigger(); const handleError = useReactQueryErrorHandler(); + const triggerRef = useRef(null); + const { isFetching, data: operations, @@ -128,12 +131,36 @@ export const useGetOperations = (accounts: Account[], isEnabled = true): UseGetO void fetchPreviousPage(); }, [refetchTrigger, fetchPreviousPage, isEnabled]); + useEffect(() => { + if (!isEnabled || !triggerRef.current) { + return; + } + + const observer = new IntersectionObserver( + entries => { + if (entries[0].isIntersecting && hasNextPage && !isLoading) { + void fetchNextPage(); + } + }, + { + root: null, + rootMargin: "100px", + threshold: 0, + } + ); + + observer.observe(triggerRef.current); + + return () => observer.disconnect(); + }, [hasNextPage, isLoading, fetchNextPage, isEnabled]); + return { + hasMore: hasNextPage, operations: operations || [], isFirstLoad: isLoading, isLoading: isFetching, - hasMore: hasNextPage, loadMore: fetchNextPage, + triggerRef, }; };