diff --git a/components/HistoryView/HistoryFilter.tsx b/components/HistoryView/HistoryFilter.tsx new file mode 100644 index 00000000..3e68eb56 --- /dev/null +++ b/components/HistoryView/HistoryFilter.tsx @@ -0,0 +1,257 @@ +import { + Badge, + Box, + Button, + Checkbox, + Flex, + Popover, + PopoverContent, + PopoverTrigger, + Text, +} from "@livepeer/design-system"; +import { CheckIcon, MixerHorizontalIcon } from "@radix-ui/react-icons"; + +export type EventFilterKey = + | "delegated" + | "redelegated" + | "undelegated" + | "reward" + | "transcoderUpdate" + | "withdrawStake" + | "withdrawFees" + | "winningTicket" + | "deposit" + | "reserve" + | "votes" + | "round"; + +// Each filter maps a human-readable label to one or more event __typenames. +export const EVENT_FILTERS: { + key: EventFilterKey; + label: string; + typenames: string[]; +}[] = [ + { key: "delegated", label: "Delegated", typenames: ["BondEvent"] }, + { key: "redelegated", label: "Redelegated", typenames: ["RebondEvent"] }, + { key: "undelegated", label: "Undelegated", typenames: ["UnbondEvent"] }, + { key: "reward", label: "Reward calls", typenames: ["RewardEvent"] }, + { + key: "transcoderUpdate", + label: "Reward cut & fee changes", + typenames: ["TranscoderUpdateEvent"], + }, + { + key: "withdrawStake", + label: "Stake withdrawals", + typenames: ["WithdrawStakeEvent"], + }, + { + key: "withdrawFees", + label: "Fee withdrawals", + typenames: ["WithdrawFeesEvent"], + }, + { + key: "winningTicket", + label: "Winning tickets", + typenames: ["WinningTicketRedeemedEvent"], + }, + { + key: "deposit", + label: "Deposit funded", + typenames: ["DepositFundedEvent"], + }, + { + key: "reserve", + label: "Reserve funded", + typenames: ["ReserveFundedEvent"], + }, + { + key: "votes", + label: "Votes", + typenames: ["VoteEvent", "TreasuryVoteEvent"], + }, + { key: "round", label: "Round initialized", typenames: ["NewRoundEvent"] }, +]; + +export const ALL_FILTER_KEYS: EventFilterKey[] = EVENT_FILTERS.map( + (f) => f.key +); + +// Shared checkbox styling: a solid fill plus a stronger border so the box +// reads clearly against the $neutral4 popover background (the design-system +// default $neutral7 outline is too faint here). +const checkboxCss = { + backgroundColor: "$loContrast", + boxShadow: "inset 0 0 0 1px $colors$neutral8", +} as const; + +// Reverse lookup from an event's __typename to the filter key it belongs to. +export const TYPENAME_TO_FILTER: Record = + EVENT_FILTERS.reduce((acc, filter) => { + filter.typenames.forEach((typename) => { + acc[typename] = filter.key; + }); + return acc; + }, {} as Record); + +interface HistoryFilterProps { + selected: Set; + onToggle: (key: EventFilterKey) => void; + onSelectAll: () => void; + onClear: () => void; +} + +const HistoryFilter = ({ + selected, + onToggle, + onSelectAll, + onClear, +}: HistoryFilterProps) => { + const activeCount = selected.size; + // A filter is "active" whenever the selection differs from showing everything. + const isFiltered = activeCount !== EVENT_FILTERS.length; + const allSelected = activeCount === EVENT_FILTERS.length; + const noneSelected = activeCount === 0; + // Single toggle: when everything is on, clicking clears; otherwise select all. + const handleToggleAll = () => (allSelected ? onClear() : onSelectAll()); + + return ( + + + + + + { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleToggleAll(); + } + }} + css={{ + alignItems: "center", + gap: "$2", + padding: "$3", + borderBottom: "1px solid $neutral6", + cursor: "pointer", + userSelect: "none", + "&:hover": { bc: "$neutral5" }, + "&:focus-visible": { outline: "2px solid $primary11" }, + }} + > + + {allSelected && ( + + )} + {!allSelected && !noneSelected && ( + + )} + + + Event types + + + + {EVENT_FILTERS.map((filter) => { + const checked = selected.has(filter.key); + return ( + onToggle(filter.key)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onToggle(filter.key); + } + }} + css={{ + alignItems: "center", + gap: "$2", + padding: "$2", + borderRadius: "$2", + cursor: "pointer", + userSelect: "none", + "&:hover": { bc: "$neutral5" }, + "&:focus-visible": { outline: "2px solid $primary11" }, + }} + > + + {filter.label} + + ); + })} + + + + ); +}; + +export default HistoryFilter; diff --git a/components/HistoryView/index.tsx b/components/HistoryView/index.tsx index 25c76097..7f5b4be4 100644 --- a/components/HistoryView/index.tsx +++ b/components/HistoryView/index.tsx @@ -11,6 +11,7 @@ import { Flex, Link as A, styled, + Text, } from "@livepeer/design-system"; import { formatETH, @@ -32,6 +33,12 @@ import { useRouter } from "next/router"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { catIpfsJson, IpfsPoll } from "utils/ipfs"; +import { + ALL_FILTER_KEYS, + EventFilterKey, + TYPENAME_TO_FILTER, +} from "./HistoryFilter"; + const PAGE_SIZE = 25; const Card = styled(CardBase, { @@ -41,7 +48,27 @@ const Card = styled(CardBase, { p: "$4", }); -const Index = () => { +// Matches the empty-state card used by GatewayList / DelegatorList so empty +// account lists look consistent across the app. +const EmptyState = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); + +const Index = ({ + selectedFilters, +}: { + selectedFilters: Set; +}) => { const router = useRouter(); const query = router.query; const account = query.account as string; @@ -62,6 +89,13 @@ const Index = () => { const [reachedEnd, setReachedEnd] = useState(false); + // Event-type filter selection is owned by the account layout (so the filter + // button can live on the nav row); here we only consume it. + const isFiltered = selectedFilters.size !== ALL_FILTER_KEYS.length; + // Zero types selected — the user explicitly wants nothing shown, so we must + // never auto-load history chasing matches that cannot exist. + const noneSelected = selectedFilters.size === 0; + const events = useMemo(() => { // First reverse the order of the array of events per transaction to have events in descending order const reversedEvents = data?.transactions?.map((tx) => { @@ -196,6 +230,18 @@ const Index = () => { ] ); + const visibleEvents = useMemo( + () => + mergedEvents.filter((event) => { + const key = TYPENAME_TO_FILTER[event?.__typename ?? ""]; + // Only event types we actually render (and can filter on) are shown. + // Unmapped types render as null anyway, so excluding them keeps the + // visible count accurate (e.g. so the empty state shows for "None"). + return key ? selectedFilters.has(key) : false; + }), + [mergedEvents, selectedFilters] + ); + const totalLoaded = Math.max( data?.transactions?.length ?? 0, data?.winningTicketRedeemedEvents?.length ?? 0 @@ -203,8 +249,10 @@ const Index = () => { const fetchingRef = useRef(false); const fetchNext = useCallback(async () => { - // Concurrency lock — ref so it's synchronous. - if (fetchingRef.current || loading) return; + // Concurrency lock — ref so it's synchronous. Also stop once we've reached + // the end or there was never more than a page to begin with. + if (fetchingRef.current || loading || reachedEnd || totalLoaded < PAGE_SIZE) + return; fetchingRef.current = true; try { @@ -238,22 +286,42 @@ const Index = () => { } finally { fetchingRef.current = false; } - }, [loading, totalLoaded, fetchMoreTransactions]); + }, [loading, reachedEnd, totalLoaded, fetchMoreTransactions]); - // Create a sentinel ref and observe it while more pages are available. - const sentinelRef = useRef(null); + // Keep the latest fetchNext / noneSelected in refs so the sentinel observer + // can read them without being torn down and re-created on every render. + // Re-creating the observer re-fires on the already-in-view sentinel, which + // (for a short, filtered list) would crawl the entire history. + const fetchNextRef = useRef(fetchNext); + const noneSelectedRef = useRef(noneSelected); useEffect(() => { - const el = sentinelRef.current; - if (!el || reachedEnd || totalLoaded < PAGE_SIZE) return; + fetchNextRef.current = fetchNext; + noneSelectedRef.current = noneSelected; + }); + + // Infinite scroll: load the next page when the sentinel scrolls into view. + // Loading is filter-agnostic — identical to the unfiltered view — so the + // filter only changes what is displayed, never what is fetched. fetchNext() + // no-ops once the end is reached. The callback ref attaches the observer once + // when the sentinel mounts, so paging never self-retriggers. + const observerRef = useRef(null); + const sentinelRef = useCallback((node: HTMLDivElement | null) => { + observerRef.current?.disconnect(); + if (!node) { + observerRef.current = null; + return; + } const observer = new IntersectionObserver( ([entry]) => { - if (entry.isIntersecting) fetchNext(); + if (entry.isIntersecting && !noneSelectedRef.current) { + fetchNextRef.current(); + } }, { rootMargin: "200px" } ); - observer.observe(el); - return () => observer.disconnect(); - }, [fetchNext, reachedEnd, totalLoaded]); + observer.observe(node); + observerRef.current = observer; + }, []); if (error) { console.error(error); @@ -278,7 +346,7 @@ const Index = () => { !data?.transactions?.length && !data?.winningTicketRedeemedEvents?.length ) { - return No history; + return No history; } return ( @@ -291,9 +359,18 @@ const Index = () => { }} > - {mergedEvents.map((event, i: number) => renderSwitch(event, i))} + {visibleEvents.map((event, i: number) => renderSwitch(event, i))} - {totalLoaded >= PAGE_SIZE && !reachedEnd && ( + {visibleEvents.length === 0 && !loading && ( + + {noneSelected + ? "Select at least one event type to view history." + : isFiltered + ? "No events match the selected filters." + : "No history"} + + )} + {!noneSelected && loading && !reachedEnd && ( @@ -68,7 +72,7 @@ const scrollActiveTabIntoView = (container: HTMLDivElement) => { const HorizontalScrollContainer = forwardRef< HTMLDivElement, HorizontalScrollContainerProps ->(({ children, ariaLabel, role, activeItemKey }, ref) => { +>(({ children, ariaLabel, role, activeItemKey, hideBorder }, ref) => { const innerRef = useRef(null); const [hasOverflow, setHasOverflow] = useState(false); @@ -136,14 +140,18 @@ const HorizontalScrollContainer = forwardRef< + >(() => new Set(ALL_FILTER_KEYS)); + const toggleHistoryFilter = useCallback((key: EventFilterKey) => { + setSelectedHistoryFilters((current) => { + const next = new Set(current); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + }, []); + const selectAllHistoryFilters = useCallback( + () => setSelectedHistoryFilters(new Set(ALL_FILTER_KEYS)), + [] + ); + const clearHistoryFilters = useCallback( + () => setSelectedHistoryFilters(new Set()), + [] + ); + const { setSelectedStakingAction, setBottomDrawerOpen, latestTransaction } = useExplorerStore(); @@ -255,54 +285,83 @@ const AccountLayout = ({ )} - - {tabs.map((tab: TabType, i: number) => ( - + + {tabs.map((tab: TabType) => ( + + + {tab.name} + + + ))} + + + {view === "history" && ( + - - {tab.name} - - - ))} - + + + )} + {view === "orchestrating" && ( )} - {view === "history" && } + {view === "history" && ( + + )} {view === "broadcasting" && (