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

feat(mfi-v2-ui): leaderboard pagination #249

Closed
wants to merge 6 commits into from
Closed
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
286 changes: 193 additions & 93 deletions apps/marginfi-v2-ui/src/pages/points.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Fragment, useCallback, useMemo } from "react";
import { Fragment, useCallback, useMemo, useRef } from "react";
import {
Button,
Table,
Expand Down Expand Up @@ -34,6 +34,7 @@ import { numeralFormatter, groupedNumberFormatterDyn } from "@mrgnlabs/mrgn-comm
import { useWalletContext } from "~/components/useWalletContext";
import { getFavoriteDomain } from "@bonfida/spl-name-service";
import { Connection, PublicKey } from "@solana/web3.js";
import throttle from "lodash/throttle";

const HtmlTooltip = styled(({ className, ...props }: TooltipProps) => (
<Tooltip {...props} classes={{ popper: className }} />
Expand All @@ -57,18 +58,55 @@ const Points: FC = () => {
state.userPointsData,
]);

const [leaderboardData, setLeaderboardData] = useState<LeaderboardRow[]>([]);
const leaderboardPerPage = 50;
const [leaderboardData, setLeaderboardData] = useState<LeaderboardRow[] | {}[]>([
...new Array(leaderboardPerPage).fill({}),
]);
const [leaderboardPage, setLeaderboardPage] = useState(0);
const [isFetchingLeaderboardPage, setIsFetchingLeaderboardPage] = useState(false);
const leaderboardSentinelRef = useRef<HTMLDivElement>(null);

// fetch next page of leaderboard results
const fetchLeaderboardPage = useCallback(async () => {
// grab last row of current leaderboard data for cursor
const lastRow = [...leaderboardData].filter((row) => row.hasOwnProperty("id"))[
leaderboardPage * leaderboardPerPage - 2
] as LeaderboardRow;
if (!lastRow || !lastRow.hasOwnProperty("id")) return;
setIsFetchingLeaderboardPage(true);

// fetch new page of data with cursor
const queryCursor = leaderboardData.length > 0 ? lastRow.doc : undefined;
setLeaderboardData((current) => [...current, ...new Array(50).fill({})]);
fetchLeaderboardData({
connection,
queryCursor,
}).then((data) => {
// filter out skeleton rows
const filtered = [...leaderboardData].filter((row) => row.hasOwnProperty("id"));

// additional check for duplicate values
const uniqueData = data.reduce((acc, curr) => {
const isDuplicate = acc.some((item) => {
const data = item as LeaderboardRow;
data.id === curr.id;
});
if (!isDuplicate) {
acc.push(curr);
}
return acc;
}, filtered);

setLeaderboardData(uniqueData);
setIsFetchingLeaderboardPage(false);
});
}, [connection, leaderboardData, setLeaderboardData, setIsFetchingLeaderboardPage, leaderboardPage]);

const [domain, setDomain] = useState<string>();

const currentUserId = useMemo(() => domain ?? currentFirebaseUser?.uid, [currentFirebaseUser, domain]);
const referralCode = useMemo(() => routerQuery.referralCode as string | undefined, [routerQuery.referralCode]);

useEffect(() => {
if (connection && walletAddress) {
resolveDomain(connection, new PublicKey(walletAddress));
}
}, [connection, walletAddress]);

const resolveDomain = async (connection: Connection, user: PublicKey) => {
try {
const { domain, reverse } = await getFavoriteDomain(connection, user);
Expand All @@ -79,8 +117,51 @@ const Points: FC = () => {
};

useEffect(() => {
fetchLeaderboardData(connection).then(setLeaderboardData); // TODO: cache leaderboard and avoid call
}, [connection, connected, walletAddress]); // Dependency array to re-fetch when these variables change
if (connection && walletAddress) {
resolveDomain(connection, new PublicKey(walletAddress));
}
}, [connection, walletAddress]);

// fetch new page when page counter changed
useEffect(() => {
fetchLeaderboardPage();
}, [leaderboardPage]);

useEffect(() => {
// fetch initial page and overwrite skeleton rows
if (leaderboardPage === 0) {
fetchLeaderboardData({
connection,
}).then((data) => {
setLeaderboardData([...data]);
});
}

// intersection observer to fetch new page of data
// when sentinel element is scrolled into view
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !isFetchingLeaderboardPage) {
setLeaderboardPage((current) => current + 1);
}
},
{
root: null,
rootMargin: "0px",
threshold: 0,
}
);

if (leaderboardSentinelRef.current) {
observer.observe(leaderboardSentinelRef.current);
}

return () => {
if (leaderboardSentinelRef.current) {
observer.unobserve(leaderboardSentinelRef.current);
}
};
}, [connection, fetchLeaderboardPage]);

return (
<>
Expand Down Expand Up @@ -333,93 +414,112 @@ const Points: FC = () => {
</TableRow>
</TableHead>
<TableBody>
{leaderboardData.map((row: LeaderboardRow, index: number) => (
<TableRow key={row.id} className={`${row.id === currentUserId ? "glow" : ""}`}>
<TableCell
align="center"
className={`${index <= 2 ? "text-2xl" : "text-base"} border-none font-aeonik ${
row.id === currentUserId ? "text-[#DCE85D]" : "text-white"
}`}
>
{index === 0 ? "🥇" : index === 1 ? "🥈" : index === 2 ? "🥉" : index + 1}
</TableCell>
<TableCell
className={`text-base border-none font-aeonik ${
row.id === currentUserId ? "text-[#DCE85D]" : "text-white"
}`}
style={{ fontWeight: 400 }}
>
<a
href={`https://solscan.io/account/${row.id}`}
target="_blank"
rel="noopener noreferrer"
style={{ textDecoration: "none", color: "inherit" }}
className="hover:text-[#DCE85D]"
{leaderboardData.map((row: LeaderboardRow | {}, index: number) => {
if (!row.hasOwnProperty("id")) {
return (
<TableRow key={index}>
{[...new Array(7)].map((_, index) => (
<TableCell className="border-none">
<Skeleton variant="text" animation="pulse" sx={{ fontSize: "1rem", bgcolor: "grey.900" }} />
</TableCell>
))}
</TableRow>
);
}

const data = row as LeaderboardRow;

return (
<TableRow key={data.id} className={`${data.id === currentUserId ? "glow" : ""}`}>
<TableCell
align="center"
className={`${index <= 2 ? "text-2xl" : "text-base"} border-none font-aeonik ${
data.id === currentUserId ? "text-[#DCE85D]" : "text-white"
}`}
>
{row.id.endsWith(".sol") ? row.id : `${row.id.slice(0, 5)}...${row.id.slice(-5)}`}
<style jsx>{`
a:hover {
text-decoration: underline;
}
`}</style>
</a>
</TableCell>
<TableCell
align="right"
className={`text-base border-none font-aeonik ${
row.id === currentUserId ? "text-[#DCE85D]" : "text-white"
}`}
style={{ fontWeight: 400 }}
>
{groupedNumberFormatterDyn.format(Math.round(row.total_activity_deposit_points))}
</TableCell>
<TableCell
align="right"
className={`text-base border-none font-aeonik ${
row.id === currentUserId ? "text-[#DCE85D]" : "text-white"
}`}
style={{ fontWeight: 400 }}
>
{groupedNumberFormatterDyn.format(Math.round(row.total_activity_borrow_points))}
</TableCell>
<TableCell
align="right"
className={`text-base border-none font-aeonik ${
row.id === currentUserId ? "text-[#DCE85D]" : "text-white"
}`}
style={{ fontWeight: 400 }}
>
{groupedNumberFormatterDyn.format(
Math.round(row.total_referral_deposit_points + row.total_referral_borrow_points)
)}
</TableCell>
<TableCell
align="right"
className={`text-base border-none font-aeonik ${
row.id === currentUserId ? "text-[#DCE85D]" : "text-white"
}`}
style={{ fontWeight: 400 }}
>
{groupedNumberFormatterDyn.format(Math.round(row.socialPoints ? row.socialPoints : 0))}
</TableCell>
<TableCell
align="right"
className={`text-base border-none font-aeonik ${
row.id === currentUserId ? "text-[#DCE85D]" : "text-white"
}`}
style={{ fontWeight: 400 }}
>
{groupedNumberFormatterDyn.format(
Math.round(
row.total_deposit_points + row.total_borrow_points + (row.socialPoints ? row.socialPoints : 0)
)
)}
</TableCell>
</TableRow>
))}
{index === 0 ? "🥇" : index === 1 ? "🥈" : index === 2 ? "🥉" : index + 1}
</TableCell>
<TableCell
className={`text-base border-none font-aeonik ${
data.id === currentUserId ? "text-[#DCE85D]" : "text-white"
}`}
style={{ fontWeight: 400 }}
>
<a
href={`https://solscan.io/account/${data.id}`}
target="_blank"
rel="noopener noreferrer"
style={{ textDecoration: "none", color: "inherit" }}
className="hover:text-[#DCE85D]"
>
{data.id.endsWith(".sol") ? data.id : `${data.id.slice(0, 5)}...${data.id.slice(-5)}`}
<style jsx>{`
a:hover {
text-decoration: underline;
}
`}</style>
</a>
</TableCell>
<TableCell
align="right"
className={`text-base border-none font-aeonik ${
data.id === currentUserId ? "text-[#DCE85D]" : "text-white"
}`}
style={{ fontWeight: 400 }}
>
{groupedNumberFormatterDyn.format(Math.round(data.total_activity_deposit_points))}
</TableCell>
<TableCell
align="right"
className={`text-base border-none font-aeonik ${
data.id === currentUserId ? "text-[#DCE85D]" : "text-white"
}`}
style={{ fontWeight: 400 }}
>
{groupedNumberFormatterDyn.format(Math.round(data.total_activity_borrow_points))}
</TableCell>
<TableCell
align="right"
className={`text-base border-none font-aeonik ${
data.id === currentUserId ? "text-[#DCE85D]" : "text-white"
}`}
style={{ fontWeight: 400 }}
>
{groupedNumberFormatterDyn.format(
Math.round(data.total_referral_deposit_points + data.total_referral_borrow_points)
)}
</TableCell>
<TableCell
align="right"
className={`text-base border-none font-aeonik ${
data.id === currentUserId ? "text-[#DCE85D]" : "text-white"
}`}
style={{ fontWeight: 400 }}
>
{groupedNumberFormatterDyn.format(Math.round(data.socialPoints ? data.socialPoints : 0))}
</TableCell>
<TableCell
align="right"
className={`text-base border-none font-aeonik ${
data.id === currentUserId ? "text-[#DCE85D]" : "text-white"
}`}
style={{ fontWeight: 400 }}
>
{groupedNumberFormatterDyn.format(
Math.round(
data.total_deposit_points +
data.total_borrow_points +
(data.socialPoints ? data.socialPoints : 0)
)
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
<div ref={leaderboardSentinelRef} style={{ height: 10, width: "100%", transform: "translateY(-50vh)" }} />
</div>
</>
);
Expand Down
Loading