diff --git a/__tests__/components/brain/left-sidebar/waves/UnifiedWavesList.test.tsx b/__tests__/components/brain/left-sidebar/waves/UnifiedWavesList.test.tsx index b63eb97e06..d825a2d3cd 100644 --- a/__tests__/components/brain/left-sidebar/waves/UnifiedWavesList.test.tsx +++ b/__tests__/components/brain/left-sidebar/waves/UnifiedWavesList.test.tsx @@ -55,7 +55,6 @@ describe('UnifiedWavesList', () => { render( { render( ({ @@ -37,11 +38,23 @@ const phases = [ { id: 'p1', name: 'Phase1', components: [{ id: 'c1', name: 'Comp1' }] }, ] as any; +const baseConfig: PhaseGroupSnapshotConfig = { + groupSnapshotId: 'g1', + snapshotId: 's1', + snapshotType: null, + snapshotSchema: null, + excludeComponentWinners: [], + excludeSnapshots: [], + topHoldersFilter: null, + tokenIds: null, + uniqueWalletsCount: null, +}; + test('shows error toast when nothing selected', () => { const setToasts = jest.fn(); render( - + ); fireEvent.click(screen.getByTestId('next')); @@ -50,10 +63,14 @@ test('shows error toast when nothing selected', () => { test('returns selected component ids on next', () => { const onSelect = jest.fn(); + const config: PhaseGroupSnapshotConfig = { + ...baseConfig, + uniqueWalletsCount: 42, + }; render( - , { wrapper: Wrapper } + , { wrapper: Wrapper } ); fireEvent.click(screen.getByTestId('opt-c1')); fireEvent.click(screen.getByTestId('next')); - expect(onSelect).toHaveBeenCalledWith({ excludeComponentWinners: ['c1'], uniqueWalletsCount: null }); + expect(onSelect).toHaveBeenCalledWith({ excludeComponentWinners: ['c1'], uniqueWalletsCount: 42 }); }); diff --git a/components/app-wallets/AppWallet.tsx b/components/app-wallets/AppWallet.tsx index 3ab54d9afc..0886f387a9 100644 --- a/components/app-wallets/AppWallet.tsx +++ b/components/app-wallets/AppWallet.tsx @@ -119,7 +119,7 @@ export default function AppWalletComponent( url: result.uri, dialogTitle: "Share or Save File", }); - } catch (_error) { + } catch { alert("Unable to write file"); } }; diff --git a/components/app-wallets/AppWalletsContext.tsx b/components/app-wallets/AppWalletsContext.tsx index dadff98c02..6b97322620 100644 --- a/components/app-wallets/AppWalletsContext.tsx +++ b/components/app-wallets/AppWalletsContext.tsx @@ -1,6 +1,7 @@ "use client"; import React, { createContext, + useCallback, useContext, useEffect, useMemo, @@ -53,7 +54,7 @@ export const AppWalletsProvider: React.FC<{ children: React.ReactNode }> = ({ const capacitor = useCapacitor(); const { isCapacitor } = capacitor; - const fetchAppWallets = async () => { + const fetchAppWallets = useCallback(async () => { if (!appWalletsSupported) { setFetchingAppWallets(false); setAppWallets([]); @@ -66,7 +67,7 @@ export const AppWalletsProvider: React.FC<{ children: React.ReactNode }> = ({ setAppWallets(wallets); setFetchingAppWallets(false); - }; + }, [appWalletsSupported]); useEffect(() => { let cancelled = false; @@ -112,97 +113,107 @@ export const AppWalletsProvider: React.FC<{ children: React.ReactNode }> = ({ }; }, [isCapacitor]); - const createAppWallet = async ( - name: string, - pass: string - ): Promise => { - if (!appWalletsSupported) return false; - - const wallet = ethers.Wallet.createRandom(); - const encryptedAddress = await encryptData( - wallet.address, - wallet.address, - pass - ); - const encryptedMnemonic = await encryptData( - wallet.address, - wallet.mnemonic?.phrase ?? "", - pass - ); - const encryptedPrivateKey = await encryptData( - wallet.address, - wallet.privateKey, - pass - ); - - const appWallet: AppWallet = { - name, - created_at: Time.now().toSeconds(), - address: wallet.address, - address_hashed: encryptedAddress, - mnemonic: encryptedMnemonic, - private_key: encryptedPrivateKey, - imported: false, - }; - - const result = await SecureStoragePlugin.set({ - key: `${WALLET_KEY_PREFIX}${wallet.address}`, - value: JSON.stringify(appWallet), - }); - - await fetchAppWallets(); - - return result.value; - }; - - const importAppWallet = async ( - walletName: string, - walletPass: string, - address: string, - mnemonic: string, - privateKey: string - ): Promise => { - if (!appWalletsSupported) return false; - - const encryptedAddress = await encryptData(address, address, walletPass); - const encryptedMnemonic = await encryptData(address, mnemonic, walletPass); - const encryptedPrivateKey = await encryptData( - address, - privateKey, - walletPass - ); - - const wallet: AppWallet = { - name: walletName, - created_at: Time.now().toSeconds(), - address, - address_hashed: encryptedAddress, - mnemonic: encryptedMnemonic, - private_key: encryptedPrivateKey, - imported: true, - }; - - const result = await SecureStoragePlugin.set({ - key: `${WALLET_KEY_PREFIX}${address}`, - value: JSON.stringify(wallet), - }); - - await fetchAppWallets(); + const createAppWallet = useCallback( + async (name: string, pass: string): Promise => { + if (!appWalletsSupported) return false; + + const wallet = ethers.Wallet.createRandom(); + const encryptedAddress = await encryptData( + wallet.address, + wallet.address, + pass + ); + const encryptedMnemonic = await encryptData( + wallet.address, + wallet.mnemonic?.phrase ?? "", + pass + ); + const encryptedPrivateKey = await encryptData( + wallet.address, + wallet.privateKey, + pass + ); + + const appWallet: AppWallet = { + name, + created_at: Time.now().toSeconds(), + address: wallet.address, + address_hashed: encryptedAddress, + mnemonic: encryptedMnemonic, + private_key: encryptedPrivateKey, + imported: false, + }; + + const result = await SecureStoragePlugin.set({ + key: `${WALLET_KEY_PREFIX}${wallet.address}`, + value: JSON.stringify(appWallet), + }); + + await fetchAppWallets(); + + return result.value; + }, + [appWalletsSupported, fetchAppWallets] + ); - return result.value; - }; + const importAppWallet = useCallback( + async ( + walletName: string, + walletPass: string, + address: string, + mnemonic: string, + privateKey: string + ): Promise => { + if (!appWalletsSupported) return false; + + const encryptedAddress = await encryptData(address, address, walletPass); + const encryptedMnemonic = await encryptData( + address, + mnemonic, + walletPass + ); + const encryptedPrivateKey = await encryptData( + address, + privateKey, + walletPass + ); + + const wallet: AppWallet = { + name: walletName, + created_at: Time.now().toSeconds(), + address, + address_hashed: encryptedAddress, + mnemonic: encryptedMnemonic, + private_key: encryptedPrivateKey, + imported: true, + }; + + const result = await SecureStoragePlugin.set({ + key: `${WALLET_KEY_PREFIX}${address}`, + value: JSON.stringify(wallet), + }); + + await fetchAppWallets(); + + return result.value; + }, + [appWalletsSupported, fetchAppWallets] + ); - const deleteAppWallet = async (address: string): Promise => { - if (!appWalletsSupported) return false; + const deleteAppWallet = useCallback( + async (address: string): Promise => { + if (!appWalletsSupported) return false; - const result = await SecureStoragePlugin.remove({ - key: `${WALLET_KEY_PREFIX}${address}`, - }); + const result = await SecureStoragePlugin.remove({ + key: `${WALLET_KEY_PREFIX}${address}`, + }); - await fetchAppWallets(); + await fetchAppWallets(); - return result.value; - }; + return result.value; + }, + [appWalletsSupported, fetchAppWallets] + ); const value = useMemo( () => ({ diff --git a/components/block-picker/advanced/BlockPickerAdvancedItemBlock.tsx b/components/block-picker/advanced/BlockPickerAdvancedItemBlock.tsx index 2994cd078a..680dc7705f 100644 --- a/components/block-picker/advanced/BlockPickerAdvancedItemBlock.tsx +++ b/components/block-picker/advanced/BlockPickerAdvancedItemBlock.tsx @@ -1,6 +1,8 @@ "use client"; import Link from "next/link"; +import { faCopy } from "@fortawesome/free-regular-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useState } from "react"; import { useCopyToClipboard } from "react-use"; @@ -35,7 +37,7 @@ export default function BlockPickerAdvancedItemBlock({ parts.push(number.substring(lastIndex)); } - const [copyState, copyToClipboard] = useCopyToClipboard(); + const [, copyToClipboard] = useCopyToClipboard(); const [coping, setCoping] = useState(false); const copy = () => { @@ -46,20 +48,11 @@ export default function BlockPickerAdvancedItemBlock({ return (
- - - + className="tw-h-5 tw-w-5 tw-cursor-pointer tw-mr-2.5 tw-text-iron-300 hover:tw-text-white tw-transition tw-duration-300 tw-ease-out" + icon={faCopy} + /> {coping ? ( "Copied" ) : ( diff --git a/components/brain/direct-messages/DirectMessagesList.tsx b/components/brain/direct-messages/DirectMessagesList.tsx index 048ffd7dab..699b8adbbb 100644 --- a/components/brain/direct-messages/DirectMessagesList.tsx +++ b/components/brain/direct-messages/DirectMessagesList.tsx @@ -1,6 +1,12 @@ "use client"; -import React, { useRef, useEffect, useContext } from "react"; +import React, { + useRef, + useEffect, + useContext, + useEffectEvent, + useMemo, +} from "react"; import UnifiedWavesListWaves, { UnifiedWavesListWavesHandle, } from "../left-sidebar/waves/UnifiedWavesListWaves"; @@ -26,53 +32,57 @@ const DirectMessagesList: React.FC = ({ const { connectedProfile } = useContext(AuthContext); const { isApp } = useDeviceInfo(); - // Moved all hooks to the top level, before any conditional logic const listRef = useRef(null); const hasFetchedRef = useRef(false); const { directMessages, registerWave } = useMyStream(); + const { + list, + hasNextPage, + isFetchingNextPage, + isFetching, + fetchNextPage, + } = directMessages; - // Reset the fetch flag when dependencies change useEffect(() => { hasFetchedRef.current = false; - }, [directMessages.hasNextPage, directMessages.isFetchingNextPage]); + }, [hasNextPage, isFetchingNextPage]); + + const fetchNextPageIfNeeded = useEffectEvent(() => { + if (!hasNextPage || isFetchingNextPage || hasFetchedRef.current) { + return; + } + + hasFetchedRef.current = true; + fetchNextPage(); + }); useEffect(() => { - const node = listRef.current?.sentinelRef.current; - if ( - !node || - !directMessages.hasNextPage || - directMessages.isFetchingNextPage - ) + const listHandle = listRef.current; + const sentinel = listHandle?.sentinelRef.current; + + if (!sentinel || !hasNextPage || isFetchingNextPage) { return; + } - const cb = (entries: IntersectionObserverEntry[]) => { - const [entry] = entries; - if ( - entry.isIntersecting && - directMessages.hasNextPage && - !directMessages.isFetchingNextPage && - !hasFetchedRef.current - ) { - hasFetchedRef.current = true; - directMessages.fetchNextPage(); + const observer = new IntersectionObserver(([entry]) => { + if (entry.isIntersecting) { + fetchNextPageIfNeeded(); } - }; - - const obs = new IntersectionObserver(cb, { - root: listRef.current?.containerRef.current, + }, { + root: listHandle?.containerRef.current, rootMargin: "100px", }); - obs.observe(node); + observer.observe(sentinel); - return () => obs.disconnect(); - }, [ - listRef.current?.sentinelRef.current, - directMessages.hasNextPage, - directMessages.isFetchingNextPage, - ]); + return () => observer.disconnect(); + }, [hasNextPage, isFetchingNextPage, list.length > 0]); const shouldShowPlaceholder = !isAuthenticated || !connectedProfile?.handle; + const wavesWithPinned = useMemo( + () => list.map((w) => ({ ...w, isPinned: false })), + [list], + ); if (shouldShowPlaceholder) { if (!isAuthenticated) { @@ -121,7 +131,7 @@ const DirectMessagesList: React.FC = ({ ({ ...w, isPinned: false }))} + waves={wavesWithPinned} onHover={registerWave} hideToggle hidePin @@ -130,13 +140,14 @@ const DirectMessagesList: React.FC = ({ />
diff --git a/components/brain/feed/FeedScrollContainer.tsx b/components/brain/feed/FeedScrollContainer.tsx index 5e01804bd5..7e6dbb1123 100644 --- a/components/brain/feed/FeedScrollContainer.tsx +++ b/components/brain/feed/FeedScrollContainer.tsx @@ -76,7 +76,7 @@ export const FeedScrollContainer = forwardRef< return () => observer.disconnect(); } - }, []); + }, [ref]); useEffect(() => { if ( @@ -90,18 +90,19 @@ export const FeedScrollContainer = forwardRef< } const scrollContainer = ref.current; + const observedFeedItems = observedFeedItemsRef.current; if (!scrollContainer) { return; } const updateOutOfViewCount = (element: Element, isAbove: boolean) => { - const previous = observedFeedItemsRef.current.get(element) ?? false; + const previous = observedFeedItems.get(element) ?? false; if (previous === isAbove) { return; } - observedFeedItemsRef.current.set(element, isAbove); + observedFeedItems.set(element, isAbove); outOfViewAboveCountRef.current += isAbove ? 1 : -1; if (outOfViewAboveCountRef.current < 0) { @@ -126,20 +127,20 @@ export const FeedScrollContainer = forwardRef< ); const observeElement = (element: Element) => { - if (observedFeedItemsRef.current.has(element)) { + if (observedFeedItems.has(element)) { return; } - observedFeedItemsRef.current.set(element, false); + observedFeedItems.set(element, false); intersectionObserver.observe(element); }; const unobserveElement = (element: Element) => { - if (!observedFeedItemsRef.current.has(element)) { + if (!observedFeedItems.has(element)) { return; } - const wasAbove = observedFeedItemsRef.current.get(element) ?? false; + const wasAbove = observedFeedItems.get(element) ?? false; if (wasAbove) { outOfViewAboveCountRef.current = Math.max( 0, @@ -147,7 +148,7 @@ export const FeedScrollContainer = forwardRef< ); } - observedFeedItemsRef.current.delete(element); + observedFeedItems.delete(element); intersectionObserver.unobserve(element); }; @@ -176,7 +177,7 @@ export const FeedScrollContainer = forwardRef< }; const initializeFeedItems = () => { - observedFeedItemsRef.current.clear(); + observedFeedItems.clear(); outOfViewAboveCountRef.current = 0; const initialElements = contentRef.current?.querySelectorAll( @@ -219,7 +220,7 @@ export const FeedScrollContainer = forwardRef< return () => { feedItemsMutationObserver.disconnect(); intersectionObserver.disconnect(); - observedFeedItemsRef.current.clear(); + observedFeedItems.clear(); outOfViewAboveCountRef.current = 0; }; }, [ref]); diff --git a/components/brain/left-sidebar/waves/BrainLeftSidebarWaves.tsx b/components/brain/left-sidebar/waves/BrainLeftSidebarWaves.tsx index 87f9651203..317842b591 100644 --- a/components/brain/left-sidebar/waves/BrainLeftSidebarWaves.tsx +++ b/components/brain/left-sidebar/waves/BrainLeftSidebarWaves.tsx @@ -9,9 +9,7 @@ interface BrainLeftSidebarWavesProps { const BrainLeftSidebarWaves: React.FC = ({ scrollContainerRef, }) => { - - - const { waves, activeWave, registerWave } = useMyStream(); + const { waves, registerWave } = useMyStream(); const onNextPage = () => { if (waves.hasNextPage && !waves.isFetchingNextPage && !waves.isFetching) { @@ -22,7 +20,6 @@ const BrainLeftSidebarWaves: React.FC = ({ return ( void; readonly hasNextPage: boolean | undefined; readonly isFetching: boolean; @@ -25,7 +24,6 @@ interface UnifiedWavesListProps { const UnifiedWavesList: React.FC = ({ waves, - activeWaveId, fetchNextPage, hasNextPage, isFetching, @@ -40,6 +38,11 @@ const UnifiedWavesList: React.FC = ({ // Track if we've triggered a fetch to avoid multiple triggers const hasFetchedRef = useRef(false); + const triggerFetchNextPage = useEffectEvent(() => { + hasFetchedRef.current = true; + fetchNextPage(); + }); + // Reset the fetch flag when dependencies change useEffect(() => { hasFetchedRef.current = false; @@ -47,8 +50,8 @@ const UnifiedWavesList: React.FC = ({ // Set up intersection observer for infinite scrolling useEffect(() => { - const node = listRef.current?.sentinelRef.current; - if (!node || !hasNextPage || isFetchingNextPage) return; + const sentinel = listRef.current?.sentinelRef.current; + if (!sentinel || !hasNextPage || isFetchingNextPage) return; const cb = (entries: IntersectionObserverEntry[]) => { const [entry] = entries; @@ -58,8 +61,7 @@ const UnifiedWavesList: React.FC = ({ !isFetchingNextPage && !hasFetchedRef.current ) { - hasFetchedRef.current = true; - fetchNextPage(); + triggerFetchNextPage(); } }; @@ -68,10 +70,10 @@ const UnifiedWavesList: React.FC = ({ rootMargin: "100px", }); - obs.observe(node); + obs.observe(sentinel); return () => obs.disconnect(); - }, [listRef.current?.sentinelRef.current, hasNextPage, isFetchingNextPage]); + }, [hasNextPage, isFetchingNextPage]); return (
diff --git a/components/brain/left-sidebar/web/WebBrainLeftSidebarWaves.tsx b/components/brain/left-sidebar/web/WebBrainLeftSidebarWaves.tsx index efd109f30f..c92bd0e3a5 100644 --- a/components/brain/left-sidebar/web/WebBrainLeftSidebarWaves.tsx +++ b/components/brain/left-sidebar/web/WebBrainLeftSidebarWaves.tsx @@ -13,7 +13,7 @@ const WebBrainLeftSidebarWaves: React.FC = ({ }) => { - const { waves, activeWave, registerWave } = useMyStream(); + const { waves, registerWave } = useMyStream(); const onNextPage = () => { if (waves.hasNextPage && !waves.isFetchingNextPage && !waves.isFetching) { @@ -24,7 +24,6 @@ const WebBrainLeftSidebarWaves: React.FC = ({ return ( void; readonly hasNextPage: boolean | undefined; readonly isFetching: boolean; @@ -21,17 +20,17 @@ interface WebUnifiedWavesListProps { readonly isCollapsed?: boolean; } -const WebUnifiedWavesList: React.FC = ({ - waves, - activeWaveId, - fetchNextPage, - hasNextPage, - isFetching, - isFetchingNextPage, - onHover, - scrollContainerRef, - isCollapsed = false, -}) => { +const WebUnifiedWavesList: React.FC = (props) => { + const { + waves, + fetchNextPage, + hasNextPage, + isFetching, + isFetchingNextPage, + onHover, + scrollContainerRef, + isCollapsed = false, + } = props; // Refs to the scroll container and sentinel const listRef = useRef(null); diff --git a/components/brain/my-stream/MyStreamWaveDesktopTabs.tsx b/components/brain/my-stream/MyStreamWaveDesktopTabs.tsx index 258fbf6726..17d0a2b2fc 100644 --- a/components/brain/my-stream/MyStreamWaveDesktopTabs.tsx +++ b/components/brain/my-stream/MyStreamWaveDesktopTabs.tsx @@ -139,6 +139,8 @@ const MyStreamWaveDesktopTabs: React.FC = ({ ); }, [ wave, + isMemesWave, + isChatWave, isUpcoming, isCompleted, isInProgress, @@ -163,17 +165,21 @@ const MyStreamWaveDesktopTabs: React.FC = ({ [MyStreamWaveTab.FAQ]: "FAQ", }; - const options: TabOption[] = availableTabs - .filter( - (tab) => - isMemesWave || - ![MyStreamWaveTab.MY_VOTES, MyStreamWaveTab.FAQ].includes(tab) - ) - .map((tab) => ({ - key: tab, - label: tabLabels[tab], - panelId: getContentTabPanelId(tab), - })); + const options: TabOption[] = React.useMemo( + () => + availableTabs + .filter( + (tab) => + isMemesWave || + ![MyStreamWaveTab.MY_VOTES, MyStreamWaveTab.FAQ].includes(tab) + ) + .map((tab) => ({ + key: tab, + label: tabLabels[tab], + panelId: getContentTabPanelId(tab), + })), + [availableTabs, isMemesWave] + ); useEffect(() => { if ( @@ -183,7 +189,7 @@ const MyStreamWaveDesktopTabs: React.FC = ({ ) { setActiveTab(options[0].key); } - }, [isMemesWave, activeTab, options]); + }, [isMemesWave, activeTab, options, setActiveTab]); // For simple waves, don't render any tabs if (isChatWave) { diff --git a/components/brain/my-stream/MyStreamWaveFAQ.tsx b/components/brain/my-stream/MyStreamWaveFAQ.tsx index 657f94ade9..f47c231e62 100644 --- a/components/brain/my-stream/MyStreamWaveFAQ.tsx +++ b/components/brain/my-stream/MyStreamWaveFAQ.tsx @@ -14,7 +14,7 @@ import { } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import Link from "next/link"; -import React, { useEffect, useMemo } from "react"; +import React, { useEffect } from "react"; import { useContentTab } from "../ContentTabContext"; import { useLayout } from "./layout/LayoutContext"; @@ -22,7 +22,7 @@ interface MyStreamWaveFAQProps { readonly wave: ApiWave; } -const MyStreamWaveFAQ: React.FC = ({ wave }) => { +const MyStreamWaveFAQ: React.FC = ({ wave: _wave }) => { const { setActiveContentTab } = useContentTab(); const { faqViewStyle } = useLayout(); @@ -30,9 +30,8 @@ const MyStreamWaveFAQ: React.FC = ({ wave }) => { setActiveContentTab(MyStreamWaveTab.FAQ); }, [setActiveContentTab]); - const containerClassName = useMemo(() => { - return "tw-w-full tw-flex tw-flex-col tw-pt-4 lg:tw-pr-2 tw-overflow-y-auto no-scrollbar lg:tw-scrollbar-thin tw-scrollbar-thumb-iron-500 tw-scrollbar-track-iron-800 desktop-desktop-hover:hover:desktop-hover:hover:tw-scrollbar-thumb-iron-300 tw-h-full"; - }, []); + const containerClassName = + "tw-w-full tw-flex tw-flex-col tw-pt-4 lg:tw-pr-2 tw-overflow-y-auto no-scrollbar lg:tw-scrollbar-thin tw-scrollbar-thumb-iron-500 tw-scrollbar-track-iron-800 desktop-desktop-hover:hover:desktop-hover:hover:tw-scrollbar-thumb-iron-300 tw-h-full"; return (
diff --git a/components/brain/my-stream/votes/MyStreamWaveMyVoteInput.tsx b/components/brain/my-stream/votes/MyStreamWaveMyVoteInput.tsx index f04b5e02c4..76db13f0a9 100644 --- a/components/brain/my-stream/votes/MyStreamWaveMyVoteInput.tsx +++ b/components/brain/my-stream/votes/MyStreamWaveMyVoteInput.tsx @@ -1,6 +1,6 @@ "use client"; -import { useContext, useEffect, useState } from "react"; +import { useContext, useState } from "react"; import { ExtendedDrop } from "@/helpers/waves/drop.helpers"; import { AuthContext } from "@/components/auth/Auth"; import { DropRateChangeRequest } from "@/entities/IDrop"; @@ -20,28 +20,43 @@ const MyStreamWaveMyVoteInput: React.FC = ({ const { requestAuth, setToast } = useContext(AuthContext); const [isProcessing, setIsProcessing] = useState(false); const currentVoteValue = drop.context_profile_context?.rating ?? 0; + const currentVoteValueString = String(currentVoteValue); const minRating = drop.context_profile_context?.min_rating ?? 0; const maxRating = drop.context_profile_context?.max_rating ?? 0; - const [voteValue, setVoteValue] = useState(currentVoteValue); - const [isEditing, setIsEditing] = useState(false); - - useEffect(() => { - if (currentVoteValue !== voteValue) { - setIsEditing(true); - } - }, [voteValue]); + const [voteValue, setVoteValue] = useState(currentVoteValueString); + const parsedVoteValue = Number.parseInt(voteValue, 10); + const hasValidVoteValue = !Number.isNaN(parsedVoteValue); + const isEditing = hasValidVoteValue && parsedVoteValue !== currentVoteValue; const handleInputChange = (e: React.ChangeEvent) => { const inputValue = e.target.value; + if (inputValue === "") { + setVoteValue(""); + return; + } + + if (inputValue === "-") { + setVoteValue(inputValue); + return; + } - if (inputValue === "" || inputValue === "-") { - setVoteValue(inputValue as any); + const value = Number.parseInt(inputValue, 10); + if (Number.isNaN(value)) return; + const clampedValue = Math.min(Math.max(value, minRating), maxRating); + setVoteValue(String(clampedValue)); + }; + + const handleBlur = () => { + if (!hasValidVoteValue || voteValue === "" || voteValue === "-") { + setVoteValue(currentVoteValueString); return; } - const value = parseInt(inputValue); - if (isNaN(value)) return; - setVoteValue(Math.min(Math.max(value, minRating), maxRating)); + const clampedValue = Math.min( + Math.max(parsedVoteValue, minRating), + maxRating, + ); + setVoteValue(String(clampedValue)); }; const rateChangeMutation = useMutation({ @@ -53,15 +68,13 @@ const MyStreamWaveMyVoteInput: React.FC = ({ category: DEFAULT_DROP_RATE_CATEGORY, }, }), - onSuccess: (response: ApiDrop) => { - // Show success toast + onSuccess: (_response: ApiDrop) => { setToast({ message: "Vote updated", type: "success", }); }, onError: (error) => { - // Show error toast setToast({ message: error as unknown as string, type: "error", @@ -73,13 +86,22 @@ const MyStreamWaveMyVoteInput: React.FC = ({ const handleSubmit = async () => { if (isProcessing || isResetting) return; + if (!hasValidVoteValue) { + setVoteValue(currentVoteValueString); + return; + } + + const clampedValue = Math.min( + Math.max(parsedVoteValue, minRating), + maxRating, + ); + setVoteValue(String(clampedValue)); + setIsProcessing(true); - // Show loading message via button state (the button will show loading state) try { const { success } = await requestAuth(); if (!success) { - // Show authentication error setToast({ message: "Authentication failed", type: "error", @@ -89,12 +111,15 @@ const MyStreamWaveMyVoteInput: React.FC = ({ } await rateChangeMutation.mutateAsync({ - rate: voteValue, + rate: clampedValue, }); } catch (error) { - // Any errors not caught in mutation will be handled here + console.error("Failed to submit vote:", error); + + const errorMessage = + error instanceof Error ? error.message : "Something went wrong"; setToast({ - message: "Something went wrong", + message: errorMessage, type: "error", }); } finally { @@ -117,6 +142,7 @@ const MyStreamWaveMyVoteInput: React.FC = ({ type="text" value={voteValue} onChange={handleInputChange} + onBlur={handleBlur} onKeyDown={handleKeyDown} disabled={isResetting} pattern="-?[0-9]*" diff --git a/components/brain/notifications/NotificationsFollowBtn.tsx b/components/brain/notifications/NotificationsFollowBtn.tsx index eec37e5201..a1fcf6773f 100644 --- a/components/brain/notifications/NotificationsFollowBtn.tsx +++ b/components/brain/notifications/NotificationsFollowBtn.tsx @@ -1,6 +1,6 @@ "use client"; -import { FC, useState, useContext, useEffect } from "react"; +import { FC, useState, useContext } from "react"; import { ApiProfileMin } from "@/generated/models/ApiProfileMin"; import { FOLLOW_BTN_BUTTON_CLASSES, @@ -32,15 +32,8 @@ const NotificationsFollowBtn: FC = ({ const { setToast, requestAuth } = useContext(AuthContext); const [mutating, setMutating] = useState(false); - const getFollowing = () => !!profile.subscribed_actions.length; - const getLabel = () => (getFollowing() ? "Following" : "Follow"); - - const [following, setFollowing] = useState(getFollowing()); - const [label, setLabel] = useState(getLabel()); - useEffect(() => { - setFollowing(getFollowing()); - setLabel(getLabel()); - }, [profile.subscribed_actions]); + const following = profile.subscribed_actions.length > 0; + const label = following ? "Following" : "Follow"; const followMutation = useMutation({ mutationFn: async () => { diff --git a/components/community-downloads/CommunityDownloadsComponent.tsx b/components/community-downloads/CommunityDownloadsComponent.tsx index 240512f2bb..37389cfb3d 100644 --- a/components/community-downloads/CommunityDownloadsComponent.tsx +++ b/components/community-downloads/CommunityDownloadsComponent.tsx @@ -1,6 +1,7 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useCallback } from "react"; +import { useQuery, keepPreviousData } from "@tanstack/react-query"; import { fetchUrl } from "@/services/6529api"; import Pagination from "@/components/pagination/Pagination"; import { ApiUploadsPage } from "@/generated/models/ApiUploadsPage"; @@ -15,29 +16,42 @@ import { const PAGE_SIZE = 25; interface Props { - title: string; - url: string; + readonly title: string; + readonly url: string; } export default function CommunityDownloadsComponent(props: Readonly) { - const [downloads, setDownloads] = useState(); - const [totalResults, setTotalResults] = useState(0); const [page, setPage] = useState(1); - function fetchResults(mypage: number) { - const fullUrl = `${props.url}?page_size=${PAGE_SIZE}&page=${mypage}`; - fetchUrl(fullUrl).then((response: ApiUploadsPage) => { - setTotalResults(response.count); - setDownloads(response.data || []); - }); - } + const { data, isError, isLoading } = useQuery({ + queryKey: ["community-downloads", props.url, page], + queryFn: () => + fetchUrl(`${props.url}?page_size=${PAGE_SIZE}&page=${page}`), + placeholderData: keepPreviousData, + }); - useEffect(() => { - fetchResults(page); - }, [page]); + const downloads = data?.data; + const totalResults = data?.count || 0; + + const handlePageChange = useCallback((newPage: number) => { + setPage(newPage); + window.scrollTo(0, 0); + }, []); return ( + {isLoading && !data && ( +
+ Loading downloads... +
+ )} + + {isError && ( +
+ Failed to load community downloads. Please try again. +
+ )} + ) { page={page} pageSize={PAGE_SIZE} totalResults={totalResults} - setPage={(newPage: number) => { - setPage(newPage); - window.scrollTo(0, 0); - }} + setPage={handlePageChange} />
)} diff --git a/components/community/CommunityMembers.tsx b/components/community/CommunityMembers.tsx index 6ee40a22a8..0d3c801772 100644 --- a/components/community/CommunityMembers.tsx +++ b/components/community/CommunityMembers.tsx @@ -4,7 +4,7 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { CommunityMemberOverview } from "@/entities/IProfile"; import { Page } from "@/helpers/Types"; import { CommunityMembersQuery } from "@/app/network/page"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { commonApiFetch } from "@/services/api/common-api"; import { SortDirection } from "@/entities/ISort"; import CommunityMembersTable from "./members-table/CommunityMembersTable"; @@ -45,31 +45,37 @@ export default function CommunityMembers() { const activeGroupId = useSelector(selectActiveGroupId); - const convertSortBy = (sort: string | null): CommunityMembersSortOption => { - if (!sort) return defaultSortBy; - if ( - Object.values(CommunityMembersSortOption).includes( - sort.toLowerCase() as any - ) - ) { - return sort.toLowerCase() as CommunityMembersSortOption; - } - return defaultSortBy; - }; + const convertSortBy = useCallback( + (sort: string | null): CommunityMembersSortOption => { + if (!sort) return defaultSortBy; + if ( + Object.values(CommunityMembersSortOption).includes( + sort.toLowerCase() as any + ) + ) { + return sort.toLowerCase() as CommunityMembersSortOption; + } + return defaultSortBy; + }, + [defaultSortBy] + ); - const convertSortDirection = ( - sortDirection: string | null - ): SortDirection => { - if (!sortDirection) return defaultSortDirection; - if ( - Object.values(SortDirection).includes(sortDirection.toUpperCase() as any) - ) { - return sortDirection.toUpperCase() as SortDirection; - } - return defaultSortDirection; - }; + const convertSortDirection = useCallback( + (sortDirection: string | null): SortDirection => { + if (!sortDirection) return defaultSortDirection; + if ( + Object.values(SortDirection).includes( + sortDirection.toUpperCase() as any + ) + ) { + return sortDirection.toUpperCase() as SortDirection; + } + return defaultSortDirection; + }, + [defaultSortDirection] + ); - const getParamsFromUrl = (): CommunityMembersQuery => { + const params = useMemo(() => { const page = parseInt(searchParams?.get(SEARCH_PARAMS_FIELDS.page) || ""); const sortBy = searchParams?.get(SEARCH_PARAMS_FIELDS.sortBy); const sortDirection = searchParams?.get(SEARCH_PARAMS_FIELDS.sortDirection); @@ -86,26 +92,31 @@ export default function CommunityMembers() { query.group_id = group; } return query; - }; + }, [ + convertSortBy, + convertSortDirection, + defaultPage, + defaultPageSize, + defaultSortBy, + defaultSortDirection, + searchParams, + ]); - const createQueryString = ( - updateItems: QueryUpdateInput[], - lowerCase: boolean = true - ): string => { - const searchParamsStr = new URLSearchParams(searchParams?.toString()); - for (const { name, value } of updateItems) { - const key = SEARCH_PARAMS_FIELDS[name]; - if (!value) { - searchParamsStr.delete(key); - } else { - searchParamsStr.set(key, lowerCase ? value.toLowerCase() : value); + const createQueryString = useCallback( + (updateItems: QueryUpdateInput[], lowerCase: boolean = true): string => { + const searchParamsStr = new URLSearchParams(searchParams?.toString()); + for (const { name, value } of updateItems) { + const key = SEARCH_PARAMS_FIELDS[name]; + if (!value) { + searchParamsStr.delete(key); + } else { + searchParamsStr.set(key, lowerCase ? value.toLowerCase() : value); + } } - } - return searchParamsStr.toString(); - }; - - const [params, setParams] = useState(getParamsFromUrl()); - useEffect(() => setParams(getParamsFromUrl()), [searchParams]); + return searchParamsStr.toString(); + }, + [searchParams] + ); const calculateSortDirection = ({ newSortBy, @@ -125,8 +136,9 @@ export default function CommunityMembers() { return defaultSortDirection; }; - const [debouncedParams, setDebouncedParams] = - useState(params); + const [debouncedParams, setDebouncedParams] = useState( + () => params + ); useDebounce(() => setDebouncedParams(params), 200, [params]); @@ -156,16 +168,16 @@ export default function CommunityMembers() { placeholderData: keepPreviousData, }); - const updateFields = ( - updateItems: QueryUpdateInput[], - lowerCase: boolean = true - ): void => { - const queryString = createQueryString(updateItems, lowerCase); - const path = queryString ? pathname + "?" + queryString : pathname; - if (path) { - router.replace(path); - } - }; + const updateFields = useCallback( + (updateItems: QueryUpdateInput[], lowerCase: boolean = true): void => { + const queryString = createQueryString(updateItems, lowerCase); + const path = queryString ? `${pathname}?${queryString}` : pathname; + if (path) { + router.replace(path); + } + }, + [createQueryString, pathname, router] + ); const setSortBy = async ( sortBy: CommunityMembersSortOption @@ -205,17 +217,20 @@ export default function CommunityMembers() { ]; updateFields(items, false); } - }, [activeGroupId]); + }, [activeGroupId, params.group_id, updateFields]); - const setPage = async (page: number): Promise => { - const items: QueryUpdateInput[] = [ - { - name: "page", - value: page.toString(), - }, - ]; - await updateFields(items); - }; + const setPage = useCallback( + async (page: number): Promise => { + const items: QueryUpdateInput[] = [ + { + name: "page", + value: page.toString(), + }, + ]; + await updateFields(items); + }, + [updateFields] + ); const [totalPages, setTotalPages] = useState(1); @@ -229,7 +244,13 @@ export default function CommunityMembers() { const pagesCount = Math.ceil(members.count / debouncedParams.page_size); if (pagesCount < debouncedParams.page) setPage(pagesCount); setTotalPages(pagesCount); - }, [members?.count, isLoading]); + }, [ + debouncedParams.page, + debouncedParams.page_size, + isLoading, + members?.count, + setPage, + ]); const goToNerd = () => router.push("/network/nerd"); diff --git a/components/community/members-table/CommunityMembersTableHeaderSortableContent.tsx b/components/community/members-table/CommunityMembersTableHeaderSortableContent.tsx index adaea62929..7d72123ab3 100644 --- a/components/community/members-table/CommunityMembersTableHeaderSortableContent.tsx +++ b/components/community/members-table/CommunityMembersTableHeaderSortableContent.tsx @@ -35,7 +35,7 @@ export default function CommunityMembersTableHeaderSortableContent({ useEffect(() => setRotate(false), [sortDirection]); useEffect( () => setRotate(isActive && hoveringOption === sort), - [hoveringOption] + [hoveringOption, isActive, sort] ); const showLoader = isLoading && isActive; return ( diff --git a/components/cookies/CookieConsentContext.tsx b/components/cookies/CookieConsentContext.tsx index d946cb4f82..0868e8edc1 100644 --- a/components/cookies/CookieConsentContext.tsx +++ b/components/cookies/CookieConsentContext.tsx @@ -6,6 +6,7 @@ import React, { useState, useEffect, useMemo, + useCallback, ReactNode, } from "react"; import { @@ -73,7 +74,25 @@ export const CookieConsentProvider: React.FC = ({ const [showCookieConsent, setShowCookieConsent] = useState(false); const [country, setCountry] = useState(""); - const getCookieConsent = async (isFirstLoad: boolean = false) => { + const loadPerformanceCookies = useCallback(() => { + const script1 = document.createElement("script"); + script1.src = `https://www.googletagmanager.com/gtag/js?id=${GTM_ID}`; + script1.async = true; + document.head.appendChild(script1); + + const script2 = document.createElement("script"); + script2.innerHTML = ` + window.dataLayer = window.dataLayer || []; + function gtag(){dataLayer.push(arguments);} + gtag('js', new Date()); + gtag('config', '${GTM_ID}', { + 'cookie_expires': 31536000 + }); + `; + document.head.appendChild(script2); + }, []); + + const getCookieConsent = useCallback(async (isFirstLoad: boolean = false) => { try { const essentialCookies = getCookieConsentByName(CONSENT_ESSENTIAL_COOKIE); const performanceCookies = getCookieConsentByName( @@ -108,9 +127,9 @@ export const CookieConsentProvider: React.FC = ({ } catch (error) { console.error("Failed to fetch cookie consent status", error); } - }; + }, [loadPerformanceCookies]); - const consent = async () => { + const consent = useCallback(async () => { try { await commonApiPost({ endpoint: `policies/cookies-consent`, body: {} }); Cookies.set(CONSENT_ESSENTIAL_COOKIE, "true", { expires: 365 }); @@ -123,9 +142,9 @@ export const CookieConsentProvider: React.FC = ({ message: "Something went wrong...", }); } - }; + }, [getCookieConsent, setToast]); - const reject = async () => { + const reject = useCallback(async () => { try { await commonApiDelete({ endpoint: `policies/cookies-consent` }); Cookies.set(CONSENT_ESSENTIAL_COOKIE, "true", { expires: 365 }); @@ -142,25 +161,7 @@ export const CookieConsentProvider: React.FC = ({ message: "Something went wrong...", }); } - }; - - const loadPerformanceCookies = () => { - const script1 = document.createElement("script"); - script1.src = `https://www.googletagmanager.com/gtag/js?id=${GTM_ID}`; - script1.async = true; - document.head.appendChild(script1); - - const script2 = document.createElement("script"); - script2.innerHTML = ` - window.dataLayer = window.dataLayer || []; - function gtag(){dataLayer.push(arguments);} - gtag('js', new Date()); - gtag('config', '${GTM_ID}', { - 'cookie_expires': 31536000 - }); - `; - document.head.appendChild(script2); - }; + }, [getCookieConsent, setToast]); const value = useMemo( () => ({ consent, reject, showCookieConsent, country }), @@ -169,7 +170,7 @@ export const CookieConsentProvider: React.FC = ({ useEffect(() => { getCookieConsent(true); - }, []); + }, [getCookieConsent]); return ( diff --git a/components/delegation/CollectionDelegation.tsx b/components/delegation/CollectionDelegation.tsx index 50ec24180e..5d4dc353af 100644 --- a/components/delegation/CollectionDelegation.tsx +++ b/components/delegation/CollectionDelegation.tsx @@ -1,6 +1,6 @@ "use client"; -import { Fragment, useEffect, useRef, useState } from "react"; +import { Fragment, useEffect, useEffectEvent, useRef, useState } from "react"; import Link from "next/link"; import { Accordion, @@ -109,6 +109,35 @@ function getConsolidationReadParams( } return []; } + +function getDelegationsCount(delegations: ContractDelegation[]) { + let count = 0; + for (const delegation of delegations) { + if (delegation.wallets.length > 0) { + count += delegation.wallets.length; + } + } + return count; +} + +function getActiveKeys( + outDelegations: ContractDelegation[], + inDelegations: ContractDelegation[] +) { + const outCount = getDelegationsCount(outDelegations); + const inCount = getDelegationsCount(inDelegations); + + if (outCount > 0 && inCount > 0) { + return ["0", "1"]; + } + if (outCount > 0) { + return ["0"]; + } + if (inCount > 0) { + return ["1"]; + } + return [""]; +} export default function CollectionDelegationComponent(props: Readonly) { const toastRef = useRef(null); const accountResolution = useSeizeConnectContext(); @@ -165,10 +194,6 @@ export default function CollectionDelegationComponent(props: Readonly) { return networkResolution === DELEGATION_CONTRACT.chain_id; } - useEffect(() => { - reset(); - }, [accountResolution.address]); - function getSwitchToHtml() { return `Switch to ${ DELEGATION_CONTRACT.chain_id === 1 @@ -212,21 +237,29 @@ export default function CollectionDelegationComponent(props: Readonly) { }); useEffect(() => { - if (retrieveOutgoingConsolidations.data) { - const activeConsolidations: any[] = []; - outgoingDelegations[CONSOLIDATION_USE_CASE.index].wallets.map( - (w, index) => { - activeConsolidations.push({ - wallet: w.wallet, - status: retrieveOutgoingConsolidations.data[index].result - ? "consolidation active" - : "consolidation incomplete", - }); - } - ); - setOutgoingActiveConsolidations(activeConsolidations); + if (!retrieveOutgoingConsolidations.data) { + return; } - }, [retrieveOutgoingConsolidations.data]); + + const consolidationDelegations = + outgoingDelegations[CONSOLIDATION_USE_CASE.index]; + + if (!consolidationDelegations?.wallets.length) { + setOutgoingActiveConsolidations([]); + return; + } + + const activeConsolidations = consolidationDelegations.wallets.map( + (walletDelegation, index) => ({ + wallet: walletDelegation.wallet, + status: retrieveOutgoingConsolidations.data?.[index]?.result + ? "consolidation active" + : "consolidation incomplete", + }) + ); + + setOutgoingActiveConsolidations(activeConsolidations); + }, [outgoingDelegations, retrieveOutgoingConsolidations.data]); const retrieveIncomingDelegations = useReadContracts({ contracts: getActiveDelegationsReadParams( @@ -235,7 +268,7 @@ export default function CollectionDelegationComponent(props: Readonly) { "retrieveDelegatorsTokensIDsandExpiredDates" ), query: { - enabled: accountResolution.isConnected && incomingDelegations.length > 0, + enabled: accountResolution.isConnected, refetchInterval: 10000, }, }); @@ -263,21 +296,29 @@ export default function CollectionDelegationComponent(props: Readonly) { }); useEffect(() => { - if (retrieveIncomingConsolidations.data) { - const activeConsolidations: any[] = []; - incomingDelegations[CONSOLIDATION_USE_CASE.index].wallets.map( - (w, index) => { - activeConsolidations.push({ - wallet: w.wallet, - status: retrieveIncomingConsolidations.data[index].result - ? "consolidation active" - : "consolidation incomplete", - }); - } - ); - setIncomingActiveConsolidations(activeConsolidations); + if (!retrieveIncomingConsolidations.data) { + return; } - }, [retrieveIncomingConsolidations.data]); + + const consolidationDelegations = + incomingDelegations[CONSOLIDATION_USE_CASE.index]; + + if (!consolidationDelegations?.wallets.length) { + setIncomingActiveConsolidations([]); + return; + } + + const activeConsolidations = consolidationDelegations.wallets.map( + (walletDelegation, index) => ({ + wallet: walletDelegation.wallet, + status: retrieveIncomingConsolidations.data?.[index]?.result + ? "consolidation active" + : "consolidation incomplete", + }) + ); + + setIncomingActiveConsolidations(activeConsolidations); + }, [incomingDelegations, retrieveIncomingConsolidations.data]); const useCaseLockStatusesGlobalParams = areEqualAddresses( props.collection.contract, @@ -313,6 +354,8 @@ export default function CollectionDelegationComponent(props: Readonly) { }, }); + const { refetch: refetchUseCaseLockStatuses } = useCaseLockStatuses; + const [revokeDelegationParams, setRevokeDelegationParams] = useState(); const [batchRevokeDelegationParams, setBatchRevokeDelegationParams] = useState(); @@ -391,9 +434,13 @@ export default function CollectionDelegationComponent(props: Readonly) { useEffect(() => { if (accountResolution.isConnected) { - useCaseLockStatuses.refetch(); + refetchUseCaseLockStatuses(); } - }, [waitUseCaseLockWrite.isSuccess]); + }, [ + accountResolution.isConnected, + refetchUseCaseLockStatuses, + waitUseCaseLockWrite.isSuccess, + ]); useEffect(() => { if (contractWriteRevoke.error) { @@ -543,6 +590,7 @@ export default function CollectionDelegationComponent(props: Readonly) { }, [ collectionLockWrite.error, collectionLockWrite.data, + collectionLockRead.data, waitCollectionLockWrite.isLoading, ]); @@ -596,6 +644,8 @@ export default function CollectionDelegationComponent(props: Readonly) { }, [ useCaseLockWrite.error, useCaseLockWrite.data, + lockUseCaseIndex, + useCaseLockStatuses.data, waitUseCaseLockWrite.isLoading, ]); @@ -603,7 +653,11 @@ export default function CollectionDelegationComponent(props: Readonly) { if (!showToast && outgoingDelegationsLoaded && incomingDelegationsLoaded) { setToast(undefined); } - }, [showToast]); + }, [ + incomingDelegationsLoaded, + outgoingDelegationsLoaded, + showToast, + ]); useEffect(() => { if (toast) { @@ -630,7 +684,7 @@ export default function CollectionDelegationComponent(props: Readonly) { functionName: "revokeDelegationAddress", }); } - }, [revokeDelegationParams]); + }, [contractWriteRevoke, revokeDelegationParams]); useEffect(() => { if (batchRevokeDelegationParams && !batchRevokeDelegationParams.loading) { @@ -652,7 +706,7 @@ export default function CollectionDelegationComponent(props: Readonly) { } }, [batchRevokeDelegationParams, contractWriteBatchRevoke]); - function reset() { + const reset = useEffectEvent(() => { setOutgoingDelegations([]); setOutgoingDelegationsLoaded(false); retrieveOutgoingDelegations.refetch(); @@ -669,35 +723,11 @@ export default function CollectionDelegationComponent(props: Readonly) { useCaseLockWrite.reset(); collectionLockWrite.reset(); contractWriteRevoke.reset(); - } - - function getDelegationsCount(delegations: ContractDelegation[]) { - let count: number = 0; - delegations.map((del) => { - if (del.wallets.length > 0) { - count += del.wallets.length; - } - }); - return count; - } + }); - function getActiveKeys( - outDelegations: ContractDelegation[], - inDelegations: ContractDelegation[] - ) { - const outCount = getDelegationsCount(outDelegations); - const inCount = getDelegationsCount(inDelegations); - if (outCount > 0 && inCount > 0) { - return ["0", "1"]; - } - if (outCount > 0) { - return ["0"]; - } - if (inCount > 0) { - return ["1"]; - } - return [""]; - } + useEffect(() => { + reset(); + }, [accountResolution.address, reset]); useEffect(() => { const outDelegations = [...outgoingDelegations].filter( @@ -738,7 +768,13 @@ export default function CollectionDelegationComponent(props: Readonly) { ) ); } - }, [outgoingDelegations, incomingDelegations]); + }, [ + incomingDelegations, + outgoingDelegations, + consolidationKeysChanged, + delegationKeysChanged, + subDelegationKeysChanged, + ]); function printDelegations() { const outDelegations = [...outgoingDelegations].filter( @@ -1160,7 +1196,7 @@ export default function CollectionDelegationComponent(props: Readonly) { #{del.useCase.use_case} - {del.useCase.display} - {del.wallets.map((w, addressIndex: number) => { + {del.wallets.map((w) => { const consolidationStatus = outgoingActiveConsolidations.find((i) => areEqualAddresses(w.wallet, i.wallet) @@ -1211,7 +1247,7 @@ export default function CollectionDelegationComponent(props: Readonly) { let message = "Confirm in your wallet..."; if (chainsMatch()) { setBatchRevokeDelegationParams({ - collections: [...bulkRevocations].map((br) => + collections: [...bulkRevocations].map(() => areEqualAddresses( props.collection.contract, DELEGATION_ALL_ADDRESS diff --git a/components/delegation/DelegationCenter.tsx b/components/delegation/DelegationCenter.tsx index dfd2e175c0..6cb0271720 100644 --- a/components/delegation/DelegationCenter.tsx +++ b/components/delegation/DelegationCenter.tsx @@ -15,7 +15,7 @@ import { DelegationCenterSection } from "@/enums"; import { areEqualAddresses } from "@/helpers/Helpers"; import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { useEffect, useState } from "react"; +import { useEffect, useEffectEvent, useState } from "react"; import { SUPPORTED_COLLECTIONS } from "./delegation-constants"; interface Props { setSection(section: DelegationCenterSection): any; @@ -23,29 +23,39 @@ interface Props { export default function DelegationCenterComponent(props: Readonly) { const [redirect, setRedirect] = useState(); - const accountResolution = useSeizeConnectContext(); - const { seizeConnect, seizeConnectOpen } = useSeizeConnectContext(); + const { isConnected, seizeConnect, seizeConnectOpen } = useSeizeConnectContext(); const [openConnect, setOpenConnect] = useState(false); + const { setSection } = props; + + const handleRedirect = useEffectEvent((target: DelegationCenterSection) => { + if (!isConnected) { + setOpenConnect(true); + seizeConnect(); + return; + } + + setSection(target); + }); + + const handleSeizeConnectClosed = useEffectEvent(() => { + if (openConnect && redirect && isConnected) { + setSection(redirect); + } + + setRedirect(undefined); + }); useEffect(() => { - if (redirect) { - if (!accountResolution.isConnected) { - setOpenConnect(true); - seizeConnect(); - } else { - props.setSection(redirect); - } + if (!redirect) { + return; } + + handleRedirect(redirect); }, [redirect]); useEffect(() => { if (!seizeConnectOpen) { - if (openConnect) { - if (accountResolution.isConnected && redirect) { - props.setSection(redirect); - } - } - setRedirect(undefined); + handleSeizeConnectClosed(); } }, [seizeConnectOpen]); diff --git a/components/delegation/DelegationFormParts.tsx b/components/delegation/DelegationFormParts.tsx index 1dcc251999..8ca02fdb3a 100644 --- a/components/delegation/DelegationFormParts.tsx +++ b/components/delegation/DelegationFormParts.tsx @@ -1,15 +1,15 @@ "use client"; +import { useEnsResolution } from "@/hooks/useEnsResolution"; import { DELEGATION_ALL_ADDRESS, DELEGATION_CONTRACT } from "@/constants"; import { getRandomObjectId } from "@/helpers/AllowlistToolHelpers"; import { areEqualAddresses, getTransactionLink } from "@/helpers/Helpers"; import { faInfoCircle, faTimesCircle } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { useEffect, useState } from "react"; +import { useEffect, useEffectEvent, useState } from "react"; import { Col, Container, Form, Row } from "react-bootstrap"; import { Tooltip } from "react-tooltip"; import { - useEnsAddress, useEnsName, useWaitForTransactionReceipt, useWriteContract, @@ -24,52 +24,23 @@ import styles from "./Delegation.module.scss"; function DelegationAddressInput( props: Readonly<{ setAddress: (address: string) => void }> ) { - const [newDelegationInput, setNewDelegationInput] = useState(""); - const [newDelegationAddress, setNewDelegationAddress] = useState(""); - - const newDelegationAddressEns = useEnsName({ - address: newDelegationInput?.startsWith("0x") - ? (newDelegationInput as `0x${string}`) - : undefined, + const { setAddress } = props; + const { inputValue, address, handleInputChange } = useEnsResolution({ chainId: 1, }); useEffect(() => { - if (newDelegationAddressEns.data) { - setNewDelegationAddress(newDelegationInput); - setNewDelegationInput( - `${newDelegationAddressEns.data} - ${newDelegationInput}` - ); - } - }, [newDelegationAddressEns.data]); - - const newDelegationToAddressFromEns = useEnsAddress({ - name: newDelegationInput?.endsWith(".eth") ? newDelegationInput : undefined, - chainId: 1, - }); - - useEffect(() => { - if (newDelegationToAddressFromEns.data) { - setNewDelegationAddress(newDelegationToAddressFromEns.data); - setNewDelegationInput( - `${newDelegationInput} - ${newDelegationToAddressFromEns.data}` - ); - } - }, [newDelegationToAddressFromEns.data]); - - useEffect(() => { - props.setAddress(newDelegationAddress); - }, [newDelegationAddress]); + setAddress(address); + }, [setAddress, address]); return ( { - setNewDelegationInput(e.target.value); - setNewDelegationAddress(e.target.value); + handleInputChange(e.target.value); }} /> ); @@ -263,43 +234,6 @@ export function DelegationFormDelegateAddressFormGroup( ); } -function DelegationButtons( - props: Readonly<{ - showCancel: boolean; - onSubmit: () => void; - onHide: () => void; - isLoading: boolean; - }> -) { - return ( - <> - {props.showCancel && ( - - )} - - - ); -} - export function DelegationSubmitGroups( props: Readonly<{ title: string; @@ -312,22 +246,37 @@ export function DelegationSubmitGroups( submitBtnLabel?: string; }> ) { + const { + title, + writeParams, + showCancel, + gasError, + validate, + onHide, + onSetToast, + submitBtnLabel, + } = props; const writeDelegation = useWriteContract(); const waitWriteDelegation = useWaitForTransactionReceipt({ confirmations: 1, hash: writeDelegation.data, }); const [errors, setErrors] = useState([]); + const emitToast = useEffectEvent( + (toast: { title: string; message: string }) => { + onSetToast(toast); + } + ); function submitDelegation() { - const newErrors = props.validate(); - if (newErrors.length > 0 || props.gasError) { + const newErrors = validate(); + if (newErrors.length > 0 || gasError) { setErrors(newErrors); window.scrollBy(0, 100); } else { - writeDelegation.writeContract(props.writeParams); - props.onSetToast({ - title: props.title, + writeDelegation.writeContract(writeParams); + onSetToast({ + title, message: "Confirm in your wallet...", }); } @@ -344,22 +293,22 @@ export function DelegationSubmitGroups( useEffect(() => { if (writeDelegation.error) { - props.onSetToast({ - title: props.title, + emitToast({ + title, message: writeDelegation.error.message.split("Request Arguments")[0], }); } if (writeDelegation.data) { if (waitWriteDelegation.isLoading) { - props.onSetToast({ - title: props.title, + emitToast({ + title, message: `Transaction submitted... ${getTransactionAnchor(writeDelegation.data)}
Waiting for confirmation...`, }); } else { - props.onSetToast({ - title: props.title, + emitToast({ + title, message: `Transaction Successful! ${getTransactionAnchor(writeDelegation.data)}`, }); @@ -369,6 +318,7 @@ export function DelegationSubmitGroups( writeDelegation.error, writeDelegation.data, waitWriteDelegation.isLoading, + title, ]); function isLoading() { @@ -385,10 +335,10 @@ export function DelegationSubmitGroups( - {props.showCancel && ( + {showCancel && ( )} @@ -400,7 +350,7 @@ export function DelegationSubmitGroups( e.preventDefault(); submitDelegation(); }}> - {props.submitBtnLabel ?? "Submit"}{" "} + {submitBtnLabel ?? "Submit"}{" "} {isLoading() && (
@@ -411,7 +361,7 @@ export function DelegationSubmitGroups( - {(errors.length > 0 || props.gasError) && ( + {(errors.length > 0 || gasError) && ( @@ -420,10 +370,10 @@ export function DelegationSubmitGroups(
    - {errors.map((e, index) => ( + {errors.map((e) => (
  • {e}
  • ))} - {props.gasError &&
  • {props.gasError}
  • } + {gasError &&
  • {gasError}
  • }
diff --git a/components/delegation/NewAssignPrimaryAddress.tsx b/components/delegation/NewAssignPrimaryAddress.tsx index fb55fc6be9..3649454804 100644 --- a/components/delegation/NewAssignPrimaryAddress.tsx +++ b/components/delegation/NewAssignPrimaryAddress.tsx @@ -42,6 +42,15 @@ interface Props { } export default function NewAssignPrimaryAddress(props: Readonly) { + const { + address, + subdelegation, + ens, + onHide, + new_primary_address_query: newPrimaryAddressQuery, + setNewPrimaryAddressQuery, + onSetToast, + } = props; const { connectedProfile } = useContext(AuthContext); const [selectedToAddress, setSelectedToAddress] = useState(""); @@ -49,13 +58,13 @@ export default function NewAssignPrimaryAddress(props: Readonly) { const [gasError, setGasError] = useState(); - const contractWriteDelegationConfigParams = props.subdelegation + const contractWriteDelegationConfigParams = subdelegation ? { address: DELEGATION_CONTRACT.contract, abi: DELEGATION_ABI, chainId: DELEGATION_CONTRACT.chain_id, args: [ - props.subdelegation.originalDelegator, + subdelegation.originalDelegator, DELEGATION_ALL_ADDRESS, selectedToAddress, NEVER_DATE, @@ -105,11 +114,10 @@ export default function NewAssignPrimaryAddress(props: Readonly) { if (!selectedToAddress || !isValidEthAddress(selectedToAddress)) { newErrors.push("Missing or invalid Address"); } else if ( - (props.subdelegation && - selectedToAddress.toUpperCase() == - props.subdelegation.originalDelegator.toUpperCase()) || - (!props.subdelegation && - selectedToAddress.toUpperCase() === props.address.toUpperCase()) + (subdelegation && + areEqualAddresses(selectedToAddress, subdelegation.originalDelegator)) || + (!subdelegation && + areEqualAddresses(selectedToAddress, address)) ) { newErrors.push("Invalid Address - cannot delegate to your own wallet"); } @@ -121,12 +129,11 @@ export default function NewAssignPrimaryAddress(props: Readonly) { queryKey: [ "primary-address", connectedProfile?.primary_wallet, - props.subdelegation?.originalDelegator, + subdelegation?.originalDelegator, ], queryFn: async () => { const addressPath = - props.subdelegation?.originalDelegator ?? - connectedProfile?.primary_wallet; + subdelegation?.originalDelegator ?? connectedProfile?.primary_wallet; return await commonApiFetch<{ consolidation_key: string; address: string; @@ -143,41 +150,41 @@ export default function NewAssignPrimaryAddress(props: Readonly) { const addresses = tdhAddress.consolidation_key.split("-"); setAddressOptions(addresses); if ( - props.new_primary_address_query && - addresses.some((f) => - areEqualAddresses(f, props.new_primary_address_query) + newPrimaryAddressQuery && + addresses.some((candidate) => + areEqualAddresses(candidate, newPrimaryAddressQuery) ) ) { - setSelectedToAddress(props.new_primary_address_query); + setSelectedToAddress(newPrimaryAddressQuery); } else { const newTo = addresses.length === 1 ? addresses[0] : ""; setSelectedToAddress(newTo); - props.setNewPrimaryAddressQuery?.(newTo); + setNewPrimaryAddressQuery?.(newTo); } } - }, [tdhAddress]); + }, [tdhAddress, newPrimaryAddressQuery, setNewPrimaryAddressQuery]); function printForm() { return (
- {props.subdelegation && ( + {subdelegation && ( )} @@ -194,8 +201,8 @@ export default function NewAssignPrimaryAddress(props: Readonly) { showCancel={true} gasError={gasError} validate={validate} - onHide={props.onHide} - onSetToast={props.onSetToast} + onHide={onHide} + onSetToast={onSetToast} /> @@ -232,13 +239,13 @@ export default function NewAssignPrimaryAddress(props: Readonly) {

Assign Primary Address{" "} - {props.subdelegation && `as Delegation Manager`} + {subdelegation && `as Delegation Manager`}

- +
{!connectedProfile && ( @@ -252,20 +259,3 @@ export default function NewAssignPrimaryAddress(props: Readonly) { ); } - -function getAssignPrimartAddressConfig(toAddress: string) { - return { - address: DELEGATION_CONTRACT.contract, - abi: DELEGATION_ABI, - chainId: DELEGATION_CONTRACT.chain_id, - args: [ - DELEGATION_ALL_ADDRESS, - toAddress, - NEVER_DATE, - PRIMARY_ADDRESS_USE_CASE.use_case, - true, - 0, - ], - functionName: "registerDelegationAddress", - }; -} diff --git a/components/delegation/NewSubDelegation.tsx b/components/delegation/NewSubDelegation.tsx index 556927bf8f..c8e3df7698 100644 --- a/components/delegation/NewSubDelegation.tsx +++ b/components/delegation/NewSubDelegation.tsx @@ -91,10 +91,6 @@ export default function NewSubDelegationComponent(props: Readonly) { }, }; - function clearErrors() { - setGasError(undefined); - } - function validate() { const newErrors: string[] = []; if (!newDelegationCollection || newDelegationCollection === "0") { diff --git a/components/delegation/UpdateDelegation.tsx b/components/delegation/UpdateDelegation.tsx index 07e219347f..55ebd7abb6 100644 --- a/components/delegation/UpdateDelegation.tsx +++ b/components/delegation/UpdateDelegation.tsx @@ -1,8 +1,9 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEnsResolution } from "@/hooks/useEnsResolution"; +import { useState } from "react"; import { Col, Container, Form, Row } from "react-bootstrap"; -import { useEnsAddress, useEnsName } from "wagmi"; +import { useEnsName } from "wagmi"; import styles from "./Delegation.module.scss"; import { DELEGATION_ABI } from "@/abis"; @@ -50,8 +51,11 @@ export default function UpdateDelegationComponent(props: Readonly) { undefined ); - const [delegationToInput, setDelegationToInput] = useState(""); - const [delegationToAddress, setDelegationToAddress] = useState(""); + const { + inputValue: delegationToInput, + address: delegationToAddress, + handleInputChange: handleDelegationInputChange, + } = useEnsResolution({ chainId: 1 }); const [gasError, setGasError] = useState(); @@ -60,40 +64,6 @@ export default function UpdateDelegationComponent(props: Readonly) { chainId: 1, }); - const newDelegationToAddressEns = useEnsName({ - address: - delegationToInput && delegationToInput.startsWith("0x") - ? (delegationToInput as `0x${string}`) - : undefined, - chainId: 1, - }); - - useEffect(() => { - if (newDelegationToAddressEns.data) { - setDelegationToAddress(delegationToInput); - setDelegationToInput( - `${newDelegationToAddressEns.data} - ${delegationToInput}` - ); - } - }, [newDelegationToAddressEns.data]); - - const newDelegationToAddressFromEns = useEnsAddress({ - name: - delegationToInput && delegationToInput.endsWith(".eth") - ? delegationToInput - : undefined, - chainId: 1, - }); - - useEffect(() => { - if (newDelegationToAddressFromEns.data) { - setDelegationToAddress(newDelegationToAddressFromEns.data); - setDelegationToInput( - `${delegationToInput} - ${newDelegationToAddressFromEns.data}` - ); - } - }, [newDelegationToAddressFromEns.data]); - const contractWriteDelegationConfigParams = { address: DELEGATION_CONTRACT.contract, abi: DELEGATION_ABI, @@ -235,8 +205,7 @@ export default function UpdateDelegationComponent(props: Readonly) { type="text" value={delegationToInput} onChange={(e) => { - setDelegationToInput(e.target.value); - setDelegationToAddress(e.target.value); + handleDelegationInputChange(e.target.value); setGasError(undefined); }} /> diff --git a/components/delegation/walletChecker/WalletChecker.tsx b/components/delegation/walletChecker/WalletChecker.tsx index a6b272bdaf..f4b5a96621 100644 --- a/components/delegation/walletChecker/WalletChecker.tsx +++ b/components/delegation/walletChecker/WalletChecker.tsx @@ -1,5 +1,6 @@ "use client"; +import { useEnsResolution } from "@/hooks/useEnsResolution"; import { publicEnv } from "@/config/env"; import { faCheck, @@ -7,9 +8,9 @@ import { faXmark, } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Fragment, useEffect, useState } from "react"; +import { Fragment, useCallback, useEffect, useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { Button, Col, Container, Form, Row, Table } from "react-bootstrap"; -import { useEnsAddress, useEnsName } from "wagmi"; import { DELEGATION_ALL_ADDRESS, MEMES_CONTRACT, @@ -28,10 +29,6 @@ import { } from "../delegation-constants"; import styles from "./WalletChecker.module.scss"; -interface Props { - path?: string; -} - interface ConsolidationDisplay { from: string; from_display: string | undefined; @@ -39,17 +36,43 @@ interface ConsolidationDisplay { to_display: string | undefined; } +function resolveConsolidationDisplay( + wallet: string, + candidates: ConsolidationDisplay[] +): string | undefined { + let fallback: string | undefined; + + for (const candidate of candidates) { + if (areEqualAddresses(candidate.from, wallet)) { + return candidate.from_display; + } + + if (!fallback && areEqualAddresses(candidate.to, wallet)) { + fallback = candidate.to_display; + } + } + + return fallback; +} + export default function WalletCheckerComponent( props: Readonly<{ address_query: string; setAddressQuery(address: string): any; }> ) { + const { address_query, setAddressQuery } = props; + const [fetchedAddress, setFetchedAddress] = useState(""); - const [walletInput, setWalletInput] = useState(props.address_query ?? ""); - const [walletAddress, setWalletAddress] = useState(props.address_query ?? ""); + const { + inputValue: walletInput, + address: walletAddress, + handleInputChange: handleWalletInputChange, + ensNameQuery: walletAddressEns, + ensAddressQuery: walletAddressFromEns, + } = useEnsResolution({ initialValue: address_query ?? "", chainId: 1 }); - const [checking, setChecking] = useState(!!props.address_query); + const [checking, setChecking] = useState(!!address_query); const [addressError, setAddressError] = useState(false); const [delegations, setDelegations] = useState([]); @@ -59,208 +82,250 @@ export default function WalletCheckerComponent( const [consolidations, setConsolidations] = useState( [] ); - const [consolidationActions, setConsolidationActions] = useState< - ConsolidationDisplay[] - >([]); const [consolidatedWallets, setConsolidatedWallets] = useState< { address: string; display: string | undefined }[] >([]); const [consolidationsLoaded, setConsolidationsLoaded] = useState(false); - const [activeDelegation, setActiveDelegation] = useState(); - - const walletAddressEns = useEnsName({ - address: - walletInput && walletInput.startsWith("0x") - ? (walletInput as `0x${string}`) - : undefined, - chainId: 1, - }); - useEffect(() => { - if (walletAddressEns.data) { - setWalletAddress(walletInput); - setWalletInput(`${walletAddressEns.data} - ${walletInput}`); - } - }, [walletAddressEns.data]); + const shouldFetchDelegations = checking && isValidEthAddress(walletAddress); - const walletAddressFromEns = useEnsAddress({ - name: walletInput && walletInput.endsWith(".eth") ? walletInput : undefined, - chainId: 1, + const { + data: delegationsResponse, + status: delegationsStatus, + } = useQuery({ + queryKey: ["delegations", walletAddress], + queryFn: async () => { + const url = `${publicEnv.API_ENDPOINT}/api/delegations/${walletAddress}`; + return (await fetchUrl(url)) as DBResponse; + }, + enabled: shouldFetchDelegations, + refetchOnWindowFocus: false, }); useEffect(() => { - if (walletAddressFromEns.data) { - setWalletAddress(walletAddressFromEns.data); - setWalletInput(`${walletInput} - ${walletAddressFromEns.data}`); - } - }, [walletAddressFromEns.data]); - - function fetchDelegations(address: string) { - const url = `${publicEnv.API_ENDPOINT}/api/delegations/${address}`; - fetchUrl(url).then((response: DBResponse) => { + if (delegationsStatus === "success" && delegationsResponse) { + const allDelegations = Array.isArray(delegationsResponse.data) + ? (delegationsResponse.data as Delegation[]) + : []; setDelegations( - [...response.data].filter( - (d) => d.use_case != SUB_DELEGATION_USE_CASE.use_case + allDelegations.filter( + (delegation) => + delegation.use_case !== SUB_DELEGATION_USE_CASE.use_case ) ); setSubDelegations( - [...response.data].filter( - (d) => d.use_case === SUB_DELEGATION_USE_CASE.use_case + allDelegations.filter( + (delegation) => + delegation.use_case === SUB_DELEGATION_USE_CASE.use_case ) ); setDelegationsLoaded(true); - }); - } + return; + } - function setAllConsolidations(consolidations: WalletConsolidation[]) { - const myConsolidations: ConsolidationDisplay[] = []; - consolidations.map((c) => { - const newConsolidation1: ConsolidationDisplay = { - from: c.wallet1, - from_display: c.wallet1_display, - to: c.wallet2, - to_display: c.wallet2_display, - }; - if ( - !myConsolidations.find( - (mc) => - areEqualAddresses(mc.from, newConsolidation1.from) && - areEqualAddresses(mc.to, newConsolidation1.to) - ) - ) { - myConsolidations.push(newConsolidation1); - } - if (c.confirmed) { - const newConsolidation2 = { - from: c.wallet2, - from_display: c.wallet2_display, - to: c.wallet1, - to_display: c.wallet1_display, + if (delegationsStatus === "error") { + setDelegations([]); + setSubDelegations([]); + setDelegationsLoaded(true); + } + }, [delegationsStatus, delegationsResponse]); + + const setAllConsolidations = useCallback( + (nextConsolidations: WalletConsolidation[]) => { + const normalized: ConsolidationDisplay[] = []; + + for (const consolidation of nextConsolidations) { + const primary: ConsolidationDisplay = { + from: consolidation.wallet1, + from_display: consolidation.wallet1_display, + to: consolidation.wallet2, + to_display: consolidation.wallet2_display, }; + if ( - !myConsolidations.find( - (mc) => - areEqualAddresses(mc.from, newConsolidation2.from) && - areEqualAddresses(mc.to, newConsolidation2.to) + !normalized.some( + (existing) => + areEqualAddresses(existing.from, primary.from) && + areEqualAddresses(existing.to, primary.to) ) ) { - myConsolidations.push(newConsolidation2); + normalized.push(primary); } - } - }); - setConsolidations(myConsolidations); - setConsolidationsLoaded(true); - } - function getForAddress(address: string, collection: string, useCase: number) { - const myDelegations = delegations.find( - (d) => - areEqualAddresses(address, d.from_address) && - areEqualAddresses(collection, d.collection) && - useCase === d.use_case - ); - return myDelegations; - } + if (consolidation.confirmed) { + const reciprocal: ConsolidationDisplay = { + from: consolidation.wallet2, + from_display: consolidation.wallet2_display, + to: consolidation.wallet1, + to_display: consolidation.wallet1_display, + }; - useEffect(() => { - if (delegationsLoaded) { - const memesUseCase = getForAddress( - walletAddress, - MEMES_CONTRACT, - MINTING_USE_CASE.use_case - ); - if (memesUseCase) { - setActiveDelegation(memesUseCase); - } else { - const memesAll = getForAddress(walletAddress, MEMES_CONTRACT, 1); - if (memesAll) { - setActiveDelegation(memesAll); - } else { - const anyUseCase = getForAddress( - walletAddress, - DELEGATION_ALL_ADDRESS, - MINTING_USE_CASE.use_case - ); - if (anyUseCase) { - setActiveDelegation(anyUseCase); - } else { - const anyAll = getForAddress( - walletAddress, - DELEGATION_ALL_ADDRESS, - 1 - ); - if (anyAll) { - setActiveDelegation(anyAll); - } + if ( + !normalized.some( + (existing) => + areEqualAddresses(existing.from, reciprocal.from) && + areEqualAddresses(existing.to, reciprocal.to) + ) + ) { + normalized.push(reciprocal); } } } - } - }, [delegationsLoaded]); - useEffect(() => { - if (consolidationsLoaded) { - fetchConsolidatedWallets(fetchedAddress); + setConsolidations(normalized); + setConsolidationsLoaded(true); + }, + [] + ); - const actions: ConsolidationDisplay[] = []; - consolidations.map((c) => { - if ( - !consolidations.find( - (c2) => - areEqualAddresses(c2.to, c.from) && - areEqualAddresses(c2.from, c.to) - ) - ) { - actions.push(c); + const { + data: consolidationsResponse, + status: consolidationsStatus, + } = useQuery({ + queryKey: ["consolidations", walletAddress], + queryFn: async () => { + const baseUrl = `${publicEnv.API_ENDPOINT}/api/consolidations/${walletAddress}?show_incomplete=true`; + const firstResponse = (await fetchUrl(baseUrl)) as DBResponse; + const firstData = firstResponse.data as WalletConsolidation[]; + + if (firstData.length > 0) { + const newWallet = areEqualAddresses(walletAddress, firstData[0].wallet1) + ? firstData[0].wallet2 + : firstData[0].wallet1; + const nextUrl = `${publicEnv.API_ENDPOINT}/api/consolidations/${newWallet}?show_incomplete=true`; + try { + const secondResponse = (await fetchUrl(nextUrl)) as DBResponse; + return [ + ...firstData, + ...(secondResponse.data as WalletConsolidation[]), + ]; + } catch { + console.error(`Failed to fetch consolidations for related wallet: ${newWallet}`); + return firstData; } - }); - setConsolidationActions(actions); + } + + return firstData; + }, + enabled: shouldFetchDelegations, + refetchOnWindowFocus: false, + }); + + useEffect(() => { + if (consolidationsStatus === "success" && consolidationsResponse) { + setAllConsolidations(consolidationsResponse); + return; } - }, [consolidationsLoaded]); - - function fetchConsolidatedWallets(address: string) { - const url = `${publicEnv.API_ENDPOINT}/api/consolidations/${address}`; - fetchUrl(url).then((response: DBResponse) => { - const myConsolidatedWallets: { - address: string; - display: string | undefined; - }[] = []; - response.data.map((address) => { - let display = undefined; - - const f = consolidations.find((c) => - areEqualAddresses(c.from, address) - ); - if (f) { - display = f.from_display; - } - const t = consolidations.find((c) => areEqualAddresses(c.to, address)); - if (t) { - display = t.to_display; - } - myConsolidatedWallets.push({ address, display }); - }); - setConsolidatedWallets(myConsolidatedWallets); - }); - } + if (consolidationsStatus === "error") { + setConsolidations([]); + setConsolidationsLoaded(true); + } + }, [consolidationsStatus, consolidationsResponse, setAllConsolidations]); + + const { + refetch: refetchConsolidatedWalletsRaw, + data: consolidatedWalletsResponse, + status: consolidatedWalletsStatus, + } = useQuery<{ address: string; display: string | undefined }[]>({ + queryKey: ["consolidated-wallets", fetchedAddress], + queryFn: async () => { + const url = `${publicEnv.API_ENDPOINT}/api/consolidations/${fetchedAddress}`; + const response = (await fetchUrl(url)) as DBResponse; + const wallets = response.data as string[]; + + const mappedWallets: { address: string; display: string | undefined }[] = []; - function fetchConsolidations(address: string) { - const url = `${publicEnv.API_ENDPOINT}/api/consolidations/${address}?show_incomplete=true`; - fetchUrl(url).then((response1: DBResponse) => { - if (response1.data.length > 0) { - const newWallet = areEqualAddresses(address, response1.data[0].wallet1) - ? response1.data[0].wallet2 - : response1.data[0].wallet1; - const newUrl = `${publicEnv.API_ENDPOINT}/api/consolidations/${newWallet}?show_incomplete=true`; - fetchUrl(newUrl).then((response2: DBResponse) => { - setAllConsolidations([...response1.data, ...response2.data]); + for (const wallet of wallets) { + mappedWallets.push({ + address: wallet, + display: resolveConsolidationDisplay(wallet, consolidations), }); - } else { - setAllConsolidations(response1.data); } - }); - } + + return mappedWallets; + }, + enabled: false, + refetchOnWindowFocus: false, + }); + + useEffect(() => { + if (consolidatedWalletsStatus === "success" && consolidatedWalletsResponse) { + setConsolidatedWallets(consolidatedWalletsResponse); + return; + } + + if (consolidatedWalletsStatus === "error") { + setConsolidatedWallets([]); + } + }, [consolidatedWalletsStatus, consolidatedWalletsResponse]); + + const refetchConsolidatedWallets = refetchConsolidatedWalletsRaw; + + const activeDelegation = useMemo(() => { + if (!delegationsLoaded) { + return undefined; + } + + const searchTargets: Array<[ + string, + string, + number + ]> = [ + [walletAddress, MEMES_CONTRACT, MINTING_USE_CASE.use_case], + [walletAddress, MEMES_CONTRACT, 1], + [walletAddress, DELEGATION_ALL_ADDRESS, MINTING_USE_CASE.use_case], + [walletAddress, DELEGATION_ALL_ADDRESS, 1], + ]; + + for (const [address, collection, useCase] of searchTargets) { + const match = delegations.find( + (delegation) => + areEqualAddresses(address, delegation.from_address) && + areEqualAddresses(collection, delegation.collection) && + delegation.use_case === useCase + ); + + if (match) { + return match; + } + } + + return undefined; + }, [delegationsLoaded, delegations, walletAddress]); + + const consolidationActions = useMemo(() => { + if (!consolidationsLoaded) { + return [] as ConsolidationDisplay[]; + } + + return consolidations.filter( + (candidate) => + !consolidations.some( + (comparison) => + areEqualAddresses(comparison.to, candidate.from) && + areEqualAddresses(comparison.from, candidate.to) + ) + ); + }, [consolidationsLoaded, consolidations]); + + useEffect(() => { + if (!consolidationsLoaded || !fetchedAddress) { + return; + } + + if (!consolidations.length) { + setConsolidatedWallets([]); + return; + } + + refetchConsolidatedWallets().catch(() => {}); + }, [ + consolidationsLoaded, + consolidations, + fetchedAddress, + refetchConsolidatedWallets, + ]); function getUseCaseDisplay(useCase: number) { const resolved = ALL_USE_CASES.find((u) => u.use_case === useCase); @@ -290,26 +355,62 @@ export default function WalletCheckerComponent( } useEffect(() => { - if (checking) { - if (!walletAddress || !isValidEthAddress(walletAddress)) { - setAddressError(true); - setChecking(false); + if (!checking) { + return; + } + + const ensCandidate = + walletInput?.split(" - ")[0]?.trim().toLowerCase() ?? ""; + const isEnsInput = ensCandidate.endsWith(".eth"); + + if (!walletAddress || !isValidEthAddress(walletAddress)) { + if (isEnsInput) { + if (walletAddressFromEns.isLoading) { + return; + } + + if (!walletAddressFromEns.data || walletAddressFromEns.isError) { + setFetchedAddress(""); + setDelegations([]); + setDelegationsLoaded(false); + setConsolidations([]); + setConsolidationsLoaded(false); + setConsolidatedWallets([]); + setAddressError(true); + setChecking(false); + } + return; - } else { - props.setAddressQuery(walletAddress); - setAddressError(false); - setActiveDelegation(undefined); - setFetchedAddress(walletAddress); - setDelegationsLoaded(false); - setDelegations([]); - setConsolidationsLoaded(false); - setConsolidations([]); - setConsolidatedWallets([]); - fetchDelegations(walletAddress); - fetchConsolidations(walletAddress); } + + setFetchedAddress(""); + setDelegations([]); + setDelegationsLoaded(false); + setConsolidations([]); + setConsolidationsLoaded(false); + setConsolidatedWallets([]); + setAddressError(true); + setChecking(false); + return; } - }, [checking]); + + setAddressQuery(walletAddress); + setAddressError(false); + setFetchedAddress(walletAddress); + setDelegationsLoaded(false); + setDelegations([]); + setConsolidationsLoaded(false); + setConsolidations([]); + setConsolidatedWallets([]); + }, [ + checking, + walletAddress, + walletInput, + walletAddressFromEns.isLoading, + walletAddressFromEns.data, + walletAddressFromEns.isError, + setAddressQuery, + ]); useEffect(() => { if (delegationsLoaded && consolidationsLoaded) { @@ -355,8 +456,7 @@ export default function WalletCheckerComponent( type="text" value={walletInput} onChange={(e) => { - setWalletInput(e.target.value); - setWalletAddress(e.target.value); + handleWalletInputChange(e.target.value); setAddressError(false); }} /> @@ -373,14 +473,13 @@ export default function WalletCheckerComponent( className="d-flex align-items-center justify-content-center gap-3">
diff --git a/components/distribution-plan-tool/build-phases/build-phase/form/component-config/select-snapshot/SelectSnapshot.tsx b/components/distribution-plan-tool/build-phases/build-phase/form/component-config/select-snapshot/SelectSnapshot.tsx index 99586ea564..012e2995c5 100644 --- a/components/distribution-plan-tool/build-phases/build-phase/form/component-config/select-snapshot/SelectSnapshot.tsx +++ b/components/distribution-plan-tool/build-phases/build-phase/form/component-config/select-snapshot/SelectSnapshot.tsx @@ -20,7 +20,7 @@ export default function SelectSnapshot({ snapshots: DistributionPlanSnapshot[]; onSelectSnapshot: (param: { snapshotId: string; - snapshotType: Pool.TOKEN_POOL | Pool.CUSTOM_TOKEN_POOL; + snapshotType: Pool; uniqueWalletsCount: number | null; }) => void; title: string; diff --git a/components/distribution-plan-tool/build-phases/build-phase/form/component-config/select-snapshot/SelectSnapshotDropdownListItem.tsx b/components/distribution-plan-tool/build-phases/build-phase/form/component-config/select-snapshot/SelectSnapshotDropdownListItem.tsx index 8bedac2e1a..6256cab67c 100644 --- a/components/distribution-plan-tool/build-phases/build-phase/form/component-config/select-snapshot/SelectSnapshotDropdownListItem.tsx +++ b/components/distribution-plan-tool/build-phases/build-phase/form/component-config/select-snapshot/SelectSnapshotDropdownListItem.tsx @@ -25,6 +25,9 @@ export default function SelectSnapshotDropdownListItem({ case Pool.CUSTOM_TOKEN_POOL: setSubTitle("Custom Snapshot"); break; + case Pool.WALLET_POOL: + setSubTitle("Wallets"); + break; default: assertUnreachable(snapshot.poolType); } diff --git a/components/distribution-plan-tool/build-phases/build-phase/form/component-config/snapshots-table/FinalizeSnapshotsTableSnapshotTooltip.tsx b/components/distribution-plan-tool/build-phases/build-phase/form/component-config/snapshots-table/FinalizeSnapshotsTableSnapshotTooltip.tsx index 24ec5310f4..104b776aff 100644 --- a/components/distribution-plan-tool/build-phases/build-phase/form/component-config/snapshots-table/FinalizeSnapshotsTableSnapshotTooltip.tsx +++ b/components/distribution-plan-tool/build-phases/build-phase/form/component-config/snapshots-table/FinalizeSnapshotsTableSnapshotTooltip.tsx @@ -1,23 +1,14 @@ -import { - AllowlistOperationCode, - Pool, -} from "@/components/allowlist-tool/allowlist-tool.types"; +import { Pool } from "@/components/allowlist-tool/allowlist-tool.types"; import FinalizeSnapshotsTableSnapshotTooltipDefaultSnapshot from "./FinalizeSnapshotsTableSnapshotTooltipDefaultSnapshot"; import { assertUnreachable } from "@/helpers/AllowlistToolHelpers"; import FinalizeSnapshotsTableSnapshotTooltipCustomSnapshot from "./FinalizeSnapshotsTableSnapshotTooltipCustomSnapshot"; -const PoolToCodeMap: Record = { - [Pool.TOKEN_POOL]: AllowlistOperationCode.CREATE_TOKEN_POOL, - [Pool.CUSTOM_TOKEN_POOL]: AllowlistOperationCode.CREATE_CUSTOM_TOKEN_POOL, - [Pool.WALLET_POOL]: AllowlistOperationCode.CREATE_WALLET_POOL, -}; - export default function FinalizeSnapshotsTableSnapshotTooltip({ snapshotId, snapshotType, }: { - snapshotId: string | null; - snapshotType: Pool | null; + readonly snapshotId: string | null; + readonly snapshotType: Pool | null; }) { if (!snapshotId || !snapshotType) { return
; @@ -39,6 +30,6 @@ export default function FinalizeSnapshotsTableSnapshotTooltip({ return
; default: assertUnreachable(snapshotType); - return
+ return
; } } diff --git a/components/distribution-plan-tool/common/DistributionPlanWarnings.tsx b/components/distribution-plan-tool/common/DistributionPlanWarnings.tsx index 315e400c16..72dbd341cc 100644 --- a/components/distribution-plan-tool/common/DistributionPlanWarnings.tsx +++ b/components/distribution-plan-tool/common/DistributionPlanWarnings.tsx @@ -1,26 +1,15 @@ "use client"; -import { useContext, useEffect, useState } from "react"; +import { useContext } from "react"; import { DistributionPlanToolContext } from "../DistributionPlanToolContext"; import { AllowlistRunStatus } from "@/components/allowlist-tool/allowlist-tool.types"; import DistributionPlanErrorWarning from "./DistributionPlanErrorWarning"; export default function DistributionPlanWarnings() { - const { operations, distributionPlan } = useContext( - DistributionPlanToolContext - ); - const [haveUnRunOperations, setHaveUnRunOperations] = useState(false); - const [haveErrors, setHaveErrors] = useState(false); + const { distributionPlan } = useContext(DistributionPlanToolContext); - useEffect(() => { - setHaveUnRunOperations(operations.some((operation) => !operation.hasRan)); - }, [operations]); + const hasErrors = + distributionPlan?.activeRun?.status === AllowlistRunStatus.FAILED; - useEffect(() => { - if (!distributionPlan) return; - const hasErrors = - distributionPlan.activeRun?.status === AllowlistRunStatus.FAILED; - setHaveErrors(hasErrors); - }, [distributionPlan]); - return <>{haveErrors && }; + return <>{hasErrors && }; } diff --git a/components/distribution-plan-tool/create-phases/form/CreatePhasesForm.tsx b/components/distribution-plan-tool/create-phases/form/CreatePhasesForm.tsx index f5b4662832..f5781fe386 100644 --- a/components/distribution-plan-tool/create-phases/form/CreatePhasesForm.tsx +++ b/components/distribution-plan-tool/create-phases/form/CreatePhasesForm.tsx @@ -10,7 +10,7 @@ import { distributionPlanApiPost } from "@/services/distribution-plan-api"; import { useContext, useState } from "react"; export default function CreatePhasesForm() { - const { setToasts, distributionPlan, fetchOperations } = useContext( + const { distributionPlan, fetchOperations } = useContext( DistributionPlanToolContext ); @@ -32,7 +32,7 @@ export default function CreatePhasesForm() { setIsLoading(true); const endpoint = `/allowlists/${distributionPlan.id}/operations`; const phaseId = getRandomObjectId(); - const { success, data } = await distributionPlanApiPost({ + const { success } = await distributionPlanApiPost({ endpoint, body: { code: AllowlistOperationCode.ADD_PHASE, diff --git a/components/distribution-plan-tool/create-plan/CreatePlan.tsx b/components/distribution-plan-tool/create-plan/CreatePlan.tsx index 0f4e494968..e4e4c56fdd 100644 --- a/components/distribution-plan-tool/create-plan/CreatePlan.tsx +++ b/components/distribution-plan-tool/create-plan/CreatePlan.tsx @@ -1,6 +1,7 @@ "use client"; import { useContext, useEffect } from "react"; +import { useQuery } from "@tanstack/react-query"; import { DistributionPlanToolContext, DistributionPlanToolStep, @@ -11,6 +12,7 @@ import AllowlistToolLoader, { AllowlistToolLoaderSize, } from "@/components/allowlist-tool/common/AllowlistToolLoader"; import { distributionPlanApiFetch } from "@/services/distribution-plan-api"; +import { makeErrorToast } from "@/services/distribution-plan.utils"; import { useRouter } from "next/navigation"; import StepHeader from "../common/StepHeader"; @@ -18,24 +20,46 @@ export default function CreatePlan({ id }: { readonly id: string }) { const router = useRouter(); const { setState } = useContext(DistributionPlanToolContext); + + const { + data: distributionPlanResponse, + isError, + } = useQuery({ + queryKey: ["distribution-plan", id], + queryFn: () => + distributionPlanApiFetch(`/allowlists/${id}`), + enabled: Boolean(id), + retry: false, + refetchOnWindowFocus: false, + }); + useEffect(() => { - if (!id) { - alert("No id found"); + if (id) return; + + makeErrorToast("No id found"); + setState(null); + router.push("/emma"); + }, [id, router, setState]); + + useEffect(() => { + if (!id) return; + + if (!distributionPlanResponse) { + if (isError) { + setState(null); + router.push("/emma"); + } + return; + } + + if (!distributionPlanResponse.success || !distributionPlanResponse.data) { + setState(null); router.push("/emma"); return; } - const fetchAllowlist = async () => { - const data = await distributionPlanApiFetch( - `/allowlists/${id}` - ); - if (!data.success) { - router.push("/emma"); - return; - } - setState(data.data); - }; - fetchAllowlist(); - }, []); + + setState(distributionPlanResponse.data); + }, [id, distributionPlanResponse, isError, setState, router]); return (
diff --git a/eslint.config.mjs b/eslint.config.mjs index e8b0bce216..13e8c89124 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,15 +1,65 @@ import { defineConfig, globalIgnores } from "eslint/config"; import nextCoreWebVitals from "eslint-config-next/core-web-vitals"; import unusedImports from "eslint-plugin-unused-imports"; -import reactCompiler from "eslint-plugin-react-compiler"; import reactHooks from "eslint-plugin-react-hooks"; import tseslint from "typescript-eslint"; import path from "node:path"; import { fileURLToPath } from "node:url"; +// React Compiler plugin is optional; keep linting resilient if dependency is missing. +let reactCompilerPlugin; +try { + ({ default: reactCompilerPlugin } = await import("eslint-plugin-react-compiler")); +} catch (error) { + if (!error || typeof error !== "object") { + throw error; + } + + const moduleNotFound = "code" in error && error.code === "ERR_MODULE_NOT_FOUND"; + if (!moduleNotFound) { + throw error; + } +} + const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +const plugins = { + "unused-imports": unusedImports, + "react-hooks": reactHooks, + "@typescript-eslint": tseslint.plugin, +}; + +const rules = { + "@next/next/no-html-link-for-pages": "off", + "@next/next/no-img-element": "off", + "react/display-name": "off", + "react-hooks/rules-of-hooks": "warn", + "react-hooks/exhaustive-deps": "warn", + "react-hooks/preserve-manual-memoization": "off", + "react-hooks/error-boundaries": "off", + "react-hooks/set-state-in-effect": "off", + "react-hooks/use-memo": "off", + "react-hooks/refs": "off", + "react-hooks/immutability": "off", + "react-hooks/purity": "off", + "react/no-unescaped-entities": "off", + "@next/next/no-css-tags": "off", + "unused-imports/no-unused-imports": "error", + + "unused-imports/no-unused-vars": ["warn", { + vars: "all", + varsIgnorePattern: "^_", + args: "after-used", + argsIgnorePattern: "^_", + }], +}; + +if (reactCompilerPlugin) { + plugins["react-compiler"] = reactCompilerPlugin; + rules["react-compiler/react-compiler"] = "warn"; +} + export default defineConfig([globalIgnores([ "**/node_modules", "**/.next", @@ -25,38 +75,9 @@ export default defineConfig([globalIgnores([ ]), { extends: [...nextCoreWebVitals], - plugins: { - "unused-imports": unusedImports, - "react-hooks": reactHooks, - "react-compiler": reactCompiler, - "@typescript-eslint": tseslint.plugin, - }, - - rules: { - "@next/next/no-html-link-for-pages": "off", - "@next/next/no-img-element": "off", - "react/display-name": "off", - "react-hooks/rules-of-hooks": "warn", - "react-hooks/exhaustive-deps": "warn", - "react-compiler/react-compiler": "warn", - "react-hooks/preserve-manual-memoization": "off", - "react-hooks/error-boundaries": "off", - "react-hooks/set-state-in-effect": "off", - "react-hooks/use-memo": "off", - "react-hooks/refs": "off", - "react-hooks/immutability": "off", - "react-hooks/purity": "off", - "react/no-unescaped-entities": "off", - "@next/next/no-css-tags": "off", - "unused-imports/no-unused-imports": "error", + plugins, - "unused-imports/no-unused-vars": ["warn", { - vars: "all", - varsIgnorePattern: "^_", - args: "after-used", - argsIgnorePattern: "^_", - }], - }, + rules, }, { files: ["**/*.{ts,tsx}"], diff --git a/hooks/useEnsResolution.ts b/hooks/useEnsResolution.ts new file mode 100644 index 0000000000..29e84d4d0c --- /dev/null +++ b/hooks/useEnsResolution.ts @@ -0,0 +1,124 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useEnsAddress, useEnsName } from "wagmi"; + +const LABEL_SEPARATOR = " - "; + +type UseEnsResolutionOptions = Readonly<{ + initialValue?: string; + chainId?: number; +}>; + +export function useEnsResolution( + options: UseEnsResolutionOptions = {} +) { + const { initialValue = "", chainId = 1 } = options; + const [inputValue, setInputValue] = useState(initialValue); + const [resolvedAddress, setResolvedAddress] = useState(initialValue); + + useEffect(() => { + setInputValue(initialValue); + setResolvedAddress(initialValue); + }, [initialValue]); + + const ensNameQuery = useEnsName({ + address: + inputValue?.startsWith("0x") + ? (inputValue as `0x${string}`) + : undefined, + chainId, + }); + + const ensAddressQuery = useEnsAddress({ + name: + inputValue?.endsWith(".eth") + ? inputValue + : undefined, + chainId, + }); + + useEffect(() => { + const ensName = ensNameQuery.data; + if (!ensName) { + return; + } + + let pendingAddress: string | null = null; + + setInputValue((current) => { + if (!current || current.includes(LABEL_SEPARATOR)) { + return current; + } + + if (!current.startsWith("0x")) { + return current; + } + + pendingAddress = current; + return `${ensName}${LABEL_SEPARATOR}${current}`; + }); + + if (pendingAddress) { + setResolvedAddress(pendingAddress); + } + }, [ensNameQuery.data]); + + useEffect(() => { + const resolvedAddressFromEns = ensAddressQuery.data; + if (!resolvedAddressFromEns) { + return; + } + + setResolvedAddress(resolvedAddressFromEns); + setInputValue((current) => + normalizeInputWithResolvedAddress(current, resolvedAddressFromEns) + ); + }, [ensAddressQuery.data]); + + const handleInputChange = useCallback((value: string) => { + setInputValue(value); + setResolvedAddress(value); + }, []); + + const setAddress = useCallback((value: string) => { + setResolvedAddress(value); + }, []); + + return { + inputValue, + address: resolvedAddress, + setInputValue, + setAddress, + handleInputChange, + ensNameQuery, + ensAddressQuery, + }; +} + +function normalizeInputWithResolvedAddress( + current: string, + resolvedAddress: string +): string { + if (!current) { + return resolvedAddress; + } + + if (current.endsWith(`${LABEL_SEPARATOR}${resolvedAddress}`)) { + return current; + } + + const parts = current.split(LABEL_SEPARATOR); + + if (parts.length === 1) { + return `${current}${LABEL_SEPARATOR}${resolvedAddress}`; + } + + const lastIndex = parts.length - 1; + if (parts[lastIndex]?.toLowerCase()?.startsWith("0x")) { + parts[lastIndex] = resolvedAddress; + return parts.join(LABEL_SEPARATOR); + } + + return `${current}${LABEL_SEPARATOR}${resolvedAddress}`; +}