diff --git a/backend/src/note/note.controller.ts b/backend/src/note/note.controller.ts index 1a1a19e44a7c6b7a7d38090aeab3fa5bceed8976..a2f225b7f4b3e1e5763da0c13b7f08619f18800c 100644 --- a/backend/src/note/note.controller.ts +++ b/backend/src/note/note.controller.ts @@ -10,7 +10,7 @@ export class NoteController { private voterService: VoterService, ) {} @Get('/all') - async getAllNotes(@Query('stakeKeys') stakeKeys?: any, @Query('beforeNoteCursor') beforeNote?: number, @Query('afterNoteCursor') afterNote?: number) { + async getAllNotes(@Query('stakeKeys') stakeKeys?: any, @Query('currentNoteCursor') currentNote?: number, @Query('request') request?: string) { const { stakeKey, stakeKeyBech32 } = stakeKeys || {}; let delegation = null; @@ -20,7 +20,7 @@ export class NoteController { await this.voterService.getAdaHolderCurrentDelegation(stakeKey); } - return this.noteService.getAllNotes(stakeKeyBech32, delegation,beforeNote, afterNote); + return this.noteService.getAllNotes(stakeKeyBech32, delegation,currentNote, request); } @Get('/:id/single') getSingleNote(@Param('id') noteId: string) { diff --git a/backend/src/note/note.service.ts b/backend/src/note/note.service.ts index 959b39aabe53d99ca471463fd27f8355d74c80b9..67b94c4aedd86c37ee3eb159ed6f35854c7315c5 100644 --- a/backend/src/note/note.service.ts +++ b/backend/src/note/note.service.ts @@ -17,12 +17,17 @@ export class NoteService { private reactionsService: ReactionsService, private commentsService: CommentsService, ) {} - async getAllNotes(stakeKeyBech32?: string, delegation?: Delegation,beforeNote?: number, afterNote?: number) { + async getAllNotes( + stakeKeyBech32?: string, + delegation?: Delegation, + currentNote?: number, + request?: string, + ) { let allNotes = await this.getNotesWithVisibility( delegation, stakeKeyBech32, - beforeNote, - afterNote + currentNote, + request, ); // Used Promise.all to ensure all asynchronous operations complete @@ -41,8 +46,8 @@ export class NoteService { return { ...note, reactions: reactions, comments: comments }; }), ); - - return allNotes + + return allNotes; } async getSingleNote(noteId: string) { @@ -92,7 +97,12 @@ export class NoteService { } } - private async getNotesWithVisibility(delegation?, stakeKeyBech32?: string, beforeNote?: number, afterNote?: number) { + private async getNotesWithVisibility( + delegation?, + stakeKeyBech32?: string, + currentNote?: number, + request?: string, + ) { const queryBuilder = this.voltaireService .getRepository('Note') .createQueryBuilder('note') @@ -104,13 +114,17 @@ export class NoteService { queryBuilder.where('note.note_visibility = :everyone', { everyone: 'everyone', }); - if (beforeNote) { - queryBuilder.where('note.id > :beforeNote', { beforeNote }); - } - if (afterNote) { - queryBuilder.where('note.id < :afterNote', { afterNote }); + if (currentNote) { + console.log(request); + if (request === 'before') { + queryBuilder.where('note.id <= :currentNote', { currentNote: Number(currentNote) }); + } else if (request === 'after') { + queryBuilder.where('note.id <= :currentNote', { + currentNote: Number(currentNote) + 20, + }); + } } - + // 'delegators' visibility if (delegation) { queryBuilder.orWhere( diff --git a/frontend/package.json b/frontend/package.json index f2f9734bda2fb3b1420d254cd256fbdea75122ca..ef09ad57172e81e2ed354a842488eb3782c74f1a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -59,8 +59,8 @@ "react": "^18", "react-copy-to-clipboard": "^5.1.0", "react-dom": "^18", + "react-easy-infinite-scroll-hook": "^2.1.4", "react-hook-form": "^7.51.3", - "react-infinite-scroll-component": "^6.1.0", "react-query": "^3.39.3", "use-debounce": "^10.0.1", "zod": "^3.23.4" diff --git a/frontend/src/app/[locale]/dreps/[drepid]/delegators/page.tsx b/frontend/src/app/[locale]/dreps/[drepid]/delegators/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d6b481724ba132e33aa10a7e3bc7fd8aade30e9b --- /dev/null +++ b/frontend/src/app/[locale]/dreps/[drepid]/delegators/page.tsx @@ -0,0 +1,13 @@ +'use client'; +import DrepProfileMetrics from '@/components/molecules/DrepProfileMetrics'; +import { useGetSingleDRepQuery } from '@/hooks/useGetSingleDRepQuery'; +import { useParams } from 'next/navigation'; + +const DelegatorsPage = () => { + const { drepid } = useParams(); + const { dRep } = useGetSingleDRepQuery(drepid); + + return <DrepProfileMetrics drepMetrics={dRep} />; +}; + +export default DelegatorsPage; \ No newline at end of file diff --git a/frontend/src/app/[locale]/dreps/[drepid]/layout.tsx b/frontend/src/app/[locale]/dreps/[drepid]/layout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..695e8bde4d7897244ce575da37b8130c19c4cfd4 --- /dev/null +++ b/frontend/src/app/[locale]/dreps/[drepid]/layout.tsx @@ -0,0 +1,51 @@ +'use client'; +import DRepProfileBar from '@/components/atoms/DrepProfileBar'; +import DrepTabGroup from '@/components/atoms/DrepTabGroup'; +import { useState } from 'react'; +import { IconButton } from '@mui/material'; +import { useCardano } from '@/context/walletContext'; +import { useGetSingleDRepQuery } from '@/hooks/useGetSingleDRepQuery'; +import { useParams } from 'next/navigation'; + +export default function Layout({ children }: { children: React.ReactNode }) { + const { dRepIDBech32 } = useCardano(); + const [isOpen, setIsOpen] = useState(false); + const { drepid } = useParams(); + const { dRep, isDRepLoading } = useGetSingleDRepQuery(drepid); + + return ( + <div className="flex"> + {/* If current user is a drep, the drawer will be available for use */} + {dRep?.drep_id && dRep?.cexplorerDetails?.view == dRepIDBech32 && ( + <DRepProfileBar isOpen={isOpen} setIsOpen={setIsOpen} /> + )} + <div className="base_container w-full"> + <div className="flex h-full w-full flex-col"> + <div className="flex items-center justify-start"> + <div className="w-[30%]"> + {dRep?.drep_id && + dRep?.cexplorerDetails?.view == dRepIDBech32 && ( + <IconButton + data-testid="close-drawer-button" + onClick={() => { + setIsOpen(!isOpen); + }} + > + <img + width={'50%'} + className="shrink-0" + src={'/svgs/menu.svg'} + /> + </IconButton> + )} + </div> + <div className="w-[70%]"> + <DrepTabGroup drepId={drepid as string}/> + </div> + </div> + {children} + </div> + </div> + </div> + ); +} \ No newline at end of file diff --git a/frontend/src/app/[locale]/dreps/[drepid]/page.tsx b/frontend/src/app/[locale]/dreps/[drepid]/page.tsx index 3fcf5a205495231ad20d5a9ed9eed60b6c184b50..5b7dd8b4b1cfc885b001e15254cc4500d747713a 100644 --- a/frontend/src/app/[locale]/dreps/[drepid]/page.tsx +++ b/frontend/src/app/[locale]/dreps/[drepid]/page.tsx @@ -1,80 +1,38 @@ 'use client'; -import DRepProfileBar from '@/components/atoms/DrepProfileBar'; import DrepProfileCard from '@/components/atoms/DrepProfileCard'; -import DrepTabGroup from '@/components/atoms/DrepTabGroup'; -import { Suspense, useState } from 'react'; -import { IconButton } from '@mui/material'; +import { Suspense } from 'react'; import DrepTimeline from '@/components/molecules/DrepTimeline'; -import DrepProfileMetrics from '@/components/molecules/DrepProfileMetrics'; -import { useParams } from 'next/navigation'; -import DrepClaimProfileCard from '@/components/atoms/DrepClaimProfileCard'; import { useCardano } from '@/context/walletContext'; import { useGetSingleDRepQuery } from '@/hooks/useGetSingleDRepQuery'; +import { useParams } from 'next/navigation'; +import DrepClaimProfileCard from '@/components/atoms/DrepClaimProfileCard'; const page = () => { - const [currentTab, setCurrentTab] = useState('profile'); - const { latestEpoch, dRepIDBech32 } = useCardano(); - const [isOpen, setIsOpen] = useState(false); + const { latestEpoch } = useCardano(); const { drepid } = useParams(); const { dRep, isDRepLoading } = useGetSingleDRepQuery(drepid); return ( - <div className="flex"> - {/* If current user is a drep, the drawer will be available for use */} - {dRep?.drep_id && dRep?.cexplorerDetails?.view == dRepIDBech32 && ( - <DRepProfileBar isOpen={isOpen} setIsOpen={setIsOpen} /> - )} - <div className="base_container w-full"> - <div className="flex h-full w-full flex-col"> - <div className="flex items-center justify-start"> - <div className="w-[30%]"> - {dRep?.drep_id && - dRep?.cexplorerDetails?.view == dRepIDBech32 && ( - <IconButton - data-testid="close-drawer-button" - onClick={() => { - setIsOpen(!isOpen); - }} - > - <img - width={'50%'} - className="shrink-0" - src={'/svgs/menu.svg'} - /> - </IconButton> - )} - </div> - <div className="w-[70%]"> - <DrepTabGroup setActiveTab={setCurrentTab} /> - </div> - </div> - {currentTab === 'profile' ? ( - <div className="flex flex-col lg:flex-row"> - <div className="lg:w-[30%]"> - {dRep?.drep_id ? ( - <DrepProfileCard drep={dRep} state={isDRepLoading} /> - ) : ( - <DrepClaimProfileCard drep={dRep} state={isDRepLoading} /> - )} - </div> - <div className="lg:w-[70%]"> - <Suspense> - <DrepTimeline - drepId={dRep?.drep_id || dRep?.cexplorerDetails?.view} - latestEpoch={latestEpoch} - cexplorerDetails={dRep?.cexplorerDetails} - activity={dRep?.activity} - /> - </Suspense> - </div> - </div> - ) : ( - <DrepProfileMetrics drepMetrics={dRep} /> - )} - </div> + <div className="flex flex-col lg:flex-row"> + <div className="lg:w-[30%]"> + {dRep?.drep_id ? ( + <DrepProfileCard drep={dRep} state={isDRepLoading} /> + ) : ( + <DrepClaimProfileCard drep={dRep} state={isDRepLoading} /> + )} + </div> + <div className="lg:w-[70%]"> + <Suspense> + <DrepTimeline + drepId={dRep?.drep_id || dRep?.cexplorerDetails?.view} + latestEpoch={latestEpoch} + cexplorerDetails={dRep?.cexplorerDetails} + activity={dRep?.activity} + /> + </Suspense> </div> </div> ); }; -export default page; +export default page; \ No newline at end of file diff --git a/frontend/src/components/atoms/DrepTabGroup.tsx b/frontend/src/components/atoms/DrepTabGroup.tsx index ca89b03a8e9eedf068d2ef24d2b2979007e686e7..b8b2e5928c82529b8a4acbf92c249070788b6416 100644 --- a/frontend/src/components/atoms/DrepTabGroup.tsx +++ b/frontend/src/components/atoms/DrepTabGroup.tsx @@ -1,15 +1,28 @@ -import React, { useState } from 'react'; +import { usePathname, useRouter } from 'next/navigation'; +import React, { useEffect, useState } from 'react'; -const DrepTabGroup = ({setActiveTab}:{setActiveTab:Function}) => { +const DrepTabGroup = ({ drepId }: { drepId: string }) => { const [active, setActive] = useState('profile'); + const router = useRouter(); + const pathname = usePathname(); const activeClasses = 'bg-white border-b-2 border-b-blue-800 rounded-t-lg text-blue-800 '; - const inactiveClasses = 'bg-white bg-opacity-40 rounded-t-lg text-gray-400 hover:text-gray-800 cursor-pointer'; + const inactiveClasses = + 'bg-white bg-opacity-40 rounded-t-lg text-gray-400 hover:text-gray-800 cursor-pointer'; const handleClick = (id) => { - setActive(id); - setActiveTab(id); + if (id === 'profile') { + router.push(`/dreps/${drepId}`); + } + if (id === 'delegators') { + router.push(`/dreps/${drepId}/delegators`); + } }; + useEffect(() => { + if (pathname.includes(`/dreps/${drepId}/delegators`)) { + setActive('delegators'); + } else setActive('profile'); + }, [pathname]); return ( <div className="flex items-center gap-1 overflow-x-auto"> <div @@ -20,11 +33,11 @@ const DrepTabGroup = ({setActiveTab}:{setActiveTab:Function}) => { <p>Profile</p> </div> <div - id="metrics" - className={`px-16 py-4 ${active === 'metrics' ? activeClasses : inactiveClasses}`} - onClick={() => handleClick('metrics')} + id="delegators" + className={`px-16 py-4 ${active === 'delegators' ? activeClasses : inactiveClasses}`} + onClick={() => handleClick('delegators')} > - <p>Metrics</p> + <p>Delegators</p> </div> </div> ); diff --git a/frontend/src/components/dreps/notes/NotesPage.tsx b/frontend/src/components/dreps/notes/NotesPage.tsx index 82a20dadecc34547baf1ec8ff8189aeddcad8db8..6dcc4ec54d088b1a8e3efe0069eb66e6796f766d 100644 --- a/frontend/src/components/dreps/notes/NotesPage.tsx +++ b/frontend/src/components/dreps/notes/NotesPage.tsx @@ -3,12 +3,11 @@ import { useEffect, useState, useRef, useCallback } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import SingleNote from './SingleNote'; import NotesPageHeader from './NotesPageHeader'; -import { Skeleton } from '@mui/material'; +import { CircularProgress, List, Skeleton } from '@mui/material'; import { useCardano } from '@/context/walletContext'; import { useDRepContext } from '@/context/drepContext'; import { useGetNotesQuery } from '@/hooks/useGetNotesQuery'; -import InfiniteScroll from 'react-infinite-scroll-component'; - +import useInfiniteScroll from 'react-easy-infinite-scroll-hook'; const LoaderComponent = () => { return Array.from({ length: 4 }).map((_, index) => ( <div @@ -28,18 +27,33 @@ function NotesPage() { const searchParams = useSearchParams(); const { stakeKeyBech32, isEnabled } = useCardano(); const { isLoggedIn } = useDRepContext(); - const [lastNoteID, setLastNoteID] = useState<number | undefined>(undefined); const [dominantNoteId, setDominantNoteId] = useState<number | undefined>( undefined, ); + const [currentNoteId, setCurrentNoteId] = useState<number | undefined>( + undefined, + ); + const [request, setRequest] = useState<'before' | 'after'>('before'); + const [prevScrollTop, setPrevScrollTop] = useState(0); + const [scrollDirection, setScrollDirection] = useState(null); const [allNotes, setAllNotes] = useState<any[]>([]); - const [hasMore, setHasMore] = useState(true); const noteRefs = useRef<{ [key: number]: HTMLDivElement | null }>({}); - + const [hasMoreBelow, setHasMoreBelow] = useState(true); + const [hasMoreAbove, setHasMoreAbove] = useState(true); const { Notes, isNotesLoading, isNotesFetching, isPreviousData } = useGetNotesQuery({ - afterNote:lastNoteID, + currentNote: currentNoteId, + request: request, }); + useEffect(() => { + if (allNotes && allNotes.length > 0) { + const topMostNote = allNotes[0]; + if (!dominantNoteId) { + setDominantNoteId(topMostNote.note_id); + updateURL(topMostNote.note_id.toString()); + } + } + }, [allNotes, dominantNoteId]); useEffect(() => { if (Notes && !isPreviousData) { @@ -51,21 +65,32 @@ function NotesPage() { // Convert the Map values back to an array const uniqueNotes = Array.from(uniqueNotesMap.values()); - + uniqueNotes.sort((a, b) => b.note_id - a.note_id); // Sort notes by note_id in descending order (assuming higher IDs are more recent) return uniqueNotes; }); - setHasMore(Notes.length > 0); + if (scrollDirection === 'down') { + setHasMoreBelow(Notes.length > 1); + } else if (scrollDirection === 'up') { + setHasMoreAbove(Notes.length > 1); + } } }, [Notes, isPreviousData]); - + const ref = useInfiniteScroll({ + next: async (scrollDirection) => fetchMoreData(), + rowCount: allNotes.length, + hasMore: { down: hasMoreBelow, up: hasMoreAbove }, + onScroll: (event) => { + handleScroll(event); + updateDominantNote(); + }, + }); useEffect(() => { const urlNote = searchParams.get('note'); if (urlNote) { - setLastNoteID(Number(urlNote)); + setCurrentNoteId(Number(urlNote)); } }, []); - const updateDominantNote = useCallback(() => { if (allNotes && allNotes.length > 0) { const windowHeight = window.innerHeight; @@ -91,65 +116,71 @@ function NotesPage() { } } }, [allNotes, dominantNoteId]); - - useEffect(() => { - if (allNotes && allNotes.length > 0) { - const topMostNote = allNotes[0]; - if (!dominantNoteId) { - setDominantNoteId(topMostNote.note_id); - updateURL(topMostNote.note_id.toString()); - } - } - }, [allNotes, dominantNoteId]); + const handleScroll = useCallback( + (event) => { + const currentScrollTop = event.scrollTop; + const isScrollingDown = currentScrollTop > prevScrollTop; + setScrollDirection(isScrollingDown ? 'down' : 'up'); + setPrevScrollTop(currentScrollTop); + }, + [prevScrollTop], + ); const updateURL = (noteId?: string) => { const params = new URLSearchParams(searchParams); if (noteId) params.set('note', noteId); router.replace(`?${params.toString()}`, { scroll: false }); }; + const fetchMoreDataDown = useCallback(() => { + if (!isNotesFetching && !isPreviousData && allNotes.length > 0) { + setCurrentNoteId(allNotes[allNotes.length - 1].note_id); + setRequest('before'); + } + }, [isNotesFetching, allNotes]); + const fetchMoreDataUp = useCallback(() => { + if (!isNotesFetching && !isPreviousData) { + setCurrentNoteId(allNotes[0].note_id); + setRequest('after'); + } + }, [isNotesFetching, allNotes]); const fetchMoreData = useCallback(() => { - if (!isNotesFetching && !isPreviousData && hasMore) { - if (allNotes && allNotes.length > 0) { - const lastNote = allNotes[allNotes.length - 1]; - if (lastNote.note_id !== lastNoteID) { - setLastNoteID(lastNote.note_id); - } - } + if (scrollDirection === 'down') { + fetchMoreDataDown(); + } else if (scrollDirection === 'up') { + fetchMoreDataUp(); } - }, [isNotesFetching, isPreviousData, hasMore, allNotes, lastNoteID]); - if (isNotesLoading) { - // first render - return ( - <div className="flex min-h-screen flex-col gap-5 bg-white bg-opacity-50 px-5 py-10"> - <NotesPageHeader /> - <LoaderComponent /> - </div> - ); - } - + }, [scrollDirection]); return ( - <div className="flex min-h-screen flex-col gap-5 bg-white bg-opacity-50 px-5 py-10"> + <div className="flex h-screen flex-col gap-5 bg-white bg-opacity-50 px-5 py-10"> <NotesPageHeader /> - <InfiniteScroll - onScroll={updateDominantNote} - dataLength={allNotes.length} - - next={fetchMoreData} - hasMore={hasMore} - loader={<LoaderComponent />} - endMessage={<p className="text-center">You've caught up!</p>} - scrollThreshold="200px" - className="flex flex-col gap-5 pt-5" + <List + id="scrollableDiv" + ref={ref as any} + style={{ + height: 1000, + overflow: 'auto', + display: 'flex', + flexDirection: 'column', + gap: '2rem', + }} > - {Array.from(new Set(allNotes.map((note) => note.note_id))).map( - (noteId) => { - const note = allNotes.find((n) => n.note_id === noteId); + {isNotesFetching && scrollDirection === 'up' && ( + <div className="flex items-center justify-center"> + <CircularProgress size={40} /> + </div> + )} + {isNotesLoading ? ( + <LoaderComponent /> + ) : ( + allNotes && + allNotes.length > 0 && + allNotes.map((note) => { return ( <div - key={noteId} + key={note.note_id} className="w-full" - ref={(el: any) => (noteRefs.current[noteId] = el)} + ref={(el: any) => (noteRefs.current[note.note_id] = el)} > <SingleNote note={note} @@ -159,10 +190,11 @@ function NotesPage() { /> </div> ); - }, + }) )} + {isNotesFetching && scrollDirection === 'down' && <LoaderComponent />} {allNotes.length === 0 && !isNotesLoading && <p>No notes</p>} - </InfiniteScroll> + </List> </div> ); } diff --git a/frontend/src/components/dreps/notes/SingleNote.tsx b/frontend/src/components/dreps/notes/SingleNote.tsx index 2a701b3b2269862338865b619da39349b75544f5..f84ac736579b403482fa9ead9d7f7cc89948ed70 100644 --- a/frontend/src/components/dreps/notes/SingleNote.tsx +++ b/frontend/src/components/dreps/notes/SingleNote.tsx @@ -279,7 +279,6 @@ const SingleNote = ({ </div> <div className="flex gap-5"> {Object.keys(reactionIcons).map((type) => { - console.log(currentReactions) return ( <div key={type} diff --git a/frontend/src/components/molecules/DrepTimeline.tsx b/frontend/src/components/molecules/DrepTimeline.tsx index 311bf383bb243f5081e38a5aea2d5d618abfee02..94b52494a4c212ee39a2313d2810f739bb6d9d38 100644 --- a/frontend/src/components/molecules/DrepTimeline.tsx +++ b/frontend/src/components/molecules/DrepTimeline.tsx @@ -2,13 +2,14 @@ import React, { useCallback, useEffect, useState } from 'react'; import DrepTimelineWaterfall from './DrepTimelineWaterfall'; import Link from 'next/link'; -import InfiniteScroll from 'react-infinite-scroll-component'; +import useInfiniteScroll from 'react-easy-infinite-scroll-hook'; import Button from '../atoms/Button'; import { useCardano } from '@/context/walletContext'; import { useRouter, useSearchParams } from 'next/navigation'; import { getSingleDRep } from '@/services/requests/getSingleDrep'; import { getSingleDRepViaVoterId } from '@/services/requests/getSingleDrepViaVoterId'; +import { CircularProgress } from '@mui/material'; const ProfileClaimedChip = ({ claimedAddress }) => { return ( <div className="flex flex-col gap-1 rounded-xl bg-yellow-500 px-3 py-2 "> @@ -38,12 +39,16 @@ const DrepTimeline = ({ }) => { const [searchText, setSearchText] = useState(''); const [allActivities, setAllActivities] = useState(activity || []); - const [hasMore, setHasMore] = useState(true); + const [hasMoreBelow, setHasMoreBelow] = useState(true); + const [hasMoreAbove, setHasMoreAbove] = useState(true); + const [prevScrollTop, setPrevScrollTop] = useState(0); + const [scrollDirection, setScrollDirection] = useState(null); const router = useRouter(); + const [isLoadingMore, setIsLoadingMore] = useState(false); const [endTime, setEndTime] = useState(() => Date.now()); const [startTime, setStartTime] = useState( () => endTime - 30 * 24 * 60 * 60 * 1000, - );// 30 days for now + ); // 30 days for now const searchParams = useSearchParams(); const { dRepIDBech32 } = useCardano(); const updateURL = (startTime?: number, endTime?: number) => { @@ -56,34 +61,94 @@ const DrepTimeline = ({ } router.replace(`?${params.toString()}`, { scroll: false }); }; + const ref = useInfiniteScroll({ + next: (scrollDirection) => fetchMoreData(), + rowCount: allActivities.length, + hasMore: { down: hasMoreBelow, up: hasMoreAbove }, + onScroll: (event) => { + handleScroll(event); + updateDominantActivity(); + }, + }); useEffect(() => { - if (activity) { - setAllActivities((prevActivities) => { - const uniqueActivitiesMap = new Map( - [...prevActivities, ...activity].map((activity) => [ - activity.timestamp, - activity, - ]), - ); - return Array.from(uniqueActivitiesMap.values()).sort( - (a, b) => b.timestamp - a.timestamp, - ); - }); - setHasMore(activity.length > 0); + let startTime, endTime; + if (searchParams) { + const params = new URLSearchParams(searchParams); + if (params.get('startTime')) { + setStartTime(Number(params.get('startTime'))); + startTime = Number(params.get('startTime')); + } + if (params.get('endTime')) { + setEndTime(Number(params.get('endTime'))); + endTime = Number(params.get('endTime')); + } } - }, [activity]); + const initialFetch = async (startTime, endTime) => { + let newStartTime = startTime; + let newEndTime = endTime; + let drep; + String(drepId).includes('drep') + ? (drep = await getSingleDRepViaVoterId( + drepId as string, + null, + newEndTime, + newStartTime, + )) + : (drep = await getSingleDRep( + Number(drepId), + null, + newEndTime, + newStartTime, + )); + if (drep.activity && drep.activity.length > 0) { + setAllActivities((prevActivities) => { + const uniqueActivitiesMap = new Map( + [...drep.activity].map((activity) => [ + activity.timestamp, + activity, + ]), + ); + return Array.from(uniqueActivitiesMap.values()).sort( + (a, b) => + new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(), + ); + }); + setHasMoreBelow(drep.activity.length > 0); + // Update time states + setEndTime(newEndTime); + setStartTime(newStartTime); + } + }; + if (drepId) initialFetch(startTime, endTime); + }, [drepId]); const updateDominantActivity = () => { updateURL(startTime, endTime); }; - + const handleScroll = (event) => { + const currentScrollTop = event.scrollTop; + const isScrollingDown = currentScrollTop > prevScrollTop; + setScrollDirection(isScrollingDown ? 'down' : 'up'); + setPrevScrollTop(currentScrollTop); + }; const fetchMoreData = useCallback(async () => { if (allActivities && allActivities.length > 0) { - const oldestActivityTimestamp = Math.min( - ...allActivities.map((a) => new Date(a.timestamp).getTime()), - ); - const newEndTime = oldestActivityTimestamp; - const newStartTime = newEndTime - 30 * 24 * 60 * 60 * 1000; // Fetch 30 more days + setIsLoadingMore(true); + let newStartTime, newEndTime; + if (scrollDirection === 'up') { + const oldestActivityTimestamp = Math.max( + ...allActivities.map((a) => new Date(a.timestamp).getTime()), + ); + newStartTime = oldestActivityTimestamp; + newEndTime = newStartTime + 30 * 24 * 60 * 60 * 1000; // 30 days earlier + } else { + const oldestActivityTimestamp = Math.min( + ...allActivities.map((a) => new Date(a.timestamp).getTime()), + ); + newEndTime = oldestActivityTimestamp; + newStartTime = newEndTime - 30 * 24 * 60 * 60 * 1000; // Fetch 30 more days + } + let drep; drepId.includes('drep') ? (drep = await getSingleDRepViaVoterId( @@ -111,15 +176,24 @@ const DrepTimeline = ({ new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(), ); }); - setHasMore(drep.activity.length > 0); + if (scrollDirection === 'up') { + setHasMoreAbove(drep.activity.length > 1); + } + if (scrollDirection === 'down') { + setHasMoreBelow(drep.activity.length > 1); + } // Update time states setEndTime(newEndTime); setStartTime(newStartTime); + setIsLoadingMore(false); } else { - setHasMore(false); + setHasMoreBelow(false); + setIsLoadingMore(false); + setHasMoreAbove(false); } } - }, [drepId, allActivities]); + }, [drepId, allActivities, scrollDirection]); + return ( <div className="flex h-full w-full flex-col gap-5 bg-white px-5 py-3"> <div className="flex flex-col items-center gap-2 sm:flex-row sm:justify-between"> @@ -138,22 +212,35 @@ const DrepTimeline = ({ <Link href={`/dreps/workflow/notes/new`}>Add a note</Link> </Button> )} - - {drepId && !drepId.includes('drep') && <ProfileClaimedChip claimedAddress={drepId} />} - {allActivities && allActivities.length > 0 && ( - <InfiniteScroll - onScroll={updateDominantActivity} - dataLength={allActivities.length} - next={fetchMoreData} - hasMore={hasMore} - loader={<p>Loading...</p>} - endMessage={<p className="text-center">You've caught up!</p>} - scrollThreshold="200px" - className="flex flex-col gap-5 pt-5" - > - <DrepTimelineWaterfall activity={allActivities} /> - </InfiniteScroll> + {drepId && (drepId).includes('drep') && ( + <ProfileClaimedChip claimedAddress={drepId} /> )} + <div + id="drep-timeline" + ref={ref as any} + style={{ + height: 1000, + overflow: 'auto', + display: 'flex', + flexDirection: 'column', + }} + > + {isLoadingMore && scrollDirection === 'up' && ( + <div className="flex items-center justify-center"> + <CircularProgress size={40} /> + </div> + )} + {allActivities && allActivities.length > 0 && ( + <div className="flex flex-col gap-5 pt-5"> + <DrepTimelineWaterfall activity={allActivities} /> + </div> + )} + {isLoadingMore && scrollDirection === 'down' && ( + <div className="flex items-center justify-center"> + <CircularProgress size={40} /> + </div> + )} + </div> </div> ); }; diff --git a/frontend/src/components/molecules/DrepTimelineWaterfall.tsx b/frontend/src/components/molecules/DrepTimelineWaterfall.tsx index a13d9a22a79b61b8213534985396946e889758a1..ebc039be5ad2501a411d546401de35d8be39f3fb 100644 --- a/frontend/src/components/molecules/DrepTimelineWaterfall.tsx +++ b/frontend/src/components/molecules/DrepTimelineWaterfall.tsx @@ -36,11 +36,10 @@ export default function DrepTimelineWaterfall({ {activity && activity.length > 0 && activity.map((item, epochIndex) => ( - <> + <div key={epochIndex}> {item.type === 'note' && ( <div className="flex w-full flex-col items-center space-y-2" - key={epochIndex} > <TimelineSeparator> <TimelineDot /> @@ -62,7 +61,6 @@ export default function DrepTimelineWaterfall({ {item.type === 'epoch' && ( <div className="flex w-full flex-col items-center space-y-2" - key={epochIndex} > <TimelineSeparator> <TimelineDot /> @@ -90,7 +88,7 @@ export default function DrepTimelineWaterfall({ </div> )} {item.type === 'voting_activity' && ( - <TimelineItem key={epochIndex}> + <TimelineItem> <TimelineSeparator> <TimelineDot /> <TimelineConnector @@ -103,7 +101,7 @@ export default function DrepTimelineWaterfall({ </TimelineContent> </TimelineItem> )} - </> + </div> ))} </Timeline> ); diff --git a/frontend/src/hooks/useGetNotesQuery.ts b/frontend/src/hooks/useGetNotesQuery.ts index 495cb262907bb6ed87adb16e01e17a444a4a6df9..8ee411a877b413343a69ae12bd3abcea6cd4b1a2 100644 --- a/frontend/src/hooks/useGetNotesQuery.ts +++ b/frontend/src/hooks/useGetNotesQuery.ts @@ -5,15 +5,15 @@ import { useQuery } from 'react-query'; import { StakeKeys } from '../../types/commonTypes'; type GetNotesProps = { - beforeNote?: number; - afterNote?: number; + currentNote?: number; + request?: string; } -export const useGetNotesQuery = ({ beforeNote, afterNote }: GetNotesProps = {}) => { +export const useGetNotesQuery = ({ currentNote, request }: GetNotesProps = {}) => { const { stakeKey, stakeKeyBech32 } = useCardano(); const stakeKeys: StakeKeys = { stakeKey, stakeKeyBech32 }; const { data, isLoading, refetch, isFetching, isPreviousData } = useQuery({ - queryKey: [QUERY_KEYS.getNotesKey, stakeKeys, beforeNote, afterNote], - queryFn: async () => await getNotes(stakeKeys, beforeNote, afterNote), + queryKey: [QUERY_KEYS.getNotesKey, stakeKeys, currentNote,request], + queryFn: async () => await getNotes(stakeKeys, currentNote,request), refetchOnWindowFocus: false, enabled: true, keepPreviousData: true, diff --git a/frontend/src/services/requests/getNotes.ts b/frontend/src/services/requests/getNotes.ts index bbf9ebfc1450d6bd08332152224f783e909e092c..85acb693cdf88cd71ca9dd547a3291106d45cb17 100644 --- a/frontend/src/services/requests/getNotes.ts +++ b/frontend/src/services/requests/getNotes.ts @@ -1,12 +1,16 @@ import { StakeKeys } from '../../../types/commonTypes'; import axiosInstance from '../axiosInstance'; -export const getNotes = async (stakeKeys?: StakeKeys, beforeNote?: number, afterNote?: number) => { +export const getNotes = async ( + stakeKeys?: StakeKeys, + currentNote?: number, + request?: string, +) => { const response = await axiosInstance.get(`/api/notes/all`, { params: { stakeKeys: stakeKeys, - beforeNoteCursor: beforeNote, - afterNoteCursor: afterNote + currentNoteCursor: currentNote, + request: request, }, }); return response.data; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 84461a3a821476707c531fcdc5eb0c3b4c98080f..89d8cb08000f72ace029b98488acfc3a5a722b37 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -8659,6 +8659,11 @@ react-dom@^18.2.0: loose-envify "^1.1.0" scheduler "^0.23.2" +react-easy-infinite-scroll-hook@^2.1.4: + version "2.1.4" + resolved "https://registry.yarnpkg.com/react-easy-infinite-scroll-hook/-/react-easy-infinite-scroll-hook-2.1.4.tgz#6b61ba1d9dbf17a28d5b99e7513e281b4b61159f" + integrity sha512-sRbQGWh4BjSvCHTUGQwqf6PtMsOJWYl+RQ7h7BagmNpoWRi+Q/X8qwMZJ5WyzZS6U5vi3OrSGAfjLFPKp73jrg== + react-error-boundary@^3.1.4: version "3.1.4" resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-3.1.4.tgz#255db92b23197108757a888b01e5b729919abde0" @@ -8676,13 +8681,6 @@ react-hook-form@^7.51.3: resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.51.3.tgz#7486dd2d52280b6b28048c099a98d2545931cab3" integrity sha512-cvJ/wbHdhYx8aviSWh28w9ImjmVsb5Y05n1+FW786vEZQJV5STNM0pW6ujS+oiBecb0ARBxJFyAnXj9+GHXACQ== -react-infinite-scroll-component@^6.1.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/react-infinite-scroll-component/-/react-infinite-scroll-component-6.1.0.tgz#7e511e7aa0f728ac3e51f64a38a6079ac522407f" - integrity sha512-SQu5nCqy8DxQWpnUVLx7V7b7LcA37aM7tvoWjTLZp1dk6EJibM5/4EJKzOnl07/BsM1Y40sKLuqjCwwH/xV0TQ== - dependencies: - throttle-debounce "^2.1.0" - react-is@^16.13.1, react-is@^16.7.0: version "16.13.1" resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz" @@ -9469,11 +9467,6 @@ thenify-all@^1.0.0: dependencies: any-promise "^1.0.0" -throttle-debounce@^2.1.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/throttle-debounce/-/throttle-debounce-2.3.0.tgz#fd31865e66502071e411817e241465b3e9c372e2" - integrity sha512-H7oLPV0P7+jgvrk+6mwwwBDmxTaxnu9HMXmloNLXwnNO0ZxZ31Orah2n8lU1eMPvsaowP2CX+USCgyovXfdOFQ== - throttleit@^1.0.0: version "1.0.1" resolved "https://registry.npmjs.org/throttleit/-/throttleit-1.0.1.tgz"