diff --git a/__tests__/components/CreateDropWrapper.test.tsx b/__tests__/components/CreateDropWrapper.test.tsx index 0411e88b6c..c0d62e20a9 100644 --- a/__tests__/components/CreateDropWrapper.test.tsx +++ b/__tests__/components/CreateDropWrapper.test.tsx @@ -71,7 +71,6 @@ describe('CreateDropWrapper', () => { setIsStormMode={jest.fn()} setViewType={jest.fn()} setDrop={setDrop} - setMentionedUsers={jest.fn()} setReferencedNfts={jest.fn()} onMentionedUser={jest.fn()} setTitle={jest.fn()} @@ -103,7 +102,6 @@ describe('CreateDropWrapper', () => { setIsStormMode={jest.fn()} setViewType={jest.fn()} setDrop={setDrop} - setMentionedUsers={jest.fn()} setReferencedNfts={jest.fn()} onMentionedUser={jest.fn()} setTitle={jest.fn()} @@ -138,7 +136,6 @@ describe('CreateDropWrapper', () => { setIsStormMode={jest.fn()} setViewType={jest.fn()} setDrop={setDrop} - setMentionedUsers={jest.fn()} setReferencedNfts={jest.fn()} onMentionedUser={jest.fn()} setTitle={jest.fn()} diff --git a/__tests__/components/distribution-plan-tool/create-snapshots/form/CreateSnapshotFormSearchCollectionDropdownItem.test.tsx b/__tests__/components/distribution-plan-tool/create-snapshots/form/CreateSnapshotFormSearchCollectionDropdownItem.test.tsx index 43d69bf4d1..b9dcffaedf 100644 --- a/__tests__/components/distribution-plan-tool/create-snapshots/form/CreateSnapshotFormSearchCollectionDropdownItem.test.tsx +++ b/__tests__/components/distribution-plan-tool/create-snapshots/form/CreateSnapshotFormSearchCollectionDropdownItem.test.tsx @@ -1,9 +1,11 @@ import React from 'react'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { QueryClientProvider } from '@tanstack/react-query'; import CreateSnapshotFormSearchCollectionDropdownItem from '@/components/distribution-plan-tool/create-snapshots/form/CreateSnapshotFormSearchCollectionDropdownItem'; import { DistributionPlanToolContext } from '@/components/distribution-plan-tool/DistributionPlanToolContext'; import { distributionPlanApiFetch } from '@/services/distribution-plan-api'; +import { createTestQueryClient } from '../../../../utils/reactQuery'; jest.mock('next/image', () => ({ __esModule: true, default: (props: any) => })); @@ -32,14 +34,18 @@ function renderItem(overrides: Partial = {}) { ...overrides }; const onCollection = jest.fn(); + const setToasts = jest.fn(); + const queryClient = createTestQueryClient(); render( - - - - - + + + + + + + ); - return { collection, onCollection }; + return { collection, onCollection, setToasts }; } describe('CreateSnapshotFormSearchCollectionDropdownItem', () => { @@ -73,5 +79,47 @@ describe('CreateSnapshotFormSearchCollectionDropdownItem', () => { ); expect(onCollection).toHaveBeenCalledWith({ name: collection.name, address: collection.address, tokenIds: '1,2' }); }); -}); + it('disables the row and shows a spinner while fetching sub collection ids', async () => { + let resolveFetch: ((value: { success: boolean; data: { tokenIds: string } }) => void) | null = null; + fetchMock.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }) + ); + const subId = `0x${'a'.repeat(40)}:sub`; + const { onCollection, collection } = renderItem({ id: subId }); + const row = screen.getByRole('row'); + await userEvent.click(row); + + await waitFor(() => expect(row).toHaveAttribute('aria-disabled', 'true')); + await screen.findByTestId('collection-loading'); + + resolveFetch?.({ success: true, data: { tokenIds: '3,4' } }); + + await waitFor(() => + expect(screen.queryByTestId('collection-loading')).not.toBeInTheDocument() + ); + await waitFor(() => + expect(onCollection).toHaveBeenCalledWith({ + name: collection.name, + address: collection.address, + tokenIds: '3,4', + }) + ); + }); + + it('does not trigger an extra toast when token id fetch fails', async () => { + fetchMock.mockResolvedValueOnce({ success: false, data: null }); + const subId = `0x${'a'.repeat(40)}:sub`; + const { setToasts } = renderItem({ id: subId }); + await userEvent.click(screen.getByRole('row')); + await waitFor(() => + expect(fetchMock).toHaveBeenCalledWith( + `/other/contract-token-ids-as-string/${subId}` + ) + ); + expect(setToasts).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/components/distribution-plan-tool/create-snapshots/table/CreateSnapshotTableRowDownload.test.tsx b/__tests__/components/distribution-plan-tool/create-snapshots/table/CreateSnapshotTableRowDownload.test.tsx index 3045fed976..9ce6f5f1af 100644 --- a/__tests__/components/distribution-plan-tool/create-snapshots/table/CreateSnapshotTableRowDownload.test.tsx +++ b/__tests__/components/distribution-plan-tool/create-snapshots/table/CreateSnapshotTableRowDownload.test.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { render, fireEvent, act } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import CreateSnapshotTableRowDownload from '@/components/distribution-plan-tool/create-snapshots/table/CreateSnapshotTableRowDownload'; import { DistributionPlanToolContext } from '@/components/distribution-plan-tool/DistributionPlanToolContext'; import { distributionPlanApiFetch } from '@/services/distribution-plan-api'; @@ -8,11 +9,17 @@ jest.mock('@/services/distribution-plan-api'); const distPlan = { id: '1' } as any; -const Wrapper: React.FC<{children: React.ReactNode}> = ({ children }) => ( - - {children} - -); +const Wrapper: React.FC<{children: React.ReactNode}> = ({ children }) => { + const [queryClient] = React.useState(() => new QueryClient()); + + return ( + + + {children} + + + ); +}; describe('CreateSnapshotTableRowDownload', () => { beforeEach(() => { diff --git a/__tests__/components/drops/create/compact/CreateDropCompact.test.tsx b/__tests__/components/drops/create/compact/CreateDropCompact.test.tsx index 88d9f4b2b3..038b057f3f 100644 --- a/__tests__/components/drops/create/compact/CreateDropCompact.test.tsx +++ b/__tests__/components/drops/create/compact/CreateDropCompact.test.tsx @@ -19,12 +19,9 @@ describe("CreateDropCompact", () => { render( { missingMedia={[] as any} missingMetadata={[] as any} onViewChange={jest.fn()} - onMetadataRemove={jest.fn()} onEditorState={jest.fn()} onMentionedUser={jest.fn()} onReferencedNft={jest.fn()} diff --git a/__tests__/components/drops/create/full/CreateDropFull.test.tsx b/__tests__/components/drops/create/full/CreateDropFull.test.tsx index eaa20a74d3..89739f50b4 100644 --- a/__tests__/components/drops/create/full/CreateDropFull.test.tsx +++ b/__tests__/components/drops/create/full/CreateDropFull.test.tsx @@ -31,7 +31,6 @@ function renderComponent(screenType: CreateDropScreenType) { { expect(desktopClearMock).not.toHaveBeenCalled(); }); }); - diff --git a/__tests__/components/drops/create/full/desktop/CreateDropFullDesktop.test.tsx b/__tests__/components/drops/create/full/desktop/CreateDropFullDesktop.test.tsx index b4b74d1b68..e77266dc74 100644 --- a/__tests__/components/drops/create/full/desktop/CreateDropFullDesktop.test.tsx +++ b/__tests__/components/drops/create/full/desktop/CreateDropFullDesktop.test.tsx @@ -24,7 +24,6 @@ function renderComponent(props: Partial { setIsStormMode: jest.fn(), setViewType: jest.fn(), setDrop: jest.fn(), - setMentionedUsers: jest.fn(), onMentionedUser: jest.fn(), setReferencedNfts: jest.fn(), setTitle: jest.fn(), diff --git a/__tests__/components/drops/view/item/rate/give/clap/DropListItemRateGiveClap.test.tsx b/__tests__/components/drops/view/item/rate/give/clap/DropListItemRateGiveClap.test.tsx index 0ca636d501..e9f78e8059 100644 --- a/__tests__/components/drops/view/item/rate/give/clap/DropListItemRateGiveClap.test.tsx +++ b/__tests__/components/drops/view/item/rate/give/clap/DropListItemRateGiveClap.test.tsx @@ -4,13 +4,36 @@ import { DropVoteState } from '@/hooks/drops/types'; const mockReplay = jest.fn(); const mockAdd = jest.fn(); +const mockStop = jest.fn(); + +const createMockMojsHtmlInstance = () => { + const base = { + tune: jest.fn(), + stop: jest.fn(), + }; + + let thenMock!: jest.Mock; + const proxy = new Proxy(base, { + // Provide a `.then` handler without turning the object into a "thenable" for linting purposes. + get(target, property, receiver) { + if (property === 'then') { + return thenMock; + } + + return Reflect.get(target, property, receiver); + }, + }); + + thenMock = jest.fn().mockReturnValue(proxy); + return proxy; +}; jest.mock('@mojs/core', () => ({ __esModule: true, default: { - Burst: jest.fn().mockImplementation(() => ({ tune: jest.fn() })), - Html: jest.fn().mockImplementation(() => ({ then: jest.fn().mockReturnThis(), tune: jest.fn() })), - Timeline: jest.fn().mockImplementation(() => ({ add: mockAdd, replay: mockReplay })), + Burst: jest.fn().mockImplementation(() => ({ tune: jest.fn(), stop: jest.fn() })), + Html: jest.fn().mockImplementation(() => createMockMojsHtmlInstance()), + Timeline: jest.fn().mockImplementation(() => ({ add: mockAdd, replay: mockReplay, stop: mockStop })), easing: { bezier: jest.fn(), out: jest.fn() }, }, })); @@ -56,6 +79,12 @@ jest.mock('react-tooltip', () => ({ })); describe('DropListItemRateGiveClap', () => { + beforeEach(() => { + mockAdd.mockClear(); + mockReplay.mockClear(); + mockStop.mockClear(); + }); + it('triggers animation and submit on click when voting positive', async () => { const onSubmit = jest.fn(); render( diff --git a/__tests__/components/groups/page/Groups.test.tsx b/__tests__/components/groups/page/Groups.test.tsx index 417599833c..9417b2a74a 100644 --- a/__tests__/components/groups/page/Groups.test.tsx +++ b/__tests__/components/groups/page/Groups.test.tsx @@ -6,12 +6,23 @@ import React from "react"; import { TitleProvider } from "@/contexts/TitleContext"; const searchParams = new Map(); +const mockReplace = jest.fn((url: string) => { + if (!url.includes("?")) { + searchParams.delete("edit"); + return; + } + const parsed = new URL(url, "http://localhost"); + searchParams.clear(); + parsed.searchParams.forEach((value, key) => { + searchParams.set(key, value); + }); +}); jest.mock("next/navigation", () => ({ useSearchParams: () => ({ get: (key: string) => searchParams.get(key) ?? null, }), usePathname: () => "/groups", - useRouter: () => ({ replace: jest.fn() }), + useRouter: () => ({ replace: mockReplace }), })); jest.mock("@/components/groups/page/create/GroupCreate", () => (props: any) => ( @@ -40,6 +51,11 @@ describe("Groups page", () => { requestAuth: jest.fn().mockResolvedValue({ success: true }), } as any; + beforeEach(() => { + searchParams.clear(); + mockReplace.mockClear(); + }); + it("opens create mode when edit param is present", async () => { searchParams.set("edit", "123"); renderGroups(baseContext); @@ -52,7 +68,7 @@ describe("Groups page", () => { renderGroups(baseContext); await screen.findByTestId("group-create"); await user.click(screen.getByRole("button", { name: /back/i })); - expect(screen.getByTestId("list-wrapper")).toBeInTheDocument(); + expect(await screen.findByTestId("list-wrapper")).toBeInTheDocument(); }); it("shows list when not authenticated", () => { diff --git a/components/brain/direct-messages/DirectMessagesList.tsx b/components/brain/direct-messages/DirectMessagesList.tsx index 699b8adbbb..4aacb83ed7 100644 --- a/components/brain/direct-messages/DirectMessagesList.tsx +++ b/components/brain/direct-messages/DirectMessagesList.tsx @@ -76,7 +76,7 @@ const DirectMessagesList: React.FC = ({ observer.observe(sentinel); return () => observer.disconnect(); - }, [hasNextPage, isFetchingNextPage, list.length > 0]); + }, [hasNextPage, isFetchingNextPage, list.length, fetchNextPageIfNeeded]); const shouldShowPlaceholder = !isAuthenticated || !connectedProfile?.handle; const wavesWithPinned = useMemo( diff --git a/components/brain/my-stream/MyStreamWaveDesktopTabs.tsx b/components/brain/my-stream/MyStreamWaveDesktopTabs.tsx index 17d0a2b2fc..957e1285f8 100644 --- a/components/brain/my-stream/MyStreamWaveDesktopTabs.tsx +++ b/components/brain/my-stream/MyStreamWaveDesktopTabs.tsx @@ -3,6 +3,7 @@ import React, { useEffect, useState } from "react"; import { TabToggle } from "@/components/common/TabToggle"; import { ApiWave } from "@/generated/models/ApiWave"; +import type { ApiWaveDecision } from "@/generated/models/ApiWaveDecision"; import { MyStreamWaveTab } from "@/types/waves.types"; import { useContentTab, WaveVotingState } from "../ContentTabContext"; import { useWave } from "@/hooks/useWave"; @@ -28,12 +29,20 @@ const getContentTabPanelId = (tab: MyStreamWaveTab): string => const AUTO_EXPAND_LIMIT = 5; +const TAB_LABELS: Record = { + [MyStreamWaveTab.CHAT]: "Chat", + [MyStreamWaveTab.LEADERBOARD]: "Leaderboard", + [MyStreamWaveTab.WINNERS]: "Winners", + [MyStreamWaveTab.OUTCOME]: "Outcome", + [MyStreamWaveTab.MY_VOTES]: "My Votes", + [MyStreamWaveTab.FAQ]: "FAQ", +}; + const MyStreamWaveDesktopTabs: React.FC = ({ activeTab, wave, setActiveTab, }) => { - // Use the available tabs from context instead of recalculating const { availableTabs, updateAvailableTabs, setActiveContentTab } = useContentTab(); @@ -43,11 +52,10 @@ const MyStreamWaveDesktopTabs: React.FC = ({ pauses: { filterDecisionsDuringPauses }, } = useWave(wave); const { - voting: { isUpcoming, isCompleted, isInProgress }, + voting: { isUpcoming, isCompleted }, decisions: { firstDecisionDone }, } = useWaveTimers(wave); - // For next decision countdown const { allDecisions, hasMoreFuture, loadMoreFuture } = useDecisionPoints( wave, { @@ -56,26 +64,19 @@ const MyStreamWaveDesktopTabs: React.FC = ({ } ); - // Filter out decisions that occur during pause periods using the helper from useWave const filteredDecisions = React.useMemo(() => { - // Convert DecisionPoint[] to ApiWaveDecision[] format for the filter function - const decisionsAsApiFormat = allDecisions.map( - (decision) => - ({ - decision_time: decision.timestamp, - } as any) - ); + const decisionsAsApiFormat: Array> = + allDecisions.map((decision) => ({ + decision_time: decision.timestamp, + })); - // Apply the filter const filtered = filterDecisionsDuringPauses(decisionsAsApiFormat); - // Convert back to DecisionPoint[] format return allDecisions.filter((decision) => filtered.some((f) => f.decision_time === decision.timestamp) ); }, [allDecisions, filterDecisionsDuringPauses]); - // Get the next valid decision time (excluding paused decisions) const nextDecisionTime = filteredDecisions.find( (decision) => decision.timestamp > Time.currentMillis() @@ -119,8 +120,6 @@ const MyStreamWaveDesktopTabs: React.FC = ({ autoExpandFutureAttempts, ]); - // Calculate time left for next decision - // Update available tabs when wave changes useEffect(() => { const votingState = isUpcoming ? WaveVotingState.NOT_STARTED @@ -143,28 +142,16 @@ const MyStreamWaveDesktopTabs: React.FC = ({ isChatWave, isUpcoming, isCompleted, - isInProgress, firstDecisionDone, updateAvailableTabs, ]); - // Always switch to Chat for Chat-type waves useEffect(() => { if (wave?.wave?.type === ApiWaveType.Chat) { setActiveContentTab(MyStreamWaveTab.CHAT); } }, [wave?.wave?.type, setActiveContentTab]); - // Map enum values to label names - const tabLabels: Record = { - [MyStreamWaveTab.CHAT]: "Chat", - [MyStreamWaveTab.LEADERBOARD]: "Leaderboard", - [MyStreamWaveTab.WINNERS]: "Winners", - [MyStreamWaveTab.OUTCOME]: "Outcome", - [MyStreamWaveTab.MY_VOTES]: "My Votes", - [MyStreamWaveTab.FAQ]: "FAQ", - }; - const options: TabOption[] = React.useMemo( () => availableTabs @@ -175,7 +162,7 @@ const MyStreamWaveDesktopTabs: React.FC = ({ ) .map((tab) => ({ key: tab, - label: tabLabels[tab], + label: TAB_LABELS[tab], panelId: getContentTabPanelId(tab), })), [availableTabs, isMemesWave] @@ -191,7 +178,6 @@ const MyStreamWaveDesktopTabs: React.FC = ({ } }, [isMemesWave, activeTab, options, setActiveTab]); - // For simple waves, don't render any tabs if (isChatWave) { return null; } diff --git a/components/delegation/CollectionDelegation.tsx b/components/delegation/CollectionDelegation.tsx index 5d4dc353af..acf47c134e 100644 --- a/components/delegation/CollectionDelegation.tsx +++ b/components/delegation/CollectionDelegation.tsx @@ -727,7 +727,7 @@ export default function CollectionDelegationComponent(props: Readonly) { useEffect(() => { reset(); - }, [accountResolution.address, reset]); + }, [accountResolution.address]); useEffect(() => { const outDelegations = [...outgoingDelegations].filter( diff --git a/components/distribution-plan-tool/create-snapshots/CreateSnapshots.tsx b/components/distribution-plan-tool/create-snapshots/CreateSnapshots.tsx index dc90b104d8..8480b3f86a 100644 --- a/components/distribution-plan-tool/create-snapshots/CreateSnapshots.tsx +++ b/components/distribution-plan-tool/create-snapshots/CreateSnapshots.tsx @@ -6,7 +6,7 @@ import { DistributionPlanTokenPoolDownloadStatus, } from "@/components/allowlist-tool/allowlist-tool.types"; import { distributionPlanApiFetch } from "@/services/distribution-plan-api"; -import { useContext, useEffect, useState } from "react"; +import { useCallback, useContext, useEffect, useState } from "react"; import { useInterval } from "react-use"; import DistributionPlanEmptyTablePlaceholder from "../common/DistributionPlanEmptyTablePlaceholder"; import DistributionPlanNextStepBtn from "../common/DistributionPlanNextStepBtn"; @@ -115,7 +115,7 @@ export default function CreateSnapshots() { setHaveSnapshots(!!snapshots.length); }, [snapshots]); - const fetchTokenPoolStatuses = async () => { + const fetchTokenPoolStatuses = useCallback(async () => { if (!distributionPlan) return; const endpoint = `/allowlists/${distributionPlan.id}/token-pool-downloads`; const { success, data } = await distributionPlanApiFetch< @@ -124,11 +124,11 @@ export default function CreateSnapshots() { if (success && data) { setTokenPoolDownloads(data); } - }; + }, [distributionPlan]); useEffect(() => { fetchTokenPoolStatuses(); - }, []); + }, [fetchTokenPoolStatuses]); useInterval( async () => { diff --git a/components/distribution-plan-tool/create-snapshots/form/CreateSnapshotFormSearchCollectionDropdownItem.tsx b/components/distribution-plan-tool/create-snapshots/form/CreateSnapshotFormSearchCollectionDropdownItem.tsx index 6f5a60fc93..3bbf9168d7 100644 --- a/components/distribution-plan-tool/create-snapshots/form/CreateSnapshotFormSearchCollectionDropdownItem.tsx +++ b/components/distribution-plan-tool/create-snapshots/form/CreateSnapshotFormSearchCollectionDropdownItem.tsx @@ -2,14 +2,14 @@ import { DistributionPlanSearchContractMetadataResult } from "@/components/allowlist-tool/allowlist-tool.types"; import DistributionPlanVerifiedIcon from "@/components/distribution-plan-tool/common/DistributionPlanVerifiedIcon"; -import { DistributionPlanToolContext } from "@/components/distribution-plan-tool/DistributionPlanToolContext"; import { formatNumber, truncateTextMiddle, } from "@/helpers/AllowlistToolHelpers"; import { distributionPlanApiFetch } from "@/services/distribution-plan-api"; +import { useMutation } from "@tanstack/react-query"; import Image from "next/image"; -import { useContext, useState } from "react"; +import Spinner from "@/components/utils/Spinner"; interface CollectionMeta { readonly imgUrl: string; @@ -20,18 +20,21 @@ interface CollectionMeta { readonly floorPrice: string; } +interface CollectionSelectionParams { + readonly address: string; + readonly name: string; + readonly tokenIds: string | null; +} + +interface CreateSnapshotFormSearchCollectionDropdownItemProps { + readonly collection: DistributionPlanSearchContractMetadataResult; + readonly onCollection: (param: CollectionSelectionParams) => void; +} + export default function CreateSnapshotFormSearchCollectionDropdownItem({ collection, onCollection, -}: { - collection: DistributionPlanSearchContractMetadataResult; - onCollection: (param: { - address: string; - name: string; - tokenIds: string | null; - }) => void; -}) { - const { setToasts } = useContext(DistributionPlanToolContext); +}: CreateSnapshotFormSearchCollectionDropdownItemProps) { const collectionMeta: CollectionMeta = { imgUrl: collection.imageUrl ?? "", openseaVerified: collection.openseaVerified, @@ -47,30 +50,43 @@ export default function CreateSnapshotFormSearchCollectionDropdownItem({ : "N/A", }; - const [isLoading, setIsLoading] = useState(false); - - const getTokenIdsString = async ( - collectionId: string - ): Promise => { - setIsLoading(true); - const endpoint = `/other/contract-token-ids-as-string/${collectionId}`; - const { data } = await distributionPlanApiFetch<{ - tokenIds: string; - }>(endpoint); - setIsLoading(false); - return data?.tokenIds ?? null; - }; + const fetchTokenIdsMutation = useMutation({ + mutationFn: async (collectionId) => { + const endpoint = `/other/contract-token-ids-as-string/${collectionId}`; + const { success, data } = await distributionPlanApiFetch<{ + readonly tokenIds: string; + }>(endpoint); + if (!success) { + throw new Error("Failed to fetch token IDs"); + } + return data?.tokenIds?.length ? data.tokenIds : null; + }, + }); + const isFetchingTokenIds = fetchTokenIdsMutation.isPending; const onCollectionClick = async () => { + if (isFetchingTokenIds) { + return; + } const regex = /^0x[0-9a-fA-F]{40}:.+$/; const isSubCollection = regex.test(collection.id); if (isSubCollection) { - const tokenIdsString = await getTokenIdsString(collection.id); - onCollection({ - name: collection.name, - address: collection.address, - tokenIds: tokenIdsString?.length ? tokenIdsString : null, - }); + try { + const tokenIdsString = await fetchTokenIdsMutation.mutateAsync( + collection.id + ); + onCollection({ + name: collection.name, + address: collection.address, + tokenIds: tokenIdsString, + }); + } catch (error: unknown) { + console.error( + `Failed to fetch token IDs for collection ${collection.id}`, + error + ); + return; + } return; } onCollection({ @@ -82,7 +98,11 @@ export default function CreateSnapshotFormSearchCollectionDropdownItem({ return ( @@ -145,6 +165,11 @@ export default function CreateSnapshotFormSearchCollectionDropdownItem({ {collectionMeta.floorPrice} + {isFetchingTokenIds && ( + + + + )} { if (!snapshot.contract) { return ""; diff --git a/components/distribution-plan-tool/create-snapshots/table/CreateSnapshotTableRowDownload.tsx b/components/distribution-plan-tool/create-snapshots/table/CreateSnapshotTableRowDownload.tsx index fdb664ad0a..8a25ecb3d7 100644 --- a/components/distribution-plan-tool/create-snapshots/table/CreateSnapshotTableRowDownload.tsx +++ b/components/distribution-plan-tool/create-snapshots/table/CreateSnapshotTableRowDownload.tsx @@ -1,12 +1,9 @@ "use client"; -import { useContext, useEffect, useState } from "react"; +import { useContext } from "react"; +import { useMutation } from "@tanstack/react-query"; import { DistributionPlanToolContext } from "@/components/distribution-plan-tool/DistributionPlanToolContext"; -import { FetchResultsType } from "@/components/distribution-plan-tool/review-distribution-plan/table/ReviewDistributionPlanTable"; -import { - DistributionPlanSnapshotToken, -} from "@/components/allowlist-tool/allowlist-tool.types"; -import { assertUnreachable } from "@/helpers/AllowlistToolHelpers"; +import { DistributionPlanSnapshotToken } from "@/components/allowlist-tool/allowlist-tool.types"; import RoundedJsonIconButton from "@/components/distribution-plan-tool/common/RoundedJsonIconButton"; import RoundedCsvIconButton from "@/components/distribution-plan-tool/common/RoundedCsvIconButton"; import { distributionPlanApiFetch } from "@/services/distribution-plan-api"; @@ -14,32 +11,23 @@ import { distributionPlanApiFetch } from "@/services/distribution-plan-api"; export default function CreateSnapshotTableRowDownload({ tokenPoolId, }: { - tokenPoolId: string; + readonly tokenPoolId: string; }) { - const { distributionPlan, setToasts } = useContext( - DistributionPlanToolContext - ); - - const [loadingType, setLoadingType] = useState(null); - const [isLoadingJson, setIsLoadingJson] = useState(false); - const [isLoadingCsv, setIsLoadingCsv] = useState(false); - - useEffect(() => { - setIsLoadingJson(loadingType === FetchResultsType.JSON); - setIsLoadingCsv(loadingType === FetchResultsType.CSV); - }, [loadingType]); + const { distributionPlan } = useContext(DistributionPlanToolContext); const downloadJson = (results: DistributionPlanSnapshotToken[]) => { const data = JSON.stringify(results); const blob = new Blob([data], { type: "application/json" }); - const url = window.URL.createObjectURL(blob); + const url = globalThis.URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = "results.json"; link.click(); + globalThis.URL.revokeObjectURL(url); }; const downloadCsv = (results: DistributionPlanSnapshotToken[]) => { + if (results.length === 0) return; const csv = [ Object.keys(results[0]).join(","), ...results.map((item) => Object.values(item).join(",")), @@ -53,40 +41,61 @@ export default function CreateSnapshotTableRowDownload({ document.body.removeChild(link); }; - const fetchResults = async (fetchType: FetchResultsType) => { - if (!distributionPlan) return; - if (loadingType) return; - setLoadingType(fetchType); + const requestSnapshotTokens = async (): Promise< + DistributionPlanSnapshotToken[] + > => { + if (!distributionPlan) { + throw new Error("No distribution plan"); + } const endpoint = `/allowlists/${distributionPlan.id}/token-pool-downloads/token-pool/${tokenPoolId}/tokens`; const { success, data } = await distributionPlanApiFetch< DistributionPlanSnapshotToken[] >(endpoint); if (!success || !data) { + throw new Error("Fetch failed"); + } + return data; + }; + + const { + mutate: fetchJson, + isPending: isLoadingJson, + } = useMutation({ + mutationFn: requestSnapshotTokens, + onSuccess: (data) => downloadJson(data), + }); + + const { + mutate: fetchCsv, + isPending: isLoadingCsv, + } = useMutation({ + mutationFn: requestSnapshotTokens, + onSuccess: (data) => downloadCsv(data), + }); + + const handleJsonDownload = () => { + if (!distributionPlan || isLoadingJson) { return; } - switch (fetchType) { - case FetchResultsType.JSON: - downloadJson(data); - break; - case FetchResultsType.CSV: - downloadCsv(data); - break; - case FetchResultsType.MANIFOLD: - break; - default: - assertUnreachable(fetchType); + fetchJson(); + }; + + const handleCsvDownload = () => { + if (!distributionPlan || isLoadingCsv) { + return; } - setLoadingType(null); + fetchCsv(); }; + return ( fetchResults(FetchResultsType.JSON)} + onClick={handleJsonDownload} loading={isLoadingJson} /> fetchResults(FetchResultsType.CSV)} + onClick={handleCsvDownload} loading={isLoadingCsv} /> diff --git a/components/distribution-plan-tool/map-delegations/MapDelegationsForm.tsx b/components/distribution-plan-tool/map-delegations/MapDelegationsForm.tsx index f16e1a7a8a..7d80f666f9 100644 --- a/components/distribution-plan-tool/map-delegations/MapDelegationsForm.tsx +++ b/components/distribution-plan-tool/map-delegations/MapDelegationsForm.tsx @@ -10,7 +10,7 @@ import DistributionPlanAddOperationBtn from "../common/DistributionPlanAddOperat import { DistributionPlanToolContext } from "../DistributionPlanToolContext"; export default function MapDelegationsForm() { - const { setToasts, distributionPlan, fetchOperations } = useContext( + const { distributionPlan, fetchOperations } = useContext( DistributionPlanToolContext ); diff --git a/components/distribution-plan-tool/review-distribution-plan/table/ReviewDistributionPlanTableHeader.tsx b/components/distribution-plan-tool/review-distribution-plan/table/ReviewDistributionPlanTableHeader.tsx index d4713e8bf9..3b1e2d6a3d 100644 --- a/components/distribution-plan-tool/review-distribution-plan/table/ReviewDistributionPlanTableHeader.tsx +++ b/components/distribution-plan-tool/review-distribution-plan/table/ReviewDistributionPlanTableHeader.tsx @@ -22,9 +22,7 @@ export default function ReviewDistributionPlanTableHeader({ }: { rows: ReviewDistributionPlanTablePhase[]; }) { - const { distributionPlan, setToasts } = useContext( - DistributionPlanToolContext - ); + const { distributionPlan } = useContext(DistributionPlanToolContext); const [loadingType, setLoadingType] = useState(null); const [isLoadingJson, setIsLoadingJson] = useState(false); const [isLoadingCsv, setIsLoadingCsv] = useState(false); diff --git a/components/distribution-plan-tool/review-distribution-plan/table/ReviewDistributionPlanTableSubscription.tsx b/components/distribution-plan-tool/review-distribution-plan/table/ReviewDistributionPlanTableSubscription.tsx index f409dd10b3..3105c7345d 100644 --- a/components/distribution-plan-tool/review-distribution-plan/table/ReviewDistributionPlanTableSubscription.tsx +++ b/components/distribution-plan-tool/review-distribution-plan/table/ReviewDistributionPlanTableSubscription.tsx @@ -9,10 +9,7 @@ import { } from "@/constants"; import { ApiIdentity } from "@/generated/models/ApiIdentity"; import { areEqualAddresses, formatAddress } from "@/helpers/Helpers"; -import { - commonApiFetch, - commonApiPost, -} from "@/services/api/common-api"; +import { commonApiFetch } from "@/services/api/common-api"; import { useContext, useState } from "react"; import { Button, Col, Container, Modal, Row } from "react-bootstrap"; import { @@ -183,17 +180,6 @@ const mergeResults = (results: WalletResult[]): WalletResult[] => { })); }; -const resetSubscriptions = async ( - contract: string, - tokenId: string, - planId: string -) => { - await commonApiPost({ - endpoint: `subscriptions/allowlists/${contract}/${tokenId}/${planId}/reset`, - body: {}, - }); -}; - export const isSubscriptionsAdmin = (connectedProfile: ApiIdentity | null) => { const connectedWallets = connectedProfile?.wallets?.map((wallet) => wallet.wallet) ?? []; diff --git a/components/distribution-plan-tool/review-distribution-plan/table/ReviewDistributionPlanTableSubscriptionFooter.tsx b/components/distribution-plan-tool/review-distribution-plan/table/ReviewDistributionPlanTableSubscriptionFooter.tsx index 2a2ca6b98b..f9cf995d43 100644 --- a/components/distribution-plan-tool/review-distribution-plan/table/ReviewDistributionPlanTableSubscriptionFooter.tsx +++ b/components/distribution-plan-tool/review-distribution-plan/table/ReviewDistributionPlanTableSubscriptionFooter.tsx @@ -111,7 +111,7 @@ export function ReviewDistributionPlanTableSubscriptionFooter() { type: downloadResponse.success ? "success" : "error", message: downloadResponse.message, }); - } catch (error: any) { + } catch (_error: unknown) { setToast({ type: "error", message: "Something went wrong.", diff --git a/components/distribution/Distribution.tsx b/components/distribution/Distribution.tsx index b760f0f8e3..54493fceb4 100644 --- a/components/distribution/Distribution.tsx +++ b/components/distribution/Distribution.tsx @@ -9,26 +9,39 @@ import { SearchModalDisplay, SearchWalletsDisplay, } from "@/components/searchModal/SearchModal"; -import { publicEnv } from "@/config/env"; import { MEMES_CONTRACT } from "@/constants"; -import { DBResponse } from "@/entities/IDBResponse"; -import { Distribution, DistributionPhoto } from "@/entities/IDistribution"; +import { Distribution } from "@/entities/IDistribution"; import { areEqualAddresses, capitalizeEveryWord, numberWithCommas, } from "@/helpers/Helpers"; -import { fetchAllPages, fetchUrl } from "@/services/6529api"; +import { + useDistributionData, + useDistributionPhotos, +} from "./useDistributionQueries"; import Image from "next/image"; import { useParams } from "next/navigation"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Carousel, Col, Container, Row, Table } from "react-bootstrap"; import styles from "./Distribution.module.scss"; interface Props { - header: string; - contract: string; - link: string; + readonly header: string; + readonly contract: string; + readonly link: string; +} + +function getCountForPhase(distribution: Distribution, phase: string) { + if (phase.toUpperCase() === "AIRDROP") { + const count = distribution.airdrops; + return count ? numberWithCommas(count) : "-"; + } + + const allowlistEntry = distribution.allowlist.find((entry) => entry.phase === phase); + const count = allowlistEntry?.spots ?? 0; + + return count ? numberWithCommas(count) : "-"; } export default function DistributionPage(props: Readonly) { @@ -40,44 +53,50 @@ export default function DistributionPage(props: Readonly) { const [nftId, setNftId] = useState(); - const [distributions, setDistributions] = useState([]); - const [distributionsPhases, setDistributionsPhases] = useState([]); - const [distributionPhotos, setDistributionPhotos] = useState< - DistributionPhoto[] - >([]); - - const [totalResults, setTotalResults] = useState(0); - const [showSearchModal, setShowSearchModal] = useState(false); const [searchWallets, setSearchWallets] = useState([]); - const [fetching, setFetching] = useState(true); + const { + data: distributionsResponse, + isFetching: isDistributionsFetching, + } = useDistributionData({ + nftId, + contract: props.contract, + page: pageProps.page, + searchWallets, + }); + + const distributions = distributionsResponse?.data ?? []; + const totalResults = distributionsResponse?.count ?? 0; - function updateDistributionPhases(mydistributions: Distribution[]) { + const distributionPhases = useMemo(() => { const phasesSet = new Set(); - mydistributions.forEach((d) => { - d.phases.forEach((p) => { - phasesSet.add(p); - }); - }); - const phases = Array.from(phasesSet); - phases.sort((a, b) => a.localeCompare(b)); - setDistributionsPhases(phases); - } + for (const distribution of distributions) { + for (const phase of distribution.phases) { + phasesSet.add(phase); + } + } + return Array.from(phasesSet).sort((a, b) => + a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }) + ); + }, [distributions]); + + const { data: distributionPhotosData } = useDistributionPhotos({ + nftId, + contract: props.contract, + }); + + const distributionPhotos = distributionPhotosData ?? []; - function fetchDistribution() { - setFetching(true); - const walletFilter = - searchWallets.length === 0 ? "" : `&search=${searchWallets.join(",")}`; - const distributionUrl = `${publicEnv.API_ENDPOINT}/api/distributions?card_id=${nftId}&contract=${props.contract}&page=${pageProps.page}${walletFilter}`; - fetchUrl(distributionUrl).then((r: DBResponse) => { - setTotalResults(r.count); - const mydistributions: Distribution[] = r.data; - setDistributions(mydistributions); - updateDistributionPhases(mydistributions); - setFetching(false); + const handlePageChange = useCallback((newPage: number) => { + setPageProps((prev) => { + if (prev.page === newPage) { + return prev; + } + + return { ...prev, page: newPage }; }); - } + }, []); useEffect(() => { const id = params?.id as string; @@ -87,29 +106,18 @@ export default function DistributionPage(props: Readonly) { }, [params]); useEffect(() => { - if (nftId) { - const distributionPhotosUrl = `${publicEnv.API_ENDPOINT}/api/distribution_photos/${props.contract}/${nftId}`; - - fetchAllPages(distributionPhotosUrl).then( - (distributionPhotos) => { - setDistributionPhotos(distributionPhotos); - fetchDistribution(); - } - ); + if (!nftId) { + return; } - }, [nftId]); - useEffect(() => { - if (nftId) { - setPageProps({ ...pageProps, page: 1 }); - } - }, [searchWallets]); + setPageProps((prev) => { + if (prev.page === 1) { + return prev; + } - useEffect(() => { - if (nftId && pageProps) { - fetchDistribution(); - } - }, [pageProps]); + return { ...prev, page: 1 }; + }); + }, [nftId, searchWallets]); function printDistributionPhotos() { if (distributionPhotos.length > 0) { @@ -137,19 +145,6 @@ export default function DistributionPage(props: Readonly) { } } - function getCountForPhase(d: Distribution, phase: string) { - let count = 0; - - if (phase.toUpperCase() === "AIRDROP") { - count = d.airdrops; - } else { - const p = d.allowlist.find((a) => a.phase === phase); - count = p?.spots ?? 0; - } - - return count ? numberWithCommas(count) : "-"; - } - function printDistribution() { return ( <> @@ -173,7 +168,7 @@ export default function DistributionPage(props: Readonly) { ALLOWLIST SPOTS @@ -184,7 +179,7 @@ export default function DistributionPage(props: Readonly) { Wallet{" "} - {fetching ? ( + {isDistributionsFetching ? ( ) : ( @@ -192,7 +187,7 @@ export default function DistributionPage(props: Readonly) { )} - {distributionsPhases.map((p) => ( + {distributionPhases.map((p) => ( {capitalizeEveryWord(p.replaceAll("_", " "))} @@ -212,7 +207,7 @@ export default function DistributionPage(props: Readonly) { hideCopy={true} /> - {distributionsPhases.map((p) => ( + {distributionPhases.map((p) => ( {getCountForPhase(d, p)} @@ -305,12 +300,10 @@ export default function DistributionPage(props: Readonly) { - {nftId && - (distributions.length > 0 || searchWallets.length > 0) && - printDistribution()} + {nftId && printDistribution()} - {!fetching && distributions.length === 0 && ( + {nftId && !isDistributionsFetching && distributions.length === 0 && ( <>{searchWallets.length > 0 ? printNotFound() : printEmpty()}> )} @@ -322,9 +315,7 @@ export default function DistributionPage(props: Readonly) { page={pageProps.page} pageSize={pageProps.pageSize} totalResults={totalResults} - setPage={function (newPage: number) { - setPageProps({ ...pageProps, page: newPage }); - }} + setPage={handlePageChange} /> )} diff --git a/components/distribution/useDistributionQueries.ts b/components/distribution/useDistributionQueries.ts new file mode 100644 index 0000000000..0b2ba179a6 --- /dev/null +++ b/components/distribution/useDistributionQueries.ts @@ -0,0 +1,72 @@ +import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; +import { publicEnv } from "@/config/env"; +import { DBResponse } from "@/entities/IDBResponse"; +import { Distribution, DistributionPhoto } from "@/entities/IDistribution"; +import { fetchAllPages, fetchUrl } from "@/services/6529api"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; + +interface DistributionDBResponse extends DBResponse { + data: Distribution[]; +} + +interface UseDistributionDataParams { + nftId?: string; + contract: string; + page: number; + searchWallets: string[]; +} + +export function useDistributionData({ + nftId, + contract, + page, + searchWallets, +}: UseDistributionDataParams) { + return useQuery({ + queryKey: [QueryKey.DISTRIBUTIONS, { nftId, contract, page, searchWallets }], + queryFn: async () => { + if (!nftId) { + throw new Error("Attempted to fetch distributions without an nftId"); + } + + const walletFilter = + searchWallets.length === 0 + ? "" + : `&search=${searchWallets + .map((wallet) => encodeURIComponent(wallet)) + .join(",")}`; + + const url = `${publicEnv.API_ENDPOINT}/api/distributions?card_id=${nftId}&contract=${contract}&page=${page}${walletFilter}`; + const response = (await fetchUrl(url)) as DistributionDBResponse; + return response; + }, + enabled: Boolean(nftId), + staleTime: 60_000, + gcTime: 5 * 60_000, + placeholderData: keepPreviousData, + }); +} + +interface UseDistributionPhotosParams { + nftId?: string; + contract: string; +} + +export function useDistributionPhotos({ + nftId, + contract, +}: UseDistributionPhotosParams) { + return useQuery({ + queryKey: [QueryKey.DISTRIBUTION_PHOTOS, { nftId, contract }], + queryFn: async () => { + if (!nftId) { + throw new Error("Attempted to fetch distribution photos without an nftId"); + } + + const url = `${publicEnv.API_ENDPOINT}/api/distribution_photos/${contract}/${nftId}`; + return await fetchAllPages(url); + }, + enabled: Boolean(nftId), + staleTime: 5 * 60_000, + }); +} diff --git a/components/downloadUrlWidget/DownloadUrlWidget.tsx b/components/downloadUrlWidget/DownloadUrlWidget.tsx index 62a6822b07..e3f5eb50ae 100644 --- a/components/downloadUrlWidget/DownloadUrlWidget.tsx +++ b/components/downloadUrlWidget/DownloadUrlWidget.tsx @@ -2,7 +2,6 @@ import styles from "./DownloadUrlWidget.module.scss"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import useDownloader from "react-use-downloader"; import { Spinner } from "../dotLoader/DotLoader"; -import { Button, Modal } from "react-bootstrap"; import { getAuthJwt, getStagingAuth } from "@/services/auth/auth.utils"; import { faDownload } from "@fortawesome/free-solid-svg-icons"; @@ -43,30 +42,3 @@ export default function DownloadUrlWidget(props: Readonly) { ); } - -function DownloadUrlWidgetConfirm( - props: Readonly<{ - show: boolean; - confirm_info: string; - handleClose(): void; - download(): void; - }> -) { - return ( - - - Confirm Download Info - - - {props.confirm_info} - - - Close - - - Looks good - - - - ); -} diff --git a/components/drops/create/DropEditor.tsx b/components/drops/create/DropEditor.tsx index 51321585d7..a6a7f291f3 100644 --- a/components/drops/create/DropEditor.tsx +++ b/components/drops/create/DropEditor.tsx @@ -149,7 +149,6 @@ const DropEditor = forwardRef( setIsStormMode={setIsStormMode} setViewType={setViewType} setDrop={setDrop} - setMentionedUsers={setMentionedUsers} onMentionedUser={onMentionedUser} setReferencedNfts={setReferencedNfts} setTitle={setTitle} diff --git a/components/drops/create/compact/CreateDropCompact.tsx b/components/drops/create/compact/CreateDropCompact.tsx index 460b01853c..cc7f72959d 100644 --- a/components/drops/create/compact/CreateDropCompact.tsx +++ b/components/drops/create/compact/CreateDropCompact.tsx @@ -7,7 +7,6 @@ import CreateDropContent, { import { EditorState } from "lexical"; import { CreateDropConfig, - DropMetadata, MentionedUser, ReferencedNft, } from "@/entities/IDrop"; @@ -19,20 +18,15 @@ import CreateDropSelectedFilePreview from "../utils/file/CreateDropSelectedFileP import { forwardRef, useImperativeHandle, useRef } from "react"; import { ApiWaveParticipationRequirement } from "@/generated/models/ApiWaveParticipationRequirement"; import { ApiWaveRequiredMetadata } from "@/generated/models/ApiWaveRequiredMetadata"; -import { ProfileMinWithoutSubs } from "@/helpers/ProfileTypes"; export interface CreateDropCompactHandles { clearEditorState: () => void; } interface CreateDropCompactProps { readonly waveId: string | null; - readonly profile: ProfileMinWithoutSubs; - readonly showProfile?: boolean; readonly screenType: CreateDropScreenType; readonly editorState: EditorState | null; - readonly title: string | null; readonly files: File[]; - readonly metadata: DropMetadata[]; readonly canSubmit: boolean; readonly canAddPart: boolean; readonly loading: boolean; @@ -44,7 +38,6 @@ interface CreateDropCompactProps { readonly missingMetadata: ApiWaveRequiredMetadata[]; readonly children: React.ReactNode; readonly onViewChange: (newV: CreateDropViewType) => void; - readonly onMetadataRemove: (key: string) => void; readonly onEditorState: (editorState: EditorState | null) => void; readonly onMentionedUser: ( newUser: Omit @@ -63,13 +56,9 @@ const CreateDropCompact = forwardRef< ( { waveId, - profile, - showProfile = true, editorState, screenType, files, - title, - metadata, canSubmit, canAddPart, loading, @@ -81,7 +70,6 @@ const CreateDropCompact = forwardRef< missingMetadata, children, onViewChange, - onMetadataRemove, onEditorState, onMentionedUser, onReferencedNft, diff --git a/components/drops/create/full/CreateDropFull.tsx b/components/drops/create/full/CreateDropFull.tsx index a76e03a72f..228231d362 100644 --- a/components/drops/create/full/CreateDropFull.tsx +++ b/components/drops/create/full/CreateDropFull.tsx @@ -18,21 +18,13 @@ import { CreateDropType, CreateDropViewType } from "../types"; import { forwardRef, useImperativeHandle, useRef, type JSX } from "react"; import { ApiWaveParticipationRequirement } from "@/generated/models/ApiWaveParticipationRequirement"; import { ApiWaveRequiredMetadata } from "@/generated/models/ApiWaveRequiredMetadata"; -import { ProfileMinWithoutSubs } from "@/helpers/ProfileTypes"; export interface CreateDropFullHandles { clearEditorState: () => void; } -interface CreateDropFullWaveProps { - readonly name: string; - readonly image: string | null; - readonly id: string | null; -} - interface CreateDropFullProps { readonly screenType: CreateDropScreenType; - readonly profile: ProfileMinWithoutSubs; readonly title: string | null; readonly metadata: DropMetadata[]; readonly editorState: EditorState | null; @@ -67,7 +59,6 @@ const CreateDropFull = forwardRef( ( { screenType, - profile, title, editorState, metadata, @@ -112,7 +103,6 @@ const CreateDropFull = forwardRef( [CreateDropScreenType.DESKTOP]: ( ( [CreateDropScreenType.MOBILE]: ( ( ( { - profile, title, editorState, metadata, diff --git a/components/drops/create/full/mobile/CreateDropFullMobile.tsx b/components/drops/create/full/mobile/CreateDropFullMobile.tsx index 210acd8914..b535003cf3 100644 --- a/components/drops/create/full/mobile/CreateDropFullMobile.tsx +++ b/components/drops/create/full/mobile/CreateDropFullMobile.tsx @@ -16,7 +16,6 @@ import { import { ApiWaveParticipationRequirement } from "@/generated/models/ApiWaveParticipationRequirement"; import { ApiWaveRequiredMetadata } from "@/generated/models/ApiWaveRequiredMetadata"; import { assertUnreachable } from "@/helpers/AllowlistToolHelpers"; -import { ProfileMinWithoutSubs } from "@/helpers/ProfileTypes"; import { EditorState } from "lexical"; import { forwardRef, useImperativeHandle, useRef, useState } from "react"; import CreateDropFullMobileMetadata from "./CreateDropFullMobileMetadata"; @@ -32,7 +31,6 @@ export interface CreateDropFullMobileHandles { } interface CreateDropFullMobileProps { - readonly profile: ProfileMinWithoutSubs; readonly title: string | null; readonly editorState: EditorState | null; readonly metadata: DropMetadata[]; @@ -68,7 +66,6 @@ const CreateDropFullMobile = forwardRef< >( ( { - profile, title, editorState, metadata, diff --git a/components/drops/create/lexical/nodes/ImageNode.tsx b/components/drops/create/lexical/nodes/ImageNode.tsx index fe23bf7225..3ca0d57218 100644 --- a/components/drops/create/lexical/nodes/ImageNode.tsx +++ b/components/drops/create/lexical/nodes/ImageNode.tsx @@ -87,7 +87,7 @@ export class ImageNode extends DecoratorNode { static importDOM(): DOMConversionMap | null { return { - img: (node: Node) => ({ + img: (_node: Node) => ({ conversion: $convertImageElement, priority: 0, }), diff --git a/components/drops/create/lexical/plugins/enter/EnterKeyPlugin.tsx b/components/drops/create/lexical/plugins/enter/EnterKeyPlugin.tsx index d038cfbfd1..62a4ab79c2 100644 --- a/components/drops/create/lexical/plugins/enter/EnterKeyPlugin.tsx +++ b/components/drops/create/lexical/plugins/enter/EnterKeyPlugin.tsx @@ -10,7 +10,7 @@ import { COMMAND_PRIORITY_HIGH, KEY_ENTER_COMMAND, } from "lexical"; -import { useEffect } from "react"; +import { useEffect, useEffectEvent } from "react"; import { $isListItemNode, $isListNode } from "@lexical/list"; import { $isHeadingNode } from "@lexical/rich-text"; import useIsMobileDevice from "@/hooks/isMobileDevice"; @@ -30,6 +30,18 @@ export default function EnterKeyPlugin({ const [editor] = useLexicalComposerContext(); + const shouldHandleEnter = useEffectEvent(() => { + if (disabled) { + return false; + } + + return canSubmitWithEnter(); + }); + + const submit = useEffectEvent(() => { + handleSubmit(); + }); + useEffect(() => { const insertParagraph = ({ forceParagraph = false } = {}) => { editor.update(() => { @@ -65,50 +77,42 @@ export default function EnterKeyPlugin({ }); }; - return editor.registerCommand( - KEY_ENTER_COMMAND, - (event) => { - if (disabled || !canSubmitWithEnter()) { - // Let the mention plugin handle the Enter key - return false; // Allows the mention plugin to process the Enter key - } + return editor.registerCommand(KEY_ENTER_COMMAND, (event) => { + if (!shouldHandleEnter()) { + return false; + } - if (isMobile || isCapacitor) { - return true; + if (isMobile || isCapacitor) { + return true; + } + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + const anchorNode = selection.anchor.getNode(); + let parentNode = anchorNode.getParent(); + if (parentNode === null) { + parentNode = anchorNode.getTopLevelElement(); } - const selection = $getSelection(); - if ($isRangeSelection(selection)) { - const anchorNode = selection.anchor.getNode(); - let parentNode = anchorNode.getParent(); - if (parentNode === null) { - parentNode = anchorNode.getTopLevelElement(); - } - // Check if the cursor is inside a list item - if ($isListItemNode(parentNode) || $isListNode(parentNode)) { - // Inside a list item: Let Lexical handle the 'Enter' key as usual - return false; // Returning false allows default Lexical behavior - } - if ($isHeadingNode(parentNode) && event?.shiftKey) { - event.preventDefault(); - insertParagraph({ forceParagraph: true }); - return true; - } + if ($isListItemNode(parentNode) || $isListNode(parentNode)) { + return false; } - - if (event?.shiftKey) { + if ($isHeadingNode(parentNode) && event?.shiftKey) { event.preventDefault(); - insertParagraph(); + insertParagraph({ forceParagraph: true }); return true; - } else { - // Handle Enter (Submit) - event?.preventDefault(); - handleSubmit(); // Your submit function - return true; // Prevents the default behavior } - }, - COMMAND_PRIORITY_HIGH - ); + } + + if (event?.shiftKey) { + event.preventDefault(); + insertParagraph(); + return true; + } else { + event?.preventDefault(); + submit(); + return true; + } + }, COMMAND_PRIORITY_HIGH); }, [editor, isMobile, isCapacitor]); return null; diff --git a/components/drops/create/lexical/plugins/hashtags/HashtagsPlugin.tsx b/components/drops/create/lexical/plugins/hashtags/HashtagsPlugin.tsx index 9687846e09..67c6ee48c0 100644 --- a/components/drops/create/lexical/plugins/hashtags/HashtagsPlugin.tsx +++ b/components/drops/create/lexical/plugins/hashtags/HashtagsPlugin.tsx @@ -144,7 +144,7 @@ const NewHashtagsPlugin = forwardRef< closeMenu(); }); }, - [editor] + [editor, onSelect] ); const checkForHashtagMatch = useCallback( diff --git a/components/drops/create/lexical/plugins/mentions/MentionsPlugin.tsx b/components/drops/create/lexical/plugins/mentions/MentionsPlugin.tsx index 5099d12400..ebda4a9b41 100644 --- a/components/drops/create/lexical/plugins/mentions/MentionsPlugin.tsx +++ b/components/drops/create/lexical/plugins/mentions/MentionsPlugin.tsx @@ -206,7 +206,7 @@ const NewMentionsPlugin = forwardRef< closeMenu(); }); }, - [editor] + [editor, onSelect] ); const checkForMentionMatch = useCallback( diff --git a/components/drops/create/utils/CreateDropWrapper.tsx b/components/drops/create/utils/CreateDropWrapper.tsx index dae50e5652..1da52d93a9 100644 --- a/components/drops/create/utils/CreateDropWrapper.tsx +++ b/components/drops/create/utils/CreateDropWrapper.tsx @@ -3,7 +3,9 @@ import { forwardRef, useEffect, + useEffectEvent, useImperativeHandle, + useMemo, useRef, useState, type JSX, @@ -28,7 +30,6 @@ import CommonAnimationHeight from "@/components/utils/animation/CommonAnimationH import { useQuery } from "@tanstack/react-query"; import { ApiWave } from "@/generated/models/ApiWave"; import { commonApiFetch } from "@/services/api/common-api"; -import { ApiWaveRequiredMetadata } from "@/generated/models/ApiWaveRequiredMetadata"; import { ApiWaveMetadataType } from "@/generated/models/ApiWaveMetadataType"; import { ApiWaveParticipationRequirement } from "@/generated/models/ApiWaveParticipationRequirement"; import { ProfileMinWithoutSubs } from "@/helpers/ProfileTypes"; @@ -41,6 +42,21 @@ import { exportDropMarkdown, } from "@/components/waves/drops/normalizeDropMarkdown"; +const getRequirementFromFileType = ( + file: File +): ApiWaveParticipationRequirement | null => { + if (file.type.startsWith("image/")) { + return ApiWaveParticipationRequirement.Image; + } + if (file.type.startsWith("audio/")) { + return ApiWaveParticipationRequirement.Audio; + } + if (file.type.startsWith("video/")) { + return ApiWaveParticipationRequirement.Video; + } + return null; +}; + export enum CreateDropScreenType { DESKTOP = "DESKTOP", MOBILE = "MOBILE", @@ -79,9 +95,6 @@ interface CreateDropWrapperProps { readonly setIsStormMode: (isStormMode: boolean) => void; readonly setViewType: (newV: CreateDropViewType) => void; readonly setDrop: (newV: CreateDropConfig) => void; - readonly setMentionedUsers: ( - newV: Omit[] - ) => void; readonly onMentionedUser: ( newUser: Omit ) => void; @@ -119,7 +132,6 @@ const CreateDropWrapper = forwardRef< setIsStormMode, setViewType, setDrop, - setMentionedUsers, setReferencedNfts, onMentionedUser, setTitle, @@ -129,23 +141,31 @@ const CreateDropWrapper = forwardRef< }, ref ) => { - const { isSafeWallet, address, isAuthenticated } = useSeizeConnectContext(); + const { + isSafeWallet, + address, + isAuthenticated, + connectionState, + } = useSeizeConnectContext(); const breakpoint = useBreakpoint(); - // SECURITY: Fail-fast if wallet is not properly authenticated useEffect(() => { + if (connectionState === "initializing" || connectionState === "connecting") { + return; + } + if (!isAuthenticated) { throw new WalletValidationError( 'Authentication required for drop creation. Please connect and authenticate your wallet.' ); } - + if (!address) { throw new WalletValidationError( 'Authenticated wallet address is missing. Please reconnect your wallet.' ); } - }, [isAuthenticated, address]); + }, [connectionState, isAuthenticated, address]); const [screenType, setScreenType] = useState( CreateDropScreenType.DESKTOP ); @@ -195,150 +215,134 @@ const CreateDropWrapper = forwardRef< newNft, ]); }; - const getMarkdown = () => - editorState - ? exportDropMarkdown(editorState, [ - ...SAFE_MARKDOWN_TRANSFORMERS, - MENTION_TRANSFORMER, - HASHTAG_TRANSFORMER, - IMAGE_TRANSFORMER, - ]) - : null; - - const getMissingRequiredMetadata = (): ApiWaveRequiredMetadata[] => { - if (!waveProps?.id) { - return []; + const markdownContent = useMemo( + () => + editorState + ? exportDropMarkdown(editorState, [ + ...SAFE_MARKDOWN_TRANSFORMERS, + MENTION_TRANSFORMER, + HASHTAG_TRANSFORMER, + IMAGE_TRANSFORMER, + ]) + : null, + [editorState] + ); + const combinedMedias = useMemo(() => { + if (!drop?.parts.length) { + return files; } + return drop.parts.reduce( + (acc, part) => [...acc, ...(part.media ?? [])], + files + ); + }, [drop, files]); - if (!wave) { - return []; - } + const missingMetadata = useMemo( + () => { + if (!waveProps?.id || !wave) { + return []; + } - if (!metadata.length) { - return wave.participation.required_metadata; - } - return wave.participation.required_metadata.filter((i) => { - const item = metadata.find((j) => j.data_key === i.name); - if (!item) { - return true; + if (!metadata.length) { + return wave.participation.required_metadata; } - if (!item.data_value.length) { - return true; + + return wave.participation.required_metadata.filter((item) => { + const existing = metadata.find((entry) => entry.data_key === item.name); + if (!existing) { + return true; + } + if (!existing.data_value.length) { + return true; + } + if ( + item.type === ApiWaveMetadataType.Number && + Number.isNaN(Number(existing.data_value)) + ) { + return true; + } + return false; + }); + }, + [waveProps, wave, metadata] + ); + + const missingMedia = useMemo( + () => { + if (!waveProps?.id || !wave) { + return []; } - if ( - i.type === ApiWaveMetadataType.Number && - isNaN(Number(item.data_value)) - ) { - return true; + + if (!drop?.parts.length && !files.length) { + return wave.participation.required_media; } - return false; - }); - }; - const getRequirementFromFileType = ( - file: File - ): ApiWaveParticipationRequirement | null => { - if (file.type.startsWith("image/")) - return ApiWaveParticipationRequirement.Image; - if (file.type.startsWith("audio/")) - return ApiWaveParticipationRequirement.Audio; - if (file.type.startsWith("video/")) - return ApiWaveParticipationRequirement.Video; - return null; // Unknown or unsupported file type - }; + return wave.participation.required_media.filter((requirement) => { + const hasFile = combinedMedias.some( + (file) => getRequirementFromFileType(file) === requirement + ); + return !hasFile; + }); + }, + [waveProps, wave, drop, files, combinedMedias] + ); - const getMedias = (): File[] => { - if (drop?.parts.length) { - return drop.parts.reduce( - (acc, part) => [...acc, ...(part.media ?? [])], - files - ); - } - return files; - }; + const canSubmit = useMemo(() => { + const hasExistingParts = Boolean(drop?.parts?.length); + const hasAnyContent = + Boolean(markdownContent) || files.length > 0 || hasExistingParts; - const getMissingRequiredMedia = (): ApiWaveParticipationRequirement[] => { - if (!waveProps?.id) { - return []; + if (!hasAnyContent) { + return false; } - if (!wave) { - return []; - } - if (!drop?.parts.length && !files.length) { - return wave.participation.required_media; + if (missingMedia.length || missingMetadata.length) { + return false; } - const medias = getMedias(); - return wave.participation.required_media.filter((i) => { - const file = medias.find((j) => getRequirementFromFileType(j) === i); - if (!file) { - return true; - } + + if ( + hasExistingParts && + markdownContent?.length && + markdownContent.length > 240 + ) { return false; - }); - }; + } - const [missingMedia, setMissingMedia] = useState< - ApiWaveParticipationRequirement[] - >(getMissingRequiredMedia()); + return true; + }, [drop, files, missingMedia, missingMetadata, markdownContent]); - const [missingMetadata, setMissingMetadata] = useState< - ApiWaveRequiredMetadata[] - >(getMissingRequiredMetadata()); + const canAddPart = useMemo(() => { + const hasMarkdownOrFile = Boolean(markdownContent) || files.length > 0; + if (!hasMarkdownOrFile) { + return false; + } - useEffect(() => { - setMissingMetadata(getMissingRequiredMetadata()); - }, [waveProps, wave, drop, metadata]); + const dropContentLength = + drop?.parts?.reduce( + (acc, part) => acc + (part.content?.length ?? 0), + 0 + ) ?? 0; - useEffect(() => { - setMissingMedia(getMissingRequiredMedia()); - }, [waveProps, wave, drop, files]); + const totalContentLength = dropContentLength + (markdownContent?.length ?? 0); - const getCanSubmitStorm = () => { - const markdown = getMarkdown(); - if (markdown?.length && markdown.length > 240) { + if (totalContentLength >= 24000) { return false; } - return true; - }; - const getCanSubmit = () => - !!(!!getMarkdown() || !!files.length || !!drop?.parts.length) && - !missingMedia.length && - !missingMetadata.length && - !!(drop?.parts.length ? getCanSubmitStorm() : true); - - const [canSubmit, setCanSubmit] = useState(getCanSubmit()); - - const getHaveMarkdownOrFile = () => !!getMarkdown() || !!files.length; - const getIsDropLimit = () => - (drop?.parts.reduce( - (acc, part) => acc + (part.content?.length ?? 0), - getMarkdown()?.length ?? 0 - ) ?? 0) >= 24000; - - const getIsCharsLimit = () => { - const markDown = getMarkdown(); - if (!!markDown?.length && markDown.length > 240) { - return true; + if (markdownContent?.length && markdownContent.length > 240) { + return false; } - return false; - }; - const getCanAddPart = () => - getHaveMarkdownOrFile() && !getIsDropLimit() && !getIsCharsLimit(); - const [canAddPart, setCanAddPart] = useState(getCanAddPart()); - useEffect(() => { - setCanSubmit(getCanSubmit()); - setCanAddPart(getCanAddPart()); - }, [editorState, files, drop, missingMedia, missingMetadata]); + return true; + }, [drop, files, markdownContent]); + + const emitCanSubmitChange = useEffectEvent((nextCanSubmit: boolean) => { + onCanSubmitChange?.(nextCanSubmit); + }); useEffect(() => { - if (!onCanSubmitChange) { - return; - } - onCanSubmitChange(canSubmit); - }, [canSubmit]); + emitCanSubmitChange(canSubmit); + }, [canSubmit, emitCanSubmitChange]); const createDropContentFullRef = useRef(null); const createDropContendCompactRef = useRef( @@ -350,8 +354,7 @@ const CreateDropWrapper = forwardRef< setFiles([]); }; const onDropPart = (): CreateDropConfig => { - const markdown = getMarkdown(); - if (!markdown?.length && !files.length) { + if (!markdownContent?.length && !files.length) { const currentDrop: CreateDropConfig = { title, parts: drop?.parts.length ? drop.parts : [], @@ -360,14 +363,14 @@ const CreateDropWrapper = forwardRef< metadata, signature: null, is_safe_signature: isSafeWallet, - signer_address: address, // Already validated via useEffect above + signer_address: address, }; setDrop(currentDrop); clearInputState(); return currentDrop; } const mentions = mentionedUsers.filter((user) => - markdown?.includes(`@[${user.handle_in_content}]`) + markdownContent?.includes(`@[${user.handle_in_content}]`) ); const partMentions = mentions.map((mention) => ({ ...mention, @@ -384,7 +387,7 @@ const CreateDropWrapper = forwardRef< ...notAddedMentions, ]; const partNfts = referencedNfts.filter((nft) => - markdown?.includes(`#[${nft.name}]`) + markdownContent?.includes(`#[${nft.name}]`) ); const notAddedNfts = partNfts.filter( (nft) => @@ -402,10 +405,10 @@ const CreateDropWrapper = forwardRef< metadata, signature: null, is_safe_signature: isSafeWallet, - signer_address: address, // Already validated via useEffect above + signer_address: address, }; currentDrop.parts.push({ - content: markdown?.length ? markdown : null, + content: markdownContent?.length ? markdownContent : null, quoted_drop: quotedDrop && !currentDrop.parts.length ? { @@ -439,13 +442,9 @@ const CreateDropWrapper = forwardRef< [CreateDropViewType.COMPACT]: ( { + const partConfig = useMemo(() => { if (!drop) { return null; } const part = drop.parts.find( - (part) => part.part_id === quotedDrop.drop_part_id + (candidate) => candidate.part_id === quotedDrop.drop_part_id ); if (!part) { return null; @@ -66,12 +66,7 @@ export default function CreateDropStormViewPartQuote({ id: drop.wave.id, }, }; - }; - - const [partConfig, setPartConfig] = useState( - getPartConfig() - ); - useEffect(() => setPartConfig(getPartConfig()), [drop]); + }, [drop, quotedDrop.drop_part_id]); return ( {!!partConfig && ( diff --git a/components/drops/view/Drops.tsx b/components/drops/view/Drops.tsx index fe45550d3f..68b48216fd 100644 --- a/components/drops/view/Drops.tsx +++ b/components/drops/view/Drops.tsx @@ -117,23 +117,24 @@ export default function Drops() { observerRef.current = observer; return () => { - if (observerRef.current) { - observerRef.current.disconnect(); - } + observer.disconnect(); }; }, [onBottomIntersection]); useEffect(() => { - if (bottomRef.current && observerRef.current) { - observerRef.current.observe(bottomRef.current); + const observer = observerRef.current; + const bottomElement = bottomRef.current; + + if (!observer || !bottomElement) { + return; } + observer.observe(bottomElement); + return () => { - if (bottomRef.current && observerRef.current) { - observerRef.current.unobserve(bottomRef.current); - } + observer.unobserve(bottomElement); }; - }, [drops]); + }, [drops.length]); const navigateToDropWave = (drop: Pick) => { const waveInfo = drop.wave as any; diff --git a/components/drops/view/DropsList.tsx b/components/drops/view/DropsList.tsx index 17a48aef78..4bd1c1f52a 100644 --- a/components/drops/view/DropsList.tsx +++ b/components/drops/view/DropsList.tsx @@ -168,7 +168,7 @@ const DropsList = memo(function DropsList({ ); }), - [orderedDrops, getItemData] // Only depends on orderedDrops array and the memoized item data + [orderedDrops, getItemData, location] // Only depends on orderedDrops array, memoized item data, and drop location ); return memoizedDrops; diff --git a/components/drops/view/item/content/nft-tag/DropListItemContentNftDetails.tsx b/components/drops/view/item/content/nft-tag/DropListItemContentNftDetails.tsx index c3d31dc5f7..3cf28786b1 100644 --- a/components/drops/view/item/content/nft-tag/DropListItemContentNftDetails.tsx +++ b/components/drops/view/item/content/nft-tag/DropListItemContentNftDetails.tsx @@ -6,7 +6,7 @@ import { } from "@/helpers/image.helpers"; export default function DropListItemContentNftDetails({ - referencedNft: { contract, token, name: tokenName }, + referencedNft: { name: tokenName }, nft, }: { readonly referencedNft: ReferencedNft; diff --git a/components/drops/view/item/rate/give/DropListItemRateGive.tsx b/components/drops/view/item/rate/give/DropListItemRateGive.tsx index b58c0719db..d278c59541 100644 --- a/components/drops/view/item/rate/give/DropListItemRateGive.tsx +++ b/components/drops/view/item/rate/give/DropListItemRateGive.tsx @@ -33,16 +33,16 @@ export default function DropListItemRateGive({ const minRating = drop.context_profile_context?.min_rating ?? 0; useEffect(() => { - if (!canVote) { - setOnProgressRate(0); - return; - } - if (Math.abs(onProgressRate) > maxRating) { - setOnProgressRate(onProgressRate > 0 ? maxRating : minRating); - return; - } - setOnProgressRate(1); - }, [canVote, drop]); + setOnProgressRate((previousRate) => { + if (!canVote) { + return 0; + } + if (Math.abs(previousRate) > maxRating) { + return previousRate > 0 ? maxRating : minRating; + } + return 1; + }); + }, [canVote, maxRating, minRating]); const onSuccessfulRateChange = () => { setOnProgressRate(1); diff --git a/components/drops/view/item/rate/give/DropListItemRateGiveSubmit.tsx b/components/drops/view/item/rate/give/DropListItemRateGiveSubmit.tsx index c48effd613..514b60bb3e 100644 --- a/components/drops/view/item/rate/give/DropListItemRateGiveSubmit.tsx +++ b/components/drops/view/item/rate/give/DropListItemRateGiveSubmit.tsx @@ -62,7 +62,7 @@ export default function DropListItemRateGiveSubmit({ category: param.category, }, }), - onSuccess: (response: ApiDrop) => { + onSuccess: () => { onSuccessfulRateChange(); optimisticRollbackRef.current = null; }, diff --git a/components/drops/view/item/rate/give/clap/DropListItemRateGiveClap.tsx b/components/drops/view/item/rate/give/clap/DropListItemRateGiveClap.tsx index 8dd58b5c10..aa169ea256 100644 --- a/components/drops/view/item/rate/give/clap/DropListItemRateGiveClap.tsx +++ b/components/drops/view/item/rate/give/clap/DropListItemRateGiveClap.tsx @@ -1,8 +1,9 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import styles from "./Clap.module.scss"; import mojs from "@mojs/core"; +import type { MojsTimelineInstance } from "@mojs/core"; import { formatLargeNumber } from "@/helpers/Helpers"; import { getRandomObjectId } from "@/helpers/AllowlistToolHelpers"; import { Tooltip } from "react-tooltip"; @@ -15,6 +16,10 @@ enum RateStatus { NEGATIVE = "NEGATIVE", } +const POSITIVE_RGBA = "rgba(39, 174, 96, 1)"; +const NEGATIVE_RGBA = "rgba(192, 57, 43, 1)"; +const TIMELINE_DURATION = 300; + export default function DropListItemRateGiveClap({ rate, voteState, @@ -28,94 +33,87 @@ export default function DropListItemRateGiveClap({ readonly onSubmit: () => void; readonly isMobile?: boolean; }) { - const positiveRgba = "rgba(39, 174, 96, 1)"; - const negativeRgba = "rgba(192, 57, 43, 1)"; - - const tlDuration = 300; - const [animationTimeline, setAnimationTimeline] = useState(null); + const animationTimelineRef = useRef(null); const [triangleBurst, setTriangleBurst] = useState(null); const [circleBurst, setCircleBurst] = useState(null); - const [countAnimation, setCountAnimation] = useState(null); - const [scaleButton, setScaleButton] = useState(null); - const [init, setInit] = useState(false); - const randomID = getRandomObjectId(); + const [randomID] = useState(() => getRandomObjectId()); + useEffect(() => { - setTriangleBurst( - new mojs.Burst({ - parent: `#clap-${randomID}`, - radius: { 50: 95 }, - count: 5, - angle: 30, - children: { - shape: "polygon", - radius: { 6: 0 }, - scale: 1, - stroke: positiveRgba, - strokeWidth: 2, - angle: 210, - delay: 30, - speed: 0.2, - easing: mojs.easing.bezier(0.1, 1, 0.3, 1), - duration: tlDuration, - }, - }) - ); - setCircleBurst( - new mojs.Burst({ - parent: `#clap-${randomID}`, - radius: { 50: 75 }, - angle: 25, - duration: tlDuration, - children: { - shape: "circle", - fill: positiveRgba, - delay: 30, - speed: 0.2, - radius: { 3: 0 }, - easing: mojs.easing.bezier(0.1, 1, 0.3, 1), - }, - }) - ); - setCountAnimation( - new mojs.Html({ - el: `#clap--count-${randomID}`, - isShowStart: false, - isShowEnd: true, - y: { 0: -30 }, - opacity: { 0: 1 }, - duration: tlDuration, - }).then({ - opacity: { 1: 0 }, - y: -80, - delay: tlDuration / 2, - }) - ); + const triangle = new mojs.Burst({ + parent: `#clap-${randomID}`, + radius: { 50: 95 }, + count: 5, + angle: 30, + children: { + shape: "polygon", + radius: { 6: 0 }, + scale: 1, + stroke: POSITIVE_RGBA, + strokeWidth: 2, + angle: 210, + delay: 30, + speed: 0.2, + easing: mojs.easing.bezier(0.1, 1, 0.3, 1), + duration: TIMELINE_DURATION, + }, + }); + + const circle = new mojs.Burst({ + parent: `#clap-${randomID}`, + radius: { 50: 75 }, + angle: 25, + duration: TIMELINE_DURATION, + children: { + shape: "circle", + fill: POSITIVE_RGBA, + delay: 30, + speed: 0.2, + radius: { 3: 0 }, + easing: mojs.easing.bezier(0.1, 1, 0.3, 1), + }, + }); + + const countAnimation = new mojs.Html({ + el: `#clap--count-${randomID}`, + isShowStart: false, + isShowEnd: true, + y: { 0: -30 }, + opacity: { 0: 1 }, + duration: TIMELINE_DURATION, + }).then({ + opacity: { 1: 0 }, + y: -80, + delay: TIMELINE_DURATION / 2, + }); + + const scaleButton = new mojs.Html({ + el: `#clap-${randomID}`, + duration: TIMELINE_DURATION, + scale: { 1.3: 1 }, + easing: mojs.easing.out, + }); - setScaleButton( - new mojs.Html({ - el: `#clap-${randomID}`, - duration: tlDuration, - scale: { 1.3: 1 }, - easing: mojs.easing.out, - }) - ); + const timeline = new mojs.Timeline(); + timeline.add([countAnimation, scaleButton, circle, triangle]); + + setTriangleBurst(triangle); + setCircleBurst(circle); + animationTimelineRef.current = timeline; const clap = document.getElementById(`clap-${randomID}`); - clap!.style.transform = "scale(1, 1)"; - setInit(true); - }, []); + if (clap) { + clap.style.transform = "scale(1, 1)"; + } - useEffect(() => { - if (!init) return; - const tempAnimationTimeline = new mojs.Timeline(); - tempAnimationTimeline.add([ - countAnimation, - scaleButton, - circleBurst, - triangleBurst, - ]); - setAnimationTimeline(tempAnimationTimeline); - }, [init]); + return () => { + timeline.stop(); + triangle.stop(); + circle.stop(); + scaleButton.stop(); + const countAnimationInstance = countAnimation as { stop?: () => void }; + countAnimationInstance.stop?.(); + }; + }, [randomID]); const getRateStatus = (): RateStatus => { if (rate > 0) return RateStatus.POSITIVE; @@ -127,15 +125,13 @@ export default function DropListItemRateGiveClap({ if (!canVote) return; const status = getRateStatus(); if (status === RateStatus.NEUTRAL) return; - animationTimeline.replay(); + animationTimelineRef.current?.replay(); onSubmit(); }; const getCountShort = () => `${rate > 0 ? "+" : ""}${formatLargeNumber(rate)}`; - const [countShort, setCountShort] = useState(getCountShort()); - const CLAP_CLASSES: Record = { [RateStatus.POSITIVE]: `${styles.clapPositive}`, [RateStatus.NEGATIVE]: `${styles.clapNegative}`, @@ -197,18 +193,8 @@ export default function DropListItemRateGiveClap({ return `${getClapCountColorClasses()} ${getClapCountSizeAndPositionClasses()}`; }; - const [clapClasses, setClapClasses] = useState(getClapClasses()); - const [textClasses, setTextClasses] = useState(getTextClasses()); - const [clapCountClasses, setClapCountClasses] = useState( - getClapCountClasses() - ); - useEffect(() => { - setCountShort(getCountShort()); - setClapClasses(getClapClasses()); - setTextClasses(getTextClasses()); - setClapCountClasses(getClapCountClasses()); - const burstColor = rate > 0 ? positiveRgba : negativeRgba; + const burstColor = rate > 0 ? POSITIVE_RGBA : NEGATIVE_RGBA; triangleBurst?.tune({ children: { stroke: burstColor, @@ -219,11 +205,16 @@ export default function DropListItemRateGiveClap({ fill: burstColor, }, }); - }, [rate]); + }, [rate, circleBurst, triangleBurst]); + + const countShort = getCountShort(); + const clapClasses = getClapClasses(); + const textClasses = getTextClasses(); + const clapCountClasses = getClapCountClasses(); const svgSize = isMobile ? "tw-size-7" : "tw-h-[18px] tw-w-[18px]"; const tooltipId = `clap-tooltip-${randomID}`; - + return ( <> diff --git a/components/drops/view/part/dropPartMarkdown/handlers/artBlocks.tsx b/components/drops/view/part/dropPartMarkdown/handlers/artBlocks.tsx index a59292a25d..a978e1c458 100644 --- a/components/drops/view/part/dropPartMarkdown/handlers/artBlocks.tsx +++ b/components/drops/view/part/dropPartMarkdown/handlers/artBlocks.tsx @@ -15,8 +15,6 @@ const ART_BLOCKS_FLAG_CANDIDATES = [ "FEATURE_AB_CARD", ] as const; -type ArtBlocksFlagKey = (typeof ART_BLOCKS_FLAG_CANDIDATES)[number]; - const parseFeatureFlagValue = (value: string): boolean => { const normalized = value.trim().toLowerCase(); diff --git a/components/gas-royalties/Gas.tsx b/components/gas-royalties/Gas.tsx index c5efdc2f2f..5780431aae 100644 --- a/components/gas-royalties/Gas.tsx +++ b/components/gas-royalties/Gas.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo } from "react"; import { Container, Row, Col, Table } from "react-bootstrap"; import styles from "./GasRoyalties.module.scss"; import { Gas } from "@/entities/IGas"; @@ -14,6 +14,7 @@ import { import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useTitle } from "@/contexts/TitleContext"; import { GasRoyaltiesCollectionFocus } from "@/enums"; +import { useQuery } from "@tanstack/react-query"; export default function GasComponent() { const router = useRouter(); @@ -21,13 +22,27 @@ export default function GasComponent() { const searchParams = useSearchParams(); const { setTitle } = useTitle(); + const { + setDateSelection, + setIsPrimary, + setIsCustomBlocks, + collectionFocus, + setCollectionFocus, + fetching, + setFetching, + getUrl, + getSharedProps, + } = useSharedState(); + useEffect(() => { const routerFocus = searchParams?.get("focus") as string; const resolvedFocus = Object.values(GasRoyaltiesCollectionFocus).find( (sd) => sd === routerFocus ); if (resolvedFocus) { - setCollectionFocus(resolvedFocus); + if (resolvedFocus !== collectionFocus) { + setCollectionFocus(resolvedFocus); + } const title = `Meme Gas - ${capitalizeEveryWord( resolvedFocus.replace("-", " ") )}`; @@ -35,68 +50,54 @@ export default function GasComponent() { } else { router.push(`${pathname}?focus=${GasRoyaltiesCollectionFocus.MEMES}`); } - }, [searchParams]); - - const [gas, setGas] = useState([]); - const [sumGas, setSumGas] = useState(0); - - const { - dateSelection, - setDateSelection, - fromDate, - toDate, - isPrimary, - setIsPrimary, - isCustomBlocks, - setIsCustomBlocks, - selectedArtist, + }, [ collectionFocus, + pathname, + router, + searchParams, setCollectionFocus, - fetching, - setFetching, - getUrl, - getSharedProps, - fromBlock, - toBlock, - } = useSharedState(); + setTitle, + ]); - function getUrlWithParams() { - return getUrl("gas"); - } + const gasUrl = useMemo(() => getUrl("gas"), [getUrl]); + const getUrlWithParams = useCallback(() => gasUrl, [gasUrl]); - function fetchGas() { - setFetching(true); - fetchUrl(getUrlWithParams()).then((res: Gas[]) => { - res.forEach((r) => { - r.gas = Math.round(r.gas * 100000) / 100000; - }); - setGas(res); - setSumGas(res.map((g) => g.gas).reduce((a, b) => a + b, 0)); - setFetching(false); - }); - } + const { + data: gas = [], + isFetching, + } = useQuery({ + queryKey: ["gas-royalties", gasUrl], + enabled: Boolean(collectionFocus && gasUrl), + placeholderData: [], + queryFn: async () => { + if (!gasUrl) { + return []; + } - useEffect(() => { - if (collectionFocus) { - fetchGas(); - } - }, [ - dateSelection, - fromDate, - toDate, - fromBlock, - toBlock, - selectedArtist, - isPrimary, - isCustomBlocks, - ]); + try { + const res = (await fetchUrl(gasUrl)) as Gas[]; + return res.map((item) => ({ + ...item, + gas: Math.round(item.gas * 100000) / 100000, + })); + } catch (error) { + console.error("Failed to fetch gas", error); + return []; + } + }, + }); + + const sumGas = useMemo( + () => gas.reduce((total, item) => total + item.gas, 0), + [gas] + ); useEffect(() => { - if (collectionFocus) { - setGas([]); - fetchGas(); + if (!collectionFocus) { + return; } - }, [collectionFocus]); + setFetching(isFetching); + }, [collectionFocus, isFetching, setFetching]); if (!collectionFocus) { return <>>; diff --git a/components/gas-royalties/GasRoyalties.tsx b/components/gas-royalties/GasRoyalties.tsx index 40fb4476c2..ccb90829f9 100644 --- a/components/gas-royalties/GasRoyalties.tsx +++ b/components/gas-royalties/GasRoyalties.tsx @@ -11,7 +11,7 @@ import { faInfoCircle } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import Image from "next/image"; import { usePathname, useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Col, Container, Dropdown, Row } from "react-bootstrap"; import { Tooltip } from "react-tooltip"; import DotLoader from "../dotLoader/DotLoader"; @@ -378,9 +378,21 @@ export function useSharedState() { useState(); const [fetching, setFetching] = useState(true); - function getUrl(type: string) { - return getUrlParams( - type, + const getUrl = useCallback( + (type: string) => + getUrlParams( + type, + isPrimary, + isCustomBlocks, + dateSelection, + collectionFocus, + fromDate, + toDate, + fromBlock, + toBlock, + selectedArtist + ), + [ isPrimary, isCustomBlocks, dateSelection, @@ -389,9 +401,9 @@ export function useSharedState() { toDate, fromBlock, toBlock, - selectedArtist - ); - } + selectedArtist, + ] + ); function getSharedProps() { return { diff --git a/components/gas-royalties/Royalties.tsx b/components/gas-royalties/Royalties.tsx index a243dd091f..816ba85354 100644 --- a/components/gas-royalties/Royalties.tsx +++ b/components/gas-royalties/Royalties.tsx @@ -1,6 +1,7 @@ "use client"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; import { Container, Row, Col, Table } from "react-bootstrap"; import styles from "./GasRoyalties.module.scss"; import { Royalty } from "@/entities/IRoyalty"; @@ -26,91 +27,120 @@ export default function RoyaltiesComponent() { const searchParams = useSearchParams(); const { setTitle } = useTitle(); - useEffect(() => { - const routerFocus = searchParams?.get("focus") as string; - const resolvedFocus = Object.values(GasRoyaltiesCollectionFocus).find( - (sd) => sd === routerFocus - ); - if (resolvedFocus) { - setCollectionFocus(resolvedFocus); - const title = `Meme Accounting - ${capitalizeEveryWord( - resolvedFocus.replace("-", " ") - )}`; - setTitle(title); - } else { - router.push(`${pathname}?focus=${GasRoyaltiesCollectionFocus.MEMES}`); - } - }, [searchParams]); - - const [royalties, setRoyalties] = useState([]); - const [sumVolume, setSumVolume] = useState(0); - const [sumProceeds, setSumProceeds] = useState(0); - const [sumArtistTake, setSumArtistTake] = useState(0); - const { - dateSelection, setDateSelection, - fromDate, - toDate, isPrimary, setIsPrimary, isCustomBlocks, setIsCustomBlocks, - selectedArtist, collectionFocus, setCollectionFocus, + dateSelection, + fromDate, + toDate, + fromBlock, + toBlock, + selectedArtist, fetching, setFetching, getUrl, getSharedProps, - fromBlock, - toBlock, } = useSharedState(); - function getUrlWithParams() { - return getUrl("royalties"); - } - - function fetchRoyalties() { - setFetching(true); - fetchUrl(getUrlWithParams()).then((res: Royalty[]) => { - res.forEach((r) => { - r.volume = Math.round(r.volume * 100000) / 100000; - r.proceeds = Math.round(r.proceeds * 100000) / 100000; - r.artist_split = Math.round(r.artist_split * 100000) / 100000; - r.artist_take = Math.round(r.artist_take * 100000) / 100000; - }); - setRoyalties(res); - setSumVolume(res.reduce((prev, current) => prev + current.volume, 0)); - setSumProceeds(res.reduce((prev, current) => prev + current.proceeds, 0)); - setSumArtistTake( - res.reduce((prev, current) => prev + current.artist_take, 0) - ); - setFetching(false); - }); - } - useEffect(() => { - if (collectionFocus) { - fetchRoyalties(); + const routerFocus = searchParams?.get("focus"); + const resolvedFocus = Object.values(GasRoyaltiesCollectionFocus).find( + (sd) => sd === routerFocus + ); + if (resolvedFocus) { + if (resolvedFocus !== collectionFocus) { + setCollectionFocus(resolvedFocus); + } + const title = `Meme Accounting - ${capitalizeEveryWord( + resolvedFocus.replace("-", " ") + )}`; + setTitle(title); + } else { + router.push(`${pathname}?focus=${GasRoyaltiesCollectionFocus.MEMES}`); } }, [ - dateSelection, - fromDate, - toDate, - fromBlock, - toBlock, - selectedArtist, - isPrimary, - isCustomBlocks, + collectionFocus, + pathname, + router, + searchParams, + setCollectionFocus, + setTitle, ]); + const getUrlWithParams = useCallback(() => getUrl("royalties"), [getUrl]); + + const royaltiesUrl = useMemo( + () => (collectionFocus ? getUrlWithParams() : ""), + [collectionFocus, getUrlWithParams] + ); + + const { data: royalties = [], isFetching: isRoyaltiesFetching } = useQuery({ + queryKey: [ + "gas-royalties", + "royalties", + collectionFocus, + isPrimary, + isCustomBlocks, + dateSelection, + fromDate, + toDate, + fromBlock, + toBlock, + selectedArtist, + ], + enabled: Boolean(royaltiesUrl), + queryFn: async () => { + if (!royaltiesUrl) { + return []; + } + + try { + const res = (await fetchUrl(royaltiesUrl)) as Royalty[]; + return res.map((royalty) => ({ + ...royalty, + volume: Math.round(royalty.volume * 100000) / 100000, + proceeds: Math.round(royalty.proceeds * 100000) / 100000, + artist_split: Math.round(royalty.artist_split * 100000) / 100000, + artist_take: Math.round(royalty.artist_take * 100000) / 100000, + })); + } catch (error) { + console.error("Failed to fetch royalties", error); + return []; + } + }, + }); + useEffect(() => { - if (collectionFocus) { - setRoyalties([]); - fetchRoyalties(); + if (!collectionFocus) { + setFetching(true); + return; } - }, [collectionFocus]); + + setFetching(isRoyaltiesFetching); + }, [collectionFocus, isRoyaltiesFetching, setFetching]); + + const { sumVolume, sumProceeds, sumArtistTake } = useMemo(() => { + if (!royalties.length) { + return { sumVolume: 0, sumProceeds: 0, sumArtistTake: 0 }; + } + + return { + sumVolume: royalties.reduce((total, current) => total + current.volume, 0), + sumProceeds: royalties.reduce( + (total, current) => total + current.proceeds, + 0 + ), + sumArtistTake: royalties.reduce( + (total, current) => total + current.artist_take, + 0 + ), + }; + }, [royalties]); if (!collectionFocus) { return <>>; diff --git a/components/groups/header/GroupHeaderSelect.tsx b/components/groups/header/GroupHeaderSelect.tsx index 27e34c8dbc..2320456fd4 100644 --- a/components/groups/header/GroupHeaderSelect.tsx +++ b/components/groups/header/GroupHeaderSelect.tsx @@ -1,14 +1,12 @@ "use client"; -import { useContext, useEffect, useState } from "react"; +import { useContext } from "react"; import { AuthContext } from "@/components/auth/Auth"; import PrimaryButtonLink from "@/components/utils/button/PrimaryButtonLink"; export default function GroupHeaderSelect() { const { connectedProfile } = useContext(AuthContext); - const getHaveProfile = (): boolean => !!connectedProfile?.handle; - const [haveProfile, setHaveProfile] = useState(getHaveProfile()); - useEffect(() => setHaveProfile(getHaveProfile()), [connectedProfile]); + const haveProfile = Boolean(connectedProfile?.handle); const noProfileTitle = connectedProfile && !haveProfile diff --git a/components/groups/page/Groups.tsx b/components/groups/page/Groups.tsx index ff4eed8283..12d56fee40 100644 --- a/components/groups/page/Groups.tsx +++ b/components/groups/page/Groups.tsx @@ -1,6 +1,8 @@ "use client"; -import { useContext, useEffect, useState, type JSX } from "react"; +import { faArrowLeft } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useCallback, useContext, useEffect, useState, type JSX } from "react"; import GroupCreate from "./create/GroupCreate"; import { AuthContext } from "@/components/auth/Auth"; import GroupsPageListWrapper from "./GroupsPageListWrapper"; @@ -28,38 +30,52 @@ export default function Groups() { const [viewMode, setViewMode] = useState(GroupsViewMode.VIEW); - const onViewModeChange = async (mode: GroupsViewMode): Promise => { - if (mode === GroupsViewMode.CREATE) { - const { success } = await requestAuth(); - if (!success) return; - } else if (pathname) { - router.replace(pathname); - } + const onViewModeChange = useCallback( + async (mode: GroupsViewMode): Promise => { + if (mode === GroupsViewMode.CREATE) { + const { success } = await requestAuth(); + if (!success) return; + } else if (pathname) { + router.replace(pathname); + } + + setViewMode(mode); + }, + [pathname, requestAuth, router], + ); - setViewMode(mode); - }; + const triggerViewModeChange = useCallback( + (mode: GroupsViewMode): void => { + onViewModeChange(mode).catch((error) => { + console.error("Failed to update groups view mode", error); + }); + }, + [onViewModeChange], + ); + + const connectedHandle = connectedProfile?.handle; useEffect(() => { - if (edit && !!connectedProfile?.handle && !activeProfileProxy) { - onViewModeChange(GroupsViewMode.CREATE); + if (edit && !!connectedHandle && !activeProfileProxy) { + triggerViewModeChange(GroupsViewMode.CREATE); } - }, [edit]); + }, [activeProfileProxy, connectedHandle, edit, triggerViewModeChange]); useEffect(() => { - if (!connectedProfile?.handle || activeProfileProxy) { - onViewModeChange(GroupsViewMode.VIEW); + if (!connectedHandle || activeProfileProxy) { + triggerViewModeChange(GroupsViewMode.VIEW); } - }, [connectedProfile, activeProfileProxy]); + }, [activeProfileProxy, connectedHandle, triggerViewModeChange]); const components: Record = { [GroupsViewMode.VIEW]: ( onViewModeChange(GroupsViewMode.CREATE)} + onCreateNewGroup={() => triggerViewModeChange(GroupsViewMode.CREATE)} /> ), [GroupsViewMode.CREATE]: ( onViewModeChange(GroupsViewMode.VIEW)} + onCompleted={() => triggerViewModeChange(GroupsViewMode.VIEW)} edit={edit ?? "new"} /> ), @@ -70,22 +86,13 @@ export default function Groups() { {viewMode === GroupsViewMode.CREATE && ( onViewModeChange(GroupsViewMode.VIEW)} + onClick={() => triggerViewModeChange(GroupsViewMode.VIEW)} type="button" className="tw-py-2 tw-px-2 -tw-ml-2 tw-flex tw-items-center tw-gap-x-2 tw-justify-center tw-text-sm tw-font-semibold tw-border-0 tw-rounded-lg tw-transition tw-duration-300 tw-ease-out tw-cursor-pointer tw-text-iron-400 tw-bg-transparent hover:tw-text-iron-50"> - - - + /> Back )} diff --git a/components/groups/page/GroupsPageListWrapper.tsx b/components/groups/page/GroupsPageListWrapper.tsx index e0040f0cbb..399935b8fa 100644 --- a/components/groups/page/GroupsPageListWrapper.tsx +++ b/components/groups/page/GroupsPageListWrapper.tsx @@ -26,25 +26,12 @@ export default function GroupsPageListWrapper({ readonly onCreateNewGroup: () => void; }) { const { connectedProfile, activeProfileProxy } = useContext(AuthContext); - const getShowCreateNewGroupButton = () => { - return !!connectedProfile?.handle && !activeProfileProxy; - }; + const showCreateNewGroupButton = + !!connectedProfile?.handle && !activeProfileProxy; - const [showCreateNewGroupButton, setShowCreateNewGroupButton] = useState( - getShowCreateNewGroupButton() - ); - - const getShowMyGroupsButton = () => + const showMyGroupsButton = !!connectedProfile?.handle || !!activeProfileProxy; - const [showMyGroupsButton, setShowMyGroupsButton] = useState( - getShowMyGroupsButton() - ); - useEffect(() => { - setShowCreateNewGroupButton(getShowCreateNewGroupButton()); - setShowMyGroupsButton(getShowMyGroupsButton()); - }, [connectedProfile, activeProfileProxy]); - const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); diff --git a/components/groups/page/create/GroupCreate.tsx b/components/groups/page/create/GroupCreate.tsx index 2048ef0c6d..c6405dbacb 100644 --- a/components/groups/page/create/GroupCreate.tsx +++ b/components/groups/page/create/GroupCreate.tsx @@ -152,19 +152,15 @@ export default function GroupCreate({ }); }, [originalGroup, originalGroupWallets, originalGroupExcludedWallets]); - const getMyAddresses = () => { - if (!connectedProfile) { - return []; - } - return connectedProfile.wallets?.map((w) => w.wallet.toLowerCase()) ?? []; - }; - useEffect(() => { if (!connectedProfile) { return; } - const myAddresses = getMyAddresses(); + const myAddresses = + connectedProfile.wallets?.map((wallet) => + wallet.wallet.toLowerCase() + ) ?? []; setIAmIncluded( groupConfig.group.identity_addresses?.some((address) => diff --git a/components/groups/page/create/config/wallets/CreateGroupWalletsEmma.tsx b/components/groups/page/create/config/wallets/CreateGroupWalletsEmma.tsx index 0d12f79069..3316ea63ef 100644 --- a/components/groups/page/create/config/wallets/CreateGroupWalletsEmma.tsx +++ b/components/groups/page/create/config/wallets/CreateGroupWalletsEmma.tsx @@ -1,6 +1,6 @@ "use client"; -import { useContext, useEffect, useState } from "react"; +import { useContext, useEffect, useState, useCallback } from "react"; import EmmaListSearch from "@/components/utils/input/emma/EmmaListSearch"; import { AllowlistDescription, @@ -24,7 +24,6 @@ export default function CreateGroupWalletsEmma({ const { data: emmaList, isFetching } = useQuery({ queryKey: [QueryKey.EMMA_ALLOWLIST_RESULT, { allowlistId: selected?.id }], queryFn: async () => { - await requestAuth(); const { success } = await requestAuth(); if (!success) { return []; @@ -38,16 +37,14 @@ export default function CreateGroupWalletsEmma({ enabled: !!connectedProfile?.handle && !!selected, }); - useEffect( - () => - setWallets(emmaList?.map((item) => item.wallet.toLowerCase()) ?? null), - [emmaList] - ); + useEffect(() => { + setWallets(emmaList?.map((item) => item.wallet.toLowerCase()) ?? null); + }, [emmaList, setWallets]); - const onWalletsRemove = () => { + const onWalletsRemove = useCallback(() => { setWallets(null); setSelected(null); - }; + }, [setWallets]); return ( diff --git a/components/groups/page/create/config/wallets/GroupCreateWallets.tsx b/components/groups/page/create/config/wallets/GroupCreateWallets.tsx index 047a438be5..d40ef67ef4 100644 --- a/components/groups/page/create/config/wallets/GroupCreateWallets.tsx +++ b/components/groups/page/create/config/wallets/GroupCreateWallets.tsx @@ -13,7 +13,14 @@ import { AuthContext } from "@/components/auth/Auth"; import GroupCreateIdentitiesSelect from "../identities/select/GroupCreateIdentitiesSelect"; import CreateGroupWalletsEmma from "./CreateGroupWalletsEmma"; import CreateGroupWalletsUpload from "./CreateGroupWalletsUpload"; -import { useContext, useEffect, useRef, useState } from "react"; +import { + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; export enum GroupCreateWalletsType { INCLUDE = "INCLUDE", @@ -50,10 +57,9 @@ export default function GroupCreateWallets({ CommunityMemberMinimal[] >([]); - const getSelectedWallets = () => - selectedIdentities.map((i) => i.wallet ?? i.primary_wallet); - const [selectedWallets, setSelectedWallets] = useState( - getSelectedWallets() + const selectedWallets = useMemo( + () => selectedIdentities.map((i) => i.wallet ?? i.primary_wallet), + [selectedIdentities] ); const walletsRef = useRef(wallets); @@ -77,11 +83,6 @@ export default function GroupCreateWallets({ setUploadedWallets(dedupedWallets); }, [wallets]); - useEffect( - () => setSelectedWallets(getSelectedWallets()), - [selectedIdentities] - ); - useEffect(() => { if (type === GroupCreateWalletsType.EXCLUDE || iAmIncluded) { return; @@ -112,11 +113,17 @@ export default function GroupCreateWallets({ }); }; - const onUploadedWalletsChange = (newV: string[] | null) => - setUploadedWallets(newV ? dedupeWallets(newV) : null); + const onUploadedWalletsChange = useCallback( + (newV: string[] | null) => + setUploadedWallets(newV ? dedupeWallets(newV) : null), + [] + ); - const onEmmaWalletsChange = (newV: string[] | null) => - setEmmaWallets(newV ? dedupeWallets(newV) : null); + const onEmmaWalletsChange = useCallback( + (newV: string[] | null) => + setEmmaWallets(newV ? dedupeWallets(newV) : null), + [] + ); useEffect(() => { const uploaded = uploadedWallets ?? []; diff --git a/components/groups/page/list/card/GroupCard.tsx b/components/groups/page/list/card/GroupCard.tsx index 77c681b757..dd88ddb3cf 100644 --- a/components/groups/page/list/card/GroupCard.tsx +++ b/components/groups/page/list/card/GroupCard.tsx @@ -64,15 +64,20 @@ export default function GroupCard({ if (!isActiveGroupVoteAll) { setState(GroupCardState.IDLE); } - }); + }, [isActiveGroupVoteAll]); const onActionCancel = () => onGroupStateChange(GroupCardState.IDLE); useEffect(() => { - if (!connectedProfile?.handle) { - onGroupStateChange(GroupCardState.IDLE); + if (connectedProfile?.handle) { + return; } - }, [connectedProfile?.handle]); + if (!setActiveGroupIdVoteAll || !group) { + return; + } + setActiveGroupIdVoteAll(null); + setState(GroupCardState.IDLE); + }, [connectedProfile?.handle, group, setActiveGroupIdVoteAll]); const onEditClick = (group: ApiGroupFull) => { router.push(`/network/groups?edit=${group.id}`); diff --git a/components/groups/page/list/card/GroupCardActionWrapper.tsx b/components/groups/page/list/card/GroupCardActionWrapper.tsx index 06d083d8f8..77c03c95c3 100644 --- a/components/groups/page/list/card/GroupCardActionWrapper.tsx +++ b/components/groups/page/list/card/GroupCardActionWrapper.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import type { ReactNode } from "react"; import GroupCardActionFooter from "./utils/GroupCardActionFooter"; import { ApiRateMatter } from "@/generated/models/ApiRateMatter"; @@ -23,28 +23,18 @@ export default function GroupCardActionWrapper({ readonly matter: ApiRateMatter; readonly onSave: () => void; readonly onCancel: () => void; - - readonly children: React.ReactNode; + readonly children: ReactNode; }) { const MATTER_LABEL: Record = { [ApiRateMatter.Rep]: "Rep", [ApiRateMatter.Cic]: "NIC", }; - const getProgress = (): string => { - if ( - typeof membersCount !== "number" || - typeof doneMembersCount !== "number" - ) { - return "0%"; - } - return `${(doneMembersCount / membersCount) * 100}%`; - }; - - const [progress, setProgress] = useState(getProgress()); - - useEffect(() => { - setProgress(getProgress()); - }, [membersCount, doneMembersCount]); + const progress = + typeof membersCount !== "number" || + typeof doneMembersCount !== "number" || + membersCount === 0 + ? "0%" + : `${(doneMembersCount / membersCount) * 100}%`; return ( diff --git a/components/react-query-wrapper/ReactQueryWrapper.tsx b/components/react-query-wrapper/ReactQueryWrapper.tsx index cd894bf40f..7ea6b3ecd0 100644 --- a/components/react-query-wrapper/ReactQueryWrapper.tsx +++ b/components/react-query-wrapper/ReactQueryWrapper.tsx @@ -82,6 +82,8 @@ export enum QueryKey { WAVE_FOLLOWERS = "WAVE_FOLLOWERS", FEED_ITEMS = "FEED_ITEMS", WAVE_DECISIONS = "WAVE_DECISIONS", + DISTRIBUTIONS = "DISTRIBUTIONS", + DISTRIBUTION_PHOTOS = "DISTRIBUTION_PHOTOS", } interface InitProfileRatersParamsAndData { diff --git a/types/mojs.d.ts b/types/mojs.d.ts index b94ffd4d36..beb1f5d367 100644 --- a/types/mojs.d.ts +++ b/types/mojs.d.ts @@ -1 +1,74 @@ -declare module "@mojs/core"; +declare module "@mojs/core" { + type NumericRecord = Record; + + interface MojsAnimatable { + tune?(options: Record): this; + replay?(progress?: number): this; + stop?(): this; + } + + interface BurstOptions { + parent?: string | Element; + radius?: number | NumericRecord; + count?: number; + angle?: number; + duration?: number; + delay?: number | string; + children?: Record; + [key: string]: unknown; + } + + interface HtmlOptions { + el: string | Element; + duration?: number; + scale?: number | NumericRecord; + easing?: unknown; + isShowStart?: boolean; + isShowEnd?: boolean; + opacity?: number | NumericRecord; + y?: number | NumericRecord; + [key: string]: unknown; + } + + export class Burst implements MojsAnimatable { + constructor(options?: BurstOptions); + tune(options: Record): this; + replay(): this; + stop(): this; + } + + export class Html implements MojsAnimatable { + constructor(options?: HtmlOptions); + then(options: Record): this; + tune(options: Record): this; + replay(): this; + stop(): this; + } + + export class Timeline { + add(items: MojsAnimatable | MojsAnimatable[]): this; + replay(): this; + stop(): this; + } + + export const easing: { + bezier: (...args: number[]) => unknown; + out: unknown; + [key: string]: unknown; + }; + + export interface MojsStatic { + Burst: typeof Burst; + Html: typeof Html; + Timeline: typeof Timeline; + easing: typeof easing; + [key: string]: unknown; + } + + const mojs: MojsStatic; + export default mojs; + + export type MojsTimelineInstance = Timeline; + export type MojsBurstInstance = Burst; + export type MojsHtmlInstance = Html; +}