diff --git a/apps/marginfi-v2-ui/package.json b/apps/marginfi-v2-ui/package.json index 799873397b..0ec8d73ab7 100644 --- a/apps/marginfi-v2-ui/package.json +++ b/apps/marginfi-v2-ui/package.json @@ -41,6 +41,7 @@ "bignumber.js": "^9.1.1", "bn.js": "^5.2.1", "bs58": "^5.0.0", + "clsx": "^2.0.0", "firebase": "^9.22.1", "firebase-admin": "^11.9.0", "jsbi": "^4.3.0", diff --git a/apps/marginfi-v2-ui/src/components/desktop/Points/PointsLeaderBoard.tsx b/apps/marginfi-v2-ui/src/components/desktop/Points/PointsLeaderBoard.tsx index 248b446672..2a2725bc77 100644 --- a/apps/marginfi-v2-ui/src/components/desktop/Points/PointsLeaderBoard.tsx +++ b/apps/marginfi-v2-ui/src/components/desktop/Points/PointsLeaderBoard.tsx @@ -1,154 +1,396 @@ -import React, { FC } from "react"; -import { Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from "@mui/material"; - +import React, { FC, useEffect, useState, useCallback, useRef, useMemo } from "react"; +import clsx from "clsx"; +import { Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Skeleton } from "@mui/material"; +import ArrowDropUpIcon from "@mui/icons-material/ArrowDropUp"; +import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown"; import { groupedNumberFormatterDyn } from "@mrgnlabs/mrgn-common"; -import { LeaderboardRow } from "@mrgnlabs/marginfi-v2-ui-state"; +import { LeaderboardRow, fetchLeaderboardData, fetchTotalUserCount } from "@mrgnlabs/marginfi-v2-ui-state"; +import { useConnection } from "@solana/wallet-adapter-react"; + +const SortIcon = ({ orderDir }: { orderDir: "asc" | "desc" }) => { + return ( +
+ + + + + + + +
+ ); +}; interface PointsLeaderBoardProps { - leaderboardData: LeaderboardRow[]; currentUserId?: string; } -export const PointsLeaderBoard: FC = ({ leaderboardData, currentUserId }) => { +export const PointsLeaderBoard: FC = ({ currentUserId }) => { + const { connection } = useConnection(); + const [leaderboardSettings, setLeaderboardSettings] = useState<{ + orderCol: string; + orderDir: "asc" | "desc"; + totalUserCount: number; + perPage: number; + currentPage: number; + isFetchingLeaderboardPage: boolean; + initialLoad: boolean; + }>({ + orderCol: "total_points", + orderDir: "desc", + totalUserCount: 0, + perPage: 50, + currentPage: 0, + isFetchingLeaderboardPage: false, + initialLoad: true, + }); + const [leaderboardData, setLeaderboardData] = useState([ + ...new Array(leaderboardSettings.perPage).fill({}), + ]); + const leaderboardSentinelRef = useRef(null); + + const setOrderCol = useCallback( + (col: string) => { + setLeaderboardData([...new Array(leaderboardSettings.perPage).fill({})]); + const direction = + col === leaderboardSettings.orderCol ? (leaderboardSettings.orderDir === "asc" ? "desc" : "asc") : "desc"; + setLeaderboardSettings({ + ...leaderboardSettings, + orderCol: col, + orderDir: direction, + currentPage: 0, + initialLoad: true, + }); + }, + [setLeaderboardSettings, leaderboardSettings] + ); + + const getTotalUserCount = useCallback(async () => { + const totalUserCount = await fetchTotalUserCount(); + setLeaderboardSettings({ + ...leaderboardSettings, + totalUserCount, + }); + }, [setLeaderboardSettings, fetchTotalUserCount]); + + // fetch next page of leaderboard results + const fetchLeaderboardPage = useCallback(async () => { + // grab last row of current leaderboard data for cursor + const filtered = [...leaderboardData].filter((row) => row.hasOwnProperty("id")); + const lastRow = filtered[filtered.length - 1] as LeaderboardRow; + if (!lastRow || !lastRow.hasOwnProperty("id")) return; + setLeaderboardSettings({ + ...leaderboardSettings, + isFetchingLeaderboardPage: 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, + orderCol: leaderboardSettings.orderCol, + orderDir: leaderboardSettings.orderDir, + }).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); + setLeaderboardSettings({ + ...leaderboardSettings, + isFetchingLeaderboardPage: false, + }); + }); + }, [setLeaderboardData, leaderboardSettings, setLeaderboardSettings]); + + // fetch new page when page counter changed + useEffect(() => { + fetchLeaderboardPage(); + }, [leaderboardSettings.currentPage]); + + useEffect(() => { + // fetch initial page and overwrite skeleton rows + if (leaderboardSettings.currentPage === 0 && leaderboardSettings.initialLoad) { + setLeaderboardSettings({ + ...leaderboardSettings, + initialLoad: false, + }); + fetchLeaderboardData({ + connection, + orderDir: leaderboardSettings.orderDir, + orderCol: leaderboardSettings.orderCol, + }).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 && !leaderboardSettings.isFetchingLeaderboardPage) { + setLeaderboardSettings({ + ...leaderboardSettings, + isFetchingLeaderboardPage: true, + currentPage: leaderboardSettings.currentPage + 1, + }); + } + }, + { + root: null, + rootMargin: "0px", + threshold: 0, + } + ); + + if (leaderboardSentinelRef.current) { + observer.observe(leaderboardSentinelRef.current); + } + + return () => { + if (leaderboardSentinelRef.current) { + observer.unobserve(leaderboardSentinelRef.current); + } + }; + }, [ + connection, + fetchLeaderboardPage, + setLeaderboardSettings, + leaderboardSettings.isFetchingLeaderboardPage, + leaderboardSettings.currentPage, + leaderboardSettings.initialLoad, + setLeaderboardSettings, + ]); + + useEffect(() => { + getTotalUserCount(); + }, []); + return ( - - - - - - Rank - - - User - - - Lending Points - - - Borrowing Points - - - Referral Points - - - Social Points - - - Total Points - - - - - {leaderboardData.map((row: LeaderboardRow, index: number) => ( - + <> + +
+ + - {index === 0 ? "🥇" : index === 1 ? "🥈" : index === 2 ? "🥉" : index + 1} - - - - {`${row.id.slice(0, 5)}...${row.id.slice(-5)}`} - - - - setOrderCol("total_points")} > - {groupedNumberFormatterDyn.format(Math.round(row.total_activity_deposit_points))} + Rank + User setOrderCol("total_activity_deposit_points")} > - {groupedNumberFormatterDyn.format(Math.round(row.total_activity_borrow_points))} + Lending Points + {leaderboardSettings.orderCol === "total_activity_deposit_points" && ( + + )} setOrderCol("total_activity_borrow_points")} > - {groupedNumberFormatterDyn.format( - Math.round(row.total_referral_deposit_points + row.total_referral_borrow_points) + Borrowing Points + {leaderboardSettings.orderCol === "total_activity_borrow_points" && ( + )} + + Referral Points + setOrderCol("socialPoints")} > - {groupedNumberFormatterDyn.format(Math.round(row.socialPoints ? row.socialPoints : 0))} + Social Points + {leaderboardSettings.orderCol === "socialPoints" && ( + + )} setOrderCol("total_points")} > - {groupedNumberFormatterDyn.format( - Math.round( - row.total_deposit_points + row.total_borrow_points + (row.socialPoints ? row.socialPoints : 0) - ) + Total Points + {leaderboardSettings.orderCol === "total_points" && ( + )} - ))} - -
-
+ + + {leaderboardData.map((row: LeaderboardRow | {}, index: number) => { + if (!row.hasOwnProperty("id")) { + return ( + + {[...new Array(7)].map((_, index) => ( + 1 && "w-[15%] min-w-[190px]" + )} + key={index} + > + + + ))} + + ); + } + + const data = row as LeaderboardRow; + + return ( + 0 ? "bg-zinc-800/50" : "bg-none", + `${data.id === currentUserId ? "glow" : ""}` + )} + > + 2 && + "text-base", + leaderboardSettings.orderCol !== "total_points" && "text-base", + data.id === currentUserId ? "text-[#DCE85D]" : "text-white" + )} + > + {leaderboardSettings.orderCol === "total_points" && leaderboardSettings.orderDir === "desc" && ( + <>{index === 0 ? "🥇" : index === 1 ? "🥈" : index === 2 ? "🥉" : index + 1} + )} + {leaderboardSettings.orderCol !== "total_points" && leaderboardSettings.orderDir === "desc" && ( + <>{index + 1} + )} + {leaderboardSettings.orderDir === "asc" && <>{leaderboardSettings.totalUserCount - (index + 1)}} + + + + {data.domain || data.shortAddress || data.id} + + + + {groupedNumberFormatterDyn.format(Math.round(data.total_activity_deposit_points))} + + + {groupedNumberFormatterDyn.format(Math.round(data.total_activity_borrow_points))} + + + {groupedNumberFormatterDyn.format( + Math.round(data.total_referral_deposit_points + data.total_referral_borrow_points) + )} + + + {groupedNumberFormatterDyn.format(Math.round(data.socialPoints ? data.socialPoints : 0))} + + + {groupedNumberFormatterDyn.format( + Math.round( + data.total_deposit_points + + data.total_borrow_points + + (data.socialPoints ? data.socialPoints : 0) + ) + )} + + + ); + })} + + + +
+ ); }; diff --git a/apps/marginfi-v2-ui/src/components/desktop/Points/PointsOverview.tsx b/apps/marginfi-v2-ui/src/components/desktop/Points/PointsOverview.tsx index 8abf225ab6..498001be2d 100644 --- a/apps/marginfi-v2-ui/src/components/desktop/Points/PointsOverview.tsx +++ b/apps/marginfi-v2-ui/src/components/desktop/Points/PointsOverview.tsx @@ -12,7 +12,7 @@ interface PointsOverviewProps { export const PointsOverview: FC = ({ userPointsData }) => { return ( <> -
+
diff --git a/apps/marginfi-v2-ui/src/pages/points.tsx b/apps/marginfi-v2-ui/src/pages/points.tsx index 3f336076f8..cb65235602 100644 --- a/apps/marginfi-v2-ui/src/pages/points.tsx +++ b/apps/marginfi-v2-ui/src/pages/points.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, FC, useEffect, useState, useCallback } from "react"; +import React, { useMemo, FC, useEffect, useState, useCallback, useRef } from "react"; import Link from "next/link"; import { Button } from "@mui/material"; import FileCopyIcon from "@mui/icons-material/FileCopy"; @@ -6,7 +6,7 @@ import CheckIcon from "@mui/icons-material/Check"; import { useRouter } from "next/router"; import { getFavoriteDomain } from "@bonfida/spl-name-service"; import { Connection, PublicKey } from "@solana/web3.js"; -import { LeaderboardRow, fetchLeaderboardData } from "@mrgnlabs/marginfi-v2-ui-state"; +import { useConnection } from "@solana/wallet-adapter-react"; import { CopyToClipboard } from "react-copy-to-clipboard"; import { useUserProfileStore } from "~/store"; import { useWalletContext } from "~/hooks/useWalletContext"; @@ -19,7 +19,6 @@ import { PointsCheckingUser, PointsConnectWallet, } from "~/components/desktop/Points"; -import { useConnection } from "@solana/wallet-adapter-react"; const Points: FC = () => { const { connected, walletAddress } = useWalletContext(); @@ -31,7 +30,6 @@ const Points: FC = () => { state.userPointsData, ]); - const [leaderboardData, setLeaderboardData] = useState([]); const [domain, setDomain] = useState(); const currentUserId = useMemo(() => domain ?? currentFirebaseUser?.uid, [currentFirebaseUser, domain]); @@ -53,14 +51,10 @@ const Points: FC = () => { } }, [connection, walletAddress]); - useEffect(() => { - fetchLeaderboardData(connection).then(setLeaderboardData); // TODO: cache leaderboard and avoid call - }, [connection, connected, walletAddress]); // Dependency array to re-fetch when these variables change - return ( <> points -
+
{!connected ? ( ) : currentFirebaseUser ? ( @@ -129,7 +123,7 @@ const Points: FC = () => {
- +
); diff --git a/apps/marginfi-v2-ui/tailwind.config.js b/apps/marginfi-v2-ui/tailwind.config.js index 3f0c40a196..bb3775d25d 100644 --- a/apps/marginfi-v2-ui/tailwind.config.js +++ b/apps/marginfi-v2-ui/tailwind.config.js @@ -32,9 +32,22 @@ module.exports = { warning: "#daa204", error: "#e07d6f", }, + maxWidth: { + "8xl": "90rem", + }, }, fontFamily: { aeonik: ['"Aeonik Pro"'], + mono: [ + "ui-monospace", + "SFMono-Regular", + "Menlo", + "Monaco", + "Consolas", + "Liberation Mono", + "Courier New", + "monospace", + ], }, screens: { sm: "640px", diff --git a/packages/marginfi-v2-ui-state/src/lib/points.ts b/packages/marginfi-v2-ui-state/src/lib/points.ts index 2898bd2551..458efcfba9 100644 --- a/packages/marginfi-v2-ui-state/src/lib/points.ts +++ b/packages/marginfi-v2-ui-state/src/lib/points.ts @@ -11,13 +11,24 @@ import { getDoc, getCountFromServer, where, + QueryDocumentSnapshot, } from "firebase/firestore"; -import { FavouriteDomain, NAME_OFFERS_ID, reverseLookupBatch } from "@bonfida/spl-name-service"; +import { + FavouriteDomain, + NAME_OFFERS_ID, + reverseLookupBatch, + reverseLookup, + getAllDomains, + getFavoriteDomain, +} from "@bonfida/spl-name-service"; import { Connection, PublicKey } from "@solana/web3.js"; import { firebaseApi } from "."; type LeaderboardRow = { id: string; + shortAddress?: string; + domain?: string; + doc: QueryDocumentSnapshot; total_activity_deposit_points: number; total_activity_borrow_points: number; total_referral_deposit_points: number; @@ -27,52 +38,75 @@ type LeaderboardRow = { socialPoints: number; }; -async function fetchLeaderboardData(connection?: Connection, rowCap = 100, pageSize = 50): Promise { +const shortAddress = (address: string) => `${address.slice(0, 4)}...${address.slice(-4)}`; + +async function fetchLeaderboardData({ + connection, + queryCursor, + pageSize = 50, + orderCol = "total_points", + orderDir = "desc", +}: { + connection?: Connection; + queryCursor?: QueryDocumentSnapshot; + pageSize?: number; + orderCol?: string; + orderDir?: "desc" | "asc"; +}): Promise { const pointsCollection = collection(firebaseApi.db, "points"); - const leaderboardMap = new Map(); - let initialQueryCursor = null; - do { - let pointsQuery: Query; - if (initialQueryCursor) { - pointsQuery = query( - pointsCollection, - orderBy("total_points", "desc"), - startAfter(initialQueryCursor), - limit(pageSize) - ); - } else { - pointsQuery = query(pointsCollection, orderBy("total_points", "desc"), limit(pageSize)); - } - - const querySnapshot = await getDocs(pointsQuery); - const leaderboardSlice = querySnapshot.docs.map((doc) => ({ id: doc.id, ...doc.data() })); - const leaderboardSliceFiltered = leaderboardSlice.filter( - (item) => item.id !== null && item.id !== undefined && item.id != "None" - ); - - for (const row of leaderboardSliceFiltered) { - leaderboardMap.set(row.id, row); - } - - initialQueryCursor = querySnapshot.docs.length > 0 ? querySnapshot.docs[querySnapshot.docs.length - 1] : null; - } while (initialQueryCursor !== null && leaderboardMap.size < rowCap); - - const leaderboardFinalSlice = [...leaderboardMap.values()].slice(0, 100); - - if (connection) { - const publicKeys = leaderboardFinalSlice.map((value) => { - const [favoriteDomains] = FavouriteDomain.getKeySync(NAME_OFFERS_ID, new PublicKey(value.id)); - return favoriteDomains; + const pointsQuery: Query = query( + pointsCollection, + orderBy(orderCol, orderDir), + ...(queryCursor ? [startAfter(queryCursor)] : []), + limit(pageSize) + ); + + const querySnapshot = await getDocs(pointsQuery); + const leaderboardSlice = querySnapshot.docs + .filter((item) => item.id !== null && item.id !== undefined && item.id != "None") + .map((doc) => { + const data = { id: doc.id, doc, ...doc.data() } as LeaderboardRow; + return data; }); - const favoriteDomainsInfo = (await connection.getMultipleAccountsInfo(publicKeys)).map((accountInfo, idx) => - accountInfo ? FavouriteDomain.deserialize(accountInfo.data).nameAccount : publicKeys[idx] - ); - const reverseLookup = await reverseLookupBatch(connection, favoriteDomainsInfo); - leaderboardFinalSlice.map((value, idx) => (value.id = reverseLookup[idx] ? `${reverseLookup[idx]}.sol` : value.id)); + const leaderboardFinalSlice: LeaderboardRow[] = [...leaderboardSlice]; + + if (!connection) { + return leaderboardFinalSlice; } - return leaderboardFinalSlice; + + const leaderboardFinalSliceWithDomains: LeaderboardRow[] = await Promise.all( + leaderboardFinalSlice.map(async (value) => { + const newValue = { ...value, shortAddress: shortAddress(value.id) }; + // attempt to get favorite domain + try { + const { reverse } = await getFavoriteDomain(connection, new PublicKey(value.id)); + return { + ...newValue, + domain: `${reverse}.sol`, + }; + } catch (e) { + // attempt to get all domains + try { + const domains = await getAllDomains(connection, new PublicKey(value.id)); + if (domains.length > 0) { + const reverse = await reverseLookup(connection, domains[0]); + return { + ...newValue, + domain: `${reverse}.sol`, + }; + } + } catch (e) { + return newValue; + } + } + + return newValue; + }) + ); + + return leaderboardFinalSliceWithDomains; } // Firebase query is very constrained, so we calculate the number of users with more points @@ -95,7 +129,15 @@ async function fetchUserRank(userPoints: number): Promise { const nullGreaterDocsCount = querySnapshot1.data().count; const allGreaterDocsCount = querySnapshot2.data().count; - return allGreaterDocsCount - nullGreaterDocsCount; + return allGreaterDocsCount - nullGreaterDocsCount + 1; +} + +async function fetchTotalUserCount() { + const q1 = query(collection(firebaseApi.db, "points")); + const q2 = query(collection(firebaseApi.db, "points"), where("owner", "==", null)); + const q1Count = await getCountFromServer(q1); + const q2Count = await getCountFromServer(q2); + return q1Count.data().count - q2Count.data().count; } interface UserPointsData { @@ -183,6 +225,13 @@ async function getPointsSummary() { return pointSummary; } -export { fetchLeaderboardData, fetchUserRank, getPointsSummary, getPointsDataForUser, DEFAULT_USER_POINTS_DATA }; +export { + fetchLeaderboardData, + fetchUserRank, + fetchTotalUserCount, + getPointsSummary, + getPointsDataForUser, + DEFAULT_USER_POINTS_DATA, +}; export type { LeaderboardRow, UserPointsData };