Skip to content

Commit

Permalink
Fix infinite scroll on Operations and Activity pages (#2035)
Browse files Browse the repository at this point in the history
  • Loading branch information
OKendigelyan authored Oct 31, 2024
1 parent 455437e commit 8c8e472
Show file tree
Hide file tree
Showing 6 changed files with 70 additions and 51 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ beforeEach(() => {
isFirstLoad: false,
isLoading: false,
loadMore: jest.fn(),
triggerRef: { current: null },
});
store.dispatch(networksActions.setCurrent(MAINNET));
addTestAccount(store, selectedAccount);
Expand Down Expand Up @@ -300,6 +301,7 @@ describe("<AccountDrawerDisplay />", () => {
isFirstLoad: false,
isLoading: false,
loadMore: jest.fn(),
triggerRef: { current: null },
});
multisigsFixture.forEach(account => addTestAccount(store, account));
});
Expand Down
17 changes: 17 additions & 0 deletions apps/desktop/src/setupTests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -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
Expand Down
24 changes: 2 additions & 22 deletions apps/desktop/src/views/operations/OperationsView.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<boolean>(false);

const onScroll = (e: UIEvent<HTMLDivElement>) => {
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 (
<Flex flexDirection="column" height="100%">
Expand All @@ -52,7 +32,6 @@ export const OperationsView = () => {
marginBottom="20px"
background={colors.gray[900]}
borderRadius="8px"
onScroll={onScroll}
paddingX="20px"
>
<OperationTileContext.Provider value={{ mode: "page" }}>
Expand All @@ -75,6 +54,7 @@ export const OperationsView = () => {
);
})}
</OperationTileContext.Provider>
<Box ref={triggerRef} />
<Center flexDirection="column" display={isLoading && !isFirstLoad ? "flex" : "none"}>
<Divider />
<Image width="100px" height="50px" src="./static/media/loading-dots.gif" />
Expand Down
21 changes: 16 additions & 5 deletions apps/web/src/setupTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));

Expand All @@ -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", () => ({
Expand Down
26 changes: 4 additions & 22 deletions apps/web/src/views/Activity/Activity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<boolean>(false);

const isEmpty = operations.length === 0 && !isLoading;

const onScroll = (e: UIEvent<HTMLDivElement>) => {
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 (
<>
<Flex zIndex={1} flexDirection="column" flexGrow={1}>
Expand All @@ -68,7 +48,7 @@ export const Activity = () => {
<VerifyMessage />
)}
{operations.length > 0 && (
<Box borderRadius="8px" onScroll={onScroll}>
<Box borderRadius="8px">
{operations.map((operation, i) => {
const isFirst = i === 0;
const isLast = i === operations.length - 1;
Expand All @@ -84,6 +64,8 @@ export const Activity = () => {
/>
);
})}
{/* trigger for loading more operations */}
<Box ref={triggerRef} />
<Center flexDirection="column" display={isLoading && !isFirstLoad ? "flex" : "none"}>
<Divider />
<Image width="100px" height="50px" src={loadingDots} />
Expand Down
31 changes: 29 additions & 2 deletions packages/data-polling/src/useGetOperations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -34,6 +34,7 @@ type UseGetOperationsResult = {
isFirstLoad: boolean;
isLoading: boolean;
hasMore: boolean;
triggerRef: React.RefObject<HTMLDivElement>;
loadMore: InfiniteQueryObserverBaseResult["fetchNextPage"];
};

Expand Down Expand Up @@ -63,6 +64,8 @@ export const useGetOperations = (accounts: Account[], isEnabled = true): UseGetO
const refetchTrigger = useRefetchTrigger();
const handleError = useReactQueryErrorHandler();

const triggerRef = useRef<HTMLDivElement>(null);

const {
isFetching,
data: operations,
Expand Down Expand Up @@ -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,
};
};

Expand Down

1 comment on commit 8c8e472

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Title Lines Statements Branches Functions
apps/desktop Coverage: 84%
83.87% (1763/2102) 79.33% (837/1055) 78.42% (447/570)
apps/web Coverage: 84%
83.87% (1763/2102) 79.33% (837/1055) 78.42% (447/570)
packages/components Coverage: 97%
97.84% (182/186) 96.51% (83/86) 87.03% (47/54)
packages/core Coverage: 81%
82.22% (222/270) 71.73% (99/138) 81.96% (50/61)
packages/crypto Coverage: 100%
100% (43/43) 90.9% (10/11) 100% (7/7)
packages/data-polling Coverage: 97%
95.27% (141/148) 87.5% (21/24) 92.85% (39/42)
packages/multisig Coverage: 98%
98.47% (129/131) 85.71% (18/21) 100% (35/35)
packages/social-auth Coverage: 100%
100% (21/21) 100% (11/11) 100% (3/3)
packages/state Coverage: 85%
84.71% (798/942) 81.33% (170/209) 78.77% (297/377)
packages/tezos Coverage: 86%
85.57% (89/104) 89.47% (17/19) 82.75% (24/29)
packages/tzkt Coverage: 86%
84.05% (58/69) 81.25% (13/16) 76.92% (30/39)

Please sign in to comment.