Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix infinite scroll on Operations and Activity pages #2035

Merged
merged 2 commits into from
Oct 31, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
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
Loading