From 0322c367a78f0033c5c62208c8a93a09404cc515 Mon Sep 17 00:00:00 2001 From: GelatoGenesis Date: Tue, 5 May 2026 17:24:25 +0300 Subject: [PATCH 1/6] Frontend thats mostly using v2 APIs Signed-off-by: GelatoGenesis --- .../NotificationWaveCreated.test.tsx | 35 +- .../explore-waves/ExploreWaveCard.test.tsx | 139 +--- .../components/memes/MemeDropTraits.test.tsx | 44 +- .../memes/drops/MemeWinnerDrop.test.tsx | 30 + .../memes/drops/MemesLeaderboardDrop.test.tsx | 24 + .../increaseWavesOverviewDropsCount.test.ts | 65 ++ .../waves/drop/DefaultSingleWaveDrop.test.tsx | 12 +- .../waves/drop/MemesSingleWaveDrop.test.tsx | 12 +- .../waves/drop/SingleWaveDropLogs.test.tsx | 46 +- .../waves/drop/SingleWaveDropVoters.test.tsx | 64 +- .../waves/drop/useSingleWaveDropData.test.tsx | 59 ++ .../WaveLeaderboardDropContent.test.tsx | 17 +- ...ltWaveLeaderboardDrop.interaction.test.tsx | 8 +- .../drops/DefaultWaveLeaderboardDrop.test.tsx | 9 +- ...uorumParticipationDropLinkPreview.test.tsx | 85 +- .../WaveWinnersDropHeaderVoters.test.tsx | 24 +- .../drops/MemesWaveWinnerDrop.test.tsx | 28 + __tests__/hooks/useDmWavesList.test.tsx | 18 +- .../hooks/useUnreadNotifications.test.ts | 40 +- __tests__/hooks/useWaveCurationDrops.test.ts | 81 ++ __tests__/hooks/useWaveDrops.test.ts | 84 +- .../useWaveDropsLeaderboard.extra.test.ts | 6 +- __tests__/hooks/useWaveDropsSearch.test.ts | 76 ++ __tests__/hooks/useWavesList.test.tsx | 122 ++- __tests__/hooks/useWavesOverview.test.tsx | 76 -- .../hooks/waves/useWaveDecisions.test.ts | 2 +- .../hooks/waves/useWaveSalesDecisions.test.ts | 2 +- .../services/api/notifications-v2-api.test.ts | 129 +++ app/discover/page.tsx | 1 - .../brain/my-stream/MyStreamWaveChat.tsx | 1 + .../brain/my-stream/MyStreamWaveContent.tsx | 18 + .../notifications/NotificationsWrapper.tsx | 7 +- .../NotificationDropReactedGroup.tsx | 2 +- .../notifications/utils/navigationUtils.ts | 20 +- .../wave-created/NotificationWaveCreated.tsx | 39 +- .../NotificationWaveFollowBtn.tsx | 149 ++++ .../home/explore-waves/ExploreWaveCard.tsx | 31 +- .../explore-waves/ExploreWavesSection.tsx | 20 +- components/memes/drops/MemeDropTraits.tsx | 49 +- components/memes/drops/MemeWinnerDrop.tsx | 21 +- .../memes/drops/MemesLeaderboardDrop.tsx | 20 +- .../react-query-wrapper/ReactQueryWrapper.tsx | 4 + .../utils/increaseWavesOverviewDropsCount.tsx | 118 +++ .../brain/UserPageBrainSidebarMobileStrip.tsx | 26 +- .../brain/UserPageBrainSidebarMostActive.tsx | 4 +- .../brain/UserPageBrainSidebarWaveItem.tsx | 49 +- .../brain/userPageBrainSidebarWave.helpers.ts | 50 ++ components/waves/drop/SingleWaveDropChat.tsx | 15 + components/waves/drop/SingleWaveDropLogs.tsx | 41 +- .../waves/drop/SingleWaveDropVoters.tsx | 43 +- .../waves/drop/useSingleWaveDropData.ts | 20 +- .../waves/drops/WaveDropActionsMarkUnread.tsx | 3 + .../waves/drops/WaveDropQuoteWithSerialNo.tsx | 23 +- components/waves/drops/WaveDropReactions.tsx | 253 ++++-- .../drops/WaveDropReactionsDetailDialog.tsx | 20 +- components/waves/drops/WaveDropReply.tsx | 14 +- components/waves/drops/reaction-utils.ts | 98 ++- components/waves/drops/useDropContent.ts | 15 +- .../waves/drops/wave-drops-all/index.tsx | 33 +- components/waves/header/WaveHeaderFollow.tsx | 6 +- .../waves/header/options/mute/WaveMute.tsx | 5 +- .../content/WaveLeaderboardDropContent.tsx | 31 - .../drops/DefaultWaveLeaderboardDrop.tsx | 2 - .../footer/WaveLeaderboardDropFooter.tsx | 13 - .../header/WaveleaderboardDropRaters.tsx | 51 -- ...aveLeaderboardRightSidebarBoostedDrops.tsx | 1 + .../QuorumParticipationDropLinkPreview.tsx | 32 +- .../useWaveMuteSettings.ts | 3 + components/waves/winners/WaveWinners.tsx | 1 + components/waves/winners/WaveWinnersSmall.tsx | 1 + .../winners/drops/MemesWaveWinnerDrop.tsx | 108 ++- .../header/WaveWinnersDropHeaderVoters.tsx | 23 +- .../wave/hooks/useEnhancedWavesListCore.ts | 33 +- contexts/wave/hooks/useNewDropCounter.ts | 18 +- contexts/wave/utils/wave-messages-utils.ts | 85 +- generated/models/ApiNotificationV2.ts | 8 + generated/models/ApiWaveOverview.ts | 30 + .../ApiWaveOverviewContextProfileContext.ts | 7 + .../models/ApiWaveOverviewContributor.ts | 44 + .../models/ApiWaveOverviewDescriptionDrop.ts | 45 ++ generated/models/ObjectSerializer.ts | 8 +- helpers/stream.helpers.ts | 78 +- hooks/drops/useDropInteractionRules.ts | 5 +- hooks/drops/useDropReaction.ts | 34 +- ...useConnectedAccountsUnreadNotifications.ts | 7 +- hooks/useDmWavesList.ts | 11 +- hooks/useDropMessages.ts | 52 +- hooks/useFavouriteWavesOfIdentity.ts | 27 +- hooks/useNotificationsQuery.tsx | 20 +- hooks/usePinnedWavesServer.ts | 114 +-- hooks/useUnreadNotifications.ts | 10 +- hooks/useVirtualizedWaveDrops.ts | 5 +- hooks/useVirtualizedWaveMessages.ts | 6 +- hooks/useWaveActivityLogs.ts | 43 +- hooks/useWaveBoostedDrops.ts | 13 + hooks/useWaveCurationDrops.ts | 16 +- hooks/useWaveDrops.ts | 72 +- hooks/useWaveDropsLeaderboard.ts | 6 +- hooks/useWaveDropsSearch.ts | 21 +- hooks/useWaveTopVoters.ts | 34 +- hooks/useWavesList.ts | 49 +- hooks/useWavesOverview.ts | 140 ---- hooks/useWavesV2.ts | 145 ++++ hooks/waves/useWaveDecisions.ts | 21 +- hooks/waves/useWaveSalesDecisions.ts | 12 +- openapi.yaml | 77 ++ services/api/drop-api.ts | 28 +- services/api/notifications-v2-api.ts | 330 ++++++++ services/api/pinned-waves-api.ts | 26 +- ...uorum-participation-drop-preview-v2-api.ts | 43 + services/api/wave-curation-drops-v2-api.ts | 69 ++ services/api/wave-decisions-v2-api.ts | 354 +++++++++ services/api/wave-drops-v2-api.ts | 750 ++++++++++++++++++ services/api/waves-v2-api.ts | 197 +++++ types/feed.types.ts | 2 + types/waves.types.ts | 45 +- 116 files changed, 4529 insertions(+), 1432 deletions(-) create mode 100644 __tests__/components/waves/drop/useSingleWaveDropData.test.tsx create mode 100644 __tests__/hooks/useWaveCurationDrops.test.ts create mode 100644 __tests__/hooks/useWaveDropsSearch.test.ts delete mode 100644 __tests__/hooks/useWavesOverview.test.tsx create mode 100644 __tests__/services/api/notifications-v2-api.test.ts create mode 100644 components/brain/notifications/wave-created/NotificationWaveFollowBtn.tsx create mode 100644 components/user/brain/userPageBrainSidebarWave.helpers.ts delete mode 100644 components/waves/leaderboard/drops/footer/WaveLeaderboardDropFooter.tsx create mode 100644 generated/models/ApiWaveOverviewContributor.ts create mode 100644 generated/models/ApiWaveOverviewDescriptionDrop.ts delete mode 100644 hooks/useWavesOverview.ts create mode 100644 hooks/useWavesV2.ts create mode 100644 services/api/notifications-v2-api.ts create mode 100644 services/api/quorum-participation-drop-preview-v2-api.ts create mode 100644 services/api/wave-curation-drops-v2-api.ts create mode 100644 services/api/wave-decisions-v2-api.ts create mode 100644 services/api/wave-drops-v2-api.ts create mode 100644 services/api/waves-v2-api.ts diff --git a/__tests__/components/brain/notifications/NotificationWaveCreated.test.tsx b/__tests__/components/brain/notifications/NotificationWaveCreated.test.tsx index b9de0a906c..95581516c5 100644 --- a/__tests__/components/brain/notifications/NotificationWaveCreated.test.tsx +++ b/__tests__/components/brain/notifications/NotificationWaveCreated.test.tsx @@ -1,18 +1,20 @@ import { render, screen } from "@testing-library/react"; import NotificationWaveCreated from "@/components/brain/notifications/wave-created/NotificationWaveCreated"; -const queryMock = jest.fn(); -jest.mock("@tanstack/react-query", () => ({ - useQuery: (...args: any[]) => queryMock(...args), -})); jest.mock("next/link", () => ({ __esModule: true, default: (p: any) => {p.children}, })); -jest.mock("@/components/waves/header/WaveHeaderFollow", () => ({ +jest.mock( + "@/components/brain/notifications/wave-created/NotificationWaveFollowBtn", + () => ({ + __esModule: true, + default: () =>
, + }) +); +jest.mock("@/hooks/useDeviceInfo", () => ({ __esModule: true, - default: () =>
, - WaveFollowBtnSize: {}, + default: () => ({ isApp: false }), })); jest.mock("@/components/brain/notifications/NotificationsFollowBtn", () => ({ __esModule: true, @@ -20,6 +22,7 @@ jest.mock("@/components/brain/notifications/NotificationsFollowBtn", () => ({ })); jest.mock("@/helpers/image.helpers", () => ({ getScaledImageUri: () => "/scaled.jpg", + getScaledResolvedImageUri: () => "/scaled.jpg", ImageScale: {}, })); jest.mock("@/helpers/Helpers", () => ({ @@ -30,23 +33,15 @@ jest.mock("@/helpers/Helpers", () => ({ const notification = { related_identity: { handle: "alice", pfp: "pfp.png" }, additional_context: { wave_id: "1" }, + related_wave: { + id: "1", + name: "Wave 1", + is_dm_wave: false, + }, created_at: "2024-01-01T00:00:00Z", } as any; it("renders wave data and links", () => { - queryMock.mockReturnValue({ - data: { - id: "1", - name: "Wave 1", - chat: { - scope: { - group: { - is_direct_message: false, - }, - }, - }, - }, - }); render(); expect(screen.getByRole("link", { name: "alice" })).toHaveAttribute( "href", diff --git a/__tests__/components/home/explore-waves/ExploreWaveCard.test.tsx b/__tests__/components/home/explore-waves/ExploreWaveCard.test.tsx index 2e414879ad..46497288b6 100644 --- a/__tests__/components/home/explore-waves/ExploreWaveCard.test.tsx +++ b/__tests__/components/home/explore-waves/ExploreWaveCard.test.tsx @@ -1,8 +1,9 @@ import { render, screen } from "@testing-library/react"; -import type { ApiWave } from "@/generated/models/ApiWave"; import { ExploreWaveCard } from "@/components/home/explore-waves/ExploreWaveCard"; import { getTimeAgoShort } from "@/helpers/Helpers"; import type { ImgHTMLAttributes, ReactNode } from "react"; +import type { SidebarWave } from "@/types/waves.types"; +import { ApiWaveType } from "@/generated/models/ApiWaveType"; jest.mock("next/link", () => ({ __esModule: true, @@ -49,24 +50,15 @@ describe("ExploreWaveCard", () => { mockContentDisplay.mockClear(); }); - it("uses ApiWave last_drop_time and description_drop preview content", () => { + it("uses sidebar wave latest drop time, total drops, and description preview", () => { render( @@ -93,114 +85,41 @@ describe("ExploreWaveCard", () => { }); }); - it("does not render the preview container when description_drop is empty", () => { + it("shows empty state when latest drop is missing", () => { render( ); - expect(screen.queryByTestId("content-display")).not.toBeInTheDocument(); + expect(screen.getByText("No drops yet")).toBeInTheDocument(); }); }); -function createWave(overrides: Partial = {}): ApiWave { - const baseWave = { +function createWave(overrides: Partial = {}): SidebarWave { + return { id: "wave-1", - serial_no: 1, - author: { - handle: "alice", - banner1_color: null, - banner2_color: null, - }, name: "Wave One", + type: ApiWaveType.Chat, picture: null, - created_at: 1, - last_drop_time: 1_000, - description_drop: { - id: "drop-1", - serial_no: 1, - drop_type: "CHAT", - rank: null, - wave: { - id: "wave-1", - }, - author: { - handle: "alice", - }, - created_at: 1, - updated_at: null, - title: null, - parts: [ - { - part_id: 1, - content: "Description preview", - media: [], - quoted_drop: null, - }, - ], - parts_count: 1, - referenced_nfts: [], - mentioned_users: [], - mentioned_waves: [], - metadata: [], - rating: 0, - realtime_rating: 0, - rating_prediction: 0, - top_raters: [], - raters_count: 0, - context_profile_context: null, - subscribed_actions: [], - is_signed: false, - reactions: [], - boosts: 0, - hide_link_preview: false, - }, - voting: {}, - visibility: {}, - participation: {}, - chat: { - scope: { - group: { - is_direct_message: false, - }, - }, - enabled: true, - }, - wave: { - type: "CHAT", + contributors: [], + isDirectMessage: false, + hasCompetition: false, + descriptionDrop: { + contents: "Description preview", + media: [], }, - contributors_overview: [], - subscribed_actions: [], - metrics: { - drops_count: 3, - latest_drop_timestamp: 1_000, - }, - pauses: [], + totalDropsCount: 3, + isPrivate: false, + latestDropTimestamp: 1_000, + firstUnreadDropSerialNo: null, + unreadDropsCount: 0, + latestReadTimestamp: 0, pinned: false, - } as any; - - return { - ...baseWave, + muted: false, + subscribed: false, ...overrides, - metrics: { - ...baseWave.metrics, - ...(overrides.metrics as object | undefined), - }, - description_drop: { - ...baseWave.description_drop, - ...(overrides.description_drop as object | undefined), - }, - } as ApiWave; + }; } diff --git a/__tests__/components/memes/MemeDropTraits.test.tsx b/__tests__/components/memes/MemeDropTraits.test.tsx index 36f0213cdb..834cb83597 100644 --- a/__tests__/components/memes/MemeDropTraits.test.tsx +++ b/__tests__/components/memes/MemeDropTraits.test.tsx @@ -2,16 +2,13 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import MemeDropTraits from "@/components/memes/drops/MemeDropTraits"; -jest.mock( - "@/components/memes/drops/MemeDropTrait", - () => (props: any) => - ( -
- ) -); +jest.mock("@/components/memes/drops/MemeDropTrait", () => (props: any) => ( +
+)); describe("MemeDropTraits", () => { const drop = { @@ -33,4 +30,31 @@ describe("MemeDropTraits", () => { await user.click(screen.getByText("Show less")); expect(screen.getAllByTestId("trait")).toHaveLength(2); }); + + it("does not render the show all control when there are no traits", () => { + const { container } = render( + + ); + + expect(container).toBeEmptyDOMElement(); + expect(screen.queryByText("Show all")).not.toBeInTheDocument(); + }); + + it("does not render the show all control when all traits are visible", () => { + render( + + ); + + expect(screen.getAllByTestId("trait")).toHaveLength(2); + expect(screen.queryByText("Show all")).not.toBeInTheDocument(); + }); }); diff --git a/__tests__/components/memes/drops/MemeWinnerDrop.test.tsx b/__tests__/components/memes/drops/MemeWinnerDrop.test.tsx index 29f1642ef7..f65735b6ca 100644 --- a/__tests__/components/memes/drops/MemeWinnerDrop.test.tsx +++ b/__tests__/components/memes/drops/MemeWinnerDrop.test.tsx @@ -70,3 +70,33 @@ test("hides actions when mobile", () => { ); expect(queryByTestId("reply")).toBeNull(); }); + +test("uses v2 title and part one content before metadata fallbacks", () => { + render( + + ); + + expect(screen.getByText("Part title")).toBeInTheDocument(); + expect(screen.getByText("Part description")).toBeInTheDocument(); + expect(screen.queryByText("Metadata title")).not.toBeInTheDocument(); + expect(screen.queryByText("Metadata description")).not.toBeInTheDocument(); +}); diff --git a/__tests__/components/memes/drops/MemesLeaderboardDrop.test.tsx b/__tests__/components/memes/drops/MemesLeaderboardDrop.test.tsx index 6c84612fe6..377af7bf1d 100644 --- a/__tests__/components/memes/drops/MemesLeaderboardDrop.test.tsx +++ b/__tests__/components/memes/drops/MemesLeaderboardDrop.test.tsx @@ -185,6 +185,30 @@ test("uses mobile modal on small screens", async () => { expect(screen.getByTestId("mobile-modal")).toHaveTextContent("open"); }); +test("uses v2 part one title and content before metadata fallbacks", () => { + useDeviceInfo.mockReturnValue({ hasTouchScreen: false }); + useIsMobileScreen.mockReturnValue(false); + + render( + + ); + + expect(screen.getByTestId("header")).toHaveTextContent("Part title"); + expect(screen.getByTestId("desc")).toHaveTextContent("Part description"); +}); + test("opens mobile resubmit modal after the touch menu leaves", async () => { const setIsActive = jest.fn(); useDeviceInfo.mockReturnValue({ hasTouchScreen: true }); diff --git a/__tests__/components/react-query-wrapper/utils/increaseWavesOverviewDropsCount.test.ts b/__tests__/components/react-query-wrapper/utils/increaseWavesOverviewDropsCount.test.ts index 2d2495ccf4..33c7222661 100644 --- a/__tests__/components/react-query-wrapper/utils/increaseWavesOverviewDropsCount.test.ts +++ b/__tests__/components/react-query-wrapper/utils/increaseWavesOverviewDropsCount.test.ts @@ -17,6 +17,31 @@ function createWave(id: string) { } as any; } +function createSidebarWave(id: string) { + return { + id, + name: id, + type: "CHAT", + picture: null, + contributors: [], + isDirectMessage: false, + hasCompetition: false, + descriptionDrop: { + contents: null, + media: [], + }, + totalDropsCount: 0, + isPrivate: false, + latestDropTimestamp: 0, + firstUnreadDropSerialNo: null, + unreadDropsCount: 0, + latestReadTimestamp: 0, + pinned: false, + muted: false, + subscribed: false, + } as any; +} + describe("increaseWavesOverviewDropsCount", () => { afterEach(() => { jest.restoreAllMocks(); @@ -98,6 +123,46 @@ describe("increaseWavesOverviewDropsCount", () => { expect(result.pageParams).toEqual([undefined, 1]); }); + it("updates v2 overview and pinned caches", async () => { + jest.spyOn(Date, "now").mockReturnValue(4321); + const client = new QueryClient(); + const waveOne = createSidebarWave("w1"); + const waveTwo = createSidebarWave("w2"); + const overviewKey = [ + QueryKey.WAVES_V2, + { + view: "OVERVIEW", + page_size: 20, + overview_type: ApiWavesOverviewType.RecentlyDroppedTo, + only_waves_followed_by_authenticated_user: true, + direct_message: false, + }, + ]; + const pinnedKey = [ + QueryKey.WAVES_V2, + { pinned: ApiWavesPinFilter.Pinned, viewer_identity: "0xabc:primary" }, + ]; + + client.setQueryData(overviewKey, { + pages: [ + { waves: [waveOne], page: 1, next: true }, + { waves: [waveTwo], page: 2, next: false }, + ], + pageParams: [1, 2], + }); + client.setQueryData(pinnedKey, [waveOne]); + + await increaseWavesOverviewDropsCount(client, "w1"); + + const overviewResult: any = client.getQueryData(overviewKey); + const pinnedResult: any = client.getQueryData(pinnedKey); + + expect(overviewResult.pages[0].waves[0].latestDropTimestamp).toBe(4321); + expect(overviewResult.pages[1].waves[0]).toEqual(waveTwo); + expect(overviewResult.pageParams).toEqual([1, 2]); + expect(pinnedResult[0].latestDropTimestamp).toBe(4321); + }); + it("leaves unrelated overview caches untouched", async () => { const client = new QueryClient(); const key = [ diff --git a/__tests__/components/waves/drop/DefaultSingleWaveDrop.test.tsx b/__tests__/components/waves/drop/DefaultSingleWaveDrop.test.tsx index 5b5f8bdb09..487e7c1a52 100644 --- a/__tests__/components/waves/drop/DefaultSingleWaveDrop.test.tsx +++ b/__tests__/components/waves/drop/DefaultSingleWaveDrop.test.tsx @@ -22,12 +22,14 @@ jest.mock("@/components/waves/drop/SingleWaveDropInfoPanel", () => ({ }, })); -jest.mock("@/hooks/useDrop", () => ({ - useDrop: () => ({ drop: { id: "1", wave: { id: "w1" } } }), -})); -jest.mock("@/hooks/useWaveData", () => ({ - useWaveData: () => ({ data: { id: "w1" } }), +jest.mock("@/components/waves/drop/useSingleWaveDropData", () => ({ + useSingleWaveDropData: () => ({ + drop: { id: "1", wave: { id: "w1" } }, + wave: { id: "w1" }, + extendedDrop: { id: "1", wave: { id: "w1" } }, + }), })); + jest.mock("@/hooks/waves/useApprovalWaveStatus", () => ({ useApprovalWaveStatus: (args: any) => mockApprovalStatus(args), })); diff --git a/__tests__/components/waves/drop/MemesSingleWaveDrop.test.tsx b/__tests__/components/waves/drop/MemesSingleWaveDrop.test.tsx index 150b3229a6..9dcddeb4f0 100644 --- a/__tests__/components/waves/drop/MemesSingleWaveDrop.test.tsx +++ b/__tests__/components/waves/drop/MemesSingleWaveDrop.test.tsx @@ -22,12 +22,14 @@ jest.mock("@/components/waves/drop/MemesSingleWaveDropInfoPanel", () => ({ }, })); -jest.mock("@/hooks/useDrop", () => ({ - useDrop: () => ({ drop: { id: "d1", wave: { id: "w1" } } }), -})); -jest.mock("@/hooks/useWaveData", () => ({ - useWaveData: () => ({ data: { id: "w1" } }), +jest.mock("@/components/waves/drop/useSingleWaveDropData", () => ({ + useSingleWaveDropData: () => ({ + drop: { id: "d1", wave: { id: "w1" } }, + wave: { id: "w1" }, + extendedDrop: { id: "d1", wave: { id: "w1" } }, + }), })); + jest.mock("@/hooks/waves/useApprovalWaveStatus", () => ({ useApprovalWaveStatus: (args: any) => mockApprovalStatus(args), })); diff --git a/__tests__/components/waves/drop/SingleWaveDropLogs.test.tsx b/__tests__/components/waves/drop/SingleWaveDropLogs.test.tsx index 13e013b0ce..6bceb6a33e 100644 --- a/__tests__/components/waves/drop/SingleWaveDropLogs.test.tsx +++ b/__tests__/components/waves/drop/SingleWaveDropLogs.test.tsx @@ -1,26 +1,40 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { SingleWaveDropLogs } from '@/components/waves/drop/SingleWaveDropLogs'; -import { useWaveActivityLogs } from '@/hooks/useWaveActivityLogs'; -import { useAuth } from '@/components/auth/Auth'; -import { useIntersectionObserver } from '@/hooks/useIntersectionObserver'; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { SingleWaveDropLogs } from "@/components/waves/drop/SingleWaveDropLogs"; +import { useWaveActivityLogs } from "@/hooks/useWaveActivityLogs"; +import { useAuth } from "@/components/auth/Auth"; +import { useIntersectionObserver } from "@/hooks/useIntersectionObserver"; -jest.mock('@/hooks/useWaveActivityLogs'); -jest.mock('@/components/auth/Auth'); -jest.mock('@/hooks/useIntersectionObserver'); -jest.mock('@/components/waves/drop/SingleWaveDropLog', () => ({ SingleWaveDropLog: (p:any) =>
{p.log.id}
})); +jest.mock("@/hooks/useWaveActivityLogs"); +jest.mock("@/components/auth/Auth"); +jest.mock("@/hooks/useIntersectionObserver"); +jest.mock("@/components/waves/drop/SingleWaveDropLog", () => ({ + SingleWaveDropLog: (p: any) =>
{p.log.id}
, +})); const useWaveActivityLogsMock = useWaveActivityLogs as jest.Mock; (useAuth as jest.Mock).mockReturnValue({ connectedProfile: null }); (useIntersectionObserver as jest.Mock).mockReturnValue({ current: null }); -const drop = { id:'d1', wave:{id:'w', voting_credit_type:0} } as any; +const drop = { id: "d1", wave: { id: "w", voting_credit_type: 0 } } as any; -test('toggles logs display', async () => { +test("toggles logs display", async () => { const user = userEvent.setup(); - useWaveActivityLogsMock.mockReturnValue({ logs:[{id:'1'}], isFetchingNextPage:false, fetchNextPage:jest.fn(), hasNextPage:false, isLoading:false }); + useWaveActivityLogsMock.mockReturnValue({ + logs: [{ id: "1" }], + isFetchingNextPage: false, + fetchNextPage: jest.fn(), + hasNextPage: false, + isLoading: false, + }); render(); - expect(screen.queryByTestId('log')).toBeNull(); - await user.click(screen.getByRole('button')); - expect(screen.getByTestId('log')).toBeInTheDocument(); + expect(useWaveActivityLogsMock).toHaveBeenLastCalledWith( + expect.objectContaining({ enabled: false }) + ); + expect(screen.queryByTestId("log")).toBeNull(); + await user.click(screen.getByRole("button")); + expect(useWaveActivityLogsMock).toHaveBeenLastCalledWith( + expect.objectContaining({ enabled: true }) + ); + expect(screen.getByTestId("log")).toBeInTheDocument(); }); diff --git a/__tests__/components/waves/drop/SingleWaveDropVoters.test.tsx b/__tests__/components/waves/drop/SingleWaveDropVoters.test.tsx index 509af97a7e..21645ed7ca 100644 --- a/__tests__/components/waves/drop/SingleWaveDropVoters.test.tsx +++ b/__tests__/components/waves/drop/SingleWaveDropVoters.test.tsx @@ -1,48 +1,72 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { SingleWaveDropVoters } from '@/components/waves/drop/SingleWaveDropVoters'; -import { useWaveTopVoters } from '@/hooks/useWaveTopVoters'; -import { useAuth } from '@/components/auth/Auth'; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { SingleWaveDropVoters } from "@/components/waves/drop/SingleWaveDropVoters"; +import { useWaveTopVoters } from "@/hooks/useWaveTopVoters"; +import { useAuth } from "@/components/auth/Auth"; let intersectionCb: any; -jest.mock('@/hooks/useWaveTopVoters'); -jest.mock('@/components/auth/Auth', () => ({ useAuth: jest.fn() })); -jest.mock('@/hooks/useIntersectionObserver', () => ({ +jest.mock("@/hooks/useWaveTopVoters"); +jest.mock("@/components/auth/Auth", () => ({ useAuth: jest.fn() })); +jest.mock("@/hooks/useIntersectionObserver", () => ({ useIntersectionObserver: (cb: any) => { intersectionCb = cb; return { current: null }; }, })); -jest.mock('@/components/waves/drop/SingleWaveDropVoter', () => ({ - SingleWaveDropVoter: (props: any) =>
{props.voter.voter.id}
, +jest.mock("@/components/waves/drop/SingleWaveDropVoter", () => ({ + SingleWaveDropVoter: (props: any) => ( +
{props.voter.voter.id}
+ ), })); const useVoters = useWaveTopVoters as jest.Mock; const useAuthMock = useAuth as jest.Mock; -const baseDrop = { id: 'd', wave: { id: 'w', voting_credit_type: 'REP' } } as any; +const baseDrop = { + id: "d", + wave: { id: "w", voting_credit_type: "REP" }, +} as any; -describe('SingleWaveDropVoters', () => { +describe("SingleWaveDropVoters", () => { beforeEach(() => { + useVoters.mockReset(); useAuthMock.mockReturnValue({ connectedProfile: null }); }); - it('shows placeholder when no voters', async () => { + it("shows placeholder when no voters", async () => { const user = userEvent.setup(); - useVoters.mockReturnValue({ voters: [], isFetchingNextPage: false, fetchNextPage: jest.fn(), hasNextPage: false, isLoading: false }); + useVoters.mockReturnValue({ + voters: [], + isFetchingNextPage: false, + fetchNextPage: jest.fn(), + hasNextPage: false, + isLoading: false, + }); render(); - await user.click(screen.getByRole('button')); - expect(screen.getByText('Be the First to Make a Vote')).toBeInTheDocument(); + expect(useVoters).toHaveBeenLastCalledWith( + expect.objectContaining({ enabled: false }) + ); + await user.click(screen.getByRole("button")); + expect(useVoters).toHaveBeenLastCalledWith( + expect.objectContaining({ enabled: true }) + ); + expect(screen.getByText("Be the First to Make a Vote")).toBeInTheDocument(); }); - it('fetches next page on intersection', async () => { + it("fetches next page on intersection", async () => { const user = userEvent.setup(); const fetchNextPage = jest.fn(); - useVoters.mockReturnValue({ voters: [{ voter: { id: 'v1' } }], isFetchingNextPage: false, fetchNextPage, hasNextPage: true, isLoading: false }); + useVoters.mockReturnValue({ + voters: [{ voter: { id: "v1" } }], + isFetchingNextPage: false, + fetchNextPage, + hasNextPage: true, + isLoading: false, + }); render(); - await user.click(screen.getByRole('button')); - expect(screen.getByTestId('voter')).toHaveTextContent('v1'); + await user.click(screen.getByRole("button")); + expect(screen.getByTestId("voter")).toHaveTextContent("v1"); intersectionCb(); expect(fetchNextPage).toHaveBeenCalled(); }); diff --git a/__tests__/components/waves/drop/useSingleWaveDropData.test.tsx b/__tests__/components/waves/drop/useSingleWaveDropData.test.tsx new file mode 100644 index 0000000000..e23a85cfad --- /dev/null +++ b/__tests__/components/waves/drop/useSingleWaveDropData.test.tsx @@ -0,0 +1,59 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import React from "react"; + +import { useSingleWaveDropData } from "@/components/waves/drop/useSingleWaveDropData"; +import { fetchDropV2ById } from "@/services/api/wave-drops-v2-api"; + +jest.mock("@/services/api/wave-drops-v2-api", () => ({ + fetchDropV2ById: jest.fn(), +})); + +jest.mock("@/hooks/useWaveData", () => ({ + useWaveData: () => ({ data: { id: "wave-1" } }), +})); + +const fetchDropV2ByIdMock = fetchDropV2ById as jest.MockedFunction< + typeof fetchDropV2ById +>; + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); +}; + +describe("useSingleWaveDropData", () => { + it("fetches single-drop detail without eager top raters", async () => { + fetchDropV2ByIdMock.mockResolvedValue({ + id: "drop-1", + wave: { id: "wave-1" }, + } as any); + + renderHook( + () => + useSingleWaveDropData( + { + id: "drop-1", + wave: { id: "wave-1" }, + stableHash: "hash", + stableKey: "key", + } as any, + jest.fn() + ), + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(fetchDropV2ByIdMock).toHaveBeenCalledWith( + "drop-1", + expect.objectContaining({ aborted: false }), + { includeTopRaters: false } + ); + }); + }); +}); diff --git a/__tests__/components/waves/leaderboard/content/WaveLeaderboardDropContent.test.tsx b/__tests__/components/waves/leaderboard/content/WaveLeaderboardDropContent.test.tsx index c0c537b397..2406784042 100644 --- a/__tests__/components/waves/leaderboard/content/WaveLeaderboardDropContent.test.tsx +++ b/__tests__/components/waves/leaderboard/content/WaveLeaderboardDropContent.test.tsx @@ -15,22 +15,10 @@ jest.mock("@/components/waves/drops/WaveDropContent", () => ({ __esModule: true, default: (props: any) => waveDropContentMock(props), })); -jest.mock("@/components/waves/drops/WaveDropMetadata", () => ({ - __esModule: true, - default: ({ metadata }: any) => ( -
{metadata.length}
- ), -})); jest.mock("@/components/waves/drops/WaveDropReactions", () => ({ __esModule: true, default: () =>
, })); -jest.mock( - "@/components/waves/leaderboard/identity/WaveLeaderboardIdentity", - () => ({ - WaveLeaderboardIdentity: () =>
, - }) -); const routerMock = useRouter as jest.Mock; @@ -39,7 +27,7 @@ describe("WaveLeaderboardDropContent", () => { waveDropContentMock.mockClear(); }); - it("navigates on drop click, renders identity, and filters reserved metadata", () => { + it("navigates on drop click and renders reactions", () => { const push = jest.fn(); routerMock.mockReturnValue({ push }); const drop = { @@ -56,8 +44,7 @@ describe("WaveLeaderboardDropContent", () => { render(); fireEvent.click(screen.getByTestId("content")); expect(push).toHaveBeenCalledWith("/waves/w?serialNo=5"); - expect(screen.getByTestId("identity")).toBeInTheDocument(); - expect(screen.getByTestId("meta")).toHaveTextContent("1"); + expect(screen.getByTestId("reactions")).toBeInTheDocument(); }); it("forwards custom content presentation to the shared drop content", () => { diff --git a/__tests__/components/waves/leaderboard/drops/DefaultWaveLeaderboardDrop.interaction.test.tsx b/__tests__/components/waves/leaderboard/drops/DefaultWaveLeaderboardDrop.interaction.test.tsx index d8af763ca0..56c425d6ee 100644 --- a/__tests__/components/waves/leaderboard/drops/DefaultWaveLeaderboardDrop.interaction.test.tsx +++ b/__tests__/components/waves/leaderboard/drops/DefaultWaveLeaderboardDrop.interaction.test.tsx @@ -34,6 +34,10 @@ jest.mock("@/components/waves/drops/WaveDropActionsOpen", () => ({ __esModule: true, default: () =>
, })); +jest.mock("@/components/waves/drops/DropCurationButton", () => ({ + __esModule: true, + default: () =>
, +})); jest.mock("@/components/waves/drops/WaveDropMobileMenuOpen", () => (p: any) => ( ), })); +jest.mock("@/components/waves/drops/WaveDropMobileMenuCopyLink", () => ({ + __esModule: true, + default: () => +
+ ); +} diff --git a/components/home/explore-waves/ExploreWaveCard.tsx b/components/home/explore-waves/ExploreWaveCard.tsx index d895ba40fa..fbfe3204e2 100644 --- a/components/home/explore-waves/ExploreWaveCard.tsx +++ b/components/home/explore-waves/ExploreWaveCard.tsx @@ -1,41 +1,38 @@ "use client"; -import type { ApiWave } from "@/generated/models/ApiWave"; -import { getRandomColorWithSeed, getTimeAgoShort } from "@/helpers/Helpers"; import ContentDisplay from "@/components/waves/drops/ContentDisplay"; import { buildProcessedContent, type ProcessedContent, } from "@/components/waves/drops/media-utils"; +import { getRandomColorWithSeed, getTimeAgoShort } from "@/helpers/Helpers"; import { getScaledImageUri, ImageScale } from "@/helpers/image.helpers"; import { getWaveRoute } from "@/helpers/navigation.helpers"; +import type { SidebarWave } from "@/types/waves.types"; import Image from "next/image"; import Link from "next/link"; interface ExploreWaveCardProps { - readonly wave: ApiWave; + readonly wave: SidebarWave; } export function ExploreWaveCard({ wave }: ExploreWaveCardProps) { const waveHref = getWaveRoute({ waveId: wave.id, - isDirectMessage: wave.chat.scope.group?.is_direct_message ?? false, + isDirectMessage: wave.isDirectMessage, isApp: false, }); - const author = wave.author; - const banner1 = - author.banner1_color ?? getRandomColorWithSeed(author.handle ?? ""); - const banner2 = - author.banner2_color ?? getRandomColorWithSeed(author.handle ?? ""); + const banner1 = getRandomColorWithSeed(wave.id); + const banner2 = getRandomColorWithSeed(wave.name); const imageAreaStyle = !wave.picture ? { background: `linear-gradient(135deg, ${banner1} 0%, ${banner2} 100%)`, } : undefined; - const lastMessageTime = wave.last_drop_time; - const hasDrops = wave.metrics.drops_count > 0; + const lastMessageTime = wave.latestDropTimestamp; + const hasDrops = lastMessageTime !== null; const descriptionPreview = getWavePreviewContent(wave); return ( @@ -78,7 +75,7 @@ export function ExploreWaveCard({ wave }: ExploreWaveCardProps) { {getTimeAgoShort(lastMessageTime)} ·{" "} - {wave.metrics.drops_count.toLocaleString()} + {wave.totalDropsCount.toLocaleString()} {" "} drops
@@ -114,13 +111,9 @@ function MessagePreviewContent({ ); } -function getWavePreviewContent(wave: ApiWave): ProcessedContent | null { - const descriptionParts = wave.description_drop.parts; - const combinedText = descriptionParts - .map((part) => part.content?.trim()) - .filter((content): content is string => Boolean(content)) - .join("\n\n"); - const media = descriptionParts.flatMap((part) => part.media); +function getWavePreviewContent(wave: SidebarWave): ProcessedContent | null { + const combinedText = wave.descriptionDrop.contents?.trim() ?? ""; + const media = [...wave.descriptionDrop.media]; if (!combinedText && media.length === 0) { return null; diff --git a/components/home/explore-waves/ExploreWavesSection.tsx b/components/home/explore-waves/ExploreWavesSection.tsx index 87d92b6f8d..1c2a6d9129 100644 --- a/components/home/explore-waves/ExploreWavesSection.tsx +++ b/components/home/explore-waves/ExploreWavesSection.tsx @@ -1,8 +1,8 @@ "use client"; import { useAuth } from "@/components/auth/Auth"; -import type { ApiWave } from "@/generated/models/ApiWave"; -import { commonApiFetch } from "@/services/api/common-api"; +import { ApiWavesV2ListType } from "@/generated/models/ApiWavesV2ListType"; +import { fetchWavesV2Page } from "@/services/api/waves-v2-api"; import { ArrowRightIcon } from "@heroicons/react/24/outline"; import { useQuery } from "@tanstack/react-query"; import Link from "next/link"; @@ -15,7 +15,6 @@ interface ExploreWavesSectionProps { readonly title?: string | undefined; readonly subtitle?: string | null | undefined; readonly limit?: number | undefined; - readonly endpoint?: string | undefined; readonly viewAllHref?: string | null | undefined; readonly excludeFollowed?: boolean | undefined; } @@ -24,7 +23,6 @@ export function ExploreWavesSection({ title = "Tired of bot replies? Join the most interesting chats in crypto", subtitle = "Most active waves", limit = DEFAULT_WAVES_LIMIT, - endpoint = "waves-overview/hot", viewAllHref = "/waves", excludeFollowed = false, }: ExploreWavesSectionProps) { @@ -39,20 +37,22 @@ export function ExploreWavesSection({ data: waves, isLoading, isError, - } = useQuery({ + } = useQuery({ queryKey: [ "explore-waves", - endpoint, + ApiWavesV2ListType.Hot, limit, excludeFollowed, excludeFollowed ? userScope : null, ], queryFn: async () => { - const data = await commonApiFetch({ - endpoint, - params: excludeFollowed ? { exclude_followed: "true" } : undefined, + const page = await fetchWavesV2Page({ + view: ApiWavesV2ListType.Hot, + page: 1, + pageSize: limit, + excludeFollowed, }); - return data.slice(0, limit); + return page.waves; }, staleTime: excludeFollowed ? 0 : 5 * 60 * 1000, ...(excludeFollowed ? { gcTime: 0 } : {}), diff --git a/components/memes/drops/MemeDropTraits.tsx b/components/memes/drops/MemeDropTraits.tsx index eb13ef2758..6b867e557c 100644 --- a/components/memes/drops/MemeDropTraits.tsx +++ b/components/memes/drops/MemeDropTraits.tsx @@ -29,6 +29,7 @@ const MemeDropTraits: React.FC = ({ drop }) => { const bIndex = MEME_TRAITS_SORT_ORDER.indexOf(b.data_key); return aIndex - bIndex; }); + const hasHiddenTraits = traits.length > 2; const handleShowLess = (e: React.MouseEvent) => { e.stopPropagation(); @@ -40,9 +41,39 @@ const MemeDropTraits: React.FC = ({ drop }) => { setShowAllTraits(true); }; + if (!traits.length) { + return null; + } + + const traitToggle = (() => { + if (showAllTraits) { + return ( + + ); + } + + if (!hasHiddenTraits) { + return null; + } + + return ( + + ); + })(); + return ( -
-
+
+
{traits.slice(0, 2).map((trait) => ( = ({ drop }) => { dropId={drop.id} /> ))} - + {traitToggle} ) : ( - + traitToggle )}
diff --git a/components/memes/drops/MemeWinnerDrop.tsx b/components/memes/drops/MemeWinnerDrop.tsx index 6ea89d2d1c..1ab184c5cd 100644 --- a/components/memes/drops/MemeWinnerDrop.tsx +++ b/components/memes/drops/MemeWinnerDrop.tsx @@ -34,6 +34,21 @@ const getRankHoverClass = (rank: number | null): string => { return getRankHoverBorderClass(rank); }; +const getNonEmptyText = ( + value: string | null | undefined +): string | undefined => { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : undefined; +}; + +const getMetadataValue = ( + drop: ExtendedDrop, + dataKey: string +): string | undefined => + getNonEmptyText( + drop.metadata.find((metadata) => metadata.data_key === dataKey)?.data_value + ); + export default function MemeWinnerDrop({ drop, showReplyAndQuote, @@ -46,10 +61,12 @@ export default function MemeWinnerDrop({ // Extract metadata const title = - drop.metadata.find((m) => m.data_key === "title")?.data_value ?? + getNonEmptyText(drop.title) ?? + getMetadataValue(drop, "title") ?? "Artwork Title"; const description = - drop.metadata.find((m) => m.data_key === "description")?.data_value ?? + getNonEmptyText(drop.parts.at(0)?.content) ?? + getMetadataValue(drop, "description") ?? "This is an artwork submission for The Memes collection."; // Get artwork media URL if available diff --git a/components/memes/drops/MemesLeaderboardDrop.tsx b/components/memes/drops/MemesLeaderboardDrop.tsx index 7281f807a4..3eb2472e56 100644 --- a/components/memes/drops/MemesLeaderboardDrop.tsx +++ b/components/memes/drops/MemesLeaderboardDrop.tsx @@ -41,6 +41,16 @@ interface MemesLeaderboardDropProps { readonly onSourceDropDeleted?: (() => void) | undefined; } +const getNonEmptyText = (value: string | null | undefined): string | null => { + const text = value?.trim(); + return text && text.length > 0 ? text : null; +}; + +const getMetadataValue = (drop: ExtendedDrop, dataKey: string): string | null => + getNonEmptyText( + drop.metadata.find((metadata) => metadata.data_key === dataKey)?.data_value + ); + export const MemesLeaderboardDrop: React.FC = ({ drop, onDropClick, @@ -96,16 +106,18 @@ export const MemesLeaderboardDrop: React.FC = ({ setIsResubmitModalOpen(true); }, [clearDeferredResubmitOpen]); - // Extract metadata + const firstPart = drop.parts.at(0); const title = - drop.metadata.find((m) => m.data_key === "title")?.data_value ?? + getNonEmptyText(drop.title) ?? + getMetadataValue(drop, "title") ?? "Artwork Title"; const description = - drop.metadata.find((m) => m.data_key === "description")?.data_value ?? + getNonEmptyText(firstPart?.content) ?? + getMetadataValue(drop, "description") ?? "This is an artwork submission for The Memes collection."; // Get artwork media URL if available - const artworkMedia = drop.parts.at(0)?.media.at(0); + const artworkMedia = firstPart?.media.at(0); // Get top voters for votes display const firstThreeVoters = drop.top_raters.slice(0, 3); diff --git a/components/react-query-wrapper/ReactQueryWrapper.tsx b/components/react-query-wrapper/ReactQueryWrapper.tsx index 33b09530de..21d009c814 100644 --- a/components/react-query-wrapper/ReactQueryWrapper.tsx +++ b/components/react-query-wrapper/ReactQueryWrapper.tsx @@ -91,6 +91,7 @@ export enum QueryKey { EMMA_ALLOWLIST_RESULT = "EMMA_ALLOWLIST_RESULT", WAVES_OVERVIEW = "WAVES_OVERVIEW", WAVES_OVERVIEW_PUBLIC = "WAVES_OVERVIEW_PUBLIC", + WAVES_V2 = "WAVES_V2", WAVES = "WAVES", WAVES_PUBLIC = "WAVES_PUBLIC", WAVES_SEARCH = "WAVES_SEARCH", @@ -911,6 +912,9 @@ const createReactQueryContextValue = ( queryClient.invalidateQueries({ queryKey: [QueryKey.WAVES_OVERVIEW_PUBLIC], }); + void queryClient.invalidateQueries({ + queryKey: [QueryKey.WAVES_V2], + }); queryClient.invalidateQueries({ queryKey: [QueryKey.WAVES], }); diff --git a/components/react-query-wrapper/utils/increaseWavesOverviewDropsCount.tsx b/components/react-query-wrapper/utils/increaseWavesOverviewDropsCount.tsx index 76067b1697..62ed171ea3 100644 --- a/components/react-query-wrapper/utils/increaseWavesOverviewDropsCount.tsx +++ b/components/react-query-wrapper/utils/increaseWavesOverviewDropsCount.tsx @@ -1,5 +1,6 @@ import type { QueryClient } from "@tanstack/react-query"; import type { ApiWave } from "@/generated/models/ApiWave"; +import type { SidebarWave, SidebarWavesPage } from "@/types/waves.types"; import { QueryKey } from "../ReactQueryWrapper"; type WavesOverviewQueryData = { @@ -8,6 +9,12 @@ type WavesOverviewQueryData = { type WavesOverviewCacheData = ApiWave[] | WavesOverviewQueryData; +type WavesV2QueryData = { + pages?: SidebarWavesPage[] | undefined; +}; + +type WavesV2CacheData = SidebarWave[] | WavesV2QueryData; + const updateWaveDropMetrics = (wave: ApiWave, timestamp: number): ApiWave => { const latestDropTimestamp = Math.max( timestamp, @@ -97,6 +104,97 @@ const updateWavesOverviewCacheData = ( }; }; +const updateSidebarWaveDropMetrics = ( + wave: SidebarWave, + timestamp: number +): SidebarWave => ({ + ...wave, + latestDropTimestamp: Math.max(timestamp, wave.latestDropTimestamp ?? 0), +}); + +const updateSidebarWaveInArray = ( + waves: SidebarWave[], + waveId: string, + timestamp: number +): { waves: SidebarWave[]; didUpdate: boolean } => { + let didUpdate = false; + + const updatedWaves = waves.map((wave) => { + if (wave.id !== waveId) { + return wave; + } + + didUpdate = true; + return updateSidebarWaveDropMetrics(wave, timestamp); + }); + + return { waves: updatedWaves, didUpdate }; +}; + +const hasSidebarWaveInArray = (waves: SidebarWave[], waveId: string): boolean => + waves.some((wave) => wave.id === waveId); + +const hasSidebarWaveInCacheData = ( + data: WavesV2CacheData | undefined, + waveId: string +): boolean => { + if (!data) { + return false; + } + + if (Array.isArray(data)) { + return hasSidebarWaveInArray(data, waveId); + } + + return ( + data.pages?.some((page) => hasSidebarWaveInArray(page.waves, waveId)) ?? + false + ); +}; + +const updateWavesV2CacheData = ( + oldData: WavesV2CacheData | undefined, + waveId: string, + timestamp: number +): WavesV2CacheData | undefined => { + if (!oldData) { + return oldData; + } + + if (Array.isArray(oldData)) { + const { waves, didUpdate } = updateSidebarWaveInArray( + oldData, + waveId, + timestamp + ); + return didUpdate ? waves : oldData; + } + + if (!oldData.pages || oldData.pages.length === 0) { + return oldData; + } + + const pageUpdates = oldData.pages.map((page) => { + const update = updateSidebarWaveInArray(page.waves, waveId, timestamp); + return { + page, + ...update, + }; + }); + + if (!pageUpdates.some(({ didUpdate }) => didUpdate)) { + return oldData; + } + + return { + ...oldData, + pages: pageUpdates.map(({ page, waves }) => ({ + ...page, + waves, + })), + }; +}; + export const increaseWavesOverviewDropsCount = async ( queryClient: QueryClient, waveId: string @@ -121,4 +219,24 @@ export const increaseWavesOverviewDropsCount = async ( } ); } + + const v2Queries = queryClient.getQueriesData({ + queryKey: [QueryKey.WAVES_V2], + }); + + for (const [queryKey, data] of v2Queries) { + if (!hasSidebarWaveInCacheData(data, waveId)) { + continue; + } + + await queryClient.cancelQueries({ queryKey }); + + queryClient.setQueryData( + queryKey, + (oldData) => updateWavesV2CacheData(oldData, waveId, timestamp), + { + updatedAt: timestamp, + } + ); + } }; diff --git a/components/user/brain/UserPageBrainSidebarMobileStrip.tsx b/components/user/brain/UserPageBrainSidebarMobileStrip.tsx index b1ba7e718f..2c0f9efa6b 100644 --- a/components/user/brain/UserPageBrainSidebarMobileStrip.tsx +++ b/components/user/brain/UserPageBrainSidebarMobileStrip.tsx @@ -3,17 +3,23 @@ import type { QueryStatus } from "@tanstack/react-query"; import type { ReactNode } from "react"; import type { ApiWave } from "@/generated/models/ApiWave"; -import { getScaledImageUri, ImageScale } from "@/helpers/image.helpers"; -import { getWaveRoute } from "@/helpers/navigation.helpers"; +import { ImageScale } from "@/helpers/image.helpers"; +import type { SidebarWave } from "@/types/waves.types"; import { ChatBubbleLeftRightIcon, PlusIcon } from "@heroicons/react/24/outline"; import Link from "next/link"; import Image from "next/image"; import WavesIcon from "@/components/common/icons/WavesIcon"; +import { + getSidebarWaveHref, + getSidebarWaveImageSrc, + getSidebarWaveIsDirectMessage, + type UserPageBrainSidebarWave, +} from "./userPageBrainSidebarWave.helpers"; interface UserPageBrainSidebarMobileStripProps { readonly createdWaves: ApiWave[]; readonly createdStatus: QueryStatus; - readonly mostActiveWaves: ApiWave[]; + readonly mostActiveWaves: SidebarWave[]; readonly mostActiveStatus: QueryStatus; readonly onOpenCreatedWaves: () => void; } @@ -21,17 +27,11 @@ interface UserPageBrainSidebarMobileStripProps { function UserPageBrainSidebarMobileWavePill({ wave, }: { - readonly wave: ApiWave; + readonly wave: UserPageBrainSidebarWave; }) { - const isDirectMessage = wave.chat.scope.group?.is_direct_message ?? false; - const href = getWaveRoute({ - waveId: wave.id, - isDirectMessage, - isApp: false, - }); - const imageSrc = wave.picture - ? getScaledImageUri(wave.picture, ImageScale.W_200_H_200) - : null; + const isDirectMessage = getSidebarWaveIsDirectMessage(wave); + const href = getSidebarWaveHref(wave); + const imageSrc = getSidebarWaveImageSrc(wave, ImageScale.W_200_H_200); const FallbackIcon = isDirectMessage ? ChatBubbleLeftRightIcon : WavesIcon; return ( diff --git a/components/user/brain/UserPageBrainSidebarMostActive.tsx b/components/user/brain/UserPageBrainSidebarMostActive.tsx index 9cfc1634dc..19c5eb7286 100644 --- a/components/user/brain/UserPageBrainSidebarMostActive.tsx +++ b/components/user/brain/UserPageBrainSidebarMostActive.tsx @@ -1,11 +1,11 @@ "use client"; import type { QueryStatus } from "@tanstack/react-query"; -import type { ApiWave } from "@/generated/models/ApiWave"; +import type { SidebarWave } from "@/types/waves.types"; import UserPageBrainSidebarWaveItem from "./UserPageBrainSidebarWaveItem"; interface UserPageBrainSidebarMostActiveProps { - readonly waves: ApiWave[]; + readonly waves: SidebarWave[]; readonly status: QueryStatus; } diff --git a/components/user/brain/UserPageBrainSidebarWaveItem.tsx b/components/user/brain/UserPageBrainSidebarWaveItem.tsx index 1a31e96018..280fb19b13 100644 --- a/components/user/brain/UserPageBrainSidebarWaveItem.tsx +++ b/components/user/brain/UserPageBrainSidebarWaveItem.tsx @@ -3,46 +3,49 @@ import type { ReactNode } from "react"; import { LockClosedIcon, ChevronRightIcon } from "@heroicons/react/24/solid"; import { ChatBubbleLeftRightIcon } from "@heroicons/react/24/outline"; -import type { ApiWave } from "@/generated/models/ApiWave"; import { getTimeAgoShort, numberWithCommas } from "@/helpers/Helpers"; -import { getScaledImageUri, ImageScale } from "@/helpers/image.helpers"; -import { getWaveRoute } from "@/helpers/navigation.helpers"; +import { ImageScale } from "@/helpers/image.helpers"; import Link from "next/link"; import Image from "next/image"; import WavesIcon from "@/components/common/icons/WavesIcon"; +import { + getSidebarWaveDropsCount, + getSidebarWaveHref, + getSidebarWaveImageSrc, + getSidebarWaveIsDirectMessage, + getSidebarWaveIsPrivate, + getSidebarWaveLatestDropTimestamp, + type UserPageBrainSidebarWave, +} from "./userPageBrainSidebarWave.helpers"; type BrainSidebarWaveItemDisplay = { readonly href: string; readonly isPrivate: boolean; readonly isDirectMessage: boolean; readonly dropsCount: string; - readonly lastDropTimestamp: ApiWave["metrics"]["latest_drop_timestamp"]; + readonly lastDropTimestamp: number | null; readonly imageSrc: string | null; }; const getBrainSidebarWaveItemDisplay = ( - wave: ApiWave -): BrainSidebarWaveItemDisplay => ({ - isDirectMessage: wave.chat.scope.group?.is_direct_message ?? false, - href: getWaveRoute({ - waveId: wave.id, - isDirectMessage: wave.chat.scope.group?.is_direct_message ?? false, - isApp: false, - }), - isPrivate: - Boolean(wave.visibility.scope.group) && - !(wave.chat.scope.group?.is_direct_message ?? false), - dropsCount: numberWithCommas(wave.metrics.drops_count), - lastDropTimestamp: wave.metrics.latest_drop_timestamp, - imageSrc: wave.picture - ? getScaledImageUri(wave.picture, ImageScale.W_200_H_200) - : null, -}); + wave: UserPageBrainSidebarWave +): BrainSidebarWaveItemDisplay => { + const dropsCount = getSidebarWaveDropsCount(wave); + + return { + isDirectMessage: getSidebarWaveIsDirectMessage(wave), + href: getSidebarWaveHref(wave), + isPrivate: getSidebarWaveIsPrivate(wave), + dropsCount: numberWithCommas(dropsCount), + lastDropTimestamp: getSidebarWaveLatestDropTimestamp(wave), + imageSrc: getSidebarWaveImageSrc(wave, ImageScale.W_200_H_200), + }; +}; export default function UserPageBrainSidebarWaveItem({ wave, }: { - readonly wave: ApiWave; + readonly wave: UserPageBrainSidebarWave; }) { const { href, @@ -55,7 +58,7 @@ export default function UserPageBrainSidebarWaveItem({ const FallbackIcon = isDirectMessage ? ChatBubbleLeftRightIcon : WavesIcon; let metaContent: ReactNode = No drops yet; - if (lastDropTimestamp) { + if (lastDropTimestamp !== null && lastDropTimestamp > 0) { metaContent = ( <> {getTimeAgoShort(lastDropTimestamp)} diff --git a/components/user/brain/userPageBrainSidebarWave.helpers.ts b/components/user/brain/userPageBrainSidebarWave.helpers.ts new file mode 100644 index 0000000000..82882102ef --- /dev/null +++ b/components/user/brain/userPageBrainSidebarWave.helpers.ts @@ -0,0 +1,50 @@ +import type { ApiWave } from "@/generated/models/ApiWave"; +import { getScaledImageUri } from "@/helpers/image.helpers"; +import type { ImageScale } from "@/helpers/image.helpers"; +import { getWaveRoute } from "@/helpers/navigation.helpers"; +import type { SidebarWave } from "@/types/waves.types"; + +export type UserPageBrainSidebarWave = ApiWave | SidebarWave; + +const isSidebarWave = (wave: UserPageBrainSidebarWave): wave is SidebarWave => + "latestDropTimestamp" in wave; + +export const getSidebarWaveIsDirectMessage = ( + wave: UserPageBrainSidebarWave +): boolean => + isSidebarWave(wave) + ? wave.isDirectMessage + : (wave.chat.scope.group?.is_direct_message ?? false); + +export const getSidebarWaveHref = (wave: UserPageBrainSidebarWave): string => + getWaveRoute({ + waveId: wave.id, + isDirectMessage: getSidebarWaveIsDirectMessage(wave), + isApp: false, + }); + +export const getSidebarWaveImageSrc = ( + wave: UserPageBrainSidebarWave, + scale: ImageScale +): string | null => + wave.picture ? getScaledImageUri(wave.picture, scale) : null; + +export const getSidebarWaveLatestDropTimestamp = ( + wave: UserPageBrainSidebarWave +): number | null => + isSidebarWave(wave) + ? wave.latestDropTimestamp + : wave.metrics.latest_drop_timestamp; + +export const getSidebarWaveIsPrivate = ( + wave: UserPageBrainSidebarWave +): boolean => + isSidebarWave(wave) + ? wave.isPrivate + : Boolean(wave.visibility.scope.group) && + !(wave.chat.scope.group?.is_direct_message ?? false); + +export const getSidebarWaveDropsCount = ( + wave: UserPageBrainSidebarWave +): number => + isSidebarWave(wave) ? wave.totalDropsCount : wave.metrics.drops_count; diff --git a/components/waves/drop/SingleWaveDropChat.tsx b/components/waves/drop/SingleWaveDropChat.tsx index ca2472549f..a8a8fcfb5f 100644 --- a/components/waves/drop/SingleWaveDropChat.tsx +++ b/components/waves/drop/SingleWaveDropChat.tsx @@ -15,6 +15,7 @@ import PrivilegedDropCreator from "../PrivilegedDropCreator"; import { useAndroidKeyboard } from "@/hooks/useAndroidKeyboard"; import { DropMode } from "../dropComposer.types"; import { WaveDropLayerProvider } from "../drops/WaveDropLayerContext"; +import { useWaveEligibility } from "@/contexts/wave/WaveEligibilityContext"; interface SingleWaveDropChatProps { readonly wave: ApiWave; @@ -33,6 +34,7 @@ export const SingleWaveDropChat: React.FC = ({ }) => { const { isApp } = useDeviceInfo(); const { isVisible: isKeyboardVisible } = useAndroidKeyboard(); + const { updateEligibility } = useWaveEligibility(); // Apply Android keyboard adjustments to the fixed input area const inputContainerStyle = useMemo(() => { @@ -71,6 +73,18 @@ export const SingleWaveDropChat: React.FC = ({ }); }; + React.useEffect(() => { + updateEligibility(wave.id, { + authenticated_user_eligible_to_chat: + wave.chat.authenticated_user_eligible, + authenticated_user_eligible_to_vote: + wave.voting.authenticated_user_eligible, + authenticated_user_eligible_to_participate: + wave.participation.authenticated_user_eligible, + authenticated_user_admin: wave.wave.authenticated_user_eligible_for_admin, + }); + }, [updateEligibility, wave]); + return ( = ({
= ({ reverse: false, dropId: drop.id, logTypes: ["DROP_VOTE_EDIT"], + enabled: isActivityOpen, }); const intersectionElementRef = useIntersectionObserver(() => { @@ -38,18 +39,22 @@ export const SingleWaveDropLogs: React.FC = ({
@@ -60,10 +65,11 @@ export const SingleWaveDropLogs: React.FC = ({ animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={{ duration: 0.3 }} - className="tw-overflow-hidden"> -
+ className="tw-overflow-hidden" + > +
{logs.length > 0 || isLoading ? ( -
+
{logs.map((log) => ( = ({ ))}
) : ( -
-
-
-
-
+
+
+
+
+
- + Be the First to Make a Vote -

+

Vote on this drop to see activity updates appear here in real-time.

@@ -106,8 +113,8 @@ export const SingleWaveDropLogs: React.FC = ({ )} {isFetchingNextPage && ( -
-
+
+
)}
diff --git a/components/waves/drop/SingleWaveDropVoters.tsx b/components/waves/drop/SingleWaveDropVoters.tsx index cbd23599ba..2551d7e7c1 100644 --- a/components/waves/drop/SingleWaveDropVoters.tsx +++ b/components/waves/drop/SingleWaveDropVoters.tsx @@ -17,6 +17,7 @@ export const SingleWaveDropVoters: React.FC = ({ drop, }) => { const { connectedProfile } = useAuth(); + const [isVotersOpen, setIsVotersOpen] = useState(false); const { voters, isFetchingNextPage, fetchNextPage, hasNextPage, isLoading } = useWaveTopVoters({ waveId: drop.wave.id, @@ -25,6 +26,7 @@ export const SingleWaveDropVoters: React.FC = ({ dropId: drop.id, sortDirection: "DESC", sort: "ABSOLUTE", + enabled: isVotersOpen, }); const intersectionElementRef = useIntersectionObserver(() => { @@ -33,23 +35,26 @@ export const SingleWaveDropVoters: React.FC = ({ } }); - const [isVotersOpen, setIsVotersOpen] = useState(false); return (
@@ -60,11 +65,12 @@ export const SingleWaveDropVoters: React.FC = ({ animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={{ duration: 0.3 }} - className="tw-overflow-hidden"> -
+ className="tw-overflow-hidden" + > +
{voters.length > 0 || isLoading ? ( <> -
+
{voters.map((voter, index) => ( = ({ ))}
{(isFetchingNextPage || isLoading) && ( -
-
+
+
)}
) : ( -
-
-
-
-
+
+
+
+
+
- + Be the First to Make a Vote -

+

Vote on this drop to see voter rankings appear here.

diff --git a/components/waves/drop/useSingleWaveDropData.ts b/components/waves/drop/useSingleWaveDropData.ts index 0eb7381abf..d7188f1cee 100644 --- a/components/waves/drop/useSingleWaveDropData.ts +++ b/components/waves/drop/useSingleWaveDropData.ts @@ -3,14 +3,30 @@ import { useCallback, useMemo } from "react"; import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; import { DropSize } from "@/helpers/waves/drop.helpers"; -import { useDrop } from "@/hooks/useDrop"; +import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; import { useWaveData } from "@/hooks/useWaveData"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import type { ApiDrop } from "@/generated/models/ApiDrop"; +import { DROP_DETAIL_STALE_TIME_MS } from "@/services/api/drop-api"; +import { fetchDropV2ById } from "@/services/api/wave-drops-v2-api"; export const useSingleWaveDropData = ( initialDrop: ExtendedDrop, onClose: () => void ) => { - const { drop } = useDrop({ dropId: initialDrop.id }); + const { data: drop } = useQuery({ + queryKey: [ + QueryKey.DROP, + { + drop_id: initialDrop.id, + view: "single-wave-drop", + }, + ], + queryFn: ({ signal }) => + fetchDropV2ById(initialDrop.id, signal, { includeTopRaters: false }), + placeholderData: keepPreviousData, + staleTime: DROP_DETAIL_STALE_TIME_MS, + }); const onWaveNotFound = useCallback(() => { onClose(); diff --git a/components/waves/drops/WaveDropActionsMarkUnread.tsx b/components/waves/drops/WaveDropActionsMarkUnread.tsx index 0f4780d5e7..93de1438ff 100644 --- a/components/waves/drops/WaveDropActionsMarkUnread.tsx +++ b/components/waves/drops/WaveDropActionsMarkUnread.tsx @@ -60,6 +60,9 @@ export default function WaveDropActionsMarkUnread({ queryClient.invalidateQueries({ queryKey: [QueryKey.WAVES_OVERVIEW], }); + void queryClient.invalidateQueries({ + queryKey: [QueryKey.WAVES_V2], + }); queryClient.invalidateQueries({ queryKey: [QueryKey.WAVE, { wave_id: drop.wave.id }], diff --git a/components/waves/drops/WaveDropQuoteWithSerialNo.tsx b/components/waves/drops/WaveDropQuoteWithSerialNo.tsx index 925d5e6ad7..1812a09de6 100644 --- a/components/waves/drops/WaveDropQuoteWithSerialNo.tsx +++ b/components/waves/drops/WaveDropQuoteWithSerialNo.tsx @@ -3,12 +3,12 @@ import React, { useEffect, useState } from "react"; import WaveDropQuote from "./WaveDropQuote"; -import { commonApiFetch } from "@/services/api/common-api"; import { useQuery } from "@tanstack/react-query"; import { WaveDropsSearchStrategy } from "@/contexts/wave/hooks/types"; import type { ApiWaveDropsFeed } from "@/generated/models/ApiWaveDropsFeed"; import type { ApiDrop } from "@/generated/models/ApiDrop"; import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; +import { fetchWaveDropsFeedV2 } from "@/services/api/wave-drops-v2-api"; interface WaveDropQuoteWithSerialNoProps { readonly serialNo: number; readonly waveId: string; @@ -43,20 +43,13 @@ const WaveDropQuoteWithSerialNo: React.FC = ({ strategy: WaveDropsSearchStrategy.Both, }, ], - queryFn: async () => { - const params: Record = { - limit: "1", - serial_no_limit: `${serialNo}`, - search_strategy: WaveDropsSearchStrategy.Both, - }; - - const results = await commonApiFetch({ - endpoint: `waves/${waveId}/drops`, - params, - }); - - return results; - }, + queryFn: async () => + fetchWaveDropsFeedV2({ + waveId, + limit: 1, + serialNoLimit: serialNo, + searchStrategy: WaveDropsSearchStrategy.Both, + }), }); const [drop, setDrop] = useState(null); useEffect(() => { diff --git a/components/waves/drops/WaveDropReactions.tsx b/components/waves/drops/WaveDropReactions.tsx index aeadf28588..2d9fa2dd6a 100644 --- a/components/waves/drops/WaveDropReactions.tsx +++ b/components/waves/drops/WaveDropReactions.tsx @@ -28,8 +28,9 @@ import React, { import { Tooltip } from "react-tooltip"; import { cloneReactionEntries, - findReactionIndex, + applyProfileReactionToEntries, getReactionErrorMessage, + getReactionCount, removeUserFromReactions, toProfileMin, } from "./reaction-utils"; @@ -44,18 +45,100 @@ import { } from "@/utils/monitoring/dropReactionMonitoring"; import styles from "./WaveDropReactions.module.scss"; import WaveDropReactionsDetailDialog from "./WaveDropReactionsDetailDialog"; +import { fetchDropReactionDetailsV2 } from "@/services/api/wave-drops-v2-api"; interface WaveDropReactionsProps { readonly drop: ApiDrop; } +interface DetailedReactionsState { + readonly dropId: string; + readonly reactions: ApiDropReaction[]; +} + const WaveDropReactions: React.FC = ({ drop }) => { const [dialogReaction, setDialogReaction] = useState(null); + const [detailedReactionsState, setDetailedReactionsState] = + useState(null); + const [detailsLoadingDropId, setDetailsLoadingDropId] = useState< + string | null + >(null); + const detailsRequestRef = useRef<{ + readonly dropId: string; + readonly promise: Promise; + } | null>(null); const isTouchDevice = useIsTouchDevice(); + const detailedReactions = + detailedReactionsState?.dropId === drop.id + ? detailedReactionsState.reactions + : null; + const detailsLoading = detailsLoadingDropId === drop.id; + + const reactionsWithDetails = useMemo(() => { + if (!detailedReactions) { + return drop.reactions; + } - const handleOpenDialog = useCallback((reactionKey: string) => { - setDialogReaction(reactionKey); - }, []); + const detailsByReaction = new Map( + detailedReactions.map((reaction) => [reaction.reaction, reaction]) + ); + + return drop.reactions.map((reaction) => { + const detailedReaction = detailsByReaction.get(reaction.reaction); + if (!detailedReaction) { + return reaction; + } + const profilesById = new Map( + detailedReaction.profiles.map((profile) => [profile.id, profile]) + ); + for (const profile of reaction.profiles) { + profilesById.set(profile.id, profile); + } + + return { + ...reaction, + profiles: [...profilesById.values()], + count: getReactionCount(reaction), + }; + }); + }, [detailedReactions, drop.reactions]); + + const loadReactionDetails = useCallback(() => { + if (detailedReactions) { + return null; + } + + if (detailsRequestRef.current?.dropId === drop.id) { + return detailsRequestRef.current.promise; + } + + const requestDropId = drop.id; + const request = (async () => { + setDetailsLoadingDropId(requestDropId); + try { + const reactions = await fetchDropReactionDetailsV2(requestDropId); + setDetailedReactionsState({ dropId: requestDropId, reactions }); + } catch { + setDetailedReactionsState({ dropId: requestDropId, reactions: [] }); + } finally { + setDetailsLoadingDropId((current) => + current === requestDropId ? null : current + ); + detailsRequestRef.current = null; + } + })(); + + detailsRequestRef.current = { dropId: requestDropId, promise: request }; + return request; + }, [detailedReactions, drop.id]); + + const handleOpenDialog = useCallback( + (reactionKey: string) => { + setDialogReaction(reactionKey); + void loadReactionDetails(); + }, + [loadReactionDetails] + ); const handleCloseDialog = useCallback(() => { setDialogReaction(null); @@ -63,20 +146,23 @@ const WaveDropReactions: React.FC = ({ drop }) => { return ( <> - {drop.reactions.map((reaction) => ( + {reactionsWithDetails.map((reaction) => ( ))} ); @@ -86,11 +172,15 @@ function WaveDropReaction({ drop, reaction, onOpenDetailDialog, + onLoadDetails, + isDetailsLoading, isTouchDevice, }: { readonly drop: ApiDrop; readonly reaction: ApiDropReaction; readonly onOpenDetailDialog: (reactionKey: string) => void; + readonly onLoadDetails: () => Promise | null; + readonly isDetailsLoading: boolean; readonly isTouchDevice: boolean; }) { const { setToast, connectedProfile } = useAuth(); @@ -134,7 +224,7 @@ function WaveDropReaction({ [touchHandlers] ); - const [total, setTotal] = useState(reaction.profiles.length); + const [total, setTotal] = useState(getReactionCount(reaction)); const [selected, setSelected] = useState( reaction.reaction === drop.context_profile_context?.reaction ); @@ -163,18 +253,20 @@ function WaveDropReaction({ }, [drop.context_profile_context?.reaction, reaction.reaction]); useEffect(() => { + const nextTotal = getReactionCount(reaction); if (reaction.profiles === prevProfilesRef.current) { - return; + const timeoutId = setTimeout(() => { + setTotal((current) => (current === nextTotal ? current : nextTotal)); + }, 0); + return () => clearTimeout(timeoutId); } prevProfilesRef.current = reaction.profiles; - const nextTotal = reaction.profiles.length; - const timeoutId = setTimeout(() => { setTotal((current) => (current === nextTotal ? current : nextTotal)); }, 0); return () => clearTimeout(timeoutId); - }, [reaction.profiles]); + }, [reaction]); // Trigger animation when total changes useEffect(() => { @@ -275,32 +367,15 @@ function WaveDropReaction({ const reactions = cloneReactionEntries(draft.reactions); const userId = connectedProfile?.id ?? null; - const reactionsWithoutUser = removeUserFromReactions( - reactions, - userId - ); - - if (willSelect && userProfileMin) { - const existingIndex = findReactionIndex( - reactionsWithoutUser, - reaction.reaction - ); - - if (existingIndex >= 0) { - const target = reactionsWithoutUser[existingIndex]!; - reactionsWithoutUser[existingIndex] = { - ...target, - profiles: [...target.profiles, userProfileMin], - }; - } else { - reactionsWithoutUser.push({ - reaction: reaction.reaction, - profiles: [userProfileMin], - }); - } - } - - draft.reactions = reactionsWithoutUser; + draft.reactions = userProfileMin + ? applyProfileReactionToEntries({ + entries: reactions, + nextReaction: willSelect ? reaction.reaction : null, + previousReaction: + drop.context_profile_context?.reaction ?? null, + profileMin: userProfileMin, + }) + : removeUserFromReactions(reactions, userId); const existingContext: ApiDropContextProfileContext = draft.context_profile_context ?? drop.context_profile_context ?? { @@ -423,6 +498,12 @@ function WaveDropReaction({ return { displayProfiles, moreCount }; }, [reaction.profiles, total]); + const handlePointerEnter = useCallback(() => { + if (!isTouchDevice) { + void onLoadDetails(); + } + }, [isTouchDevice, onLoadDetails]); + const handleMoreClick = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); @@ -449,6 +530,60 @@ function WaveDropReaction({ } } + let tooltipContent: React.ReactNode; + if (isDetailsLoading && tooltipProfiles.displayProfiles.length === 0) { + tooltipContent = ( + Loading reactions... + ); + } else if (tooltipProfiles.displayProfiles.length > 0) { + tooltipContent = ( + + by{" "} + {tooltipProfiles.displayProfiles.map((profile, index) => { + const displayName = profile.handle ?? profile.id; + const isLast = index === tooltipProfiles.displayProfiles.length - 1; + const showComma = !isLast; + + return ( + + {profile.handle ? ( + e.stopPropagation()} + > + {displayName} + + ) : ( + {displayName} + )} + {showComma && ", "} + + ); + })} + {tooltipProfiles.moreCount > 0 && ( + <> + {" "} + + + )} + + ); + } else { + tooltipContent = ( + + {formatLargeNumber(total)} {total === 1 ? "reaction" : "reactions"} + + ); + } + if (!emojiNode || total === 0) return null; return ( <> @@ -457,6 +592,8 @@ function WaveDropReaction({ onClick={handleClick} disabled={!canReact} aria-disabled={!canReact} + onMouseEnter={handlePointerEnter} + onFocus={handlePointerEnter} {...(!isTouchDevice && { "data-tooltip-id": tooltipId })} data-text-selection-exclude="true" className={clsx( @@ -493,45 +630,7 @@ function WaveDropReaction({ >
{emojiNodeTooltip} - - by{" "} - {tooltipProfiles.displayProfiles.map((profile, index) => { - const displayName = profile.handle ?? profile.id; - const isLast = - index === tooltipProfiles.displayProfiles.length - 1; - const showComma = !isLast; - - return ( - - {profile.handle ? ( - e.stopPropagation()} - > - {displayName} - - ) : ( - {displayName} - )} - {showComma && ", "} - - ); - })} - {tooltipProfiles.moreCount > 0 && ( - <> - {" "} - - - )} - + {tooltipContent}
)} diff --git a/components/waves/drops/WaveDropReactionsDetailDialog.tsx b/components/waves/drops/WaveDropReactionsDetailDialog.tsx index 99986bfa63..163ad7f5cd 100644 --- a/components/waves/drops/WaveDropReactionsDetailDialog.tsx +++ b/components/waves/drops/WaveDropReactionsDetailDialog.tsx @@ -8,12 +8,14 @@ import clsx from "clsx"; import Image from "next/image"; import Link from "next/link"; import { useEffect, useMemo, useState } from "react"; +import { getReactionCount } from "./reaction-utils"; interface WaveDropReactionsDetailDialogProps { readonly isOpen: boolean; readonly onClose: () => void; readonly reactions: ApiDropReaction[]; readonly initialReaction?: string | undefined; + readonly isLoading?: boolean | undefined; } export default function WaveDropReactionsDetailDialog({ @@ -21,6 +23,7 @@ export default function WaveDropReactionsDetailDialog({ onClose, reactions, initialReaction, + isLoading = false, }: WaveDropReactionsDetailDialogProps) { const [selectedReaction, setSelectedReaction] = useState( initialReaction ?? reactions[0]?.reaction ?? "" @@ -45,7 +48,10 @@ export default function WaveDropReactionsDetailDialog({ selectedReaction={selectedReaction} onSelectReaction={setSelectedReaction} /> - +
); @@ -135,7 +141,7 @@ function ReactionButton({ {emojiNode}
- {reaction.profiles.length} + {getReactionCount(reaction)} ); @@ -143,9 +149,19 @@ function ReactionButton({ function ProfilesList({ profiles, + isLoading, }: { readonly profiles: ApiDropReaction["profiles"]; + readonly isLoading: boolean; }) { + if (isLoading && profiles.length === 0) { + return ( +
+ Loading reactions... +
+ ); + } + return (
diff --git a/components/waves/drops/WaveDropReply.tsx b/components/waves/drops/WaveDropReply.tsx index 546449a975..b7556559de 100644 --- a/components/waves/drops/WaveDropReply.tsx +++ b/components/waves/drops/WaveDropReply.tsx @@ -23,16 +23,12 @@ interface WaveDropReplyProps { */ export default function WaveDropReply({ dropId, - dropPartId, + dropPartId: _dropPartId, maybeDrop, onReplyClick, }: WaveDropReplyProps) { const fixedReplyHeightClasses = "tw-h-[24px] tw-min-h-[24px] tw-max-h-[24px]"; - const { drop, content, isLoading } = useDropContent( - dropId, - dropPartId, - maybeDrop - ); + const { drop, content, isLoading } = useDropContent(dropId, 1, maybeDrop); const replyPreviewContent = useMemo(() => { if (content.apiMedia.length > 0 || content.segments.length !== 1) { return content; @@ -88,7 +84,11 @@ export default function WaveDropReply({ onReplyClick(drop.serial_no)} + onClick={() => { + if (drop.serial_no > 0) { + onReplyClick(drop.serial_no); + } + }} className="tw-min-w-0 tw-flex-1 tw-overflow-hidden" textClassName="tw-min-w-0 tw-overflow-hidden" linkify={false} diff --git a/components/waves/drops/reaction-utils.ts b/components/waves/drops/reaction-utils.ts index 954ef7df29..7d29c5c9a9 100644 --- a/components/waves/drops/reaction-utils.ts +++ b/components/waves/drops/reaction-utils.ts @@ -9,6 +9,28 @@ type ReactionEntry = { [key: string]: unknown; }; +export const getReactionCount = ( + reaction: Pick & { readonly count?: unknown } +): number => { + if ( + typeof reaction.count === "number" && + Number.isFinite(reaction.count) && + reaction.count >= 0 + ) { + return reaction.count; + } + + return reaction.profiles.length; +}; + +const withReactionCount = ( + entry: ReactionEntry, + count: number +): ReactionEntry => ({ + ...entry, + count: Math.max(0, count), +}); + export const cloneReactionEntries = ( reactions: readonly ApiDropReaction[] | null | undefined ): ReactionEntry[] => { @@ -64,7 +86,7 @@ export const removeUserFromReactions = ( return sanitizedEntries; }; -export const findReactionIndex = ( +const findReactionIndex = ( entries: ReactionEntry[], reactionCode: string ): number => { @@ -77,6 +99,80 @@ export const findReactionIndex = ( return -1; }; +export const applyProfileReactionToEntries = ({ + entries, + nextReaction, + previousReaction, + profileMin, +}: { + readonly entries: ReactionEntry[]; + readonly nextReaction: string | null; + readonly previousReaction: string | null; + readonly profileMin: ApiProfileMin; +}): ReactionEntry[] => { + const normalizedPreviousReaction = + previousReaction === nextReaction ? null : previousReaction; + const userId = profileMin.id; + const nextEntries: ReactionEntry[] = []; + + for (const entry of entries) { + const filteredProfiles = duplicateProfilesWithoutUser( + entry.profiles, + userId + ); + const shouldDecrement = + normalizedPreviousReaction !== null && + entry.reaction === normalizedPreviousReaction; + const nextCount = getReactionCount(entry) - (shouldDecrement ? 1 : 0); + + if (nextCount > 0) { + nextEntries.push( + withReactionCount( + { + ...entry, + profiles: filteredProfiles, + }, + nextCount + ) + ); + } + } + + if (nextReaction === null) { + return nextEntries; + } + + const existingIndex = findReactionIndex(nextEntries, nextReaction); + if (existingIndex >= 0) { + const target = nextEntries[existingIndex]!; + const hasProfile = target.profiles.some( + (profile) => profile.id === profileMin.id + ); + nextEntries[existingIndex] = withReactionCount( + { + ...target, + profiles: hasProfile + ? target.profiles + : [...target.profiles, profileMin], + }, + getReactionCount(target) + 1 + ); + return nextEntries; + } + + nextEntries.push( + withReactionCount( + { + reaction: nextReaction, + profiles: [profileMin], + }, + 1 + ) + ); + + return nextEntries; +}; + export const toProfileMin = ( profile: ApiIdentity | null ): ApiProfileMin | null => { diff --git a/components/waves/drops/useDropContent.ts b/components/waves/drops/useDropContent.ts index 1c372e9abb..f2c61d0dad 100644 --- a/components/waves/drops/useDropContent.ts +++ b/components/waves/drops/useDropContent.ts @@ -1,7 +1,7 @@ "use client"; import { useMemo } from "react"; -import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import type { ApiDrop } from "@/generated/models/ApiDrop"; import { sanitizeErrorForUser } from "@/utils/error-sanitizer"; import type { ProcessedContent } from "./media-utils"; @@ -27,6 +27,11 @@ export const useDropContent = ( dropPartId: number, maybeDrop: ApiDrop | null ): UseDropContentResult => { + const previewPart = maybeDrop?.parts.find((p) => p.part_id === dropPartId); + const shouldFetchDrop = + !maybeDrop || + (!previewPart?.content?.trim() && (previewPart?.media.length ?? 0) === 0); + // Fetch drop data const { data: drop, @@ -35,9 +40,9 @@ export const useDropContent = ( } = useQuery({ queryKey: getDropQueryKey(dropId), queryFn: () => fetchDropByIdBatched(dropId), - placeholderData: keepPreviousData, - initialData: maybeDrop ?? undefined, - enabled: !maybeDrop, + placeholderData: (previousDrop) => previousDrop ?? maybeDrop ?? undefined, + initialData: shouldFetchDrop ? undefined : maybeDrop, + enabled: shouldFetchDrop, staleTime: DROP_DETAIL_STALE_TIME_MS, }); @@ -76,7 +81,7 @@ export const useDropContent = ( return { drop: drop ?? null, content, - isLoading: isFetching && !maybeDrop, + isLoading: isFetching && !drop, error, }; }; diff --git a/components/waves/drops/wave-drops-all/index.tsx b/components/waves/drops/wave-drops-all/index.tsx index 8c3a9b11fc..7b70d4440d 100644 --- a/components/waves/drops/wave-drops-all/index.tsx +++ b/components/waves/drops/wave-drops-all/index.tsx @@ -9,10 +9,14 @@ import { } from "@/contexts/wave/UnreadDividerContext"; import { useWaveChatScrollOptional } from "@/contexts/wave/WaveChatScrollContext"; import type { ApiDrop } from "@/generated/models/ApiDrop"; +import type { ApiWave } from "@/generated/models/ApiWave"; import { getWaveRoute } from "@/helpers/navigation.helpers"; import type { Drop, ExtendedDrop } from "@/helpers/waves/drop.helpers"; import { DropSize } from "@/helpers/waves/drop.helpers"; -import { isWaveDirectMessage } from "@/helpers/waves/wave.helpers"; +import { + isWaveDirectMessage, + toApiWaveMin, +} from "@/helpers/waves/wave.helpers"; import useDeviceInfo from "@/hooks/useDeviceInfo"; import { useScrollBehavior } from "@/hooks/useScrollBehavior"; import { useVirtualizedWaveDrops } from "@/hooks/useVirtualizedWaveDrops"; @@ -35,6 +39,7 @@ const EMPTY_DROPS: Drop[] = []; interface WaveDropsAllProps { readonly waveId: string; + readonly wave?: ApiWave | undefined; readonly dropId: string | null; readonly onReply: ({ drop, @@ -57,6 +62,7 @@ interface WaveDropsAllProps { const WaveDropsAllInner: React.FC = ({ waveId, + wave, dropId, onReply, activeDrop, @@ -77,7 +83,7 @@ const WaveDropsAllInner: React.FC = ({ const containerRef = useRef(null); const { waveMessages, fetchNextPage, waitAndRevealDrop } = - useVirtualizedWaveDrops(waveId, dropId); + useVirtualizedWaveDrops(waveId, dropId, wave); const { setUnreadDividerSerialNo } = useUnreadDivider(); @@ -87,7 +93,7 @@ const WaveDropsAllInner: React.FC = ({ isMuted ); - const { data: boostedDrops } = useWaveBoostedDrops({ waveId }); + const { data: boostedDrops } = useWaveBoostedDrops({ waveId, wave }); const scrollBehavior = useScrollBehavior(); const { @@ -138,6 +144,23 @@ const WaveDropsAllInner: React.FC = ({ shouldPinToBottom, }); + const renderedWaveMessagesWithFullWave = useMemo(() => { + if (!renderedWaveMessages || !wave) { + return renderedWaveMessages; + } + + const waveMin = toApiWaveMin(wave); + return { + ...renderedWaveMessages, + drops: renderedWaveMessages.drops.map( + (drop: Drop): Drop => + drop.type === DropSize.FULL && drop.wave.id === wave.id + ? { ...drop, wave: waveMin } + : drop + ), + }; + }, [renderedWaveMessages, wave]); + const { serialTarget, queueSerialTarget, @@ -269,7 +292,7 @@ const WaveDropsAllInner: React.FC = ({ > = const WaveDropsAll: React.FC = ({ waveId, + wave, dropId, onReply, activeDrop, @@ -331,6 +355,7 @@ const WaveDropsAll: React.FC = ({ diff --git a/components/waves/leaderboard/content/WaveLeaderboardDropContent.tsx b/components/waves/leaderboard/content/WaveLeaderboardDropContent.tsx index 3aa3c1bd9c..1f5f501dfd 100644 --- a/components/waves/leaderboard/content/WaveLeaderboardDropContent.tsx +++ b/components/waves/leaderboard/content/WaveLeaderboardDropContent.tsx @@ -3,17 +3,10 @@ import React, { useState } from "react"; import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; import WaveDropContent from "@/components/waves/drops/WaveDropContent"; -import WaveDropMetadata from "@/components/waves/drops/WaveDropMetadata"; import { useRouter } from "next/navigation"; import WaveDropReactions from "@/components/waves/drops/WaveDropReactions"; import type { DropContentPresentation } from "@/components/waves/drops/dropContentPresentation"; -import { - getDropIdentityProfile, - getDropVisibleMetadata, -} from "@/components/waves/drops/identityDisplay.helpers"; -import { areSameProfileIdentity } from "@/helpers/ProfileHelpers"; import { getWaveRoute } from "@/helpers/navigation.helpers"; -import { WaveLeaderboardIdentity } from "../identity/WaveLeaderboardIdentity"; interface WaveLeaderboardDropContentProps { readonly drop: ExtendedDrop; @@ -26,20 +19,6 @@ export const WaveLeaderboardDropContent: React.FC< > = ({ drop, isCompetitionDrop = false, contentPresentation = "default" }) => { const router = useRouter(); const [activePartIndex, setActivePartIndex] = useState(0); - const visibleMetadata = getDropVisibleMetadata({ - wave: drop.wave, - metadata: drop.metadata, - }); - const identityProfile = getDropIdentityProfile({ - wave: drop.wave, - metadata: drop.metadata, - }); - const isSelfNominee = identityProfile - ? areSameProfileIdentity({ - left: drop.author, - right: identityProfile, - }) - : false; const onDropContentClick = (clickedDrop: ExtendedDrop) => { const href = getWaveRoute({ @@ -64,16 +43,6 @@ export const WaveLeaderboardDropContent: React.FC< isCompetitionDrop={isCompetitionDrop} contentPresentation={contentPresentation} /> - - {!!visibleMetadata.length && ( - - )}
diff --git a/components/waves/leaderboard/drops/DefaultWaveLeaderboardDrop.tsx b/components/waves/leaderboard/drops/DefaultWaveLeaderboardDrop.tsx index 6d9329766c..7c6a4032a4 100644 --- a/components/waves/leaderboard/drops/DefaultWaveLeaderboardDrop.tsx +++ b/components/waves/leaderboard/drops/DefaultWaveLeaderboardDrop.tsx @@ -20,7 +20,6 @@ import { startDropOpen } from "@/utils/monitoring/dropOpenTiming"; import React from "react"; import { createPortal } from "react-dom"; import { WaveLeaderboardDropContent } from "../content/WaveLeaderboardDropContent"; -import { WaveLeaderboardDropFooter } from "./footer/WaveLeaderboardDropFooter"; import { WaveLeaderboardDropAuthorAvatar } from "./header/WaveLeaderboardDropAuthor"; import { WaveLeaderboardDropHeader } from "./header/WaveLeaderboardDropHeader"; import { WaveLeaderboardDropRaters } from "./header/WaveleaderboardDropRaters"; @@ -169,7 +168,6 @@ export const DefaultWaveLeaderboardDrop: React.FC< winningThreshold={winningThreshold} isVotingClosed={isVotingClosed} /> -
= ({ drop }) => { - return ; -}; diff --git a/components/waves/leaderboard/drops/header/WaveleaderboardDropRaters.tsx b/components/waves/leaderboard/drops/header/WaveleaderboardDropRaters.tsx index 039c8927fc..c81adaec11 100644 --- a/components/waves/leaderboard/drops/header/WaveleaderboardDropRaters.tsx +++ b/components/waves/leaderboard/drops/header/WaveleaderboardDropRaters.tsx @@ -1,12 +1,7 @@ import React from "react"; import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; import { formatNumberWithCommas } from "@/helpers/Helpers"; -import { Tooltip } from "react-tooltip"; -import Link from "next/link"; -import Image from "next/image"; -import { getScaledImageUri, ImageScale } from "@/helpers/image.helpers"; import DropVoteProgressing from "@/components/drops/view/utils/DropVoteProgressing"; -import { TOOLTIP_STYLES } from "@/helpers/tooltip.helpers"; import { WAVE_VOTING_LABELS, WAVE_VOTE_STATS_LABELS, @@ -105,52 +100,6 @@ export const WaveLeaderboardDropRaters: React.FC<
-
- {drop.top_raters.map((voter, index) => { - const voterLabel = - voter.profile.handle ?? voter.profile.primary_address; - const tooltipId = `voter-${drop.id}-${voter.profile.id}`; - - return ( - -
- - {voter.profile.pfp ? ( - {`${voterLabel}'s - ) : ( -
- )} - -
- - {voterLabel} • {formatNumberWithCommas(voter.rating)}{" "} - {votingLabel} - - - ); - })} -
{formatNumberWithCommas(drop.raters_count)} {votersCountLabel} diff --git a/components/waves/leaderboard/sidebar/WaveLeaderboardRightSidebarBoostedDrops.tsx b/components/waves/leaderboard/sidebar/WaveLeaderboardRightSidebarBoostedDrops.tsx index 4517498ae8..972fb83ac2 100644 --- a/components/waves/leaderboard/sidebar/WaveLeaderboardRightSidebarBoostedDrops.tsx +++ b/components/waves/leaderboard/sidebar/WaveLeaderboardRightSidebarBoostedDrops.tsx @@ -22,6 +22,7 @@ export const WaveLeaderboardRightSidebarBoostedDrops = const { data: boostedDrops, isLoading } = useWaveBoostedDrops({ waveId: wave.id, + wave, limit: MAX_BOOSTED_DROPS, timeWindow, }); diff --git a/components/waves/quorum/QuorumParticipationDropLinkPreview.tsx b/components/waves/quorum/QuorumParticipationDropLinkPreview.tsx index c0b6d26c09..78318e1890 100644 --- a/components/waves/quorum/QuorumParticipationDropLinkPreview.tsx +++ b/components/waves/quorum/QuorumParticipationDropLinkPreview.tsx @@ -8,10 +8,8 @@ import LinkHandlerFrame from "@/components/waves/LinkHandlerFrame"; import { DropLocation } from "@/components/waves/drops/drop.types"; import type { DropInteractionParams } from "@/components/waves/drops/drop.types"; import WaveDropQuote from "@/components/waves/drops/WaveDropQuote"; -import { WaveDropsSearchStrategy } from "@/contexts/wave/hooks/types"; import type { ApiDrop } from "@/generated/models/ApiDrop"; import { ApiDropType } from "@/generated/models/ApiDropType"; -import type { ApiWaveDropsFeed } from "@/generated/models/ApiWaveDropsFeed"; import { convertApiDropToExtendedDrop, type ExtendedDrop, @@ -21,7 +19,7 @@ import { fetchDropByIdBatched, getDropQueryKey, } from "@/services/api/drop-api"; -import { commonApiFetch } from "@/services/api/common-api"; +import { fetchQuorumParticipationDropPreviewBySerialNoV2 } from "@/services/api/quorum-participation-drop-preview-v2-api"; import QuorumParticipationDrop from "./QuorumParticipationDrop"; interface QuorumParticipationDropLinkPreviewProps { @@ -52,32 +50,6 @@ const toSerialNumber = ( return Number.isFinite(parsed) ? parsed : null; }; -const fetchDropBySerialNo = async ({ - waveId, - serialNo, -}: { - readonly waveId: string; - readonly serialNo: number; -}): Promise => { - const results = await commonApiFetch({ - endpoint: `waves/${waveId}/drops`, - params: { - limit: "1", - serial_no_limit: `${serialNo}`, - search_strategy: WaveDropsSearchStrategy.Both, - }, - }); - - const drop = results.drops.find( - (candidate) => candidate.serial_no === serialNo - ); - if (!drop) { - return null; - } - - return { ...drop, wave: results.wave } as ApiDrop; -}; - export default function QuorumParticipationDropLinkPreview({ href, waveId, @@ -112,7 +84,7 @@ export default function QuorumParticipationDropLinkPreview({ } if (parsedSerialNo !== null) { - return await fetchDropBySerialNo({ + return await fetchQuorumParticipationDropPreviewBySerialNoV2({ waveId, serialNo: parsedSerialNo, }); diff --git a/components/waves/specs/wave-notification-settings/useWaveMuteSettings.ts b/components/waves/specs/wave-notification-settings/useWaveMuteSettings.ts index 5c9c01adea..cafde342c2 100644 --- a/components/waves/specs/wave-notification-settings/useWaveMuteSettings.ts +++ b/components/waves/specs/wave-notification-settings/useWaveMuteSettings.ts @@ -27,6 +27,9 @@ export function useWaveMuteSettings(wave: ApiWave) { queryClient.invalidateQueries({ queryKey: [QueryKey.WAVES_OVERVIEW], }), + queryClient.invalidateQueries({ + queryKey: [QueryKey.WAVES_V2], + }), ]); } catch (error) { const defaultMessage = isMuted diff --git a/components/waves/winners/WaveWinners.tsx b/components/waves/winners/WaveWinners.tsx index f12d4ee7f2..37c4603981 100644 --- a/components/waves/winners/WaveWinners.tsx +++ b/components/waves/winners/WaveWinners.tsx @@ -41,6 +41,7 @@ export const WaveWinners: React.FC = ({ hasNextPage, } = useWaveDecisions({ waveId: wave.id, + wave, enabled: true, // Always enabled now that we use it for both types loadAllPages: isApproveWave, pageSize: isApproveWave diff --git a/components/waves/winners/WaveWinnersSmall.tsx b/components/waves/winners/WaveWinnersSmall.tsx index 144f111678..0a27f10c2e 100644 --- a/components/waves/winners/WaveWinnersSmall.tsx +++ b/components/waves/winners/WaveWinnersSmall.tsx @@ -51,6 +51,7 @@ export const WaveWinnersSmall = memo( hasNextPage, } = useWaveDecisions({ waveId: wave.id, + wave, enabled: true, // Always enabled now that we use it for both types loadAllPages: isApproveWave, pageSize: isApproveWave diff --git a/components/waves/winners/drops/MemesWaveWinnerDrop.tsx b/components/waves/winners/drops/MemesWaveWinnerDrop.tsx index e1c31922d6..9186af0513 100644 --- a/components/waves/winners/drops/MemesWaveWinnerDrop.tsx +++ b/components/waves/winners/drops/MemesWaveWinnerDrop.tsx @@ -51,6 +51,22 @@ const isClickFromCardDom = ( return event.currentTarget.contains(event.target as Node); }; +const getNonEmptyText = ( + value: string | null | undefined +): string | undefined => { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : undefined; +}; + +const getMetadataValue = ( + winner: ApiWaveDecisionWinner, + dataKey: string +): string | undefined => + getNonEmptyText( + winner.drop.metadata.find((metadata) => metadata.data_key === dataKey) + ?.data_value + ); + export const MemesWaveWinnersDrop: React.FC = ({ winner, wave, @@ -108,11 +124,13 @@ export const MemesWaveWinnersDrop: React.FC = ({ }, [handleMobileMenuOpenChange]); const title = - winner.drop.metadata.find((m) => m.data_key === "title")?.data_value ?? + getNonEmptyText(winner.drop.title) ?? + getMetadataValue(winner, "title") ?? "Artwork Title"; const description = - winner.drop.metadata.find((m) => m.data_key === "description") - ?.data_value ?? "This is an artwork submission for The Memes collection."; + getNonEmptyText(winner.drop.parts.at(0)?.content) ?? + getMetadataValue(winner, "description") ?? + "This is an artwork submission for The Memes collection."; const artworkMedia = winner.drop.parts.at(0)?.media.at(0); @@ -266,47 +284,49 @@ export const MemesWaveWinnersDrop: React.FC = ({
-
- {topVoters.map((voter) => ( - - e.stopPropagation()} - scroll={false} - className="tw-transition-transform desktop-hover:hover:tw-translate-y-[-2px]" - data-tooltip-id={`voter-${voter.profile.handle ?? voter.profile.primary_address}-${voter.rating}`} - > - {voter.profile.pfp ? ( - {`${ - ) : ( -
- )} - - - {voter.profile.handle} -{" "} - {formatNumberWithCommas(voter.rating)} - - - ))} -
+ {topVoters.length > 0 && ( +
+ {topVoters.map((voter) => ( + + e.stopPropagation()} + scroll={false} + className="tw-transition-transform desktop-hover:hover:tw-translate-y-[-2px]" + data-tooltip-id={`voter-${voter.profile.handle ?? voter.profile.primary_address}-${voter.rating}`} + > + {voter.profile.pfp ? ( + {`${ + ) : ( +
+ )} + + + {voter.profile.handle} -{" "} + {formatNumberWithCommas(voter.rating)} + + + ))} +
+ )} {formatNumberWithCommas(ratersCount)}{" "} diff --git a/components/waves/winners/drops/header/WaveWinnersDropHeaderVoters.tsx b/components/waves/winners/drops/header/WaveWinnersDropHeaderVoters.tsx index 912f773b27..72478cd0a2 100644 --- a/components/waves/winners/drops/header/WaveWinnersDropHeaderVoters.tsx +++ b/components/waves/winners/drops/header/WaveWinnersDropHeaderVoters.tsx @@ -17,20 +17,23 @@ export default function WaveWinnersDropHeaderVoters({ const hasUserVoted = userVote !== 0; const isNegativeVote = userVote < 0; const userVoteClass = isNegativeVote ? "tw-text-rose-400" : "tw-text-iron-50"; + const hasTopRaters = winner.drop.top_raters.length > 0; return (
-
- {winner.drop.top_raters.map((voter, index) => ( - - ))} -
+ {hasTopRaters && ( +
+ {winner.drop.top_raters.map((voter, index) => ( + + ))} +
+ )} {formatNumberWithCommas(winner.drop.raters_count)}{" "} diff --git a/contexts/wave/hooks/useEnhancedWavesListCore.ts b/contexts/wave/hooks/useEnhancedWavesListCore.ts index 0328b608f1..4f9f0b7c28 100644 --- a/contexts/wave/hooks/useEnhancedWavesListCore.ts +++ b/contexts/wave/hooks/useEnhancedWavesListCore.ts @@ -1,7 +1,7 @@ "use client"; -import type { ApiWave } from "@/generated/models/ApiWave"; import type { ApiWaveType } from "@/generated/models/ApiWaveType"; +import type { SidebarWave, SidebarWaveContributor } from "@/types/waves.types"; import { useCallback, useEffect, useMemo, useState } from "react"; import type { MinimalWaveNewDropsCount } from "./useNewDropCounter"; import useNewDropCounter, { getNewestTimestamp } from "./useNewDropCounter"; @@ -14,7 +14,7 @@ export interface MinimalWave { type: ApiWaveType; newDropsCount: MinimalWaveNewDropsCount; picture: string | null; - contributors: { pfp: string; identity: string }[]; + contributors: readonly SidebarWaveContributor[]; isPinned: boolean; isMuted: boolean; unreadDropsCount: number; @@ -22,13 +22,10 @@ export interface MinimalWave { firstUnreadDropSerialNo: number | null; } -// Wave type that includes the computed isPinned field from useWavesList -interface EnhancedApiWave extends ApiWave { - isPinned?: boolean; -} +type EnhancedSidebarWave = SidebarWave & { readonly isPinned?: boolean }; interface WavesDataSource { - waves: EnhancedApiWave[]; + waves: EnhancedSidebarWave[]; isFetching: boolean; isFetchingNextPage: boolean; hasNextPage: boolean; @@ -117,20 +114,20 @@ function useEnhancedWavesListCore( }, [activeWaveId, resetWaveUnreadCount]); const mapWave = useCallback( - (wave: EnhancedApiWave): MinimalWave => { + (wave: EnhancedSidebarWave): MinimalWave => { const wsData = newDropsCounts[wave.id]; const hasNewWsDrops = (wsData?.count ?? 0) > 0; const newDrops = { count: wsData?.count ?? 0, latestDropTimestamp: getNewestTimestamp( wsData?.latestDropTimestamp, - wave.metrics.latest_drop_timestamp ?? null + wave.latestDropTimestamp ?? null ), firstUnreadSerialNo: wsData?.firstUnreadSerialNo ?? null, }; const isCleared = clearedUnreadWaveIds.has(wave.id) && !hasNewWsDrops; const forcedCount = forcedUnreadCounts[wave.id]; - const apiFirstUnread = wave.metrics.first_unread_drop_serial_no ?? null; + const apiFirstUnread = wave.firstUnreadDropSerialNo ?? null; const wsFirstUnread = wsData?.firstUnreadSerialNo ?? null; const wasCleared = clearedUnreadWaveIds.has(wave.id); let firstUnreadDropSerialNo: number | null = null; @@ -152,28 +149,24 @@ function useEnhancedWavesListCore( } else if (wasCleared && hasNewWsDrops) { unreadDropsCount = wsData?.count ?? 0; } else if (hasNewWsDrops) { - unreadDropsCount = - wave.metrics.your_unread_drops_count + (wsData?.count ?? 0); + unreadDropsCount = wave.unreadDropsCount + (wsData?.count ?? 0); } else { - unreadDropsCount = wave.metrics.your_unread_drops_count; + unreadDropsCount = wave.unreadDropsCount; } return { id: wave.id, name: wave.name, - type: wave.wave.type, + type: wave.type, picture: wave.picture, - contributors: wave.contributors_overview.map((c) => ({ - pfp: c.contributor_pfp, - identity: c.contributor_identity, - })), + contributors: wave.contributors, newDropsCount: newDrops, isPinned: options.supportsPinning ? (wave.isPinned ?? wave.pinned ?? false) : false, - isMuted: wave.metrics.muted, + isMuted: wave.muted, unreadDropsCount, - latestReadTimestamp: wave.metrics.your_latest_read_timestamp, + latestReadTimestamp: wave.latestReadTimestamp, firstUnreadDropSerialNo, }; }, diff --git a/contexts/wave/hooks/useNewDropCounter.ts b/contexts/wave/hooks/useNewDropCounter.ts index f7a98a8466..bf88f24c05 100644 --- a/contexts/wave/hooks/useNewDropCounter.ts +++ b/contexts/wave/hooks/useNewDropCounter.ts @@ -1,11 +1,11 @@ "use client"; -import { AuthContext } from "@/components/auth/Auth"; -import type { ApiWave } from "@/generated/models/ApiWave"; +import { useAuth } from "@/components/auth/Auth"; import type { WsDropUpdateMessage } from "@/helpers/Types"; import { WsMessageType } from "@/helpers/Types"; import { useWebSocketMessage } from "@/services/websocket/useWebSocketMessage"; -import { useCallback, useContext, useEffect, useRef, useState } from "react"; +import type { SidebarWave } from "@/types/waves.types"; +import { useCallback, useEffect, useRef, useState } from "react"; /** * Interface for tracking new drops count for a wave @@ -53,11 +53,11 @@ export function getNewestTimestamp( */ function useNewDropCounter( activeWaveId: string | null, - waves: ApiWave[], + waves: SidebarWave[], refetchWaves: () => void, options: UseNewDropCounterOptions = {} ) { - const { connectedProfile } = useContext(AuthContext); + const { connectedProfile } = useAuth(); const { otherListWaveIds = DEFAULT_OTHER_LIST_WAVE_IDS, unknownWaveRefetchCooldownMs = DEFAULT_UNKNOWN_WAVE_REFETCH_COOLDOWN_MS, @@ -78,8 +78,8 @@ function useNewDropCounter( count: 0, latestDropTimestamp: getNewestTimestamp( prev[waveId]?.latestDropTimestamp, - waves.find((wave) => wave.id === waveId)?.metrics - .latest_drop_timestamp ?? null + waves.find((wave) => wave.id === waveId)?.latestDropTimestamp ?? + null ), firstUnreadSerialNo: null, }, @@ -96,7 +96,7 @@ function useNewDropCounter( count: 0, latestDropTimestamp: getNewestTimestamp( prev[wave.id]?.latestDropTimestamp, - wave.metrics.latest_drop_timestamp ?? null + wave.latestDropTimestamp ?? null ), firstUnreadSerialNo: null, }; @@ -158,7 +158,7 @@ function useNewDropCounter( return; } - if (wave.metrics.muted) return; + if (wave.muted) return; if ( connectedProfile?.handle?.toLowerCase() === diff --git a/contexts/wave/utils/wave-messages-utils.ts b/contexts/wave/utils/wave-messages-utils.ts index bb5df1bba6..08833f723c 100644 --- a/contexts/wave/utils/wave-messages-utils.ts +++ b/contexts/wave/utils/wave-messages-utils.ts @@ -1,14 +1,11 @@ import { WAVE_DROPS_PARAMS } from "@/components/react-query-wrapper/utils/query-utils"; import type { ApiDrop } from "@/generated/models/ApiDrop"; import type { ApiDropId } from "@/generated/models/ApiDropId"; -import type { ApiWaveDropsFeed } from "@/generated/models/ApiWaveDropsFeed"; import { ApiDropSearchStrategy } from "@/generated/models/ApiDropSearchStrategy"; import type { Drop } from "@/helpers/waves/drop.helpers"; import { DropSize, getStableDropKey } from "@/helpers/waves/drop.helpers"; -import { - commonApiFetch, - commonApiFetchWithRetry, -} from "@/services/api/common-api"; +import { commonApiFetchWithRetry } from "@/services/api/common-api"; +import { fetchWaveDropsFeedV2 } from "@/services/api/wave-drops-v2-api"; import type { WaveMessagesUpdate } from "../hooks/types"; /** @@ -25,17 +22,13 @@ export async function fetchWaveMessages( signal?: AbortSignal, updateEligibility?: (waveId: string, eligibility: any) => void ): Promise { - const params: Record = { - limit: WAVE_DROPS_PARAMS.limit.toString(), - }; - if (serialNo) { - params["serial_no_less_than"] = `${serialNo}`; - } - try { - const data = await commonApiFetch({ - endpoint: `waves/${waveId}/drops`, - params, + const data = await fetchWaveDropsFeedV2({ + waveId, + limit: WAVE_DROPS_PARAMS.limit, + serialNoLimit: serialNo, + searchStrategy: + serialNo !== null ? ApiDropSearchStrategy.Older : undefined, signal, }); @@ -52,10 +45,7 @@ export async function fetchWaveMessages( }); } - return data.drops.map((drop) => ({ - ...drop, - wave: data.wave, - })); + return data.drops as ApiDrop[]; } catch (error) { // Check if this is an abort error if (error instanceof DOMException && error.name === "AbortError") { @@ -75,30 +65,17 @@ export async function fetchAroundSerialNoWaveMessages( serialNo: number, signal?: AbortSignal ): Promise { - const params: Record = { - limit: WAVE_DROPS_PARAMS.limit.toString(), - }; - - params["search_strategy"] = ApiDropSearchStrategy.Both; - params["serial_no_limit"] = `${serialNo}`; - try { - const data = await commonApiFetchWithRetry({ - endpoint: `waves/${waveId}/drops`, - params, + const data = await fetchWaveDropsFeedV2({ + waveId, + limit: WAVE_DROPS_PARAMS.limit, + serialNoLimit: serialNo, + searchStrategy: ApiDropSearchStrategy.Both, signal, - retryOptions: { - maxRetries: 2, - initialDelayMs: 300, - backoffFactor: 1.5, - jitter: 0.1, - }, + withRetry: true, }); - return data.drops.map((drop) => ({ - ...drop, - wave: data.wave, - })); + return data.drops as ApiDrop[]; } catch (error) { // Check if this is an abort error if (error instanceof DOMException && error.name === "AbortError") { @@ -333,26 +310,15 @@ export async function fetchNewestWaveMessages( signal?: AbortSignal, updateEligibility?: (waveId: string, eligibility: any) => void ): Promise<{ drops: ApiDrop[] | null; highestSerialNo: number | null }> { - const params: Record = { - limit: limit.toString(), - }; - if (sinceSerialNo !== null) { - // Assuming API uses these parameters for fetching newer messages - params["serial_no_limit"] = `${sinceSerialNo}`; - params["search_strategy"] = ApiDropSearchStrategy.Newer; - } - try { - const data = await commonApiFetchWithRetry({ - endpoint: `waves/${waveId}/drops`, - params, + const data = await fetchWaveDropsFeedV2({ + waveId, + limit, + serialNoLimit: sinceSerialNo, + searchStrategy: + sinceSerialNo !== null ? ApiDropSearchStrategy.Newer : undefined, signal, - retryOptions: { - maxRetries: 2, - initialDelayMs: 300, - backoffFactor: 1.5, - jitter: 0.1, - }, + withRetry: true, }); // Update centralized eligibility if callback provided @@ -368,10 +334,7 @@ export async function fetchNewestWaveMessages( }); } - const fetchedDrops = data.drops.map((drop) => ({ - ...drop, - wave: data.wave, - })); + const fetchedDrops = data.drops as ApiDrop[]; const highestSerialNo = getHighestSerialNo(fetchedDrops); diff --git a/generated/models/ApiNotificationV2.ts b/generated/models/ApiNotificationV2.ts index e94d6f50f0..41ee0c6633 100644 --- a/generated/models/ApiNotificationV2.ts +++ b/generated/models/ApiNotificationV2.ts @@ -15,6 +15,7 @@ import { ApiDropV2 } from '../models/ApiDropV2'; import { ApiIdentityOverview } from '../models/ApiIdentityOverview'; import { ApiNotificationAdditionalContextV2 } from '../models/ApiNotificationAdditionalContextV2'; import { ApiNotificationCause } from '../models/ApiNotificationCause'; +import { ApiWaveOverview } from '../models/ApiWaveOverview'; import { HttpFile } from '../http/http'; export class ApiNotificationV2 { @@ -24,6 +25,7 @@ export class ApiNotificationV2 { 'read_at': number | null; 'related_identity': ApiIdentityOverview; 'related_drops': Array; + 'related_wave'?: ApiWaveOverview; 'additional_context': ApiNotificationAdditionalContextV2; static readonly discriminator: string | undefined = undefined; @@ -67,6 +69,12 @@ export class ApiNotificationV2 { "type": "Array", "format": "" }, + { + "name": "related_wave", + "baseName": "related_wave", + "type": "ApiWaveOverview", + "format": "" + }, { "name": "additional_context", "baseName": "additional_context", diff --git a/generated/models/ApiWaveOverview.ts b/generated/models/ApiWaveOverview.ts index 1f3d1b92dc..33853e80da 100644 --- a/generated/models/ApiWaveOverview.ts +++ b/generated/models/ApiWaveOverview.ts @@ -12,6 +12,8 @@ */ import { ApiWaveOverviewContextProfileContext } from '../models/ApiWaveOverviewContextProfileContext'; +import { ApiWaveOverviewContributor } from '../models/ApiWaveOverviewContributor'; +import { ApiWaveOverviewDescriptionDrop } from '../models/ApiWaveOverviewDescriptionDrop'; import { HttpFile } from '../http/http'; export class ApiWaveOverview { @@ -23,6 +25,10 @@ export class ApiWaveOverview { 'subscribers_count': number; 'has_competition': boolean; 'is_dm_wave': boolean; + 'description_drop': ApiWaveOverviewDescriptionDrop; + 'total_drops_count': number; + 'is_private': boolean; + 'contributors'?: Array; 'context_profile_context'?: ApiWaveOverviewContextProfileContext; static readonly discriminator: string | undefined = undefined; @@ -78,6 +84,30 @@ export class ApiWaveOverview { "type": "boolean", "format": "" }, + { + "name": "description_drop", + "baseName": "description_drop", + "type": "ApiWaveOverviewDescriptionDrop", + "format": "" + }, + { + "name": "total_drops_count", + "baseName": "total_drops_count", + "type": "number", + "format": "int64" + }, + { + "name": "is_private", + "baseName": "is_private", + "type": "boolean", + "format": "" + }, + { + "name": "contributors", + "baseName": "contributors", + "type": "Array", + "format": "" + }, { "name": "context_profile_context", "baseName": "context_profile_context", diff --git a/generated/models/ApiWaveOverviewContextProfileContext.ts b/generated/models/ApiWaveOverviewContextProfileContext.ts index 1a64cfd08a..5ee989f832 100644 --- a/generated/models/ApiWaveOverviewContextProfileContext.ts +++ b/generated/models/ApiWaveOverviewContextProfileContext.ts @@ -18,6 +18,7 @@ export class ApiWaveOverviewContextProfileContext { 'pinned': boolean; 'can_chat': boolean; 'unread_drops': number; + 'first_unread_drop_serial_no'?: number; 'muted': boolean; static readonly discriminator: string | undefined = undefined; @@ -49,6 +50,12 @@ export class ApiWaveOverviewContextProfileContext { "type": "number", "format": "int64" }, + { + "name": "first_unread_drop_serial_no", + "baseName": "first_unread_drop_serial_no", + "type": "number", + "format": "int64" + }, { "name": "muted", "baseName": "muted", diff --git a/generated/models/ApiWaveOverviewContributor.ts b/generated/models/ApiWaveOverviewContributor.ts new file mode 100644 index 0000000000..783493e3b8 --- /dev/null +++ b/generated/models/ApiWaveOverviewContributor.ts @@ -0,0 +1,44 @@ +// @ts-nocheck +/** + * 6529.io API + * This is the API interface description. Brief terminology overview and an authentication example can be found at https://6529.io/about/api. + * + * OpenAPI spec version: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { HttpFile } from '../http/http'; + +export class ApiWaveOverviewContributor { + 'handle': string | null; + 'pfp': string | null; + + static readonly discriminator: string | undefined = undefined; + + static readonly mapping: {[index: string]: string} | undefined = undefined; + + static readonly attributeTypeMap: Array<{name: string, baseName: string, type: string, format: string}> = [ + { + "name": "handle", + "baseName": "handle", + "type": "string", + "format": "" + }, + { + "name": "pfp", + "baseName": "pfp", + "type": "string", + "format": "" + } ]; + + static getAttributeTypeMap() { + return ApiWaveOverviewContributor.attributeTypeMap; + } + + public constructor() { + } +} diff --git a/generated/models/ApiWaveOverviewDescriptionDrop.ts b/generated/models/ApiWaveOverviewDescriptionDrop.ts new file mode 100644 index 0000000000..a2e6ced597 --- /dev/null +++ b/generated/models/ApiWaveOverviewDescriptionDrop.ts @@ -0,0 +1,45 @@ +// @ts-nocheck +/** + * 6529.io API + * This is the API interface description. Brief terminology overview and an authentication example can be found at https://6529.io/about/api. + * + * OpenAPI spec version: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { ApiDropMedia } from '../models/ApiDropMedia'; +import { HttpFile } from '../http/http'; + +export class ApiWaveOverviewDescriptionDrop { + 'contents'?: string; + 'media'?: Array; + + static readonly discriminator: string | undefined = undefined; + + static readonly mapping: {[index: string]: string} | undefined = undefined; + + static readonly attributeTypeMap: Array<{name: string, baseName: string, type: string, format: string}> = [ + { + "name": "contents", + "baseName": "contents", + "type": "string", + "format": "" + }, + { + "name": "media", + "baseName": "media", + "type": "Array", + "format": "" + } ]; + + static getAttributeTypeMap() { + return ApiWaveOverviewDescriptionDrop.attributeTypeMap; + } + + public constructor() { + } +} diff --git a/generated/models/ObjectSerializer.ts b/generated/models/ObjectSerializer.ts index b41fefb7a2..d8bbf54075 100644 --- a/generated/models/ObjectSerializer.ts +++ b/generated/models/ObjectSerializer.ts @@ -284,6 +284,8 @@ export * from '../models/ApiWaveOutcomeType'; export * from '../models/ApiWaveOutcomesPage'; export * from '../models/ApiWaveOverview'; export * from '../models/ApiWaveOverviewContextProfileContext'; +export * from '../models/ApiWaveOverviewContributor'; +export * from '../models/ApiWaveOverviewDescriptionDrop'; export * from '../models/ApiWaveOverviewPage'; export * from '../models/ApiWaveParticipationConfig'; export * from '../models/ApiWaveParticipationIdentitySubmissionAllowDuplicates'; @@ -551,7 +553,7 @@ import { ApiNotification } from '../models/ApiNotification'; import { ApiNotificationAdditionalContextV2 } from '../models/ApiNotificationAdditionalContextV2'; import { ApiNotificationCause } from '../models/ApiNotificationCause'; import { ApiNotificationDropReactedReactor } from '../models/ApiNotificationDropReactedReactor'; -import { ApiNotificationV2 } from '../models/ApiNotificationV2'; +import { ApiNotificationV2 } from '../models/ApiNotificationV2'; import { ApiNotificationsResponse } from '../models/ApiNotificationsResponse'; import { ApiNotificationsResponseV2 } from '../models/ApiNotificationsResponseV2'; import { ApiOutgoingIdentitySubscriptionsPage } from '../models/ApiOutgoingIdentitySubscriptionsPage'; @@ -655,6 +657,8 @@ import { ApiWaveOutcomeType } from '../models/ApiWaveOutcomeType'; import { ApiWaveOutcomesPage } from '../models/ApiWaveOutcomesPage'; import { ApiWaveOverview } from '../models/ApiWaveOverview'; import { ApiWaveOverviewContextProfileContext } from '../models/ApiWaveOverviewContextProfileContext'; +import { ApiWaveOverviewContributor } from '../models/ApiWaveOverviewContributor'; +import { ApiWaveOverviewDescriptionDrop } from '../models/ApiWaveOverviewDescriptionDrop'; import { ApiWaveOverviewPage } from '../models/ApiWaveOverviewPage'; import { ApiWaveParticipationConfig } from '../models/ApiWaveParticipationConfig'; import { ApiWaveParticipationIdentitySubmissionAllowDuplicates } from '../models/ApiWaveParticipationIdentitySubmissionAllowDuplicates'; @@ -1061,6 +1065,8 @@ let typeMap: {[index: string]: any} = { "ApiWaveOutcomesPage": ApiWaveOutcomesPage, "ApiWaveOverview": ApiWaveOverview, "ApiWaveOverviewContextProfileContext": ApiWaveOverviewContextProfileContext, + "ApiWaveOverviewContributor": ApiWaveOverviewContributor, + "ApiWaveOverviewDescriptionDrop": ApiWaveOverviewDescriptionDrop, "ApiWaveOverviewPage": ApiWaveOverviewPage, "ApiWaveParticipationConfig": ApiWaveParticipationConfig, "ApiWaveParticipationSubmissionStrategy": ApiWaveParticipationSubmissionStrategy, diff --git a/helpers/stream.helpers.ts b/helpers/stream.helpers.ts index f254dd3ff1..29262a2610 100644 --- a/helpers/stream.helpers.ts +++ b/helpers/stream.helpers.ts @@ -8,9 +8,14 @@ import { } from "@/components/react-query-wrapper/utils/query-utils"; import { jwtDecode } from "jwt-decode"; import { getUserProfile } from "./server.helpers"; -import type { TypedFeedItem, TypedNotificationsResponse } from "@/types/feed.types"; -import type { ApiWaveDropsFeed } from "@/generated/models/ApiWaveDropsFeed"; +import type { TypedFeedItem } from "@/types/feed.types"; import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; +import { + fetchWavesV2Page, + getWavesV2OverviewQueryKeyParams, +} from "@/services/api/waves-v2-api"; +import { fetchWaveDropsFeedV2 } from "@/services/api/wave-drops-v2-api"; +import { fetchNotificationsV2 } from "@/services/api/notifications-v2-api"; const getWalletFromJwt = (headers: Record): string | null => { const jwt = headers["Authorization"]?.split(" ")[1] ?? null; @@ -43,36 +48,29 @@ const prefetchAuthenticatedWavesOverview = async ({ queryClient: QueryClient; headers: Record; }) => { + const queryKeyParams = getWavesV2OverviewQueryKeyParams({ + pageSize: WAVE_FOLLOWING_WAVES_PARAMS.limit, + overviewType: WAVE_FOLLOWING_WAVES_PARAMS.initialWavesOverviewType, + following: + WAVE_FOLLOWING_WAVES_PARAMS.only_waves_followed_by_authenticated_user, + directMessage: false, + }); + await queryClient.prefetchInfiniteQuery({ - queryKey: [ - QueryKey.WAVES_OVERVIEW, - { - limit: WAVE_FOLLOWING_WAVES_PARAMS.limit, - type: WAVE_FOLLOWING_WAVES_PARAMS.initialWavesOverviewType, - only_waves_followed_by_authenticated_user: + queryKey: [QueryKey.WAVES_V2, queryKeyParams], + queryFn: async ({ pageParam }: { pageParam: number }) => { + return await fetchWavesV2Page({ + page: pageParam, + pageSize: WAVE_FOLLOWING_WAVES_PARAMS.limit, + overviewType: WAVE_FOLLOWING_WAVES_PARAMS.initialWavesOverviewType, + following: WAVE_FOLLOWING_WAVES_PARAMS.only_waves_followed_by_authenticated_user, - }, - ], - queryFn: async ({ pageParam }: { pageParam: number | null }) => { - const queryParams: Record = { - limit: `${WAVE_FOLLOWING_WAVES_PARAMS.limit}`, - offset: `${pageParam}`, - type: WAVE_FOLLOWING_WAVES_PARAMS.initialWavesOverviewType, - only_waves_followed_by_authenticated_user: `${WAVE_FOLLOWING_WAVES_PARAMS.only_waves_followed_by_authenticated_user}`, - }; - - - return await commonApiFetch({ - endpoint: `waves-overview`, - params: queryParams, + directMessage: false, headers, }); }, - initialPageParam: 0, - getNextPageParam: (_, allPages) => - allPages.at(-1)?.length === WAVE_FOLLOWING_WAVES_PARAMS.limit - ? allPages.flat().length - : null, + initialPageParam: 1, + getNextPageParam: (lastPage) => (lastPage.next ? lastPage.page + 1 : null), pages: 1, staleTime: 60000, }); @@ -136,16 +134,10 @@ const prefetchAuthenticatedWaveFeedItems = async ({ }, ], queryFn: async ({ pageParam }: { pageParam: number | null }) => { - const params: Record = { - limit: WAVE_DROPS_PARAMS.limit.toString(), - }; - - if (pageParam) { - params["serial_no_less_than"] = `${pageParam}`; - } - return await commonApiFetch({ - endpoint: `waves/${waveId}/drops`, - params, + return await fetchWaveDropsFeedV2({ + waveId: waveId!, + limit: WAVE_DROPS_PARAMS.limit, + serialNoLimit: pageParam, headers, }); }, @@ -296,18 +288,12 @@ const prefetchAuthenticatedNotificationsItems = async ({ await queryClient.prefetchInfiniteQuery({ queryKey: [ QueryKey.IDENTITY_NOTIFICATIONS, - { identity: handle, limit: "10" }, + { identity: handle, limit: "10", cause: null, version: "v2" }, ], queryFn: async ({ pageParam }: { pageParam: number | null }) => { - const params: Record = { + return await fetchNotificationsV2({ limit: "10", - }; - if (pageParam) { - params["id_less_than"] = `${pageParam}`; - } - return await commonApiFetch({ - endpoint: `notifications`, - params, + pageParam, headers, }); }, diff --git a/hooks/drops/useDropInteractionRules.ts b/hooks/drops/useDropInteractionRules.ts index 92dcfc6f47..2a673672f1 100644 --- a/hooks/drops/useDropInteractionRules.ts +++ b/hooks/drops/useDropInteractionRules.ts @@ -1,7 +1,6 @@ "use client"; -import { useContext } from "react"; -import { AuthContext } from "@/components/auth/Auth"; +import { useAuth } from "@/components/auth/Auth"; import type { ApiDrop } from "@/generated/models/ApiDrop"; import { ApiDropType } from "@/generated/models/ApiDropType"; import { DropVoteState } from "./types"; @@ -25,7 +24,7 @@ interface DropInteractionRules { * @returns Object containing boolean flags for different interaction possibilities */ export function useDropInteractionRules(drop: ApiDrop): DropInteractionRules { - const { connectedProfile, activeProfileProxy } = useContext(AuthContext); + const { connectedProfile, activeProfileProxy } = useAuth(); // Check if this is a winner drop const isWinner = drop.drop_type === ApiDropType.Winner; diff --git a/hooks/drops/useDropReaction.ts b/hooks/drops/useDropReaction.ts index e8629205ed..d4ce5ed30e 100644 --- a/hooks/drops/useDropReaction.ts +++ b/hooks/drops/useDropReaction.ts @@ -17,10 +17,9 @@ import type { InfiniteData } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useRef } from "react"; import { + applyProfileReactionToEntries, cloneReactionEntries, - findReactionIndex, getReactionErrorMessage, - removeUserFromReactions, toProfileMin, } from "@/components/waves/drops/reaction-utils"; import { @@ -153,31 +152,18 @@ const applyReactionToCacheDrop = ({ return drop; } - const reactionsWithoutUser = removeUserFromReactions( - cloneReactionEntries(drop.reactions), - profileMin.id - ); - - if (reactionCode !== null) { - const targetIndex = findReactionIndex(reactionsWithoutUser, reactionCode); - - if (targetIndex >= 0) { - const target = reactionsWithoutUser[targetIndex]!; - reactionsWithoutUser[targetIndex] = { - ...target, - profiles: [...target.profiles, profileMin], - }; - } else { - reactionsWithoutUser.push({ - reaction: reactionCode, - profiles: [profileMin], - }); - } - } + const previousReaction = + drop.context_profile_context?.reaction ?? baseContext?.reaction ?? null; + const reactions = applyProfileReactionToEntries({ + entries: cloneReactionEntries(drop.reactions), + nextReaction: reactionCode, + previousReaction, + profileMin, + }); return { ...drop, - reactions: reactionsWithoutUser, + reactions, context_profile_context: { ...(drop.context_profile_context ?? baseContext ?? diff --git a/hooks/useConnectedAccountsUnreadNotifications.ts b/hooks/useConnectedAccountsUnreadNotifications.ts index 2ebdb9e295..ed489bac64 100644 --- a/hooks/useConnectedAccountsUnreadNotifications.ts +++ b/hooks/useConnectedAccountsUnreadNotifications.ts @@ -3,7 +3,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; import { getDefaultQueryRetry } from "@/components/react-query-wrapper/utils/query-utils"; -import type { ApiNotificationsResponse } from "@/generated/models/ApiNotificationsResponse"; +import type { ApiNotificationsResponseV2 } from "@/generated/models/ApiNotificationsResponseV2"; import type { ConnectedWalletAccount } from "@/services/auth/auth.utils"; import { commonApiFetch } from "@/services/api/common-api"; import useCapacitor from "./useCapacitor"; @@ -28,8 +28,8 @@ const fetchUnreadCountForAccount = async ( return 0; } - const notifications = await commonApiFetch({ - endpoint: "notifications", + const notifications = await commonApiFetch({ + endpoint: "v2/notifications", params: { limit: "1" }, headers: { Authorization: `Bearer ${account.jwt}`, @@ -46,6 +46,7 @@ export function useConnectedAccountsUnreadNotifications( const queryKey = [ QueryKey.IDENTITY_NOTIFICATIONS, "connected-account-unread-counts", + "v2", accounts.map((account) => toAddressKey(account.address)), ] as const; diff --git a/hooks/useDmWavesList.ts b/hooks/useDmWavesList.ts index 6d377c1d2f..06b65ec771 100644 --- a/hooks/useDmWavesList.ts +++ b/hooks/useDmWavesList.ts @@ -3,7 +3,7 @@ import { useCallback, useMemo } from "react"; import { useAuth } from "@/components/auth/Auth"; import { useSeizeConnectContext } from "@/components/auth/SeizeConnectContext"; -import { useWavesOverview } from "./useWavesOverview"; +import { useWavesV2 } from "./useWavesV2"; import { SIDEBAR_WAVES_OVERVIEW_REFETCH_INTERVAL_MS, WAVE_FOLLOWING_WAVES_PARAMS, @@ -33,9 +33,9 @@ const useDmWavesList = () => { fetchNextPage, status, refetch, - } = useWavesOverview({ - type: WAVE_FOLLOWING_WAVES_PARAMS.initialWavesOverviewType, - limit: WAVE_FOLLOWING_WAVES_PARAMS.limit, + } = useWavesV2({ + overviewType: WAVE_FOLLOWING_WAVES_PARAMS.initialWavesOverviewType, + pageSize: WAVE_FOLLOWING_WAVES_PARAMS.limit, directMessage: true, viewerIdentityKey, refetchInterval: SIDEBAR_WAVES_OVERVIEW_REFETCH_INTERVAL_MS, @@ -45,8 +45,7 @@ const useDmWavesList = () => { // sort by latest drop const sorted = useMemo(() => { return [...mainWaves].sort( - (a, b) => - b.metrics.latest_drop_timestamp - a.metrics.latest_drop_timestamp + (a, b) => (b.latestDropTimestamp ?? 0) - (a.latestDropTimestamp ?? 0) ); }, [mainWaves]); diff --git a/hooks/useDropMessages.ts b/hooks/useDropMessages.ts index c849b4d257..cac78821b0 100644 --- a/hooks/useDropMessages.ts +++ b/hooks/useDropMessages.ts @@ -17,20 +17,27 @@ import { } from "@/components/react-query-wrapper/utils/updateAttachmentInCachedDrops"; import type { ApiAttachment } from "@/generated/models/ApiAttachment"; import type { ApiWaveDropsFeed } from "@/generated/models/ApiWaveDropsFeed"; +import type { ApiWave } from "@/generated/models/ApiWave"; import { generateUniqueKeys, mapToExtendedDrops, } from "@/helpers/waves/wave-drops.helpers"; -import { commonApiFetch } from "@/services/api/common-api"; import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; import type { WsDropUpdateMessage } from "@/helpers/Types"; import { WsMessageType } from "@/helpers/Types"; import { useWebSocketMessage } from "@/services/websocket/useWebSocketMessage"; -import { WaveDropsSearchStrategy } from "@/contexts/wave/hooks/types"; - -export function useDropMessages(waveId: string, dropId: string | null) { +import { + fetchDropRepliesV2, + type ApiWaveDropsV2PageFeed, +} from "@/services/api/wave-drops-v2-api"; + +export function useDropMessages( + waveId: string, + dropId: string | null, + wave?: ApiWave +) { const { isCapacitor } = useCapacitor(); const queryClient = useQueryClient(); const [init, setInit] = useState(false); @@ -57,36 +64,19 @@ export function useDropMessages(waveId: string, dropId: string | null) { pageParam, }: { pageParam: { - serialNo: number | null; - strategy: WaveDropsSearchStrategy; + page: number; } | null; - }) => { - const params: Record = { - limit: WAVE_DROPS_PARAMS.limit.toString(), - drop_id: dropId ?? "", - }; - - if (pageParam?.serialNo) { - params["serial_no_limit"] = `${pageParam.serialNo}`; - params["search_strategy"] = `${pageParam.strategy}`; - } - - const results = await commonApiFetch({ - endpoint: `waves/${waveId}/drops`, - params, - }); - - return results; - }, + }) => + fetchDropRepliesV2({ + parentDropId: dropId ?? "", + page: pageParam?.page ?? 1, + pageSize: WAVE_DROPS_PARAMS.limit, + wave, + }), enabled: !!dropId, initialPageParam: null, getNextPageParam: (lastPage) => - lastPage.drops.at(-1)?.serial_no - ? { - serialNo: lastPage.drops.at(-1)?.serial_no ?? null, - strategy: WaveDropsSearchStrategy.Older, - } - : null, + lastPage.next ? { page: lastPage.page + 1 } : null, placeholderData: keepPreviousData, staleTime: 60000, refetchOnWindowFocus: true, @@ -102,7 +92,7 @@ export function useDropMessages(waveId: string, dropId: string | null) { }, [hasNextPage, isFetchingNextPage, onFetchNextPage]); const processDrops = ( - pages: ApiWaveDropsFeed[] | undefined, + pages: (ApiWaveDropsFeed | ApiWaveDropsV2PageFeed)[] | undefined, previousDrops: ExtendedDrop[], isReverse: boolean ) => { diff --git a/hooks/useFavouriteWavesOfIdentity.ts b/hooks/useFavouriteWavesOfIdentity.ts index 05f54736f0..a9ab8ea12a 100644 --- a/hooks/useFavouriteWavesOfIdentity.ts +++ b/hooks/useFavouriteWavesOfIdentity.ts @@ -3,8 +3,9 @@ import { useQuery } from "@tanstack/react-query"; import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; import { getDefaultQueryRetry } from "@/components/react-query-wrapper/utils/query-utils"; -import type { ApiWave } from "@/generated/models/ApiWave"; -import { commonApiFetch } from "@/services/api/common-api"; +import { ApiWavesV2ListType } from "@/generated/models/ApiWavesV2ListType"; +import { fetchWavesV2Page } from "@/services/api/waves-v2-api"; +import type { SidebarWave } from "@/types/waves.types"; interface UseFavouriteWavesOfIdentityProps { readonly identityKey: string | null; @@ -20,21 +21,21 @@ export function useFavouriteWavesOfIdentity({ const normalizedIdentityKey = identityKey?.trim() ?? null; const activeIdentityKey = normalizedIdentityKey ?? ""; - const query = useQuery({ + const query = useQuery({ queryKey: [ QueryKey.IDENTITY_FAVOURITE_WAVES, { identity_key: normalizedIdentityKey, limit }, ], - queryFn: async () => - await commonApiFetch({ - endpoint: `waves-overview/favourites-of-identity/${encodeURIComponent( - activeIdentityKey - )}`, - params: { - limit: `${limit}`, - offset: "0", - }, - }), + queryFn: async () => { + const page = await fetchWavesV2Page({ + view: ApiWavesV2ListType.Favourites, + page: 1, + pageSize: limit, + identity: activeIdentityKey, + }); + + return page.waves; + }, enabled: enabled && activeIdentityKey.length > 0, ...getDefaultQueryRetry(), }); diff --git a/hooks/useNotificationsQuery.tsx b/hooks/useNotificationsQuery.tsx index 511288232e..2d5fbada8f 100644 --- a/hooks/useNotificationsQuery.tsx +++ b/hooks/useNotificationsQuery.tsx @@ -3,7 +3,7 @@ import { groupReactionNotifications } from "@/components/brain/notifications/utils/groupReactionNotifications"; import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; import type { ApiNotificationCause } from "@/generated/models/ApiNotificationCause"; -import { commonApiFetch } from "@/services/api/common-api"; +import { fetchNotificationsV2 } from "@/services/api/notifications-v2-api"; import type { NotificationDisplayItem, TypedNotificationsResponse, @@ -60,6 +60,7 @@ const getIdentityNotificationsQueryKey = ( cause: cause?.length ? [...cause].sort((a, b) => a.localeCompare(b)).join(",") : null, + version: "v2", }, ] as const; @@ -69,19 +70,10 @@ const fetchNotifications = async ({ pageParam, signal, }: NotificationsQueryParams) => { - const params: Record = { limit }; - - if (pageParam != null) { - params["id_less_than"] = String(pageParam); - } - - if (cause?.length) { - params["cause"] = cause.join(","); - } - - return await commonApiFetch({ - endpoint: "notifications", - params, + return await fetchNotificationsV2({ + limit, + cause, + pageParam, signal, }); }; diff --git a/hooks/usePinnedWavesServer.ts b/hooks/usePinnedWavesServer.ts index c00d709a3e..fe9423f8d4 100644 --- a/hooks/usePinnedWavesServer.ts +++ b/hooks/usePinnedWavesServer.ts @@ -1,27 +1,27 @@ "use client"; +import { useCallback, useEffect, useMemo, useRef, type RefObject } from "react"; import { - useCallback, - useContext, - useEffect, - useMemo, - useRef, - type RefObject, -} from "react"; -import { + type InfiniteData, useMutation, useQuery, useQueryClient, type QueryClient, type QueryObserverResult, } from "@tanstack/react-query"; -import { AuthContext } from "@/components/auth/Auth"; +import { useAuth } from "@/components/auth/Auth"; import { useSeizeConnectContext } from "@/components/auth/SeizeConnectContext"; import { useSeizeSettingsOptional } from "@/contexts/SeizeSettingsContext"; import { pinnedWavesApi } from "@/services/api/pinned-waves-api"; -import type { ApiWave } from "@/generated/models/ApiWave"; import { ApiWavesPinFilter } from "@/generated/models/ApiWavesPinFilter"; +import { ApiWavesOverviewType } from "@/generated/models/ApiWavesOverviewType"; import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; +import { + fetchWavesV2Page, + getWavesV2OverviewQueryKeyParams, + type WavesV2OverviewQueryKeyParams, +} from "@/services/api/waves-v2-api"; +import type { SidebarWave, SidebarWavesPage } from "@/types/waves.types"; export const MAX_PINNED_WAVES = 20; @@ -29,34 +29,26 @@ export const MAX_PINNED_WAVES = 20; const PINNED_WAVES_STALE_TIME = 5 * 60 * 1000; // 5 minutes const PINNED_WAVES_GC_TIME = 10 * 60 * 1000; // 10 minutes const PINNED_WAVES_REFETCH_INTERVAL = 2 * 60 * 1000; // 2 minutes - -// Type definitions for React Query data structures -interface InfiniteQueryData { - pages: T[][]; - pageParams: unknown[]; -} +const PINNED_WAVES_PAGE_SIZE = MAX_PINNED_WAVES; type PinnedWavesQueryKey = readonly [ - QueryKey.WAVES_OVERVIEW, - { - readonly pinned: ApiWavesPinFilter.Pinned; - readonly viewer_identity?: string; - }, + QueryKey.WAVES_V2, + WavesV2OverviewQueryKeyParams, ]; interface MutationContext { - previousPinnedWaves: ApiWave[] | undefined; + previousPinnedWaves: SidebarWave[] | undefined; } interface UsePinnedWavesServerReturn { - pinnedWaves: ApiWave[]; + pinnedWaves: SidebarWave[]; pinnedIds: string[]; isLoading: boolean; isError: boolean; error: Error | null; pinWave: (waveId: string) => Promise; unpinWave: (waveId: string) => Promise; - refetch: () => Promise>; + refetch: () => Promise>; isOperationInProgress: (waveId: string) => boolean; canPinWave: (waveId: string) => boolean; } @@ -65,11 +57,13 @@ function createPinnedWavesQueryKey( viewerIdentityKey: string | null ): PinnedWavesQueryKey { return [ - QueryKey.WAVES_OVERVIEW, - { + QueryKey.WAVES_V2, + getWavesV2OverviewQueryKeyParams({ + overviewType: ApiWavesOverviewType.MostSubscribed, + pageSize: PINNED_WAVES_PAGE_SIZE, pinned: ApiWavesPinFilter.Pinned, - ...(viewerIdentityKey ? { viewer_identity: viewerIdentityKey } : {}), - }, + viewerIdentityKey, + }), ] as const; } @@ -87,9 +81,18 @@ function usePinnedWavesQuery( queryKey: PinnedWavesQueryKey, isAuthenticated: boolean ) { - const query = useQuery({ + const query = useQuery({ queryKey, - queryFn: pinnedWavesApi.fetchPinnedWaves, + queryFn: async () => { + const page = await fetchWavesV2Page({ + page: 1, + pageSize: PINNED_WAVES_PAGE_SIZE, + overviewType: ApiWavesOverviewType.MostSubscribed, + pinned: ApiWavesPinFilter.Pinned, + }); + + return page.waves; + }, enabled: isAuthenticated, staleTime: PINNED_WAVES_STALE_TIME, gcTime: PINNED_WAVES_GC_TIME, @@ -107,7 +110,7 @@ function usePinnedWavesQuery( } function usePinnedWavesBudget( - pinnedWaves: ApiWave[], + pinnedWaves: SidebarWave[], ongoingOperations: RefObject> ) { const seizeSettings = useSeizeSettingsOptional(); @@ -161,7 +164,7 @@ function isMainWavesQueryForViewer( ): boolean { const [key, params] = queryKey; if ( - key !== QueryKey.WAVES_OVERVIEW || + key !== QueryKey.WAVES_V2 || typeof params !== "object" || params === null ) { @@ -193,27 +196,32 @@ function useInvalidateWavesQueries( queryKey: pinnedWavesQueryKey, }); void queryClient.invalidateQueries({ - queryKey: [QueryKey.WAVES_OVERVIEW], + queryKey: [QueryKey.WAVES_V2], predicate: (query) => isMainWavesQueryForViewer(query.queryKey, viewerIdentityKey), }); + void queryClient.invalidateQueries({ + queryKey: [QueryKey.WAVES_OVERVIEW], + }); }, [queryClient, pinnedWavesQueryKey, viewerIdentityKey]); } function findWaveInQueryData( - data: ApiWave[] | InfiniteQueryData | undefined, + data: SidebarWave[] | InfiniteData | undefined, waveId: string -): ApiWave | undefined { +): SidebarWave | undefined { if (!data) { return undefined; } if (Array.isArray(data)) { - return data.find((wave): wave is ApiWave => wave.id === waveId); + return data.find((wave): wave is SidebarWave => wave.id === waveId); } for (const page of data.pages) { - const wave = page.find((item): item is ApiWave => item.id === waveId); + const wave = page.waves.find( + (item): item is SidebarWave => item.id === waveId + ); if (wave) { return wave; } @@ -225,11 +233,11 @@ function findWaveInQueryData( function findWaveForOptimisticPin( queryClient: QueryClient, waveId: string -): ApiWave | undefined { +): SidebarWave | undefined { const wavesQueries = queryClient.getQueriesData< - ApiWave[] | InfiniteQueryData + SidebarWave[] | InfiniteData >({ - queryKey: [QueryKey.WAVES_OVERVIEW], + queryKey: [QueryKey.WAVES_V2], }); for (const [, data] of wavesQueries) { @@ -243,10 +251,10 @@ function findWaveForOptimisticPin( } function createOptimisticPinnedWaves( - previousPinnedWaves: ApiWave[] | undefined, - waveToPin: ApiWave | undefined, + previousPinnedWaves: SidebarWave[] | undefined, + waveToPin: SidebarWave | undefined, waveId: string -): ApiWave[] | undefined { +): SidebarWave[] | undefined { if (!waveToPin || !previousPinnedWaves) { return undefined; } @@ -268,7 +276,7 @@ async function optimisticallyPinWave( ): Promise { await queryClient.cancelQueries({ queryKey }); - const previousPinnedWaves = queryClient.getQueryData(queryKey); + const previousPinnedWaves = queryClient.getQueryData(queryKey); const waveToPin = findWaveForOptimisticPin(queryClient, waveId); const optimisticPinnedWaves = createOptimisticPinnedWaves( previousPinnedWaves, @@ -290,7 +298,7 @@ async function optimisticallyUnpinWave( ): Promise { await queryClient.cancelQueries({ queryKey }); - const previousPinnedWaves = queryClient.getQueryData(queryKey); + const previousPinnedWaves = queryClient.getQueryData(queryKey); if (previousPinnedWaves) { queryClient.setQueryData( @@ -349,12 +357,24 @@ function usePinnedWaveMutations( } export function usePinnedWavesServer(): UsePinnedWavesServerReturn { - const { connectedProfile, activeProfileProxy } = useContext(AuthContext); + const { connectedProfile, activeProfileProxy } = useAuth(); const { address } = useSeizeConnectContext(); const queryClient = useQueryClient(); const ongoingOperations = useRef>(new Set()); const isAuthenticated = !!connectedProfile?.handle && !activeProfileProxy; - const viewerIdentityKey = address?.toLowerCase() ?? null; + const activeProfileProxyId = activeProfileProxy?.id ?? null; + const viewerIdentityKey = useMemo(() => { + if (!address) { + return null; + } + + const normalizedAddress = address.toLowerCase(); + if (activeProfileProxyId !== null) { + return `${normalizedAddress}:proxy:${activeProfileProxyId}`; + } + + return `${normalizedAddress}:primary`; + }, [address, activeProfileProxyId]); const pinnedWavesQueryKey = usePinnedWavesQueryKey(viewerIdentityKey); const { data, isLoading, isError, error, refetch } = usePinnedWavesQuery( queryClient, diff --git a/hooks/useUnreadNotifications.ts b/hooks/useUnreadNotifications.ts index 73ec8c4dbc..d3b530ef45 100644 --- a/hooks/useUnreadNotifications.ts +++ b/hooks/useUnreadNotifications.ts @@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { useState, useEffect } from "react"; -import type { ApiNotificationsResponse } from "@/generated/models/ApiNotificationsResponse"; +import type { ApiNotificationsResponseV2 } from "@/generated/models/ApiNotificationsResponseV2"; import { commonApiFetch } from "@/services/api/common-api"; import useCapacitor from "./useCapacitor"; import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; @@ -10,14 +10,14 @@ import { getDefaultQueryRetry } from "@/components/react-query-wrapper/utils/que export function useUnreadNotifications(handle: string | null) { const { isCapacitor } = useCapacitor(); - const { data: notifications } = useQuery({ + const { data: notifications } = useQuery({ queryKey: [ QueryKey.IDENTITY_NOTIFICATIONS, - { identity: handle, limit: "1" }, + { identity: handle, limit: "1", version: "v2" }, ], queryFn: async () => - await commonApiFetch({ - endpoint: `notifications`, + await commonApiFetch({ + endpoint: `v2/notifications`, params: { limit: "1", }, diff --git a/hooks/useVirtualizedWaveDrops.ts b/hooks/useVirtualizedWaveDrops.ts index d48de3e810..ce8d8cd9c3 100644 --- a/hooks/useVirtualizedWaveDrops.ts +++ b/hooks/useVirtualizedWaveDrops.ts @@ -5,6 +5,7 @@ import { useVirtualizedWaveMessages } from "./useVirtualizedWaveMessages"; import { useMyStream } from "@/contexts/wave/MyStreamContext"; import type { NextPageProps } from "@/contexts/wave/hooks/useWavePagination"; import { DropSize } from "@/helpers/waves/drop.helpers"; +import type { ApiWave } from "@/generated/models/ApiWave"; /** * Hook that adapts the useVirtualizedWaveMessages hook to match the @@ -17,6 +18,7 @@ import { DropSize } from "@/helpers/waves/drop.helpers"; export function useVirtualizedWaveDrops( waveId: string, dropId: string | null, + wave?: ApiWave, pageSize: number = 50 ) { // Original implementation - would be imported from useMyStream @@ -26,7 +28,8 @@ export function useVirtualizedWaveDrops( const virtualizedWaveMessages = useVirtualizedWaveMessages( waveId, dropId, - pageSize + pageSize, + wave ); // Create a wrapper for fetchNextPageForWave that first tries to get data locally const fetchNextPageForWave = useCallback( diff --git a/hooks/useVirtualizedWaveMessages.ts b/hooks/useVirtualizedWaveMessages.ts index e47f5bb5d8..1cea23af20 100644 --- a/hooks/useVirtualizedWaveMessages.ts +++ b/hooks/useVirtualizedWaveMessages.ts @@ -6,6 +6,7 @@ import { useMyStreamWaveMessages } from "@/contexts/wave/MyStreamContext"; import type { Drop } from "@/helpers/waves/drop.helpers"; import type { WaveMessages } from "@/contexts/wave/hooks/types"; import { useDropMessages } from "./useDropMessages"; +import type { ApiWave } from "@/generated/models/ApiWave"; interface VirtualizedWaveMessages extends Omit { readonly drops: Drop[]; @@ -142,10 +143,11 @@ const getNextVirtualLimitAfterAppend = ({ export function useVirtualizedWaveMessages( waveId: string, dropId: string | null, - pageSize: number = 50 + pageSize: number = 50, + wave?: ApiWave ): VirtualizedWaveMessages | undefined { const fullWaveMessages = useMyStreamWaveMessages(waveId); - const fullWaveMessagesForDrop = useDropMessages(waveId, dropId); + const fullWaveMessagesForDrop = useDropMessages(waveId, dropId, wave); const fetchNextPageForDrop = fullWaveMessagesForDrop.fetchNextPage; const scopeKey = getScopeKey({ waveId, dropId, pageSize }); diff --git a/hooks/useWaveActivityLogs.ts b/hooks/useWaveActivityLogs.ts index 0b7579944e..a2c9944b8e 100644 --- a/hooks/useWaveActivityLogs.ts +++ b/hooks/useWaveActivityLogs.ts @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { keepPreviousData, useInfiniteQuery, @@ -20,6 +20,7 @@ interface UseWaveActivityLogsProps { readonly reverse: boolean; readonly dropId: string | null; readonly logTypes: string[]; + readonly enabled?: boolean | undefined; } export function useWaveActivityLogs({ @@ -28,22 +29,32 @@ export function useWaveActivityLogs({ reverse, dropId, logTypes, + enabled = true, }: UseWaveActivityLogsProps) { const queryClient = useQueryClient(); const [logs, setLogs] = useState([]); + const canFetch = enabled && !!connectedProfileHandle; + const serializedLogTypes = logTypes.join(","); - const queryKey = [ - QueryKey.WAVE_LOGS, - { - waveId, - limit: WAVE_LOGS_PARAMS.limit, - dropId, - logTypes, - }, - ]; + const queryKey = useMemo( + () => [ + QueryKey.WAVE_LOGS, + { + waveId, + limit: WAVE_LOGS_PARAMS.limit, + dropId, + logTypes: serializedLogTypes, + }, + ], + [waveId, dropId, serializedLogTypes] + ); useEffect(() => { + if (!canFetch) { + return; + } + queryClient.prefetchInfiniteQuery({ queryKey, queryFn: async ({ pageParam }: { pageParam: number | null }) => { @@ -53,8 +64,8 @@ export function useWaveActivityLogs({ if (dropId) { params["drop_id"] = dropId; } - if (logTypes) { - params["log_types"] = logTypes.join(","); + if (serializedLogTypes) { + params["log_types"] = serializedLogTypes; } if (pageParam) { params["offset"] = `${pageParam}`; @@ -73,7 +84,7 @@ export function useWaveActivityLogs({ staleTime: 60000, ...getDefaultQueryRetry(), }); - }, [waveId]); + }, [canFetch, queryKey, waveId, dropId, serializedLogTypes, queryClient]); const { data, fetchNextPage, hasNextPage, isLoading, isFetchingNextPage } = useInfiniteQuery({ @@ -85,8 +96,8 @@ export function useWaveActivityLogs({ if (dropId) { params["drop_id"] = dropId; } - if (logTypes) { - params["log_types"] = logTypes.join(","); + if (serializedLogTypes) { + params["log_types"] = serializedLogTypes; } if (pageParam !== null) { params["offset"] = `${pageParam}`; @@ -105,7 +116,7 @@ export function useWaveActivityLogs({ ? allPages.length * WAVE_LOGS_PARAMS.limit : null, placeholderData: keepPreviousData, - enabled: !!connectedProfileHandle, + enabled: canFetch, staleTime: 60000, refetchInterval: 30000, ...getDefaultQueryRetry(), diff --git a/hooks/useWaveBoostedDrops.ts b/hooks/useWaveBoostedDrops.ts index d5b2d9499e..f7ed550560 100644 --- a/hooks/useWaveBoostedDrops.ts +++ b/hooks/useWaveBoostedDrops.ts @@ -4,11 +4,14 @@ import { useQuery } from "@tanstack/react-query"; import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; import { commonApiFetch } from "@/services/api/common-api"; import type { ApiDrop } from "@/generated/models/ApiDrop"; +import type { ApiWave } from "@/generated/models/ApiWave"; import type { Page } from "@/helpers/Types"; import { TimeWindow, TIME_WINDOW_MS } from "@/types/boosted-drops.types"; +import { fetchBoostedDropsV2 } from "@/services/api/wave-drops-v2-api"; interface UseWaveBoostedDropsProps { readonly waveId: string; + readonly wave?: ApiWave | undefined; readonly limit?: number; readonly timeWindow?: TimeWindow; } @@ -19,6 +22,7 @@ const REFETCH_INTERVAL = 30000; // 30 seconds export function useWaveBoostedDrops({ waveId, + wave, limit = DEFAULT_LIMIT, timeWindow = TimeWindow.DAY, }: UseWaveBoostedDropsProps) { @@ -26,6 +30,15 @@ export function useWaveBoostedDrops({ queryKey: [QueryKey.BOOSTED_DROPS, { waveId, limit, timeWindow }], queryFn: async () => { const countOnlyBoostsAfter = Date.now() - TIME_WINDOW_MS[timeWindow]; + if (wave) { + return await fetchBoostedDropsV2({ + waveId, + wave, + limit, + countOnlyBoostsAfter, + }); + } + const response = await commonApiFetch>({ endpoint: "boosted-drops", params: { diff --git a/hooks/useWaveCurationDrops.ts b/hooks/useWaveCurationDrops.ts index 30dce186b2..f4bfdb8deb 100644 --- a/hooks/useWaveCurationDrops.ts +++ b/hooks/useWaveCurationDrops.ts @@ -6,7 +6,6 @@ import { updateDropInCachedDrops, } from "@/components/react-query-wrapper/utils/updateAttachmentInCachedDrops"; import type { ApiAttachment } from "@/generated/models/ApiAttachment"; -import type { ApiCurationDropsPage } from "@/generated/models/ApiCurationDropsPage"; import type { ApiDropWithoutWave } from "@/generated/models/ApiDropWithoutWave"; import type { ApiWave } from "@/generated/models/ApiWave"; import type { WsDropUpdateMessage } from "@/helpers/Types"; @@ -17,7 +16,7 @@ import { generateUniqueKeys, mapToExtendedDrops, } from "@/helpers/waves/wave-drops.helpers"; -import { commonApiFetch } from "@/services/api/common-api"; +import { fetchWaveCurationDropsV2 } from "@/services/api/wave-curation-drops-v2-api"; import { useWebSocketMessage } from "@/services/websocket/useWebSocketMessage"; import { keepPreviousData, @@ -72,18 +71,17 @@ export function useWaveCurationDrops({ } = useInfiniteQuery({ queryKey, queryFn: async ({ pageParam }: { pageParam: number }) => { - if (!waveId || !normalizedCurationId) { + if (!wave || !normalizedCurationId) { throw new Error( "Wave and curation are required to load curation drops" ); } - return await commonApiFetch({ - endpoint: `waves/${waveId}/curations/${normalizedCurationId}/drops`, - params: { - page: String(pageParam), - page_size: String(pageSize), - }, + return await fetchWaveCurationDropsV2({ + wave, + curationId: normalizedCurationId, + page: pageParam, + pageSize, }); }, enabled: enabled && !!waveId && normalizedCurationId.length > 0, diff --git a/hooks/useWaveDrops.ts b/hooks/useWaveDrops.ts index 53d8bb98fd..327fcf3b86 100644 --- a/hooks/useWaveDrops.ts +++ b/hooks/useWaveDrops.ts @@ -13,11 +13,12 @@ import { } from "@/components/react-query-wrapper/utils/updateAttachmentInCachedDrops"; import type { ApiAttachment } from "@/generated/models/ApiAttachment"; import type { ApiDrop } from "@/generated/models/ApiDrop"; +import { ApiDropSearchStrategy } from "@/generated/models/ApiDropSearchStrategy"; import type { ApiDropType } from "@/generated/models/ApiDropType"; import { DropSize, type ExtendedDrop } from "@/helpers/waves/drop.helpers"; import type { WsDropUpdateMessage } from "@/helpers/Types"; import { WsMessageType } from "@/helpers/Types"; -import { commonApiFetch } from "@/services/api/common-api"; +import { fetchWaveDropsFeedV2 } from "@/services/api/wave-drops-v2-api"; import { useWebSocketMessage } from "@/services/websocket/useWebSocketMessage"; import { useDebouncedQueryRefetch } from "./useDebouncedQueryRefetch"; @@ -32,12 +33,20 @@ interface UseWaveDropsProps { readonly enabled?: boolean | undefined; } -const processDrops = (pages: ApiDrop[][] | undefined): ExtendedDrop[] => { +interface WaveDropsPage { + readonly drops: ApiDrop[]; + readonly nextSerialNo?: number | undefined; +} + +const hasMedia = (drop: ApiDrop): boolean => + drop.parts.some((part) => part.media.length > 0); + +const processDrops = (pages: WaveDropsPage[] | undefined): ExtendedDrop[] => { if (!pages) { return []; } - const allDrops = pages.flat(); + const allDrops = pages.flatMap((page) => page.drops); const extendedDrops: ExtendedDrop[] = allDrops.map((drop) => ({ ...drop, type: DropSize.FULL, @@ -97,35 +106,50 @@ export function useWaveDrops({ } = useInfiniteQuery({ queryKey, queryFn: async ({ pageParam }: { pageParam: number | null }) => { - const params: Record = { - wave_id: waveId, - limit: limit.toString(), - }; + const collectedDrops: ApiDrop[] = []; + let nextSerialNo: number | undefined; + let serialNoLimit = pageParam; - if (containsMedia) { - params["contains_media"] = "true"; - } - - if (dropType !== undefined) { - params["drop_type"] = dropType; - } + for (let shouldFetch = true; shouldFetch; ) { + const feed = await fetchWaveDropsFeedV2({ + waveId, + limit, + serialNoLimit, + searchStrategy: + typeof serialNoLimit === "number" + ? ApiDropSearchStrategy.Older + : undefined, + dropType, + }); + const pageDrops = feed.drops as ApiDrop[]; + + if (pageDrops.length === 0) { + return { drops: collectedDrops }; + } - if (curationId) { - params["curation_id"] = curationId; - } + nextSerialNo = pageDrops.at(-1)?.serial_no; + collectedDrops.push( + ...(containsMedia ? pageDrops.filter(hasMedia) : pageDrops) + ); + + if ( + !containsMedia || + collectedDrops.length > 0 || + nextSerialNo === undefined || + nextSerialNo === serialNoLimit + ) { + shouldFetch = false; + continue; + } - if (typeof pageParam === "number") { - params["serial_no_less_than"] = `${pageParam}`; + serialNoLimit = nextSerialNo; } - return await commonApiFetch({ - endpoint: "drops", - params, - }); + return { drops: collectedDrops, nextSerialNo }; }, enabled: enabled && !!waveId, initialPageParam: null, - getNextPageParam: (lastPage) => lastPage.at(-1)?.serial_no ?? undefined, + getNextPageParam: (lastPage) => lastPage.nextSerialNo, placeholderData: keepPreviousData, staleTime: 60000, refetchOnWindowFocus: true, diff --git a/hooks/useWaveDropsLeaderboard.ts b/hooks/useWaveDropsLeaderboard.ts index d73d982438..3fc53cd417 100644 --- a/hooks/useWaveDropsLeaderboard.ts +++ b/hooks/useWaveDropsLeaderboard.ts @@ -10,7 +10,7 @@ import { generateUniqueKeys, mapToExtendedDrops, } from "@/helpers/waves/wave-drops.helpers"; -import { commonApiFetch } from "@/services/api/common-api"; +import { fetchWaveLeaderboardV2 } from "@/services/api/wave-drops-v2-api"; import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useMemo } from "react"; @@ -185,8 +185,8 @@ export function useWaveDropsLeaderboard({ readonly targetSort: WaveDropsLeaderboardSort; readonly targetSortDirection: "ASC" | "DESC" | undefined; }) => - await commonApiFetch({ - endpoint: `waves/${waveId}/leaderboard`, + await fetchWaveLeaderboardV2({ + waveId, params: buildLeaderboardParams({ pageParam, pageSize, diff --git a/hooks/useWaveDropsSearch.ts b/hooks/useWaveDropsSearch.ts index 562c67d080..8476549e03 100644 --- a/hooks/useWaveDropsSearch.ts +++ b/hooks/useWaveDropsSearch.ts @@ -1,7 +1,6 @@ "use client"; import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; -import type { ApiDropWithoutWavesPageWithoutCount } from "@/generated/models/ApiDropWithoutWavesPageWithoutCount"; import type { ApiWave } from "@/generated/models/ApiWave"; import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; import { toApiWaveMin } from "@/helpers/waves/wave.helpers"; @@ -9,7 +8,7 @@ import { generateUniqueKeys, mapToExtendedDrops, } from "@/helpers/waves/wave-drops.helpers"; -import { commonApiFetch } from "@/services/api/common-api"; +import { fetchWaveDropsSearchV2 } from "@/services/api/wave-drops-v2-api"; import { useInfiniteQuery } from "@tanstack/react-query"; import { useMemo } from "react"; @@ -39,14 +38,16 @@ export function useWaveDropsSearch({ ], enabled: enabled && wave !== null && trimmedTerm.length > 0, initialPageParam: 1, - queryFn: async ({ pageParam }) => { - return await commonApiFetch({ - endpoint: `waves/${wave!.id}/search`, - params: { - term: trimmedTerm, - page: String(pageParam), - size: String(size), - }, + queryFn: async ({ pageParam }: { pageParam: number }) => { + if (!wave) { + throw new Error("Wave is required to search drops"); + } + + return await fetchWaveDropsSearchV2({ + wave, + term: trimmedTerm, + page: pageParam, + size, }); }, getNextPageParam: (lastPage) => diff --git a/hooks/useWaveTopVoters.ts b/hooks/useWaveTopVoters.ts index e8c4e0aca7..2e2cdfb5e1 100644 --- a/hooks/useWaveTopVoters.ts +++ b/hooks/useWaveTopVoters.ts @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { keepPreviousData, useInfiniteQuery, @@ -20,6 +20,7 @@ interface UseWaveTopVotersProps { readonly sortDirection?: "ASC" | "DESC" | undefined; readonly sort?: "ABSOLUTE" | "POSITIVE" | "NEGATIVE" | undefined; readonly refetchInterval?: number | undefined; + readonly enabled?: boolean | undefined; } export function useWaveTopVoters({ @@ -30,21 +31,30 @@ export function useWaveTopVoters({ sortDirection = "ASC", sort = "ABSOLUTE", refetchInterval = Infinity, + enabled = true, }: UseWaveTopVotersProps) { const queryClient = useQueryClient(); const [voters, setVoters] = useState([]); + const canFetch = enabled && !!connectedProfileHandle; - const queryKey = [ - QueryKey.WAVE_VOTERS, - { - waveId, - dropId, - sortDirection, - sort, - }, - ]; + const queryKey = useMemo( + () => [ + QueryKey.WAVE_VOTERS, + { + waveId, + dropId, + sortDirection, + sort, + }, + ], + [waveId, dropId, sortDirection, sort] + ); useEffect(() => { + if (!canFetch) { + return; + } + queryClient.prefetchInfiniteQuery({ queryKey, queryFn: async ({ pageParam }: { pageParam: number | null }) => { @@ -79,7 +89,7 @@ export function useWaveTopVoters({ staleTime: 60000, ...getDefaultQueryRetry(), }); - }, [waveId, dropId, sortDirection, sort]); + }, [canFetch, queryKey, queryClient, waveId, dropId, sortDirection, sort]); const { data, @@ -118,7 +128,7 @@ export function useWaveTopVoters({ return currentPage + 1; }, placeholderData: keepPreviousData, - enabled: !!connectedProfileHandle, + enabled: canFetch, staleTime: 60000, refetchInterval, ...getDefaultQueryRetry(), diff --git a/hooks/useWavesList.ts b/hooks/useWavesList.ts index bb821a1252..53d531f8cc 100644 --- a/hooks/useWavesList.ts +++ b/hooks/useWavesList.ts @@ -1,32 +1,29 @@ "use client"; -import { useContext, useMemo, useCallback } from "react"; -import { AuthContext } from "@/components/auth/Auth"; +import { useMemo, useCallback } from "react"; +import { useAuth } from "@/components/auth/Auth"; import { useSeizeConnectContext } from "@/components/auth/SeizeConnectContext"; import { useSeizeSettings } from "@/contexts/SeizeSettingsContext"; import { normalizeOptionalWaveId } from "@/helpers/waves/wave.helpers"; -import { useWavesOverview } from "./useWavesOverview"; +import { mapApiWaveToSidebarWave, useWavesV2 } from "./useWavesV2"; import { SIDEBAR_WAVES_OVERVIEW_REFETCH_INTERVAL_MS, WAVE_FOLLOWING_WAVES_PARAMS, } from "@/components/react-query-wrapper/utils/query-utils"; import { usePinnedWavesServer } from "./usePinnedWavesServer"; import { useWaveById } from "./useWaveById"; -import type { ApiWave } from "@/generated/models/ApiWave"; import { useShowFollowingWaves } from "./useShowFollowingWaves"; -import { ApiWaveType } from "@/generated/models/ApiWaveType"; +import type { SidebarWave } from "@/types/waves.types"; // Enhanced wave interface with isPinned field and newDropsCount -interface EnhancedWave extends ApiWave { - isPinned: boolean; -} +type EnhancedWave = SidebarWave & { readonly isPinned: boolean }; /** * Hook for managing and fetching waves list including pinned waves * @returns Wave list data and loading states */ const useWavesList = () => { - const { connectedProfile, activeProfileProxy } = useContext(AuthContext); + const { connectedProfile, activeProfileProxy } = useAuth(); const { address } = useSeizeConnectContext(); const { seizeSettings, isAnnouncementsWave } = useSeizeSettings(); const { @@ -69,10 +66,11 @@ const useWavesList = () => { fetchNextPage, status: mainWavesStatus, refetch: mainWavesRefetch, - } = useWavesOverview({ - type: WAVE_FOLLOWING_WAVES_PARAMS.initialWavesOverviewType, - limit: WAVE_FOLLOWING_WAVES_PARAMS.limit, + } = useWavesV2({ + overviewType: WAVE_FOLLOWING_WAVES_PARAMS.initialWavesOverviewType, + pageSize: WAVE_FOLLOWING_WAVES_PARAMS.limit, following: isConnectedIdentity && following, + directMessage: false, viewerIdentityKey, refetchInterval: SIDEBAR_WAVES_OVERVIEW_REFETCH_INTERVAL_MS, refetchIntervalInBackground: false, @@ -101,17 +99,25 @@ const useWavesList = () => { const announcementQueryError = shouldFetchAnnouncementWave ? rawAnnouncementQueryError : null; + const fetchedAnnouncementSidebarWave = useMemo( + () => + fetchedAnnouncementWave + ? mapApiWaveToSidebarWave(fetchedAnnouncementWave) + : null, + [fetchedAnnouncementWave] + ); const announcementWave = useMemo(() => { - const resolvedWave = trackedAnnouncementWave ?? fetchedAnnouncementWave; - if (!resolvedWave || waveIsDm(resolvedWave)) { + const resolvedWave = + trackedAnnouncementWave ?? fetchedAnnouncementSidebarWave; + if (!resolvedWave || resolvedWave.isDirectMessage) { return null; } return resolvedWave; - }, [trackedAnnouncementWave, fetchedAnnouncementWave]); + }, [trackedAnnouncementWave, fetchedAnnouncementSidebarWave]); // Create a map of mainWaves by ID for easy lookup const mainWavesMap = useMemo(() => { - const map = new Map(); + const map = new Map(); mainWaves.forEach((wave) => { if (!isAnnouncementsWave(wave.id)) { map.set(wave.id, wave); @@ -157,7 +163,7 @@ const useWavesList = () => { // Add all server-provided pinned waves, filtering out DMs serverPinnedWaves.forEach((wave) => { - if (!waveIsDm(wave) && !isAnnouncementsWave(wave.id)) { + if (!wave.isDirectMessage && !isAnnouncementsWave(wave.id)) { result.push({ ...wave, isPinned: true }); } }); @@ -175,7 +181,7 @@ const useWavesList = () => { const allWavesArray: EnhancedWave[] = []; [...mainWaves, ...separatelyFetchedPinnedWaves].forEach((wave) => { - if (waveIsDm(wave) || isAnnouncementsWave(wave.id)) { + if (wave.isDirectMessage || isAnnouncementsWave(wave.id)) { return; } @@ -186,8 +192,7 @@ const useWavesList = () => { }); const sortedNonAnnouncementWaves = [...allWavesMap.values()].sort( - (a, b) => - b.metrics.latest_drop_timestamp - a.metrics.latest_drop_timestamp + (a, b) => (b.latestDropTimestamp ?? 0) - (a.latestDropTimestamp ?? 0) ); if (announcementWave) { @@ -282,8 +287,4 @@ const useWavesList = () => { ); }; -const waveIsDm = (w: ApiWave) => - w.wave.type === ApiWaveType.Chat && - (w.chat as any)?.scope?.group?.is_direct_message === true; - export default useWavesList; diff --git a/hooks/useWavesOverview.ts b/hooks/useWavesOverview.ts deleted file mode 100644 index 2bed810c5c..0000000000 --- a/hooks/useWavesOverview.ts +++ /dev/null @@ -1,140 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useMemo, useState } from "react"; -import { useInfiniteQuery } from "@tanstack/react-query"; - -import type { WavesOverviewParams } from "@/types/waves.types"; -import type { ApiWavesOverviewType } from "@/generated/models/ApiWavesOverviewType"; -import { commonApiFetch } from "@/services/api/common-api"; -import type { ApiWave } from "@/generated/models/ApiWave"; -import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; -import { getDefaultQueryRetry } from "@/components/react-query-wrapper/utils/query-utils"; - -interface UseWavesOverviewProps { - readonly type: ApiWavesOverviewType; - readonly limit?: number | undefined; - readonly following?: boolean | undefined; - readonly viewerIdentityKey?: string | null | undefined; - /** - * If true, fetch only direct message waves. If false, exclude them. Undefined -> no filter. - */ - readonly directMessage?: boolean | undefined; - readonly refetchInterval?: number | undefined; - readonly refetchIntervalInBackground?: boolean | undefined; -} - -export const useWavesOverview = ({ - type, - limit = 20, - following = false, - viewerIdentityKey, - directMessage, - refetchInterval = Infinity, - refetchIntervalInBackground = false, -}: UseWavesOverviewProps) => { - const params: Omit = { - limit, - type, - only_waves_followed_by_authenticated_user: following, - ...(directMessage !== undefined ? { direct_message: directMessage } : {}), - }; - const normalizedViewerIdentityKey = - viewerIdentityKey?.trim().toLowerCase() ?? null; - const queryKeyParams = useMemo(() => { - if (!normalizedViewerIdentityKey) { - return params; - } - - return { - ...params, - viewer_identity: normalizedViewerIdentityKey, - }; - }, [normalizedViewerIdentityKey, params]); - - const [lastErrorTimestamp, setLastErrorTimestamp] = useState( - null - ); - - const query = useInfiniteQuery({ - queryKey: [QueryKey.WAVES_OVERVIEW, queryKeyParams], - queryFn: async ({ pageParam }: { pageParam: number }) => { - const queryParams: Record = { - limit: `${params.limit}`, - offset: `${pageParam}`, - type: params.type, - only_waves_followed_by_authenticated_user: `${params.only_waves_followed_by_authenticated_user}`, - }; - - if (params.direct_message !== undefined) { - queryParams["direct_message"] = `${params.direct_message}`; - } - - return await commonApiFetch({ - endpoint: `waves-overview`, - params: queryParams, - }); - }, - initialPageParam: 0, - getNextPageParam: (_, allPages) => - allPages.at(-1)?.length === params.limit ? allPages.flat().length : null, - placeholderData: (previousData, previousQuery) => { - const previousParams = previousQuery?.queryKey?.[1] as - | { viewer_identity?: string | null } - | undefined; - const previousViewerIdentity = - typeof previousParams?.viewer_identity === "string" - ? previousParams.viewer_identity - : null; - - if (previousViewerIdentity === normalizedViewerIdentityKey) { - return previousData; - } - - return undefined; - }, - refetchInterval, - refetchIntervalInBackground, - ...getDefaultQueryRetry(() => setLastErrorTimestamp(Date.now())), - }); - - const getWaves = (): ApiWave[] => query.data?.pages.flat() ?? []; - - const [waves, setWaves] = useState(getWaves()); - useEffect(() => { - setWaves(getWaves()); - }, [query.data]); - - const fetchNextPage = useCallback(() => { - if (lastErrorTimestamp && Date.now() - lastErrorTimestamp < 30000) { - setTimeout(() => { - void query.fetchNextPage(); - }, 30000); - return; - } - void query.fetchNextPage(); - }, [lastErrorTimestamp, query]); - - const refetch = useCallback(() => { - if (lastErrorTimestamp && Date.now() - lastErrorTimestamp < 30000) { - setTimeout(() => { - void query.refetch(); - }, 30000); - return; - } - void query.refetch(); - }, [lastErrorTimestamp, query]); - - const returnValue = useMemo(() => { - return { - waves, - isFetching: query.isFetching, - isFetchingNextPage: query.isFetchingNextPage, - hasNextPage: query.hasNextPage, - fetchNextPage, - status: query.status, - refetch, - }; - }, [waves, query, fetchNextPage, refetch]); - - return returnValue; -}; diff --git a/hooks/useWavesV2.ts b/hooks/useWavesV2.ts new file mode 100644 index 0000000000..af51e66590 --- /dev/null +++ b/hooks/useWavesV2.ts @@ -0,0 +1,145 @@ +"use client"; + +import { useCallback, useMemo, useState } from "react"; +import { useInfiniteQuery } from "@tanstack/react-query"; + +import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; +import { getDefaultQueryRetry } from "@/components/react-query-wrapper/utils/query-utils"; +import type { ApiWavesOverviewType } from "@/generated/models/ApiWavesOverviewType"; +import type { ApiWavesPinFilter } from "@/generated/models/ApiWavesPinFilter"; +import { + fetchWavesV2Page, + getWavesV2OverviewQueryKeyParams, +} from "@/services/api/waves-v2-api"; + +export { mapApiWaveToSidebarWave } from "@/services/api/waves-v2-api"; + +interface UseWavesV2Props { + readonly overviewType: ApiWavesOverviewType; + readonly pageSize?: number | undefined; + readonly following?: boolean | undefined; + readonly viewerIdentityKey?: string | null | undefined; + readonly directMessage?: boolean | undefined; + readonly pinned?: ApiWavesPinFilter | undefined; + readonly refetchInterval?: number | undefined; + readonly refetchIntervalInBackground?: boolean | undefined; +} + +export const useWavesV2 = ({ + overviewType, + pageSize = 20, + following = false, + viewerIdentityKey, + directMessage, + pinned, + refetchInterval = Infinity, + refetchIntervalInBackground = false, +}: UseWavesV2Props) => { + const queryKeyParams = useMemo( + () => + getWavesV2OverviewQueryKeyParams({ + overviewType, + pageSize, + following, + directMessage, + pinned, + viewerIdentityKey, + }), + [ + overviewType, + pageSize, + following, + directMessage, + pinned, + viewerIdentityKey, + ] + ); + + const [lastErrorTimestamp, setLastErrorTimestamp] = useState( + null + ); + const handleRetryFailure = useCallback(() => { + setLastErrorTimestamp(Date.now()); + }, []); + + const query = useInfiniteQuery({ + queryKey: [QueryKey.WAVES_V2, queryKeyParams], + queryFn: async ({ pageParam }: { pageParam: number }) => + await fetchWavesV2Page({ + page: pageParam, + pageSize, + overviewType, + following, + directMessage, + pinned, + }), + initialPageParam: 1, + getNextPageParam: (lastPage) => (lastPage.next ? lastPage.page + 1 : null), + placeholderData: (previousData, previousQuery) => { + const previousParams = + previousQuery !== undefined + ? (previousQuery.queryKey[1] as + | { viewer_identity?: string | null } + | undefined) + : undefined; + const previousViewerIdentity = + typeof previousParams?.viewer_identity === "string" + ? previousParams.viewer_identity + : null; + const currentViewerIdentity = queryKeyParams.viewer_identity ?? null; + + if (previousViewerIdentity === currentViewerIdentity) { + return previousData; + } + + return undefined; + }, + refetchInterval, + refetchIntervalInBackground, + ...getDefaultQueryRetry(handleRetryFailure), + }); + + const waves = useMemo( + () => query.data?.pages.flatMap((page) => page.waves) ?? [], + [query.data] + ); + + const fetchNextPage = useCallback(() => { + if ( + lastErrorTimestamp !== null && + Date.now() - lastErrorTimestamp < 30000 + ) { + setTimeout(() => { + void query.fetchNextPage(); + }, 30000); + return; + } + void query.fetchNextPage(); + }, [lastErrorTimestamp, query]); + + const refetch = useCallback(() => { + if ( + lastErrorTimestamp !== null && + Date.now() - lastErrorTimestamp < 30000 + ) { + setTimeout(() => { + void query.refetch(); + }, 30000); + return; + } + void query.refetch(); + }, [lastErrorTimestamp, query]); + + return useMemo( + () => ({ + waves, + isFetching: query.isFetching, + isFetchingNextPage: query.isFetchingNextPage, + hasNextPage: query.hasNextPage, + fetchNextPage, + status: query.status, + refetch, + }), + [waves, query, fetchNextPage, refetch] + ); +}; diff --git a/hooks/waves/useWaveDecisions.ts b/hooks/waves/useWaveDecisions.ts index 8f86076cdb..768012952f 100644 --- a/hooks/waves/useWaveDecisions.ts +++ b/hooks/waves/useWaveDecisions.ts @@ -1,13 +1,15 @@ import { useEffect, useMemo } from "react"; import { useInfiniteQuery } from "@tanstack/react-query"; -import { commonApiFetch } from "@/services/api/common-api"; import type { ApiWaveDecision } from "@/generated/models/ApiWaveDecision"; -import type { ApiWaveDecisionsPage } from "@/generated/models/ApiWaveDecisionsPage"; import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; import { prepareWaveDecisionPoint } from "@/helpers/waves/wave-decision.helpers"; +import type { ApiWave } from "@/generated/models/ApiWave"; +import type { ApiWaveMin } from "@/generated/models/ApiWaveMin"; +import { fetchWaveDecisionsV2 } from "@/services/api/wave-decisions-v2-api"; interface UseWaveDecisionsProps { readonly waveId: string; + readonly wave?: ApiWave | ApiWaveMin | undefined; readonly enabled?: boolean | undefined; readonly loadAllPages?: boolean | undefined; readonly pageSize?: number | undefined; @@ -35,6 +37,7 @@ const getValidPageSize = (pageSize: number | undefined): number => { export function useWaveDecisions({ waveId, + wave, enabled = true, loadAllPages = false, pageSize, @@ -53,17 +56,25 @@ export function useWaveDecisions({ isFetchingNextPage, } = useInfiniteQuery({ queryKey: [QueryKey.WAVE_DECISIONS, { waveId, pageSize: resolvedPageSize }], - queryFn: async ({ pageParam }: { pageParam?: number | undefined }) => { + queryFn: async ({ + pageParam, + signal, + }: { + pageParam?: number | undefined; + signal?: AbortSignal | undefined; + }) => { const currentPage = pageParam ?? DEFAULT_PAGE; - return await commonApiFetch({ - endpoint: `waves/${waveId}/decisions`, + return await fetchWaveDecisionsV2({ + waveId, + wave, params: { sort_direction: "DESC", sort: "decision_time", page: currentPage.toString(), page_size: resolvedPageSize.toString(), }, + signal, }); }, initialPageParam: DEFAULT_PAGE, diff --git a/hooks/waves/useWaveSalesDecisions.ts b/hooks/waves/useWaveSalesDecisions.ts index 9e5f50d405..c61de7e117 100644 --- a/hooks/waves/useWaveSalesDecisions.ts +++ b/hooks/waves/useWaveSalesDecisions.ts @@ -1,12 +1,14 @@ import { useInfiniteQuery } from "@tanstack/react-query"; -import { commonApiFetch } from "@/services/api/common-api"; import type { ApiWaveDecision } from "@/generated/models/ApiWaveDecision"; -import type { ApiWaveDecisionsPage } from "@/generated/models/ApiWaveDecisionsPage"; import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; import { useMemo } from "react"; +import type { ApiWave } from "@/generated/models/ApiWave"; +import type { ApiWaveMin } from "@/generated/models/ApiWaveMin"; +import { fetchWaveDecisionsV2 } from "@/services/api/wave-decisions-v2-api"; interface UseWaveSalesDecisionsProps { readonly waveId: string; + readonly wave?: ApiWave | ApiWaveMin | undefined; readonly enabled?: boolean | undefined; } @@ -23,6 +25,7 @@ const sortDecisionPoint = ( export function useWaveSalesDecisions({ waveId, + wave, enabled = true, }: UseWaveSalesDecisionsProps) { const { @@ -39,8 +42,9 @@ export function useWaveSalesDecisions({ queryFn: async ({ pageParam }: { pageParam?: number | undefined }) => { const currentPage = pageParam ?? DEFAULT_PAGE; - return await commonApiFetch({ - endpoint: `waves/${waveId}/decisions`, + return await fetchWaveDecisionsV2({ + waveId, + wave, params: { sort_direction: "DESC", sort: "decision_time", diff --git a/openapi.yaml b/openapi.yaml index fe7677edf3..89b7e11833 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2076,6 +2076,43 @@ paths: application/json: schema: $ref: "#/components/schemas/ApiDropV2Page" + /v2/drops: + get: + tags: + - DropsV2 + summary: Get V2 drops. + operationId: getDropsV2 + parameters: + - name: parent_drop_id + in: query + description: Return replies under this parent drop. + required: false + schema: + type: string + - name: page + in: query + required: false + schema: + type: integer + format: int64 + minimum: 1 + default: 1 + - name: page_size + in: query + required: false + schema: + type: integer + format: int64 + minimum: 1 + maximum: 100 + default: 50 + responses: + "200": + description: successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/ApiDropV2PageWithoutCount" /v2/drops/{id}: get: tags: @@ -11740,6 +11777,8 @@ components: type: array items: $ref: "#/components/schemas/ApiDropV2" + related_wave: + $ref: "#/components/schemas/ApiWaveOverview" additional_context: $ref: "#/components/schemas/ApiNotificationAdditionalContextV2" ApiOutgoingIdentitySubscriptionsPage: @@ -13824,6 +13863,9 @@ components: - subscribers_count - has_competition - is_dm_wave + - description_drop + - total_drops_count + - is_private properties: id: type: string @@ -13844,6 +13886,17 @@ components: type: boolean is_dm_wave: type: boolean + description_drop: + $ref: "#/components/schemas/ApiWaveOverviewDescriptionDrop" + total_drops_count: + type: integer + format: int64 + is_private: + type: boolean + contributors: + type: array + items: + $ref: "#/components/schemas/ApiWaveOverviewContributor" context_profile_context: $ref: "#/components/schemas/ApiWaveOverviewContextProfileContext" ApiWaveOverviewContextProfileContext: @@ -13864,8 +13917,32 @@ components: unread_drops: type: integer format: int64 + first_unread_drop_serial_no: + type: integer + format: int64 muted: type: boolean + ApiWaveOverviewContributor: + type: object + required: + - handle + - pfp + properties: + handle: + type: string + nullable: true + pfp: + type: string + nullable: true + ApiWaveOverviewDescriptionDrop: + type: object + properties: + contents: + type: string + media: + type: array + items: + $ref: "#/components/schemas/ApiDropMedia" ApiWaveOverviewPage: type: object required: diff --git a/services/api/drop-api.ts b/services/api/drop-api.ts index afcd12bdb3..dc2e7dc214 100644 --- a/services/api/drop-api.ts +++ b/services/api/drop-api.ts @@ -1,9 +1,7 @@ import type { QueryClient } from "@tanstack/react-query"; import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; import type { ApiDrop } from "@/generated/models/ApiDrop"; -import { commonApiFetch } from "@/services/api/common-api"; - -const DROP_BATCH_SIZE = 20; +import { fetchDropV2ById } from "@/services/api/wave-drops-v2-api"; export const DROP_DETAIL_STALE_TIME_MS = 60 * 1000; export const DROP_BATCH_STALE_TIME_MS = 5 * 60 * 1000; @@ -32,14 +30,6 @@ const getUniqueDropIds = (dropIds: readonly string[]): string[] => { return uniqueDropIds; }; -const chunkDropIds = (dropIds: readonly string[]): string[][] => { - const chunks: string[][] = []; - for (let index = 0; index < dropIds.length; index += DROP_BATCH_SIZE) { - chunks.push(dropIds.slice(index, index + DROP_BATCH_SIZE)); - } - return chunks; -}; - export const orderDropsByIds = ( dropIds: readonly string[], drops: readonly ApiDrop[] @@ -58,21 +48,11 @@ export const fetchDropsByIds = async ( return []; } - const dropChunks = chunkDropIds(uniqueDropIds); - const dropPages = await Promise.all( - dropChunks.map((chunk) => - commonApiFetch({ - endpoint: "drops", - params: { - ids: chunk.join(","), - limit: `${chunk.length}`, - include_replies: "true", - }, - }) - ) + const drops = await Promise.all( + uniqueDropIds.map((dropId) => fetchDropV2ById(dropId)) ); - return orderDropsByIds(uniqueDropIds, dropPages.flat()); + return orderDropsByIds(uniqueDropIds, drops); }; export const seedDropCache = ( diff --git a/services/api/notifications-v2-api.ts b/services/api/notifications-v2-api.ts new file mode 100644 index 0000000000..dccdeabd0d --- /dev/null +++ b/services/api/notifications-v2-api.ts @@ -0,0 +1,330 @@ +import type { ApiDrop } from "@/generated/models/ApiDrop"; +import type { ApiDropV2 } from "@/generated/models/ApiDropV2"; +import type { ApiNotificationAdditionalContextV2 } from "@/generated/models/ApiNotificationAdditionalContextV2"; +import { ApiNotificationCause } from "@/generated/models/ApiNotificationCause"; +import type { ApiNotificationDropReactedReactor } from "@/generated/models/ApiNotificationDropReactedReactor"; +import type { ApiNotificationV2 } from "@/generated/models/ApiNotificationV2"; +import type { ApiNotificationsResponseV2 } from "@/generated/models/ApiNotificationsResponseV2"; +import { ApiProfileClassification } from "@/generated/models/ApiProfileClassification"; +import type { ApiProfileMin } from "@/generated/models/ApiProfileMin"; +import type { ApiWaveMin } from "@/generated/models/ApiWaveMin"; +import type { ApiWaveOverview } from "@/generated/models/ApiWaveOverview"; +import { commonApiFetch } from "@/services/api/common-api"; +import { + mapApiWaveOverviewToApiWaveMin, + mapIdentityOverviewToProfileMin, + mapLeaderboardDropV2, +} from "@/services/api/wave-drops-v2-api"; +import type { + INotificationDropReacted, + TypedNotification, + TypedNotificationsResponse, +} from "@/types/feed.types"; + +type NotificationWaveMin = ApiWaveMin & { + readonly is_direct_message?: boolean; +}; + +type FetchNotificationsV2Params = { + readonly limit: string; + readonly cause?: ApiNotificationCause[] | null | undefined; + readonly pageParam?: number | null | undefined; + readonly signal?: AbortSignal | undefined; + readonly headers?: Record | undefined; +}; + +const toStringValue = (value: string | number | undefined): string => + value === undefined ? "" : String(value); + +const mapWaveOverviewToNotificationWaveMin = ( + wave: ApiWaveOverview +): NotificationWaveMin => ({ + ...mapApiWaveOverviewToApiWaveMin(wave), + is_direct_message: wave.is_dm_wave, +}); + +const mapDropV2ToApiDrop = ({ + drop, + wave, +}: { + readonly drop: ApiDropV2; + readonly wave: NotificationWaveMin; +}): ApiDrop => ({ + ...mapLeaderboardDropV2({ drop, wave }), + wave, +}); + +const mapRelatedDrops = (notification: ApiNotificationV2): ApiDrop[] => { + if (!notification.related_wave) { + return []; + } + + const wave = mapWaveOverviewToNotificationWaveMin(notification.related_wave); + + return notification.related_drops.map((drop) => + mapDropV2ToApiDrop({ drop, wave }) + ); +}; + +const emptyProfile = ({ + id, + handle, + pfp, +}: { + readonly id: string; + readonly handle: string | null; + readonly pfp: string | null; +}): ApiProfileMin => ({ + id, + handle, + pfp, + banner1_color: null, + banner2_color: null, + cic: 0, + rep: 0, + tdh: 0, + tdh_rate: 0, + xtdh: 0, + xtdh_rate: 0, + level: 0, + classification: ApiProfileClassification.Pseudonym, + sub_classification: null, + primary_address: "", + subscribed_actions: [], + archived: false, + active_main_stage_submission_ids: [], + winner_main_stage_drop_ids: [], + artist_of_prevote_cards: [], + profile_wave_id: null, + is_wave_creator: false, +}); + +const mapReactorToProfileMin = ( + reactor: ApiNotificationDropReactedReactor, + fallbackIndex: number +): ApiProfileMin => { + const trimmedHandle = reactor.handle?.trim(); + const handle = + trimmedHandle === undefined || trimmedHandle === "" ? null : trimmedHandle; + + return emptyProfile({ + id: handle ?? `reactor-${fallbackIndex}`, + handle, + pfp: reactor.pfp ?? null, + }); +}; + +const mapBaseNotification = (notification: ApiNotificationV2) => ({ + id: notification.id, + cause: notification.cause, + created_at: notification.created_at, + read_at: notification.read_at, + related_identity: mapIdentityOverviewToProfileMin( + notification.related_identity + ), +}); + +const mapDropReactedNotification = ( + notification: ApiNotificationV2, + relatedDrops: ApiDrop[] +): INotificationDropReacted[] => { + const reaction = notification.additional_context.reaction ?? ""; + const reactors = notification.additional_context.reactors ?? []; + const base = { + ...mapBaseNotification(notification), + cause: ApiNotificationCause.DropReacted, + related_drops: relatedDrops, + additional_context: { + reaction, + }, + } satisfies INotificationDropReacted; + + if (!reactors.length) { + return [base]; + } + + return reactors.map((reactor, index) => ({ + ...base, + related_identity: mapReactorToProfileMin(reactor, index), + })); +}; + +const mapNotificationV2 = ( + notification: ApiNotificationV2 +): TypedNotification[] => { + const base = mapBaseNotification(notification); + const relatedDrops = mapRelatedDrops(notification); + const context: ApiNotificationAdditionalContextV2 = + notification.additional_context; + + switch (notification.cause) { + case ApiNotificationCause.IdentitySubscribed: + return [ + { + ...base, + cause: ApiNotificationCause.IdentitySubscribed, + }, + ]; + case ApiNotificationCause.IdentityRep: + return [ + { + ...base, + cause: ApiNotificationCause.IdentityRep, + additional_context: { + amount: context.amount ?? 0, + total: context.total ?? 0, + category: context.category ?? "", + }, + }, + ]; + case ApiNotificationCause.IdentityNic: + return [ + { + ...base, + cause: ApiNotificationCause.IdentityNic, + additional_context: { + amount: context.amount ?? 0, + total: context.total ?? 0, + }, + }, + ]; + case ApiNotificationCause.IdentityMentioned: + return [ + { + ...base, + cause: ApiNotificationCause.IdentityMentioned, + related_drops: relatedDrops, + }, + ]; + case ApiNotificationCause.DropVoted: + return [ + { + ...base, + cause: ApiNotificationCause.DropVoted, + related_drops: relatedDrops, + additional_context: { + vote: context.vote ?? 0, + }, + }, + ]; + case ApiNotificationCause.DropReacted: + return mapDropReactedNotification(notification, relatedDrops); + case ApiNotificationCause.DropBoosted: + return [ + { + ...base, + cause: ApiNotificationCause.DropBoosted, + related_drops: relatedDrops, + additional_context: { ...context }, + }, + ]; + case ApiNotificationCause.DropQuoted: + return [ + { + ...base, + cause: ApiNotificationCause.DropQuoted, + related_drops: relatedDrops, + additional_context: { + quote_drop_id: context.quote_drop_id ?? "", + quote_drop_part: toStringValue(context.quote_drop_part), + quoted_drop_id: context.quoted_drop_id ?? "", + quoted_drop_part: toStringValue(context.quoted_drop_part), + }, + }, + ]; + case ApiNotificationCause.DropReplied: + return [ + { + ...base, + cause: ApiNotificationCause.DropReplied, + related_drops: relatedDrops, + additional_context: { + reply_drop_id: context.reply_drop_id ?? "", + replied_drop_id: context.replied_drop_id ?? "", + replied_drop_part: toStringValue(context.replied_drop_part), + }, + }, + ]; + case ApiNotificationCause.WaveCreated: + return [ + { + ...base, + cause: ApiNotificationCause.WaveCreated, + ...(notification.related_wave + ? { related_wave: notification.related_wave } + : {}), + additional_context: { + wave_id: + notification.related_wave?.id ?? + notification.additional_context.wave_id ?? + "", + }, + }, + ]; + case ApiNotificationCause.AllDrops: + return [ + { + ...base, + cause: ApiNotificationCause.AllDrops, + related_drops: relatedDrops, + additional_context: { + vote: context.vote ?? 0, + }, + }, + ]; + case ApiNotificationCause.PriorityAlert: + return [ + { + ...base, + cause: ApiNotificationCause.PriorityAlert, + related_drops: relatedDrops, + additional_context: { ...context }, + }, + ]; + } +}; + +const mapNotificationsV2Response = ( + response: ApiNotificationsResponseV2 +): TypedNotificationsResponse => ({ + unread_count: response.unread_count, + notifications: response.notifications.flatMap(mapNotificationV2), +}); + +const buildNotificationsV2Params = ({ + limit, + cause, + pageParam, +}: Pick): Record< + string, + string +> => { + const params: Record = { limit }; + + if (pageParam !== null && pageParam !== undefined) { + params["id_less_than"] = String(pageParam); + } + + if (cause !== null && cause !== undefined && cause.length > 0) { + params["cause"] = cause.join(","); + } + + return params; +}; + +export const fetchNotificationsV2 = async ({ + limit, + cause, + pageParam, + signal, + headers, +}: FetchNotificationsV2Params): Promise => { + const response = await commonApiFetch({ + endpoint: "v2/notifications", + params: buildNotificationsV2Params({ limit, cause, pageParam }), + signal, + headers, + }); + + return mapNotificationsV2Response(response); +}; diff --git a/services/api/pinned-waves-api.ts b/services/api/pinned-waves-api.ts index 09abad78f7..324d806717 100644 --- a/services/api/pinned-waves-api.ts +++ b/services/api/pinned-waves-api.ts @@ -1,32 +1,14 @@ -import { commonApiFetchWithRetry, commonApiPostWithoutBodyAndResponse, commonApiDelete } from './common-api'; -import type { ApiWave } from '@/generated/models/ApiWave'; -import { ApiWavesPinFilter } from '@/generated/models/ApiWavesPinFilter'; -import { ApiWavesOverviewType } from '@/generated/models/ApiWavesOverviewType'; +import { + commonApiDelete, + commonApiPostWithoutBodyAndResponse, +} from "./common-api"; interface PinnedWavesService { - fetchPinnedWaves: () => Promise; pinWave: (waveId: string) => Promise; unpinWave: (waveId: string) => Promise; } export const pinnedWavesApi: PinnedWavesService = { - fetchPinnedWaves: async (): Promise => { - return await commonApiFetchWithRetry({ - endpoint: 'waves-overview', - params: { - pinned: ApiWavesPinFilter.Pinned, - type: ApiWavesOverviewType.MostSubscribed, - limit: '20', - }, - retryOptions: { - maxRetries: 2, - initialDelayMs: 1000, - backoffFactor: 2, - jitter: 0.1, - }, - }); - }, - pinWave: async (waveId: string): Promise => { await commonApiPostWithoutBodyAndResponse({ endpoint: `waves/${waveId}/pins`, diff --git a/services/api/quorum-participation-drop-preview-v2-api.ts b/services/api/quorum-participation-drop-preview-v2-api.ts new file mode 100644 index 0000000000..bbdba0f864 --- /dev/null +++ b/services/api/quorum-participation-drop-preview-v2-api.ts @@ -0,0 +1,43 @@ +import type { ApiDrop } from "@/generated/models/ApiDrop"; +import { ApiDropSearchStrategy } from "@/generated/models/ApiDropSearchStrategy"; +import type { ApiWaveDropsFeedV2 } from "@/generated/models/ApiWaveDropsFeedV2"; +import { commonApiFetch } from "@/services/api/common-api"; +import { + mapApiWaveOverviewToApiWaveMin, + mapLeaderboardDropV2, +} from "@/services/api/wave-drops-v2-api"; + +interface FetchQuorumParticipationDropPreviewBySerialNoV2Props { + readonly waveId: string; + readonly serialNo: number; + readonly signal?: AbortSignal | undefined; +} + +export async function fetchQuorumParticipationDropPreviewBySerialNoV2({ + waveId, + serialNo, + signal, +}: FetchQuorumParticipationDropPreviewBySerialNoV2Props): Promise { + const response = await commonApiFetch({ + endpoint: `v2/waves/${waveId}/drops`, + params: { + limit: "1", + serial_no_limit: `${serialNo}`, + search_strategy: ApiDropSearchStrategy.Both, + }, + signal, + }); + + const drop = response.drops.find( + (candidate) => candidate.serial_no === serialNo + ); + if (!drop) { + return null; + } + + const wave = mapApiWaveOverviewToApiWaveMin(response.wave); + return { + ...mapLeaderboardDropV2({ drop, wave }), + wave, + } as ApiDrop; +} diff --git a/services/api/wave-curation-drops-v2-api.ts b/services/api/wave-curation-drops-v2-api.ts new file mode 100644 index 0000000000..c390f1f35c --- /dev/null +++ b/services/api/wave-curation-drops-v2-api.ts @@ -0,0 +1,69 @@ +import type { ApiCurationDrop } from "@/generated/models/ApiCurationDrop"; +import type { ApiCurationDropsPage } from "@/generated/models/ApiCurationDropsPage"; +import type { ApiDropContextProfileContext } from "@/generated/models/ApiDropContextProfileContext"; +import type { ApiDropV2PageWithoutCount } from "@/generated/models/ApiDropV2PageWithoutCount"; +import type { ApiWave } from "@/generated/models/ApiWave"; +import type { ApiWaveMin } from "@/generated/models/ApiWaveMin"; +import { toApiWaveMin } from "@/helpers/waves/wave.helpers"; +import { commonApiFetch } from "@/services/api/common-api"; +import { mapLeaderboardDropV2 } from "@/services/api/wave-drops-v2-api"; + +interface FetchWaveCurationDropsV2Props { + readonly wave: ApiWave | ApiWaveMin; + readonly curationId: string; + readonly page: number; + readonly pageSize: number; + readonly signal?: AbortSignal | undefined; +} + +const normalizeWaveMin = (wave: ApiWave | ApiWaveMin): ApiWaveMin => + "description_drop" in wave ? toApiWaveMin(wave) : wave; + +const FALLBACK_CONTEXT_PROFILE_CONTEXT: ApiDropContextProfileContext = { + rating: 0, + min_rating: 0, + max_rating: 0, + reaction: null, + boosted: false, + bookmarked: false, + curatable: false, + curated: false, +}; + +const mapCurationDropV2 = ( + drop: ApiDropV2PageWithoutCount["data"][number], + wave: ApiWaveMin +): ApiCurationDrop => { + const mappedDrop = mapLeaderboardDropV2({ drop, wave }); + + return { + drop_priority_order: null, + ...mappedDrop, + context_profile_context: + mappedDrop.context_profile_context ?? FALLBACK_CONTEXT_PROFILE_CONTEXT, + }; +}; + +export async function fetchWaveCurationDropsV2({ + wave, + curationId, + page, + pageSize, + signal, +}: FetchWaveCurationDropsV2Props): Promise { + const waveMin = normalizeWaveMin(wave); + const response = await commonApiFetch({ + endpoint: `v2/waves/${waveMin.id}/curations/${curationId}/drops`, + params: { + page: page.toString(), + page_size: pageSize.toString(), + }, + signal, + }); + + return { + data: response.data.map((drop) => mapCurationDropV2(drop, waveMin)), + page: response.page, + next: response.next, + }; +} diff --git a/services/api/wave-decisions-v2-api.ts b/services/api/wave-decisions-v2-api.ts new file mode 100644 index 0000000000..c42448da78 --- /dev/null +++ b/services/api/wave-decisions-v2-api.ts @@ -0,0 +1,354 @@ +import type { ApiDrop } from "@/generated/models/ApiDrop"; +import type { ApiDropContextProfileContext } from "@/generated/models/ApiDropContextProfileContext"; +import type { ApiDropPart } from "@/generated/models/ApiDropPart"; +import type { ApiDropReaction } from "@/generated/models/ApiDropReaction"; +import type { ApiDropWithoutWave } from "@/generated/models/ApiDropWithoutWave"; +import type { ApiDropWinningContext } from "@/generated/models/ApiDropWinningContext"; +import { ApiDropType } from "@/generated/models/ApiDropType"; +import type { ApiDropV2 } from "@/generated/models/ApiDropV2"; +import type { ApiIdentityOverview } from "@/generated/models/ApiIdentityOverview"; +import type { ApiMentionedWave } from "@/generated/models/ApiMentionedWave"; +import { ApiProfileClassification } from "@/generated/models/ApiProfileClassification"; +import type { ApiProfileMin } from "@/generated/models/ApiProfileMin"; +import type { ApiReplyToDropResponse } from "@/generated/models/ApiReplyToDropResponse"; +import type { ApiReplyToDropV2 } from "@/generated/models/ApiReplyToDropV2"; +import type { ApiWave } from "@/generated/models/ApiWave"; +import { ApiWaveCreditType } from "@/generated/models/ApiWaveCreditType"; +import type { ApiWaveDecision } from "@/generated/models/ApiWaveDecision"; +import type { ApiWaveDecisionAward } from "@/generated/models/ApiWaveDecisionAward"; +import type { ApiWaveDecisionV2 } from "@/generated/models/ApiWaveDecisionV2"; +import type { ApiWaveDecisionWinner } from "@/generated/models/ApiWaveDecisionWinner"; +import type { ApiWaveDecisionWinnerV2 } from "@/generated/models/ApiWaveDecisionWinnerV2"; +import type { ApiWaveDecisionsPage } from "@/generated/models/ApiWaveDecisionsPage"; +import type { ApiWaveDecisionsPageV2 } from "@/generated/models/ApiWaveDecisionsPageV2"; +import type { ApiWaveMin } from "@/generated/models/ApiWaveMin"; +import { toApiWaveMin } from "@/helpers/waves/wave.helpers"; +import { commonApiFetch } from "@/services/api/common-api"; + +interface FetchWaveDecisionsV2Props { + readonly waveId: string; + readonly params: Record; + readonly wave?: ApiWave | ApiWaveMin | undefined; + readonly signal?: AbortSignal | undefined; +} + +const mapIdentityOverviewToProfileMin = ( + identity: ApiIdentityOverview +): ApiProfileMin => ({ + id: identity.id, + handle: identity.handle ?? null, + pfp: identity.pfp ?? null, + banner1_color: null, + banner2_color: null, + cic: 0, + rep: 0, + tdh: 0, + tdh_rate: 0, + xtdh: 0, + xtdh_rate: 0, + level: identity.level, + classification: identity.classification, + sub_classification: null, + primary_address: identity.primary_address, + subscribed_actions: [], + archived: false, + active_main_stage_submission_ids: [], + winner_main_stage_drop_ids: [], + artist_of_prevote_cards: [], + profile_wave_id: null, + is_wave_creator: false, +}); + +const createFallbackWaveMin = (waveId: string): ApiWaveMin => ({ + id: waveId, + name: waveId, + picture: null, + description_drop_id: "", + last_drop_time: 0, + submission_type: null, + authenticated_user_eligible_to_vote: true, + authenticated_user_eligible_to_participate: true, + authenticated_user_eligible_to_chat: true, + authenticated_user_admin: false, + visibility_group_id: null, + participation_group_id: null, + chat_group_id: null, + voting_group_id: null, + admin_group_id: null, + voting_period_start: null, + voting_period_end: null, + voting_credit_type: ApiWaveCreditType.Tdh, + voting_credit_nfts: null, + admin_drop_deletion_enabled: false, + forbid_negative_votes: false, + pinned: false, + identity_wave: false, +}); + +const normalizeWaveMin = (wave: ApiWave | ApiWaveMin): ApiWaveMin => + "description_drop" in wave ? toApiWaveMin(wave) : wave; + +const getWaveMin = ( + wave: ApiWave | ApiWaveMin | undefined, + fallbackWaveId: string +): ApiWaveMin => + wave ? normalizeWaveMin(wave) : createFallbackWaveMin(fallbackWaveId); + +const createBasePart = (drop: ApiDropV2): ApiDropPart => ({ + part_id: 1, + content: drop.content ?? null, + media: drop.media ?? [], + attachments: drop.attachments ?? [], + quoted_drop: null, +}); + +const mapDropReactionCountersV2 = (drop: ApiDropV2): ApiDropReaction[] => + drop.reactions + ?.filter((reaction) => reaction.count > 0) + .map((reaction) => ({ + reaction: reaction.reaction, + profiles: [], + count: reaction.count, + })) ?? []; + +const mapMentionedWaves = ( + drop: ApiDropV2, + fallbackWave: ApiWaveMin +): ApiMentionedWave[] => + drop.mentioned_waves?.map((wave) => ({ + wave_name_in_content: wave.in_content, + wave_id: wave.id, + wave: { + ...fallbackWave, + id: wave.id, + name: wave.name ?? wave.in_content, + picture: wave.pfp ?? null, + }, + })) ?? []; + +const mapReplyToDropPreview = ( + replyToDrop: ApiReplyToDropV2 +): ApiDropWithoutWave => ({ + id: replyToDrop.id, + serial_no: replyToDrop.serial_no ?? 0, + drop_type: ApiDropType.Chat, + rank: null, + author: { + id: replyToDrop.author?.handle ?? "", + handle: replyToDrop.author?.handle ?? null, + pfp: replyToDrop.author?.pfp ?? null, + banner1_color: null, + banner2_color: null, + cic: 0, + rep: 0, + tdh: 0, + tdh_rate: 0, + xtdh: 0, + xtdh_rate: 0, + level: 0, + classification: ApiProfileClassification.Pseudonym, + sub_classification: null, + primary_address: "", + subscribed_actions: [], + archived: false, + active_main_stage_submission_ids: [], + winner_main_stage_drop_ids: [], + artist_of_prevote_cards: [], + profile_wave_id: null, + is_wave_creator: false, + }, + created_at: 0, + updated_at: null, + title: null, + parts: [ + { + part_id: 1, + content: replyToDrop.content ?? null, + media: [], + attachments: [], + quoted_drop: null, + }, + ], + parts_count: 1, + referenced_nfts: [], + mentioned_users: [], + mentioned_groups: [], + mentioned_waves: [], + metadata: [], + rating: 0, + realtime_rating: 0, + rating_prediction: 0, + top_raters: [], + raters_count: 0, + context_profile_context: null, + subscribed_actions: [], + is_signed: false, + reactions: [], + boosts: 0, + hide_link_preview: false, + nft_links: [], +}); + +const mapReplyToDrop = ( + drop: ApiDropV2 +): ApiReplyToDropResponse | undefined => { + if (!drop.reply_to_drop) { + return undefined; + } + + return { + drop_id: drop.reply_to_drop.id, + drop_part_id: 1, + is_deleted: false, + drop: mapReplyToDropPreview(drop.reply_to_drop), + }; +}; + +const getContextProfileContext = ( + drop: ApiDropV2 +): ApiDropContextProfileContext => { + const votingContext = drop.submission_context?.voting.context_profile_context; + const dropContext = drop.context_profile_context; + + return { + rating: votingContext?.current ?? 0, + min_rating: votingContext?.min ?? 0, + max_rating: votingContext?.max ?? 0, + reaction: dropContext?.reaction ?? null, + boosted: dropContext?.boosted ?? false, + bookmarked: dropContext?.bookmarked ?? false, + curatable: false, + curated: false, + }; +}; + +const getDecisionWinningContext = ({ + awards, + decisionTime, + place, +}: { + readonly awards: ApiWaveDecisionAward[]; + readonly decisionTime: number; + readonly place: number; +}): ApiDropWinningContext => ({ + place, + awards, + decision_time: decisionTime, + sale_time: null, + sale_price: null, + sale_price_currency: null, +}); + +const mapDecisionDropV2 = ({ + awards, + decisionTime, + drop, + place, + wave, +}: { + readonly awards: ApiWaveDecisionAward[]; + readonly decisionTime: number; + readonly drop: ApiDropV2; + readonly place: number; + readonly wave: ApiWaveMin; +}): ApiDrop => { + const voting = drop.submission_context?.voting; + const replyTo = mapReplyToDrop(drop); + + return { + id: drop.id, + serial_no: drop.serial_no, + drop_type: ApiDropType.Winner, + rank: place, + winning_context: getDecisionWinningContext({ + awards, + decisionTime, + place, + }), + wave, + ...(replyTo ? { reply_to: replyTo } : {}), + author: mapIdentityOverviewToProfileMin(drop.author), + created_at: drop.created_at, + updated_at: drop.updated_at ?? null, + title: drop.title ?? null, + parts: [createBasePart(drop)], + parts_count: 1, + referenced_nfts: drop.referenced_nfts ?? [], + mentioned_users: drop.mentioned_users ?? [], + mentioned_groups: drop.mentioned_groups ?? [], + mentioned_waves: mapMentionedWaves(drop, wave), + metadata: [], + rating: voting?.current_calculated_vote ?? 0, + realtime_rating: voting?.current_calculated_vote ?? 0, + rating_prediction: voting?.predicted_final_vote ?? 0, + top_raters: [], + raters_count: voting?.voters_count ?? 0, + context_profile_context: getContextProfileContext(drop), + subscribed_actions: [], + is_signed: drop.is_signed, + reactions: mapDropReactionCountersV2(drop), + boosts: drop.boosts, + hide_link_preview: drop.hide_link_preview, + nft_links: drop.nft_links ?? [], + }; +}; + +const mapDecisionWinnerV2 = ({ + decisionTime, + wave, + winner, +}: { + readonly decisionTime: number; + readonly wave: ApiWaveMin; + readonly winner: ApiWaveDecisionWinnerV2; +}): ApiWaveDecisionWinner => ({ + place: winner.place, + awards: winner.awards, + drop: mapDecisionDropV2({ + awards: winner.awards, + decisionTime, + drop: winner.drop, + place: winner.place, + wave, + }), +}); + +const mapDecisionPointV2 = ({ + decision, + wave, +}: { + readonly decision: ApiWaveDecisionV2; + readonly wave: ApiWaveMin; +}): ApiWaveDecision => ({ + decision_time: decision.decision_time, + winners: decision.winners.map((winner) => + mapDecisionWinnerV2({ + decisionTime: decision.decision_time, + wave, + winner, + }) + ), +}); + +export async function fetchWaveDecisionsV2({ + waveId, + params, + wave, + signal, +}: FetchWaveDecisionsV2Props): Promise { + const data = await commonApiFetch({ + endpoint: `v2/waves/${waveId}/decisions`, + params, + ...(signal ? { signal } : {}), + }); + const waveMin = getWaveMin(wave, waveId); + + return { + data: data.data.map((decision) => + mapDecisionPointV2({ decision, wave: waveMin }) + ), + count: data.count, + page: data.page, + next: data.next, + }; +} diff --git a/services/api/wave-drops-v2-api.ts b/services/api/wave-drops-v2-api.ts new file mode 100644 index 0000000000..95419c6087 --- /dev/null +++ b/services/api/wave-drops-v2-api.ts @@ -0,0 +1,750 @@ +import type { ApiDrop } from "@/generated/models/ApiDrop"; +import type { ApiDropAndWave } from "@/generated/models/ApiDropAndWave"; +import type { ApiDropContextProfileContext } from "@/generated/models/ApiDropContextProfileContext"; +import type { ApiDropsLeaderboardPage } from "@/generated/models/ApiDropsLeaderboardPage"; +import type { ApiDropsLeaderboardPageV2 } from "@/generated/models/ApiDropsLeaderboardPageV2"; +import type { ApiDropMetadataResponse } from "@/generated/models/ApiDropMetadataResponse"; +import type { ApiDropWithoutWave } from "@/generated/models/ApiDropWithoutWave"; +import type { ApiDropPart } from "@/generated/models/ApiDropPart"; +import type { ApiDropPartV2 } from "@/generated/models/ApiDropPartV2"; +import type { ApiDropRater } from "@/generated/models/ApiDropRater"; +import type { ApiDropReaction } from "@/generated/models/ApiDropReaction"; +import type { ApiDropReactionV2 } from "@/generated/models/ApiDropReactionV2"; +import type { ApiDropSearchStrategy } from "@/generated/models/ApiDropSearchStrategy"; +import { ApiDropType } from "@/generated/models/ApiDropType"; +import type { ApiDropV2 } from "@/generated/models/ApiDropV2"; +import type { ApiDropV2Page } from "@/generated/models/ApiDropV2Page"; +import type { ApiDropV2PageWithoutCount } from "@/generated/models/ApiDropV2PageWithoutCount"; +import type { ApiDropVotersPage } from "@/generated/models/ApiDropVotersPage"; +import type { ApiDropWithoutWavesPageWithoutCount } from "@/generated/models/ApiDropWithoutWavesPageWithoutCount"; +import type { ApiIdentityOverview } from "@/generated/models/ApiIdentityOverview"; +import type { ApiMentionedWave } from "@/generated/models/ApiMentionedWave"; +import { ApiProfileClassification } from "@/generated/models/ApiProfileClassification"; +import type { ApiProfileMin } from "@/generated/models/ApiProfileMin"; +import type { ApiReplyToDropResponse } from "@/generated/models/ApiReplyToDropResponse"; +import type { ApiReplyToDropV2 } from "@/generated/models/ApiReplyToDropV2"; +import { ApiSubmissionDropStatus } from "@/generated/models/ApiSubmissionDropStatus"; +import { ApiWaveCreditType } from "@/generated/models/ApiWaveCreditType"; +import type { ApiWaveDropsFeed } from "@/generated/models/ApiWaveDropsFeed"; +import type { ApiWaveMin } from "@/generated/models/ApiWaveMin"; +import type { ApiWaveOverview } from "@/generated/models/ApiWaveOverview"; +import type { ApiWaveDropsFeedV2 } from "@/generated/models/ApiWaveDropsFeedV2"; +import type { ApiWave } from "@/generated/models/ApiWave"; +import { ApiDropMainType } from "@/generated/models/ApiDropMainType"; +import { toApiWaveMin } from "@/helpers/waves/wave.helpers"; +import { + commonApiFetch, + commonApiFetchWithRetry, +} from "@/services/api/common-api"; + +const DEFAULT_RETRY_OPTIONS = { + maxRetries: 2, + initialDelayMs: 300, + backoffFactor: 1.5, + jitter: 0.1, +} as const; + +interface FetchWaveDropsV2Props { + readonly waveId: string; + readonly limit: number; + readonly serialNoLimit?: number | null | undefined; + readonly searchStrategy?: ApiDropSearchStrategy | undefined; + readonly dropType?: ApiDropType | undefined; + readonly signal?: AbortSignal | undefined; + readonly headers?: Record | undefined; + readonly withRetry?: boolean | undefined; +} + +interface FetchBoostedDropsV2Props { + readonly waveId: string; + readonly wave: ApiWave | ApiWaveMin; + readonly limit: number; + readonly sortDirection?: string | undefined; + readonly sort?: string | undefined; + readonly countOnlyBoostsAfter?: number | undefined; +} + +interface FetchDropRepliesV2Props { + readonly parentDropId: string; + readonly page: number; + readonly pageSize: number; + readonly wave?: ApiWave | ApiWaveMin | undefined; + readonly signal?: AbortSignal | undefined; +} + +interface FetchWaveLeaderboardV2Props { + readonly waveId: string; + readonly params: Record; + readonly signal?: AbortSignal | undefined; +} + +interface FetchWaveDropsSearchV2Props { + readonly wave: ApiWave | ApiWaveMin; + readonly term: string; + readonly page: number; + readonly size: number; + readonly signal?: AbortSignal | undefined; +} + +export type ApiWaveDropsV2PageFeed = ApiWaveDropsFeed & { + readonly count: number; + readonly page: number; + readonly next: boolean; +}; + +const getDropEndpointId = (dropId: string): string => + encodeURIComponent(dropId); + +export const mapIdentityOverviewToProfileMin = ( + identity: ApiIdentityOverview +): ApiProfileMin => ({ + id: identity.id, + handle: identity.handle ?? null, + pfp: identity.pfp ?? null, + banner1_color: null, + banner2_color: null, + cic: 0, + rep: 0, + tdh: 0, + tdh_rate: 0, + xtdh: 0, + xtdh_rate: 0, + level: identity.level, + classification: identity.classification, + sub_classification: null, + primary_address: identity.primary_address, + subscribed_actions: [], + archived: false, + active_main_stage_submission_ids: [], + winner_main_stage_drop_ids: [], + artist_of_prevote_cards: [], + profile_wave_id: null, + is_wave_creator: false, +}); + +export const mapApiWaveOverviewToApiWaveMin = ( + wave: ApiWaveOverview +): ApiWaveMin => ({ + id: wave.id, + name: wave.name, + picture: wave.pfp ?? null, + description_drop_id: "", + last_drop_time: wave.last_drop_time, + submission_type: null, + authenticated_user_eligible_to_vote: true, + authenticated_user_eligible_to_participate: true, + authenticated_user_eligible_to_chat: + wave.context_profile_context?.can_chat ?? true, + authenticated_user_admin: false, + visibility_group_id: wave.is_private ? "private" : null, + participation_group_id: null, + chat_group_id: null, + voting_group_id: null, + admin_group_id: null, + voting_period_start: null, + voting_period_end: null, + voting_credit_type: ApiWaveCreditType.Tdh, + voting_credit_nfts: null, + admin_drop_deletion_enabled: false, + forbid_negative_votes: false, + pinned: wave.context_profile_context?.pinned ?? false, + identity_wave: false, +}); + +const normalizeWaveMin = (wave: ApiWave | ApiWaveMin): ApiWaveMin => + "description_drop" in wave ? toApiWaveMin(wave) : wave; + +const mapDropPartV2ToApiDropPart = (part: ApiDropPartV2): ApiDropPart => ({ + part_id: part.part_no, + content: part.content ?? null, + media: part.media ?? [], + attachments: part.attachments ?? [], + quoted_drop: part.quoted_drop + ? { + drop_id: part.quoted_drop.drop_id, + drop_part_id: part.quoted_drop.drop_part_id, + } + : null, +}); + +const createBasePart = (drop: ApiDropV2): ApiDropPart => ({ + part_id: 1, + content: drop.content ?? null, + media: drop.media ?? [], + attachments: drop.attachments ?? [], + quoted_drop: null, +}); + +const fetchDropPartV2 = async ({ + dropId, + partNo, + signal, +}: { + readonly dropId: string; + readonly partNo: number; + readonly signal?: AbortSignal | undefined; +}): Promise => { + try { + return await commonApiFetch({ + endpoint: `v2/drops/${getDropEndpointId(dropId)}/parts/${partNo}`, + signal, + }); + } catch { + return null; + } +}; + +const hydrateDropParts = async ( + drop: ApiDropV2, + signal?: AbortSignal +): Promise => { + const partsCount = Math.max(1, drop.parts_count || 1); + const fetchedParts = await Promise.all( + Array.from({ length: partsCount }, (_, index) => { + const partNo = index + 1; + return fetchDropPartV2({ dropId: drop.id, partNo, signal }); + }) + ); + + const parts = fetchedParts + .map((part) => (part ? mapDropPartV2ToApiDropPart(part) : null)) + .filter((part): part is ApiDropPart => !!part); + + if (parts.length > 0) { + return parts; + } + + return [createBasePart(drop)]; +}; + +const mapDropReactionCountersV2 = (drop: ApiDropV2): ApiDropReaction[] => + drop.reactions + ?.filter((reaction) => reaction.count > 0) + .map((reaction) => ({ + reaction: reaction.reaction, + profiles: [], + count: reaction.count, + })) ?? []; + +export const fetchDropReactionDetailsV2 = async ( + dropId: string, + signal?: AbortSignal +): Promise => { + const normalizedDropId = dropId.trim(); + if (!normalizedDropId) { + return []; + } + + try { + const reactions = await commonApiFetch({ + endpoint: `v2/drops/${getDropEndpointId(normalizedDropId)}/reactions`, + signal, + }); + + return reactions.map((reaction) => ({ + reaction: reaction.reaction, + profiles: reaction.reactors.map(mapIdentityOverviewToProfileMin), + })); + } catch { + return []; + } +}; + +const fetchDropMetadataV2 = async ( + drop: ApiDropV2, + signal?: AbortSignal +): Promise => { + if (!drop.submission_context?.has_metadata) { + return []; + } + + try { + return await commonApiFetch({ + endpoint: `v2/drops/${getDropEndpointId(drop.id)}/metadata`, + signal, + }); + } catch { + return []; + } +}; + +const fetchTopRatersV2 = async ( + drop: ApiDropV2, + signal?: AbortSignal +): Promise => { + const votersCount = drop.submission_context?.voting.voters_count ?? 0; + if (votersCount <= 0) { + return []; + } + + try { + const voters = await commonApiFetch({ + endpoint: `v2/drops/${getDropEndpointId(drop.id)}/votes`, + params: { + page_size: "5", + page: "1", + sort_direction: "DESC", + }, + signal, + }); + + return voters.data.map((voter) => ({ + profile: mapIdentityOverviewToProfileMin(voter.voter), + rating: voter.vote, + })); + } catch { + return []; + } +}; + +const mapMentionedWaves = ( + drop: ApiDropV2, + fallbackWave: ApiWaveMin +): ApiMentionedWave[] => + drop.mentioned_waves?.map((wave) => ({ + wave_name_in_content: wave.in_content, + wave_id: wave.id, + wave: { + ...fallbackWave, + id: wave.id, + name: wave.name ?? wave.in_content, + picture: wave.pfp ?? null, + }, + })) ?? []; + +const mapReplyToDrop = ( + drop: ApiDropV2 +): ApiReplyToDropResponse | undefined => { + if (!drop.reply_to_drop) { + return undefined; + } + + return { + drop_id: drop.reply_to_drop.id, + drop_part_id: 1, + is_deleted: false, + drop: mapReplyToDropPreview(drop.reply_to_drop), + }; +}; + +const mapReplyToDropPreview = ( + replyToDrop: ApiReplyToDropV2 +): ApiDropWithoutWave => ({ + id: replyToDrop.id, + serial_no: replyToDrop.serial_no ?? 0, + drop_type: ApiDropType.Chat, + rank: null, + author: { + id: replyToDrop.author?.handle ?? "", + handle: replyToDrop.author?.handle ?? null, + pfp: replyToDrop.author?.pfp ?? null, + banner1_color: null, + banner2_color: null, + cic: 0, + rep: 0, + tdh: 0, + tdh_rate: 0, + xtdh: 0, + xtdh_rate: 0, + level: 0, + classification: ApiProfileClassification.Pseudonym, + sub_classification: null, + primary_address: "", + subscribed_actions: [], + archived: false, + active_main_stage_submission_ids: [], + winner_main_stage_drop_ids: [], + artist_of_prevote_cards: [], + profile_wave_id: null, + is_wave_creator: false, + }, + created_at: 0, + updated_at: null, + title: null, + parts: [ + { + part_id: 1, + content: replyToDrop.content ?? null, + media: [], + attachments: [], + quoted_drop: null, + }, + ], + parts_count: 1, + referenced_nfts: [], + mentioned_users: [], + mentioned_groups: [], + mentioned_waves: [], + metadata: [], + rating: 0, + realtime_rating: 0, + rating_prediction: 0, + top_raters: [], + raters_count: 0, + context_profile_context: null, + subscribed_actions: [], + is_signed: false, + reactions: [], + boosts: 0, + hide_link_preview: false, + nft_links: [], +}); + +const getDropType = (drop: ApiDropV2): ApiDropType => { + if (drop.drop_type === ApiDropMainType.Chat) { + return ApiDropType.Chat; + } + + if (drop.submission_context?.status === ApiSubmissionDropStatus.Winner) { + return ApiDropType.Winner; + } + + return ApiDropType.Participatory; +}; + +const getContextProfileContext = ( + drop: ApiDropV2 +): ApiDropContextProfileContext => { + const votingContext = drop.submission_context?.voting.context_profile_context; + const dropContext = drop.context_profile_context; + + return { + rating: votingContext?.current ?? 0, + min_rating: votingContext?.min ?? 0, + max_rating: votingContext?.max ?? 0, + reaction: dropContext?.reaction ?? null, + boosted: dropContext?.boosted ?? false, + bookmarked: dropContext?.bookmarked ?? false, + curatable: false, + curated: false, + }; +}; + +const getWinningContext = (drop: ApiDropV2) => { + const voting = drop.submission_context?.voting; + if (drop.submission_context?.status !== ApiSubmissionDropStatus.Winner) { + return undefined; + } + + return { + place: voting?.place ?? 0, + awards: [], + decision_time: 0, + sale_time: null, + sale_price: null, + sale_price_currency: null, + }; +}; + +const hydrateDropV2 = async ({ + drop, + wave, + signal, + includeTopRaters = true, +}: { + readonly drop: ApiDropV2; + readonly wave: ApiWaveMin; + readonly signal?: AbortSignal | undefined; + readonly includeTopRaters?: boolean | undefined; +}): Promise => { + const [parts, metadata, topRaters] = await Promise.all([ + hydrateDropParts(drop, signal), + fetchDropMetadataV2(drop, signal), + includeTopRaters ? fetchTopRatersV2(drop, signal) : Promise.resolve([]), + ]); + const voting = drop.submission_context?.voting; + const dropType = getDropType(drop); + const winningContext = getWinningContext(drop); + const replyTo = mapReplyToDrop(drop); + + return { + id: drop.id, + serial_no: drop.serial_no, + drop_type: dropType, + rank: voting?.place ?? null, + ...(winningContext ? { winning_context: winningContext } : {}), + wave, + ...(replyTo ? { reply_to: replyTo } : {}), + author: mapIdentityOverviewToProfileMin(drop.author), + created_at: drop.created_at, + updated_at: drop.updated_at ?? null, + title: drop.title ?? null, + parts, + parts_count: drop.parts_count, + referenced_nfts: drop.referenced_nfts ?? [], + mentioned_users: drop.mentioned_users ?? [], + mentioned_groups: drop.mentioned_groups ?? [], + mentioned_waves: mapMentionedWaves(drop, wave), + metadata, + rating: voting?.current_calculated_vote ?? 0, + realtime_rating: voting?.current_calculated_vote ?? 0, + rating_prediction: voting?.predicted_final_vote ?? 0, + top_raters: topRaters, + raters_count: voting?.voters_count ?? 0, + context_profile_context: getContextProfileContext(drop), + subscribed_actions: [], + is_signed: drop.is_signed, + reactions: mapDropReactionCountersV2(drop), + boosts: drop.boosts, + hide_link_preview: drop.hide_link_preview, + nft_links: drop.nft_links ?? [], + }; +}; + +export const mapLeaderboardDropV2 = ({ + drop, + wave, +}: { + readonly drop: ApiDropV2; + readonly wave: ApiWaveMin; +}): ApiDropWithoutWave => { + const voting = drop.submission_context?.voting; + const dropType = getDropType(drop); + const winningContext = getWinningContext(drop); + const replyTo = mapReplyToDrop(drop); + + return { + id: drop.id, + serial_no: drop.serial_no, + drop_type: dropType, + rank: voting?.place ?? null, + ...(winningContext ? { winning_context: winningContext } : {}), + ...(replyTo ? { reply_to: replyTo } : {}), + author: mapIdentityOverviewToProfileMin(drop.author), + created_at: drop.created_at, + updated_at: drop.updated_at ?? null, + title: drop.title ?? null, + parts: [createBasePart(drop)], + parts_count: 1, + referenced_nfts: drop.referenced_nfts ?? [], + mentioned_users: drop.mentioned_users ?? [], + mentioned_groups: drop.mentioned_groups ?? [], + mentioned_waves: mapMentionedWaves(drop, wave), + metadata: [], + rating: voting?.current_calculated_vote ?? 0, + realtime_rating: voting?.current_calculated_vote ?? 0, + rating_prediction: voting?.predicted_final_vote ?? 0, + top_raters: [], + raters_count: voting?.voters_count ?? 0, + context_profile_context: getContextProfileContext(drop), + subscribed_actions: [], + is_signed: drop.is_signed, + reactions: mapDropReactionCountersV2(drop), + boosts: drop.boosts, + hide_link_preview: drop.hide_link_preview, + nft_links: drop.nft_links ?? [], + }; +}; + +const hydrateDropsV2 = async ({ + drops, + wave, + signal, +}: { + readonly drops: ApiDropV2[]; + readonly wave: ApiWaveMin; + readonly signal?: AbortSignal | undefined; +}): Promise => + Promise.all(drops.map((drop) => hydrateDropV2({ drop, wave, signal }))); + +const getNormalizedDropId = (dropId: string): string => { + const normalizedDropId = dropId.trim(); + if (!normalizedDropId) { + throw new Error("Cannot fetch drop without a drop id"); + } + return normalizedDropId; +}; + +const fetchDropAndWaveV2 = async ( + dropId: string, + signal?: AbortSignal +): Promise => + commonApiFetch({ + endpoint: `v2/drops/${getDropEndpointId(getNormalizedDropId(dropId))}`, + signal, + }); + +export async function fetchWaveDropsFeedV2({ + waveId, + limit, + serialNoLimit, + searchStrategy, + dropType, + signal, + headers, + withRetry = false, +}: FetchWaveDropsV2Props): Promise { + const params: Record = { + limit: limit.toString(), + }; + + if (typeof serialNoLimit === "number") { + params["serial_no_limit"] = `${serialNoLimit}`; + } + + if (searchStrategy !== undefined) { + params["search_strategy"] = searchStrategy; + } + + if (dropType !== undefined) { + params["drop_type"] = dropType; + } + + const request = { + endpoint: `v2/waves/${waveId}/drops`, + params, + signal, + headers, + }; + + const data = withRetry + ? await commonApiFetchWithRetry({ + ...request, + retryOptions: DEFAULT_RETRY_OPTIONS, + }) + : await commonApiFetch(request); + + const wave = mapApiWaveOverviewToApiWaveMin(data.wave); + const drops = await hydrateDropsV2({ + drops: data.drops, + wave, + signal, + }); + + return { + wave, + drops, + } as unknown as ApiWaveDropsFeed; +} + +export async function fetchWaveLeaderboardV2({ + waveId, + params, + signal, +}: FetchWaveLeaderboardV2Props): Promise { + const data = await commonApiFetch({ + endpoint: `v2/waves/${waveId}/leaderboard`, + params, + signal, + }); + + return { + wave: data.wave, + drops: data.drops.map((drop) => + mapLeaderboardDropV2({ drop, wave: data.wave }) + ), + count: data.count, + page: data.page, + next: data.next, + }; +} + +export async function fetchWaveDropsSearchV2({ + wave, + term, + page, + size, + signal, +}: FetchWaveDropsSearchV2Props): Promise { + const waveMin = normalizeWaveMin(wave); + const response = await commonApiFetch({ + endpoint: `v2/waves/${waveMin.id}/search`, + params: { + term, + page: page.toString(), + size: size.toString(), + }, + signal, + }); + + return { + data: response.data.map((drop) => + mapLeaderboardDropV2({ drop, wave: waveMin }) + ), + page: response.page, + next: response.next, + }; +} + +export async function fetchDropV2ById( + dropId: string, + signal?: AbortSignal, + options?: { readonly includeTopRaters?: boolean | undefined } +): Promise { + const data = await fetchDropAndWaveV2(dropId, signal); + const wave = mapApiWaveOverviewToApiWaveMin(data.wave); + return hydrateDropV2({ + drop: data.drop, + wave, + signal, + includeTopRaters: options?.includeTopRaters, + }); +} + +export async function fetchDropRepliesV2({ + parentDropId, + page, + pageSize, + wave, + signal, +}: FetchDropRepliesV2Props): Promise { + const normalizedParentDropId = getNormalizedDropId(parentDropId); + const response = await commonApiFetch({ + endpoint: "v2/drops", + params: { + parent_drop_id: normalizedParentDropId, + page: page.toString(), + page_size: pageSize.toString(), + }, + signal, + }); + + const waveMin = wave + ? normalizeWaveMin(wave) + : mapApiWaveOverviewToApiWaveMin( + (await fetchDropAndWaveV2(normalizedParentDropId, signal)).wave + ); + const drops = await hydrateDropsV2({ + drops: response.data, + wave: waveMin, + signal, + }); + + return { + wave: waveMin, + drops, + count: response.count, + page: response.page, + next: response.next, + } as unknown as ApiWaveDropsV2PageFeed; +} + +export async function fetchBoostedDropsV2({ + waveId, + wave, + limit, + sortDirection = "DESC", + sort = "boosts", + countOnlyBoostsAfter, +}: FetchBoostedDropsV2Props): Promise { + const params: Record = { + wave_id: waveId, + sort, + sort_direction: sortDirection, + page_size: limit.toString(), + }; + + if (countOnlyBoostsAfter !== undefined) { + params["count_only_boosts_after"] = countOnlyBoostsAfter.toString(); + } + + const response = await commonApiFetch({ + endpoint: "v2/boosted-drops", + params, + }); + + return hydrateDropsV2({ + drops: response.data, + wave: normalizeWaveMin(wave), + }); +} diff --git a/services/api/waves-v2-api.ts b/services/api/waves-v2-api.ts new file mode 100644 index 0000000000..9e42c59b19 --- /dev/null +++ b/services/api/waves-v2-api.ts @@ -0,0 +1,197 @@ +import type { ApiWave } from "@/generated/models/ApiWave"; +import type { ApiDropMedia } from "@/generated/models/ApiDropMedia"; +import type { ApiWaveOverview } from "@/generated/models/ApiWaveOverview"; +import type { ApiWaveOverviewPage } from "@/generated/models/ApiWaveOverviewPage"; +import { ApiWaveType } from "@/generated/models/ApiWaveType"; +import { ApiWavesV2ListType } from "@/generated/models/ApiWavesV2ListType"; +import type { ApiWavesOverviewType } from "@/generated/models/ApiWavesOverviewType"; +import type { ApiWavesPinFilter } from "@/generated/models/ApiWavesPinFilter"; +import type { SidebarWave, SidebarWavesPage } from "@/types/waves.types"; +import { commonApiFetch } from "./common-api"; + +interface FetchWavesV2PageProps { + readonly page: number; + readonly pageSize: number; + readonly view?: ApiWavesV2ListType | undefined; + readonly overviewType?: ApiWavesOverviewType | undefined; + readonly following?: boolean | undefined; + readonly directMessage?: boolean | undefined; + readonly pinned?: ApiWavesPinFilter | undefined; + readonly excludeFollowed?: boolean | undefined; + readonly identity?: string | undefined; + readonly headers?: Record | undefined; +} + +export interface WavesV2OverviewQueryKeyParams { + readonly view: ApiWavesV2ListType.Overview; + readonly page_size: number; + readonly overview_type: ApiWavesOverviewType; + readonly only_waves_followed_by_authenticated_user: boolean; + readonly direct_message?: boolean | undefined; + readonly pinned?: ApiWavesPinFilter | undefined; + readonly viewer_identity?: string | undefined; +} + +export function getWavesV2OverviewQueryKeyParams({ + overviewType, + pageSize, + following = false, + directMessage, + pinned, + viewerIdentityKey, +}: { + readonly overviewType: ApiWavesOverviewType; + readonly pageSize: number; + readonly following?: boolean | undefined; + readonly directMessage?: boolean | undefined; + readonly pinned?: ApiWavesPinFilter | undefined; + readonly viewerIdentityKey?: string | null | undefined; +}): WavesV2OverviewQueryKeyParams { + const normalizedViewerIdentityKey = + viewerIdentityKey?.trim().toLowerCase() ?? null; + + return { + view: ApiWavesV2ListType.Overview, + page_size: pageSize, + overview_type: overviewType, + only_waves_followed_by_authenticated_user: following, + ...(directMessage !== undefined ? { direct_message: directMessage } : {}), + ...(pinned !== undefined ? { pinned } : {}), + ...(normalizedViewerIdentityKey + ? { viewer_identity: normalizedViewerIdentityKey } + : {}), + }; +} + +const getWaveOverviewContext = (wave: ApiWaveOverview) => + wave.context_profile_context; + +const mapApiWaveOverviewToSidebarWave = ( + wave: ApiWaveOverview +): SidebarWave => { + const context = getWaveOverviewContext(wave); + + return { + id: wave.id, + name: wave.name, + type: wave.has_competition ? ApiWaveType.Rank : ApiWaveType.Chat, + picture: wave.pfp ?? null, + contributors: + wave.contributors?.map((contributor) => ({ + pfp: contributor.pfp ?? "", + identity: contributor.handle ?? null, + })) ?? [], + isDirectMessage: wave.is_dm_wave, + hasCompetition: wave.has_competition, + descriptionDrop: { + contents: wave.description_drop.contents ?? null, + media: wave.description_drop.media ?? [], + }, + totalDropsCount: wave.total_drops_count, + isPrivate: wave.is_private, + latestDropTimestamp: wave.last_drop_time, + firstUnreadDropSerialNo: context?.first_unread_drop_serial_no ?? null, + unreadDropsCount: context?.unread_drops ?? 0, + latestReadTimestamp: 0, + pinned: context?.pinned ?? false, + muted: context?.muted ?? false, + subscribed: context?.subscribed ?? false, + }; +}; + +const getApiWaveDescriptionDrop = ( + wave: ApiWave +): { contents: string | null; media: ApiDropMedia[] } => { + const descriptionParts = wave.description_drop.parts; + const contents = + descriptionParts + .map((part) => part.content?.trim()) + .filter((content): content is string => Boolean(content)) + .join("\n\n") || null; + + return { + contents, + media: descriptionParts.flatMap((part) => part.media), + }; +}; + +export const mapApiWaveToSidebarWave = (wave: ApiWave): SidebarWave => { + const isDirectMessage = + wave.wave.type === ApiWaveType.Chat && + Boolean(wave.chat.scope.group?.is_direct_message); + + return { + id: wave.id, + name: wave.name, + type: wave.wave.type, + picture: wave.picture, + contributors: wave.contributors_overview.map((contributor) => ({ + pfp: contributor.contributor_pfp, + identity: contributor.contributor_identity, + })), + isDirectMessage, + hasCompetition: wave.wave.type !== ApiWaveType.Chat, + descriptionDrop: getApiWaveDescriptionDrop(wave), + totalDropsCount: wave.metrics.drops_count, + isPrivate: Boolean(wave.visibility.scope.group) && !isDirectMessage, + latestDropTimestamp: wave.metrics.latest_drop_timestamp, + firstUnreadDropSerialNo: wave.metrics.first_unread_drop_serial_no ?? null, + unreadDropsCount: wave.metrics.your_unread_drops_count, + latestReadTimestamp: wave.metrics.your_latest_read_timestamp, + pinned: wave.pinned, + muted: wave.metrics.muted, + subscribed: wave.subscribed_actions.length > 0, + }; +}; + +export async function fetchWavesV2Page({ + page, + pageSize, + view = ApiWavesV2ListType.Overview, + overviewType, + following = false, + directMessage, + pinned, + excludeFollowed, + identity, + headers, +}: FetchWavesV2PageProps): Promise { + const params: Record = { + view, + page: `${page}`, + page_size: `${pageSize}`, + }; + + if (view === ApiWavesV2ListType.Overview && overviewType !== undefined) { + params["overview_type"] = overviewType; + params["only_waves_followed_by_authenticated_user"] = `${following}`; + } + + if (directMessage !== undefined) { + params["direct_message"] = `${directMessage}`; + } + + if (pinned !== undefined) { + params["pinned"] = pinned; + } + + if (excludeFollowed !== undefined) { + params["exclude_followed"] = `${excludeFollowed}`; + } + + if (identity !== undefined) { + params["identity"] = identity; + } + + const response = await commonApiFetch({ + endpoint: "v2/waves", + params, + headers, + }); + + return { + waves: response.data.map(mapApiWaveOverviewToSidebarWave), + page: response.page, + next: response.next, + }; +} diff --git a/types/feed.types.ts b/types/feed.types.ts index 81d24e4f10..06c4d8c049 100644 --- a/types/feed.types.ts +++ b/types/feed.types.ts @@ -4,6 +4,7 @@ import type { ApiNotificationCause } from "@/generated/models/ApiNotificationCau import type { ApiNotificationsResponse } from "@/generated/models/ApiNotificationsResponse"; import type { ApiProfileMin } from "@/generated/models/ApiProfileMin"; import type { ApiWave } from "@/generated/models/ApiWave"; +import type { ApiWaveOverview } from "@/generated/models/ApiWaveOverview"; type IFeedItemWaveCreated = { readonly serial_no: number; @@ -118,6 +119,7 @@ export type INotificationDropReplied = NotificationBase & export type INotificationWaveCreated = NotificationBase & { readonly cause: ApiNotificationCause.WaveCreated; + readonly related_wave?: ApiWaveOverview; readonly additional_context: { readonly wave_id: string; }; diff --git a/types/waves.types.ts b/types/waves.types.ts index de4b884494..8f69328d29 100644 --- a/types/waves.types.ts +++ b/types/waves.types.ts @@ -3,8 +3,8 @@ import type { ApiWaveMetadataType } from "@/generated/models/ApiWaveMetadataType import type { ApiWaveParticipationRequirement } from "@/generated/models/ApiWaveParticipationRequirement"; import type { ApiWaveParticipationSubmissionStrategy } from "@/generated/models/ApiWaveParticipationSubmissionStrategy"; import type { ApiWaveOutcomeDistributionItem } from "@/generated/models/ApiWaveOutcomeDistributionItem"; -import type { ApiWavesOverviewType } from "@/generated/models/ApiWavesOverviewType"; import type { ApiWaveType } from "@/generated/models/ApiWaveType"; +import type { ApiDropMedia } from "@/generated/models/ApiDropMedia"; export enum MyStreamWaveTab { CHAT = "CHAT", @@ -153,13 +153,38 @@ export enum CreateWaveStepStatus { PENDING = "PENDING", } -export interface WavesOverviewParams { - limit: number; - offset: number; - type: ApiWavesOverviewType; - only_waves_followed_by_authenticated_user?: boolean | undefined; - /** - * Filter waves by direct message flag. true -> only DMs, false -> exclude DMs. - */ - direct_message?: boolean | undefined; +export interface SidebarWaveContributor { + readonly pfp: string; + readonly identity: string | null; +} + +export interface SidebarWaveDescriptionDrop { + readonly contents: string | null; + readonly media: readonly ApiDropMedia[]; +} + +export interface SidebarWave { + readonly id: string; + readonly name: string; + readonly type: ApiWaveType; + readonly picture: string | null; + readonly contributors: readonly SidebarWaveContributor[]; + readonly isDirectMessage: boolean; + readonly hasCompetition: boolean; + readonly descriptionDrop: SidebarWaveDescriptionDrop; + readonly totalDropsCount: number; + readonly isPrivate: boolean; + readonly latestDropTimestamp: number | null; + readonly firstUnreadDropSerialNo: number | null; + readonly unreadDropsCount: number; + readonly latestReadTimestamp: number; + readonly pinned: boolean; + readonly muted: boolean; + readonly subscribed: boolean; +} + +export interface SidebarWavesPage { + readonly waves: SidebarWave[]; + readonly page: number; + readonly next: boolean; } From 66b9f2456c9faf250c321549f800ebd3c8e39a70 Mon Sep 17 00:00:00 2001 From: GelatoGenesis Date: Mon, 11 May 2026 12:51:22 +0300 Subject: [PATCH 2/6] Removed the excessive parts fetching Signed-off-by: GelatoGenesis --- .../services/api/wave-drops-v2-api.test.ts | 143 ++++++++++++++++++ services/api/wave-drops-v2-api.ts | 18 ++- 2 files changed, 153 insertions(+), 8 deletions(-) create mode 100644 __tests__/services/api/wave-drops-v2-api.test.ts diff --git a/__tests__/services/api/wave-drops-v2-api.test.ts b/__tests__/services/api/wave-drops-v2-api.test.ts new file mode 100644 index 0000000000..126a356c89 --- /dev/null +++ b/__tests__/services/api/wave-drops-v2-api.test.ts @@ -0,0 +1,143 @@ +import { ApiDropMainType } from "@/generated/models/ApiDropMainType"; +import { ApiProfileClassification } from "@/generated/models/ApiProfileClassification"; +import { commonApiFetch } from "@/services/api/common-api"; +import { fetchWaveDropsFeedV2 } from "@/services/api/wave-drops-v2-api"; + +jest.mock("@/services/api/common-api", () => ({ + commonApiFetch: jest.fn(), + commonApiFetchWithRetry: jest.fn(), +})); + +const commonApiFetchMock = commonApiFetch as jest.MockedFunction< + typeof commonApiFetch +>; + +const identity = { + id: "author-id", + handle: "author", + primary_address: "0xauthor", + pfp: "author.png", + level: 1, + classification: ApiProfileClassification.Pseudonym, + badges: {}, +}; + +const wave = { + id: "wave-1", + name: "Wave 1", + pfp: "wave.png", + last_drop_time: 100, + created_at: 50, + subscribers_count: 1, + has_competition: false, + is_dm_wave: false, + description_drop: { + id: "description-drop", + title: "Wave 1", + description: "Description", + }, + total_drops_count: 1, + is_private: false, + context_profile_context: { + subscribed: true, + pinned: false, + can_chat: true, + unread_drops: 0, + muted: false, + }, +}; + +const createDrop = (partsCount: number) => ({ + id: "drop-1", + serial_no: 1, + created_at: 1000, + updated_at: null, + is_signed: false, + hide_link_preview: false, + title: "Title", + content: "Part 1", + media: [], + attachments: [], + parts_count: partsCount, + author: identity, + drop_type: ApiDropMainType.Chat, + referenced_nfts: [], + mentioned_users: [], + mentioned_groups: [], + mentioned_waves: [], + nft_links: [], + reactions: [], + boosts: 0, + context_profile_context: { + reaction: null, + boosted: false, + bookmarked: false, + }, +}); + +describe("fetchWaveDropsFeedV2", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("uses ApiDropV2 as part one without fetching the part endpoint", async () => { + commonApiFetchMock.mockResolvedValueOnce({ + wave, + drops: [createDrop(1)], + }); + + const result = await fetchWaveDropsFeedV2({ + waveId: "wave-1", + limit: 20, + }); + + expect(commonApiFetchMock).toHaveBeenCalledTimes(1); + expect(commonApiFetchMock).not.toHaveBeenCalledWith( + expect.objectContaining({ + endpoint: expect.stringContaining("/parts/1"), + }) + ); + expect(result.drops[0]?.parts).toEqual([ + expect.objectContaining({ + part_id: 1, + content: "Part 1", + }), + ]); + }); + + it("fetches only additional parts for multi-part drops", async () => { + commonApiFetchMock + .mockResolvedValueOnce({ + wave, + drops: [createDrop(2)], + }) + .mockResolvedValueOnce({ + part_no: 2, + content: "Part 2", + media: [], + attachments: [], + quoted_drop: null, + }); + + const result = await fetchWaveDropsFeedV2({ + waveId: "wave-1", + limit: 20, + }); + + expect(commonApiFetchMock).toHaveBeenCalledTimes(2); + expect(commonApiFetchMock).not.toHaveBeenCalledWith( + expect.objectContaining({ + endpoint: expect.stringContaining("/parts/1"), + }) + ); + expect(commonApiFetchMock).toHaveBeenCalledWith( + expect.objectContaining({ + endpoint: "v2/drops/drop-1/parts/2", + }) + ); + expect(result.drops[0]?.parts.map((part) => part.content)).toEqual([ + "Part 1", + "Part 2", + ]); + }); +}); diff --git a/services/api/wave-drops-v2-api.ts b/services/api/wave-drops-v2-api.ts index 95419c6087..d74345eac9 100644 --- a/services/api/wave-drops-v2-api.ts +++ b/services/api/wave-drops-v2-api.ts @@ -198,23 +198,25 @@ const hydrateDropParts = async ( drop: ApiDropV2, signal?: AbortSignal ): Promise => { + const basePart = createBasePart(drop); const partsCount = Math.max(1, drop.parts_count || 1); + + if (partsCount <= 1) { + return [basePart]; + } + const fetchedParts = await Promise.all( - Array.from({ length: partsCount }, (_, index) => { - const partNo = index + 1; + Array.from({ length: partsCount - 1 }, (_, index) => { + const partNo = index + 2; return fetchDropPartV2({ dropId: drop.id, partNo, signal }); }) ); - const parts = fetchedParts + const extraParts = fetchedParts .map((part) => (part ? mapDropPartV2ToApiDropPart(part) : null)) .filter((part): part is ApiDropPart => !!part); - if (parts.length > 0) { - return parts; - } - - return [createBasePart(drop)]; + return [basePart, ...extraParts]; }; const mapDropReactionCountersV2 = (drop: ApiDropV2): ApiDropReaction[] => From 9fd54791810f6d627d59aec94d504cb155659598 Mon Sep 17 00:00:00 2001 From: GelatoGenesis Date: Mon, 11 May 2026 13:22:24 +0300 Subject: [PATCH 3/6] refactor Signed-off-by: GelatoGenesis --- services/api/drop-v2-mappers.ts | 252 ++++++++++++++++++ services/api/notifications-v2-api.ts | 4 +- ...uorum-participation-drop-preview-v2-api.ts | 6 +- services/api/wave-curation-drops-v2-api.ts | 5 +- services/api/wave-decisions-v2-api.ts | 211 +-------------- services/api/wave-drops-v2-api.ts | 221 +-------------- 6 files changed, 277 insertions(+), 422 deletions(-) create mode 100644 services/api/drop-v2-mappers.ts diff --git a/services/api/drop-v2-mappers.ts b/services/api/drop-v2-mappers.ts new file mode 100644 index 0000000000..3e2bc3570e --- /dev/null +++ b/services/api/drop-v2-mappers.ts @@ -0,0 +1,252 @@ +import type { ApiDropContextProfileContext } from "@/generated/models/ApiDropContextProfileContext"; +import type { ApiDropPart } from "@/generated/models/ApiDropPart"; +import type { ApiDropPartV2 } from "@/generated/models/ApiDropPartV2"; +import type { ApiDropReaction } from "@/generated/models/ApiDropReaction"; +import type { ApiDropWithoutWave } from "@/generated/models/ApiDropWithoutWave"; +import { ApiDropType } from "@/generated/models/ApiDropType"; +import type { ApiDropV2 } from "@/generated/models/ApiDropV2"; +import type { ApiIdentityOverview } from "@/generated/models/ApiIdentityOverview"; +import type { ApiMentionedWave } from "@/generated/models/ApiMentionedWave"; +import { ApiProfileClassification } from "@/generated/models/ApiProfileClassification"; +import type { ApiProfileMin } from "@/generated/models/ApiProfileMin"; +import type { ApiReplyToDropResponse } from "@/generated/models/ApiReplyToDropResponse"; +import type { ApiReplyToDropV2 } from "@/generated/models/ApiReplyToDropV2"; +import type { ApiWave } from "@/generated/models/ApiWave"; +import { ApiWaveCreditType } from "@/generated/models/ApiWaveCreditType"; +import type { ApiWaveMin } from "@/generated/models/ApiWaveMin"; +import type { ApiWaveOverview } from "@/generated/models/ApiWaveOverview"; +import { toApiWaveMin } from "@/helpers/waves/wave.helpers"; + +export const mapIdentityOverviewToProfileMin = ( + identity: ApiIdentityOverview +): ApiProfileMin => ({ + id: identity.id, + handle: identity.handle ?? null, + pfp: identity.pfp ?? null, + banner1_color: null, + banner2_color: null, + cic: 0, + rep: 0, + tdh: 0, + tdh_rate: 0, + xtdh: 0, + xtdh_rate: 0, + level: identity.level, + classification: identity.classification, + sub_classification: null, + primary_address: identity.primary_address, + subscribed_actions: [], + archived: false, + active_main_stage_submission_ids: [], + winner_main_stage_drop_ids: [], + artist_of_prevote_cards: [], + profile_wave_id: null, + is_wave_creator: false, +}); + +export const mapApiWaveOverviewToApiWaveMin = ( + wave: ApiWaveOverview +): ApiWaveMin => ({ + id: wave.id, + name: wave.name, + picture: wave.pfp ?? null, + description_drop_id: "", + last_drop_time: wave.last_drop_time, + submission_type: null, + authenticated_user_eligible_to_vote: true, + authenticated_user_eligible_to_participate: true, + authenticated_user_eligible_to_chat: + wave.context_profile_context?.can_chat ?? true, + authenticated_user_admin: false, + visibility_group_id: wave.is_private ? "private" : null, + participation_group_id: null, + chat_group_id: null, + voting_group_id: null, + admin_group_id: null, + voting_period_start: null, + voting_period_end: null, + voting_credit_type: ApiWaveCreditType.Tdh, + voting_credit_nfts: null, + admin_drop_deletion_enabled: false, + forbid_negative_votes: false, + pinned: wave.context_profile_context?.pinned ?? false, + identity_wave: false, +}); + +const createFallbackWaveMin = (waveId: string): ApiWaveMin => ({ + id: waveId, + name: waveId, + picture: null, + description_drop_id: "", + last_drop_time: 0, + submission_type: null, + authenticated_user_eligible_to_vote: true, + authenticated_user_eligible_to_participate: true, + authenticated_user_eligible_to_chat: true, + authenticated_user_admin: false, + visibility_group_id: null, + participation_group_id: null, + chat_group_id: null, + voting_group_id: null, + admin_group_id: null, + voting_period_start: null, + voting_period_end: null, + voting_credit_type: ApiWaveCreditType.Tdh, + voting_credit_nfts: null, + admin_drop_deletion_enabled: false, + forbid_negative_votes: false, + pinned: false, + identity_wave: false, +}); + +export const normalizeWaveMin = (wave: ApiWave | ApiWaveMin): ApiWaveMin => + "description_drop" in wave ? toApiWaveMin(wave) : wave; + +export const getWaveMin = ( + wave: ApiWave | ApiWaveMin | undefined, + fallbackWaveId: string +): ApiWaveMin => + wave ? normalizeWaveMin(wave) : createFallbackWaveMin(fallbackWaveId); + +export const createBasePart = (drop: ApiDropV2): ApiDropPart => ({ + part_id: 1, + content: drop.content ?? null, + media: drop.media ?? [], + attachments: drop.attachments ?? [], + quoted_drop: null, +}); + +export const mapDropPartV2ToApiDropPart = ( + part: ApiDropPartV2 +): ApiDropPart => ({ + part_id: part.part_no, + content: part.content ?? null, + media: part.media ?? [], + attachments: part.attachments ?? [], + quoted_drop: part.quoted_drop + ? { + drop_id: part.quoted_drop.drop_id, + drop_part_id: part.quoted_drop.drop_part_id, + } + : null, +}); + +export const mapDropReactionCountersV2 = (drop: ApiDropV2): ApiDropReaction[] => + drop.reactions + ?.filter((reaction) => reaction.count > 0) + .map((reaction) => ({ + reaction: reaction.reaction, + profiles: [], + count: reaction.count, + })) ?? []; + +export const mapMentionedWaves = ( + drop: ApiDropV2, + fallbackWave: ApiWaveMin +): ApiMentionedWave[] => + drop.mentioned_waves?.map((wave) => ({ + wave_name_in_content: wave.in_content, + wave_id: wave.id, + wave: { + ...fallbackWave, + id: wave.id, + name: wave.name ?? wave.in_content, + picture: wave.pfp ?? null, + }, + })) ?? []; + +const mapReplyToDropPreview = ( + replyToDrop: ApiReplyToDropV2 +): ApiDropWithoutWave => ({ + id: replyToDrop.id, + serial_no: replyToDrop.serial_no ?? 0, + drop_type: ApiDropType.Chat, + rank: null, + author: { + id: replyToDrop.author?.handle ?? "", + handle: replyToDrop.author?.handle ?? null, + pfp: replyToDrop.author?.pfp ?? null, + banner1_color: null, + banner2_color: null, + cic: 0, + rep: 0, + tdh: 0, + tdh_rate: 0, + xtdh: 0, + xtdh_rate: 0, + level: 0, + classification: ApiProfileClassification.Pseudonym, + sub_classification: null, + primary_address: "", + subscribed_actions: [], + archived: false, + active_main_stage_submission_ids: [], + winner_main_stage_drop_ids: [], + artist_of_prevote_cards: [], + profile_wave_id: null, + is_wave_creator: false, + }, + created_at: 0, + updated_at: null, + title: null, + parts: [ + { + part_id: 1, + content: replyToDrop.content ?? null, + media: [], + attachments: [], + quoted_drop: null, + }, + ], + parts_count: 1, + referenced_nfts: [], + mentioned_users: [], + mentioned_groups: [], + mentioned_waves: [], + metadata: [], + rating: 0, + realtime_rating: 0, + rating_prediction: 0, + top_raters: [], + raters_count: 0, + context_profile_context: null, + subscribed_actions: [], + is_signed: false, + reactions: [], + boosts: 0, + hide_link_preview: false, + nft_links: [], +}); + +export const mapReplyToDrop = ( + drop: ApiDropV2 +): ApiReplyToDropResponse | undefined => { + if (!drop.reply_to_drop) { + return undefined; + } + + return { + drop_id: drop.reply_to_drop.id, + drop_part_id: 1, + is_deleted: false, + drop: mapReplyToDropPreview(drop.reply_to_drop), + }; +}; + +export const getContextProfileContext = ( + drop: ApiDropV2 +): ApiDropContextProfileContext => { + const votingContext = drop.submission_context?.voting.context_profile_context; + const dropContext = drop.context_profile_context; + + return { + rating: votingContext?.current ?? 0, + min_rating: votingContext?.min ?? 0, + max_rating: votingContext?.max ?? 0, + reaction: dropContext?.reaction ?? null, + boosted: dropContext?.boosted ?? false, + bookmarked: dropContext?.bookmarked ?? false, + curatable: false, + curated: false, + }; +}; diff --git a/services/api/notifications-v2-api.ts b/services/api/notifications-v2-api.ts index dccdeabd0d..f215671e12 100644 --- a/services/api/notifications-v2-api.ts +++ b/services/api/notifications-v2-api.ts @@ -13,8 +13,8 @@ import { commonApiFetch } from "@/services/api/common-api"; import { mapApiWaveOverviewToApiWaveMin, mapIdentityOverviewToProfileMin, - mapLeaderboardDropV2, -} from "@/services/api/wave-drops-v2-api"; +} from "@/services/api/drop-v2-mappers"; +import { mapLeaderboardDropV2 } from "@/services/api/wave-drops-v2-api"; import type { INotificationDropReacted, TypedNotification, diff --git a/services/api/quorum-participation-drop-preview-v2-api.ts b/services/api/quorum-participation-drop-preview-v2-api.ts index bbdba0f864..548d2506ce 100644 --- a/services/api/quorum-participation-drop-preview-v2-api.ts +++ b/services/api/quorum-participation-drop-preview-v2-api.ts @@ -2,10 +2,8 @@ import type { ApiDrop } from "@/generated/models/ApiDrop"; import { ApiDropSearchStrategy } from "@/generated/models/ApiDropSearchStrategy"; import type { ApiWaveDropsFeedV2 } from "@/generated/models/ApiWaveDropsFeedV2"; import { commonApiFetch } from "@/services/api/common-api"; -import { - mapApiWaveOverviewToApiWaveMin, - mapLeaderboardDropV2, -} from "@/services/api/wave-drops-v2-api"; +import { mapApiWaveOverviewToApiWaveMin } from "@/services/api/drop-v2-mappers"; +import { mapLeaderboardDropV2 } from "@/services/api/wave-drops-v2-api"; interface FetchQuorumParticipationDropPreviewBySerialNoV2Props { readonly waveId: string; diff --git a/services/api/wave-curation-drops-v2-api.ts b/services/api/wave-curation-drops-v2-api.ts index c390f1f35c..f7ba714562 100644 --- a/services/api/wave-curation-drops-v2-api.ts +++ b/services/api/wave-curation-drops-v2-api.ts @@ -4,8 +4,8 @@ import type { ApiDropContextProfileContext } from "@/generated/models/ApiDropCon import type { ApiDropV2PageWithoutCount } from "@/generated/models/ApiDropV2PageWithoutCount"; import type { ApiWave } from "@/generated/models/ApiWave"; import type { ApiWaveMin } from "@/generated/models/ApiWaveMin"; -import { toApiWaveMin } from "@/helpers/waves/wave.helpers"; import { commonApiFetch } from "@/services/api/common-api"; +import { normalizeWaveMin } from "@/services/api/drop-v2-mappers"; import { mapLeaderboardDropV2 } from "@/services/api/wave-drops-v2-api"; interface FetchWaveCurationDropsV2Props { @@ -16,9 +16,6 @@ interface FetchWaveCurationDropsV2Props { readonly signal?: AbortSignal | undefined; } -const normalizeWaveMin = (wave: ApiWave | ApiWaveMin): ApiWaveMin => - "description_drop" in wave ? toApiWaveMin(wave) : wave; - const FALLBACK_CONTEXT_PROFILE_CONTEXT: ApiDropContextProfileContext = { rating: 0, min_rating: 0, diff --git a/services/api/wave-decisions-v2-api.ts b/services/api/wave-decisions-v2-api.ts index c42448da78..e2e2072d94 100644 --- a/services/api/wave-decisions-v2-api.ts +++ b/services/api/wave-decisions-v2-api.ts @@ -1,19 +1,8 @@ import type { ApiDrop } from "@/generated/models/ApiDrop"; -import type { ApiDropContextProfileContext } from "@/generated/models/ApiDropContextProfileContext"; -import type { ApiDropPart } from "@/generated/models/ApiDropPart"; -import type { ApiDropReaction } from "@/generated/models/ApiDropReaction"; -import type { ApiDropWithoutWave } from "@/generated/models/ApiDropWithoutWave"; import type { ApiDropWinningContext } from "@/generated/models/ApiDropWinningContext"; import { ApiDropType } from "@/generated/models/ApiDropType"; import type { ApiDropV2 } from "@/generated/models/ApiDropV2"; -import type { ApiIdentityOverview } from "@/generated/models/ApiIdentityOverview"; -import type { ApiMentionedWave } from "@/generated/models/ApiMentionedWave"; -import { ApiProfileClassification } from "@/generated/models/ApiProfileClassification"; -import type { ApiProfileMin } from "@/generated/models/ApiProfileMin"; -import type { ApiReplyToDropResponse } from "@/generated/models/ApiReplyToDropResponse"; -import type { ApiReplyToDropV2 } from "@/generated/models/ApiReplyToDropV2"; import type { ApiWave } from "@/generated/models/ApiWave"; -import { ApiWaveCreditType } from "@/generated/models/ApiWaveCreditType"; import type { ApiWaveDecision } from "@/generated/models/ApiWaveDecision"; import type { ApiWaveDecisionAward } from "@/generated/models/ApiWaveDecisionAward"; import type { ApiWaveDecisionV2 } from "@/generated/models/ApiWaveDecisionV2"; @@ -22,8 +11,16 @@ import type { ApiWaveDecisionWinnerV2 } from "@/generated/models/ApiWaveDecision import type { ApiWaveDecisionsPage } from "@/generated/models/ApiWaveDecisionsPage"; import type { ApiWaveDecisionsPageV2 } from "@/generated/models/ApiWaveDecisionsPageV2"; import type { ApiWaveMin } from "@/generated/models/ApiWaveMin"; -import { toApiWaveMin } from "@/helpers/waves/wave.helpers"; import { commonApiFetch } from "@/services/api/common-api"; +import { + createBasePart, + getContextProfileContext, + getWaveMin, + mapDropReactionCountersV2, + mapIdentityOverviewToProfileMin, + mapMentionedWaves, + mapReplyToDrop, +} from "@/services/api/drop-v2-mappers"; interface FetchWaveDecisionsV2Props { readonly waveId: string; @@ -32,196 +29,6 @@ interface FetchWaveDecisionsV2Props { readonly signal?: AbortSignal | undefined; } -const mapIdentityOverviewToProfileMin = ( - identity: ApiIdentityOverview -): ApiProfileMin => ({ - id: identity.id, - handle: identity.handle ?? null, - pfp: identity.pfp ?? null, - banner1_color: null, - banner2_color: null, - cic: 0, - rep: 0, - tdh: 0, - tdh_rate: 0, - xtdh: 0, - xtdh_rate: 0, - level: identity.level, - classification: identity.classification, - sub_classification: null, - primary_address: identity.primary_address, - subscribed_actions: [], - archived: false, - active_main_stage_submission_ids: [], - winner_main_stage_drop_ids: [], - artist_of_prevote_cards: [], - profile_wave_id: null, - is_wave_creator: false, -}); - -const createFallbackWaveMin = (waveId: string): ApiWaveMin => ({ - id: waveId, - name: waveId, - picture: null, - description_drop_id: "", - last_drop_time: 0, - submission_type: null, - authenticated_user_eligible_to_vote: true, - authenticated_user_eligible_to_participate: true, - authenticated_user_eligible_to_chat: true, - authenticated_user_admin: false, - visibility_group_id: null, - participation_group_id: null, - chat_group_id: null, - voting_group_id: null, - admin_group_id: null, - voting_period_start: null, - voting_period_end: null, - voting_credit_type: ApiWaveCreditType.Tdh, - voting_credit_nfts: null, - admin_drop_deletion_enabled: false, - forbid_negative_votes: false, - pinned: false, - identity_wave: false, -}); - -const normalizeWaveMin = (wave: ApiWave | ApiWaveMin): ApiWaveMin => - "description_drop" in wave ? toApiWaveMin(wave) : wave; - -const getWaveMin = ( - wave: ApiWave | ApiWaveMin | undefined, - fallbackWaveId: string -): ApiWaveMin => - wave ? normalizeWaveMin(wave) : createFallbackWaveMin(fallbackWaveId); - -const createBasePart = (drop: ApiDropV2): ApiDropPart => ({ - part_id: 1, - content: drop.content ?? null, - media: drop.media ?? [], - attachments: drop.attachments ?? [], - quoted_drop: null, -}); - -const mapDropReactionCountersV2 = (drop: ApiDropV2): ApiDropReaction[] => - drop.reactions - ?.filter((reaction) => reaction.count > 0) - .map((reaction) => ({ - reaction: reaction.reaction, - profiles: [], - count: reaction.count, - })) ?? []; - -const mapMentionedWaves = ( - drop: ApiDropV2, - fallbackWave: ApiWaveMin -): ApiMentionedWave[] => - drop.mentioned_waves?.map((wave) => ({ - wave_name_in_content: wave.in_content, - wave_id: wave.id, - wave: { - ...fallbackWave, - id: wave.id, - name: wave.name ?? wave.in_content, - picture: wave.pfp ?? null, - }, - })) ?? []; - -const mapReplyToDropPreview = ( - replyToDrop: ApiReplyToDropV2 -): ApiDropWithoutWave => ({ - id: replyToDrop.id, - serial_no: replyToDrop.serial_no ?? 0, - drop_type: ApiDropType.Chat, - rank: null, - author: { - id: replyToDrop.author?.handle ?? "", - handle: replyToDrop.author?.handle ?? null, - pfp: replyToDrop.author?.pfp ?? null, - banner1_color: null, - banner2_color: null, - cic: 0, - rep: 0, - tdh: 0, - tdh_rate: 0, - xtdh: 0, - xtdh_rate: 0, - level: 0, - classification: ApiProfileClassification.Pseudonym, - sub_classification: null, - primary_address: "", - subscribed_actions: [], - archived: false, - active_main_stage_submission_ids: [], - winner_main_stage_drop_ids: [], - artist_of_prevote_cards: [], - profile_wave_id: null, - is_wave_creator: false, - }, - created_at: 0, - updated_at: null, - title: null, - parts: [ - { - part_id: 1, - content: replyToDrop.content ?? null, - media: [], - attachments: [], - quoted_drop: null, - }, - ], - parts_count: 1, - referenced_nfts: [], - mentioned_users: [], - mentioned_groups: [], - mentioned_waves: [], - metadata: [], - rating: 0, - realtime_rating: 0, - rating_prediction: 0, - top_raters: [], - raters_count: 0, - context_profile_context: null, - subscribed_actions: [], - is_signed: false, - reactions: [], - boosts: 0, - hide_link_preview: false, - nft_links: [], -}); - -const mapReplyToDrop = ( - drop: ApiDropV2 -): ApiReplyToDropResponse | undefined => { - if (!drop.reply_to_drop) { - return undefined; - } - - return { - drop_id: drop.reply_to_drop.id, - drop_part_id: 1, - is_deleted: false, - drop: mapReplyToDropPreview(drop.reply_to_drop), - }; -}; - -const getContextProfileContext = ( - drop: ApiDropV2 -): ApiDropContextProfileContext => { - const votingContext = drop.submission_context?.voting.context_profile_context; - const dropContext = drop.context_profile_context; - - return { - rating: votingContext?.current ?? 0, - min_rating: votingContext?.min ?? 0, - max_rating: votingContext?.max ?? 0, - reaction: dropContext?.reaction ?? null, - boosted: dropContext?.boosted ?? false, - bookmarked: dropContext?.bookmarked ?? false, - curatable: false, - curated: false, - }; -}; - const getDecisionWinningContext = ({ awards, decisionTime, diff --git a/services/api/wave-drops-v2-api.ts b/services/api/wave-drops-v2-api.ts index d74345eac9..c6c5dcd4d9 100644 --- a/services/api/wave-drops-v2-api.ts +++ b/services/api/wave-drops-v2-api.ts @@ -1,6 +1,5 @@ import type { ApiDrop } from "@/generated/models/ApiDrop"; import type { ApiDropAndWave } from "@/generated/models/ApiDropAndWave"; -import type { ApiDropContextProfileContext } from "@/generated/models/ApiDropContextProfileContext"; import type { ApiDropsLeaderboardPage } from "@/generated/models/ApiDropsLeaderboardPage"; import type { ApiDropsLeaderboardPageV2 } from "@/generated/models/ApiDropsLeaderboardPageV2"; import type { ApiDropMetadataResponse } from "@/generated/models/ApiDropMetadataResponse"; @@ -17,25 +16,27 @@ import type { ApiDropV2Page } from "@/generated/models/ApiDropV2Page"; import type { ApiDropV2PageWithoutCount } from "@/generated/models/ApiDropV2PageWithoutCount"; import type { ApiDropVotersPage } from "@/generated/models/ApiDropVotersPage"; import type { ApiDropWithoutWavesPageWithoutCount } from "@/generated/models/ApiDropWithoutWavesPageWithoutCount"; -import type { ApiIdentityOverview } from "@/generated/models/ApiIdentityOverview"; -import type { ApiMentionedWave } from "@/generated/models/ApiMentionedWave"; -import { ApiProfileClassification } from "@/generated/models/ApiProfileClassification"; -import type { ApiProfileMin } from "@/generated/models/ApiProfileMin"; -import type { ApiReplyToDropResponse } from "@/generated/models/ApiReplyToDropResponse"; -import type { ApiReplyToDropV2 } from "@/generated/models/ApiReplyToDropV2"; import { ApiSubmissionDropStatus } from "@/generated/models/ApiSubmissionDropStatus"; -import { ApiWaveCreditType } from "@/generated/models/ApiWaveCreditType"; import type { ApiWaveDropsFeed } from "@/generated/models/ApiWaveDropsFeed"; import type { ApiWaveMin } from "@/generated/models/ApiWaveMin"; -import type { ApiWaveOverview } from "@/generated/models/ApiWaveOverview"; import type { ApiWaveDropsFeedV2 } from "@/generated/models/ApiWaveDropsFeedV2"; import type { ApiWave } from "@/generated/models/ApiWave"; import { ApiDropMainType } from "@/generated/models/ApiDropMainType"; -import { toApiWaveMin } from "@/helpers/waves/wave.helpers"; import { commonApiFetch, commonApiFetchWithRetry, } from "@/services/api/common-api"; +import { + createBasePart, + getContextProfileContext, + mapApiWaveOverviewToApiWaveMin, + mapDropPartV2ToApiDropPart, + mapDropReactionCountersV2, + mapIdentityOverviewToProfileMin, + mapMentionedWaves, + mapReplyToDrop, + normalizeWaveMin, +} from "@/services/api/drop-v2-mappers"; const DEFAULT_RETRY_OPTIONS = { maxRetries: 2, @@ -95,86 +96,6 @@ export type ApiWaveDropsV2PageFeed = ApiWaveDropsFeed & { const getDropEndpointId = (dropId: string): string => encodeURIComponent(dropId); -export const mapIdentityOverviewToProfileMin = ( - identity: ApiIdentityOverview -): ApiProfileMin => ({ - id: identity.id, - handle: identity.handle ?? null, - pfp: identity.pfp ?? null, - banner1_color: null, - banner2_color: null, - cic: 0, - rep: 0, - tdh: 0, - tdh_rate: 0, - xtdh: 0, - xtdh_rate: 0, - level: identity.level, - classification: identity.classification, - sub_classification: null, - primary_address: identity.primary_address, - subscribed_actions: [], - archived: false, - active_main_stage_submission_ids: [], - winner_main_stage_drop_ids: [], - artist_of_prevote_cards: [], - profile_wave_id: null, - is_wave_creator: false, -}); - -export const mapApiWaveOverviewToApiWaveMin = ( - wave: ApiWaveOverview -): ApiWaveMin => ({ - id: wave.id, - name: wave.name, - picture: wave.pfp ?? null, - description_drop_id: "", - last_drop_time: wave.last_drop_time, - submission_type: null, - authenticated_user_eligible_to_vote: true, - authenticated_user_eligible_to_participate: true, - authenticated_user_eligible_to_chat: - wave.context_profile_context?.can_chat ?? true, - authenticated_user_admin: false, - visibility_group_id: wave.is_private ? "private" : null, - participation_group_id: null, - chat_group_id: null, - voting_group_id: null, - admin_group_id: null, - voting_period_start: null, - voting_period_end: null, - voting_credit_type: ApiWaveCreditType.Tdh, - voting_credit_nfts: null, - admin_drop_deletion_enabled: false, - forbid_negative_votes: false, - pinned: wave.context_profile_context?.pinned ?? false, - identity_wave: false, -}); - -const normalizeWaveMin = (wave: ApiWave | ApiWaveMin): ApiWaveMin => - "description_drop" in wave ? toApiWaveMin(wave) : wave; - -const mapDropPartV2ToApiDropPart = (part: ApiDropPartV2): ApiDropPart => ({ - part_id: part.part_no, - content: part.content ?? null, - media: part.media ?? [], - attachments: part.attachments ?? [], - quoted_drop: part.quoted_drop - ? { - drop_id: part.quoted_drop.drop_id, - drop_part_id: part.quoted_drop.drop_part_id, - } - : null, -}); - -const createBasePart = (drop: ApiDropV2): ApiDropPart => ({ - part_id: 1, - content: drop.content ?? null, - media: drop.media ?? [], - attachments: drop.attachments ?? [], - quoted_drop: null, -}); - const fetchDropPartV2 = async ({ dropId, partNo, @@ -219,15 +140,6 @@ const hydrateDropParts = async ( return [basePart, ...extraParts]; }; -const mapDropReactionCountersV2 = (drop: ApiDropV2): ApiDropReaction[] => - drop.reactions - ?.filter((reaction) => reaction.count > 0) - .map((reaction) => ({ - reaction: reaction.reaction, - profiles: [], - count: reaction.count, - })) ?? []; - export const fetchDropReactionDetailsV2 = async ( dropId: string, signal?: AbortSignal @@ -299,99 +211,6 @@ const fetchTopRatersV2 = async ( } }; -const mapMentionedWaves = ( - drop: ApiDropV2, - fallbackWave: ApiWaveMin -): ApiMentionedWave[] => - drop.mentioned_waves?.map((wave) => ({ - wave_name_in_content: wave.in_content, - wave_id: wave.id, - wave: { - ...fallbackWave, - id: wave.id, - name: wave.name ?? wave.in_content, - picture: wave.pfp ?? null, - }, - })) ?? []; - -const mapReplyToDrop = ( - drop: ApiDropV2 -): ApiReplyToDropResponse | undefined => { - if (!drop.reply_to_drop) { - return undefined; - } - - return { - drop_id: drop.reply_to_drop.id, - drop_part_id: 1, - is_deleted: false, - drop: mapReplyToDropPreview(drop.reply_to_drop), - }; -}; - -const mapReplyToDropPreview = ( - replyToDrop: ApiReplyToDropV2 -): ApiDropWithoutWave => ({ - id: replyToDrop.id, - serial_no: replyToDrop.serial_no ?? 0, - drop_type: ApiDropType.Chat, - rank: null, - author: { - id: replyToDrop.author?.handle ?? "", - handle: replyToDrop.author?.handle ?? null, - pfp: replyToDrop.author?.pfp ?? null, - banner1_color: null, - banner2_color: null, - cic: 0, - rep: 0, - tdh: 0, - tdh_rate: 0, - xtdh: 0, - xtdh_rate: 0, - level: 0, - classification: ApiProfileClassification.Pseudonym, - sub_classification: null, - primary_address: "", - subscribed_actions: [], - archived: false, - active_main_stage_submission_ids: [], - winner_main_stage_drop_ids: [], - artist_of_prevote_cards: [], - profile_wave_id: null, - is_wave_creator: false, - }, - created_at: 0, - updated_at: null, - title: null, - parts: [ - { - part_id: 1, - content: replyToDrop.content ?? null, - media: [], - attachments: [], - quoted_drop: null, - }, - ], - parts_count: 1, - referenced_nfts: [], - mentioned_users: [], - mentioned_groups: [], - mentioned_waves: [], - metadata: [], - rating: 0, - realtime_rating: 0, - rating_prediction: 0, - top_raters: [], - raters_count: 0, - context_profile_context: null, - subscribed_actions: [], - is_signed: false, - reactions: [], - boosts: 0, - hide_link_preview: false, - nft_links: [], -}); - const getDropType = (drop: ApiDropV2): ApiDropType => { if (drop.drop_type === ApiDropMainType.Chat) { return ApiDropType.Chat; @@ -404,24 +223,6 @@ const getDropType = (drop: ApiDropV2): ApiDropType => { return ApiDropType.Participatory; }; -const getContextProfileContext = ( - drop: ApiDropV2 -): ApiDropContextProfileContext => { - const votingContext = drop.submission_context?.voting.context_profile_context; - const dropContext = drop.context_profile_context; - - return { - rating: votingContext?.current ?? 0, - min_rating: votingContext?.min ?? 0, - max_rating: votingContext?.max ?? 0, - reaction: dropContext?.reaction ?? null, - boosted: dropContext?.boosted ?? false, - bookmarked: dropContext?.bookmarked ?? false, - curatable: false, - curated: false, - }; -}; - const getWinningContext = (drop: ApiDropV2) => { const voting = drop.submission_context?.voting; if (drop.submission_context?.status !== ApiSubmissionDropStatus.Winner) { From 64c186719af562439a426b8666de6ff0168675fa Mon Sep 17 00:00:00 2001 From: GelatoGenesis Date: Mon, 11 May 2026 14:06:04 +0300 Subject: [PATCH 4/6] wip Signed-off-by: GelatoGenesis --- .../increaseWavesOverviewDropsCount.test.ts | 2 + .../waves/drop/useSingleWaveDropData.test.tsx | 53 +++++ __tests__/services/api/drop-api.test.ts | 70 +++++-- .../services/api/notifications-v2-api.test.ts | 83 +++++++- .../services/api/wave-drops-v2-api.test.ts | 17 ++ .../wave-created/NotificationWaveCreated.tsx | 2 +- .../explore-waves/ExploreWavesSection.tsx | 2 +- .../react-query-wrapper/ReactQueryWrapper.tsx | 24 ++- .../utils/increaseWavesOverviewDropsCount.tsx | 1 + .../waves/drop/useSingleWaveDropData.ts | 3 +- .../waves/drops/WaveDropActionsMarkUnread.tsx | 8 +- components/waves/drops/WaveDropReactions.tsx | 182 +++++++++++------- .../waves/header/options/mute/WaveMute.tsx | 8 +- contexts/wave/utils/wave-messages-utils.ts | 4 +- hooks/useWavesV2.ts | 16 +- services/api/drop-api.ts | 71 +++++-- services/api/notifications-v2-api.ts | 30 ++- ...uorum-participation-drop-preview-v2-api.ts | 2 +- services/api/wave-drops-v2-api.ts | 42 +++- services/api/waves-v2-api.ts | 4 +- types/feed.types.ts | 6 +- 21 files changed, 482 insertions(+), 148 deletions(-) diff --git a/__tests__/components/react-query-wrapper/utils/increaseWavesOverviewDropsCount.test.ts b/__tests__/components/react-query-wrapper/utils/increaseWavesOverviewDropsCount.test.ts index 33c7222661..566123c0a5 100644 --- a/__tests__/components/react-query-wrapper/utils/increaseWavesOverviewDropsCount.test.ts +++ b/__tests__/components/react-query-wrapper/utils/increaseWavesOverviewDropsCount.test.ts @@ -157,9 +157,11 @@ describe("increaseWavesOverviewDropsCount", () => { const overviewResult: any = client.getQueryData(overviewKey); const pinnedResult: any = client.getQueryData(pinnedKey); + expect(overviewResult.pages[0].waves[0].totalDropsCount).toBe(1); expect(overviewResult.pages[0].waves[0].latestDropTimestamp).toBe(4321); expect(overviewResult.pages[1].waves[0]).toEqual(waveTwo); expect(overviewResult.pageParams).toEqual([1, 2]); + expect(pinnedResult[0].totalDropsCount).toBe(1); expect(pinnedResult[0].latestDropTimestamp).toBe(4321); }); diff --git a/__tests__/components/waves/drop/useSingleWaveDropData.test.tsx b/__tests__/components/waves/drop/useSingleWaveDropData.test.tsx index e23a85cfad..e10f9a2db6 100644 --- a/__tests__/components/waves/drop/useSingleWaveDropData.test.tsx +++ b/__tests__/components/waves/drop/useSingleWaveDropData.test.tsx @@ -27,6 +27,23 @@ const createWrapper = () => { ); }; +const createInitialDrop = (id: string) => + ({ + id, + wave: { id: "wave-1" }, + stableHash: `${id}-hash`, + stableKey: `${id}-key`, + }) as any; + +const createDeferred = () => { + let resolve!: (value: T) => void; + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve; + }); + + return { promise, resolve }; +}; + describe("useSingleWaveDropData", () => { it("fetches single-drop detail without eager top raters", async () => { fetchDropV2ByIdMock.mockResolvedValue({ @@ -56,4 +73,40 @@ describe("useSingleWaveDropData", () => { ); }); }); + + it("does not expose the previous drop while a new drop id is loading", async () => { + const secondDrop = createDeferred(); + fetchDropV2ByIdMock + .mockResolvedValueOnce({ + id: "drop-1", + wave: { id: "wave-1" }, + } as any) + .mockReturnValueOnce(secondDrop.promise); + + const { result, rerender } = renderHook( + ({ initialDrop }) => useSingleWaveDropData(initialDrop, jest.fn()), + { + initialProps: { initialDrop: createInitialDrop("drop-1") }, + wrapper: createWrapper(), + } + ); + + await waitFor(() => { + expect(result.current.drop?.id).toBe("drop-1"); + }); + + rerender({ initialDrop: createInitialDrop("drop-2") }); + + expect(result.current.drop).toBeUndefined(); + expect(result.current.extendedDrop).toBeNull(); + + secondDrop.resolve({ + id: "drop-2", + wave: { id: "wave-1" }, + }); + + await waitFor(() => { + expect(result.current.drop?.id).toBe("drop-2"); + }); + }); }); diff --git a/__tests__/services/api/drop-api.test.ts b/__tests__/services/api/drop-api.test.ts index a045e2901f..740f8d2856 100644 --- a/__tests__/services/api/drop-api.test.ts +++ b/__tests__/services/api/drop-api.test.ts @@ -1,43 +1,79 @@ import type { ApiDrop } from "@/generated/models/ApiDrop"; -import { commonApiFetch } from "@/services/api/common-api"; -import { fetchDropsByIds } from "@/services/api/drop-api"; +import { fetchDropByIdBatched, fetchDropsByIds } from "@/services/api/drop-api"; +import { fetchDropV2ById } from "@/services/api/wave-drops-v2-api"; -jest.mock("@/services/api/common-api", () => ({ - commonApiFetch: jest.fn(), +jest.mock("@/services/api/wave-drops-v2-api", () => ({ + fetchDropV2ById: jest.fn(), })); -const commonApiFetchMock = commonApiFetch as jest.Mock; +const fetchDropV2ByIdMock = fetchDropV2ById as jest.Mock; beforeEach(() => { + jest.useRealTimers(); jest.clearAllMocks(); }); +afterEach(() => { + jest.useRealTimers(); +}); + describe("fetchDropsByIds", () => { - it("includes reply drops in batched ID lookups", async () => { + it("fetches drops with the v2 drop detail endpoint", async () => { const replyDrop = { id: "reply-1" } as ApiDrop; - commonApiFetchMock.mockResolvedValue([replyDrop]); + fetchDropV2ByIdMock.mockResolvedValue(replyDrop); const result = await fetchDropsByIds(["reply-1"]); - expect(commonApiFetchMock).toHaveBeenCalledTimes(1); - expect(commonApiFetchMock).toHaveBeenCalledWith({ - endpoint: "drops", - params: { - ids: "reply-1", - limit: "1", - include_replies: "true", - }, - }); + expect(fetchDropV2ByIdMock).toHaveBeenCalledTimes(1); + expect(fetchDropV2ByIdMock).toHaveBeenCalledWith("reply-1"); expect(result).toEqual([replyDrop]); }); it("returns fetched drops in requested ID order", async () => { const firstDrop = { id: "drop-1" } as ApiDrop; const secondDrop = { id: "reply-1" } as ApiDrop; - commonApiFetchMock.mockResolvedValue([secondDrop, firstDrop]); + fetchDropV2ByIdMock + .mockResolvedValueOnce(firstDrop) + .mockResolvedValueOnce(secondDrop); const result = await fetchDropsByIds(["drop-1", "reply-1"]); expect(result).toEqual([firstDrop, secondDrop]); }); + + it("keeps fulfilled drops when another drop request fails", async () => { + const validDrop = { id: "valid-drop" } as ApiDrop; + fetchDropV2ByIdMock + .mockResolvedValueOnce(validDrop) + .mockRejectedValueOnce(new Error("Drop deleted")); + + const result = await fetchDropsByIds(["valid-drop", "deleted-drop"]); + + expect(fetchDropV2ByIdMock).toHaveBeenCalledTimes(2); + expect(result).toEqual([validDrop]); + }); + + it("resolves valid batched requests when another batched id fails", async () => { + jest.useFakeTimers(); + const validDrop = { id: "valid-drop" } as ApiDrop; + const deletedDropError = new Error("Drop deleted"); + fetchDropV2ByIdMock.mockImplementation(async (dropId: string) => { + if (dropId === "valid-drop") { + return validDrop; + } + throw deletedDropError; + }); + + const validPromise = fetchDropByIdBatched("valid-drop"); + const deletedPromise = fetchDropByIdBatched("deleted-drop"); + const assertions = Promise.all([ + expect(validPromise).resolves.toBe(validDrop), + expect(deletedPromise).rejects.toBe(deletedDropError), + ]); + + jest.runOnlyPendingTimers(); + + await assertions; + expect(fetchDropV2ByIdMock).toHaveBeenCalledTimes(2); + }); }); diff --git a/__tests__/services/api/notifications-v2-api.test.ts b/__tests__/services/api/notifications-v2-api.test.ts index e986aa16b7..64692e6c8a 100644 --- a/__tests__/services/api/notifications-v2-api.test.ts +++ b/__tests__/services/api/notifications-v2-api.test.ts @@ -121,9 +121,84 @@ describe("fetchNotificationsV2", () => { expect( response.notifications.map((n) => n.related_identity.handle) ).toEqual(["alice", "bob"]); - const mappedDrop = (response.notifications[0] as any).related_drops[0]!; - expect(mappedDrop.wave.id).toBe("wave-1"); - expect((mappedDrop.wave as any).is_direct_message).toBe(true); - expect(mappedDrop.parts[0]?.content).toBe("hello"); + const [firstNotification] = response.notifications; + if ( + firstNotification?.cause === ApiNotificationCause.DropReacted && + "related_drops" in firstNotification + ) { + const mappedDrop = firstNotification.related_drops[0]!; + expect(mappedDrop.wave.id).toBe("wave-1"); + expect(mappedDrop.wave).toMatchObject({ is_direct_message: true }); + expect(mappedDrop.parts[0]?.content).toBe("hello"); + return; + } + + throw new Error("Expected drop reacted notification"); + }); + + it("normalizes related wave on wave-created notifications", async () => { + (commonApiFetch as jest.Mock).mockResolvedValue({ + unread_count: 0, + notifications: [ + { + id: 8, + cause: ApiNotificationCause.WaveCreated, + created_at: 3000, + read_at: null, + related_identity: identity("creator"), + related_wave: wave, + related_drops: [], + additional_context: { + wave_id: "wave-1", + }, + }, + ], + }); + + const response = await fetchNotificationsV2({ limit: "30" }); + const [notification] = response.notifications; + + expect(notification).toMatchObject({ + cause: ApiNotificationCause.WaveCreated, + related_wave: { + id: "wave-1", + is_direct_message: true, + }, + additional_context: { + wave_id: "wave-1", + }, + }); + }); + + it("drops unknown notification causes safely", async () => { + const consoleErrorSpy = jest + .spyOn(console, "error") + .mockImplementation(() => undefined); + + try { + (commonApiFetch as jest.Mock).mockResolvedValue({ + unread_count: 0, + notifications: [ + { + id: 9, + cause: "NEW_CAUSE" as ApiNotificationCause, + created_at: 4000, + read_at: null, + related_identity: identity("unknown"), + related_drops: [], + additional_context: {}, + }, + ], + }); + + const response = await fetchNotificationsV2({ limit: "30" }); + + expect(response.notifications).toEqual([]); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Unsupported notification cause "NEW_CAUSE"') + ); + } finally { + consoleErrorSpy.mockRestore(); + } }); }); diff --git a/__tests__/services/api/wave-drops-v2-api.test.ts b/__tests__/services/api/wave-drops-v2-api.test.ts index 126a356c89..10fa68722c 100644 --- a/__tests__/services/api/wave-drops-v2-api.test.ts +++ b/__tests__/services/api/wave-drops-v2-api.test.ts @@ -140,4 +140,21 @@ describe("fetchWaveDropsFeedV2", () => { "Part 2", ]); }); + + it("rethrows abort errors from additional part fetches", async () => { + const abortError = new DOMException("Aborted", "AbortError"); + commonApiFetchMock + .mockResolvedValueOnce({ + wave, + drops: [createDrop(2)], + }) + .mockRejectedValueOnce(abortError); + + await expect( + fetchWaveDropsFeedV2({ + waveId: "wave-1", + limit: 20, + }) + ).rejects.toBe(abortError); + }); }); diff --git a/components/brain/notifications/wave-created/NotificationWaveCreated.tsx b/components/brain/notifications/wave-created/NotificationWaveCreated.tsx index 0fb0e4f667..c48da25549 100644 --- a/components/brain/notifications/wave-created/NotificationWaveCreated.tsx +++ b/components/brain/notifications/wave-created/NotificationWaveCreated.tsx @@ -18,7 +18,7 @@ export default function NotificationWaveCreated({ const waveId = wave?.id ?? notification.additional_context.wave_id; const invitationHref = getWaveRoute({ waveId, - isDirectMessage: wave?.is_dm_wave ?? false, + isDirectMessage: wave?.is_direct_message ?? wave?.is_dm_wave ?? false, isApp, }); const waveName = wave?.name ?? waveId; diff --git a/components/home/explore-waves/ExploreWavesSection.tsx b/components/home/explore-waves/ExploreWavesSection.tsx index 1c2a6d9129..71121f4aa4 100644 --- a/components/home/explore-waves/ExploreWavesSection.tsx +++ b/components/home/explore-waves/ExploreWavesSection.tsx @@ -43,7 +43,7 @@ export function ExploreWavesSection({ ApiWavesV2ListType.Hot, limit, excludeFollowed, - excludeFollowed ? userScope : null, + userScope, ], queryFn: async () => { const page = await fetchWavesV2Page({ diff --git a/components/react-query-wrapper/ReactQueryWrapper.tsx b/components/react-query-wrapper/ReactQueryWrapper.tsx index 8497aecf61..cc1bf6efcc 100644 --- a/components/react-query-wrapper/ReactQueryWrapper.tsx +++ b/components/react-query-wrapper/ReactQueryWrapper.tsx @@ -913,9 +913,11 @@ const createReactQueryContextValue = ( queryClient.invalidateQueries({ queryKey: [QueryKey.WAVES_OVERVIEW_PUBLIC], }); - void queryClient.invalidateQueries({ - queryKey: [QueryKey.WAVES_V2], - }); + queryClient + .invalidateQueries({ + queryKey: [QueryKey.WAVES_V2], + }) + .catch(() => undefined); queryClient.invalidateQueries({ queryKey: [QueryKey.WAVES], }); @@ -985,12 +987,16 @@ const createReactQueryContextValue = ( }; const invalidateNotifications = () => { - void queryClient.invalidateQueries({ - queryKey: [QueryKey.IDENTITY_NOTIFICATIONS], - }); - void queryClient.invalidateQueries({ - queryKey: [QueryKey.CONNECTED_ACCOUNT_UNREAD_NOTIFICATIONS], - }); + queryClient + .invalidateQueries({ + queryKey: [QueryKey.IDENTITY_NOTIFICATIONS], + }) + .catch(() => undefined); + queryClient + .invalidateQueries({ + queryKey: [QueryKey.CONNECTED_ACCOUNT_UNREAD_NOTIFICATIONS], + }) + .catch(() => undefined); }; const invalidateIdentityTdhStats = ({ identity }: { identity: string }) => { diff --git a/components/react-query-wrapper/utils/increaseWavesOverviewDropsCount.tsx b/components/react-query-wrapper/utils/increaseWavesOverviewDropsCount.tsx index 62ed171ea3..b231e41f7d 100644 --- a/components/react-query-wrapper/utils/increaseWavesOverviewDropsCount.tsx +++ b/components/react-query-wrapper/utils/increaseWavesOverviewDropsCount.tsx @@ -109,6 +109,7 @@ const updateSidebarWaveDropMetrics = ( timestamp: number ): SidebarWave => ({ ...wave, + totalDropsCount: wave.totalDropsCount + 1, latestDropTimestamp: Math.max(timestamp, wave.latestDropTimestamp ?? 0), }); diff --git a/components/waves/drop/useSingleWaveDropData.ts b/components/waves/drop/useSingleWaveDropData.ts index d7188f1cee..b9536d2649 100644 --- a/components/waves/drop/useSingleWaveDropData.ts +++ b/components/waves/drop/useSingleWaveDropData.ts @@ -5,7 +5,7 @@ import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; import { DropSize } from "@/helpers/waves/drop.helpers"; import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; import { useWaveData } from "@/hooks/useWaveData"; -import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import type { ApiDrop } from "@/generated/models/ApiDrop"; import { DROP_DETAIL_STALE_TIME_MS } from "@/services/api/drop-api"; import { fetchDropV2ById } from "@/services/api/wave-drops-v2-api"; @@ -24,7 +24,6 @@ export const useSingleWaveDropData = ( ], queryFn: ({ signal }) => fetchDropV2ById(initialDrop.id, signal, { includeTopRaters: false }), - placeholderData: keepPreviousData, staleTime: DROP_DETAIL_STALE_TIME_MS, }); diff --git a/components/waves/drops/WaveDropActionsMarkUnread.tsx b/components/waves/drops/WaveDropActionsMarkUnread.tsx index 93de1438ff..f6c154a7c5 100644 --- a/components/waves/drops/WaveDropActionsMarkUnread.tsx +++ b/components/waves/drops/WaveDropActionsMarkUnread.tsx @@ -60,9 +60,11 @@ export default function WaveDropActionsMarkUnread({ queryClient.invalidateQueries({ queryKey: [QueryKey.WAVES_OVERVIEW], }); - void queryClient.invalidateQueries({ - queryKey: [QueryKey.WAVES_V2], - }); + queryClient + .invalidateQueries({ + queryKey: [QueryKey.WAVES_V2], + }) + .catch(() => undefined); queryClient.invalidateQueries({ queryKey: [QueryKey.WAVE, { wave_id: drop.wave.id }], diff --git a/components/waves/drops/WaveDropReactions.tsx b/components/waves/drops/WaveDropReactions.tsx index 2d9fa2dd6a..fa0c42689b 100644 --- a/components/waves/drops/WaveDropReactions.tsx +++ b/components/waves/drops/WaveDropReactions.tsx @@ -56,6 +56,102 @@ interface DetailedReactionsState { readonly reactions: ApiDropReaction[]; } +const getReactionClassNames = ({ + animate, + canReact, + selected, +}: { + readonly animate: boolean; + readonly canReact: boolean; + readonly selected: boolean; +}) => { + let hoverStyle = ""; + if (canReact) { + hoverStyle = selected + ? "hover:tw-border-primary-500 hover:tw-bg-primary-500/10" + : "hover:tw-border-iron-500 hover:tw-bg-iron-900/40"; + } + + let animationStyle = ""; + if (animate) { + animationStyle = selected + ? styles["reactionSlideUp"]! + : styles["reactionSlideDown"]!; + } + + return { + borderStyle: selected ? "tw-border-primary-500" : "tw-border-iron-700", + bgStyle: selected ? "tw-bg-primary-500/10" : "tw-bg-iron-900/40", + hoverStyle, + animationStyle, + }; +}; + +function ReactionTooltipContent({ + displayProfiles, + isDetailsLoading, + moreCount, + onMoreClick, + total, +}: { + readonly displayProfiles: ApiDropReaction["profiles"]; + readonly isDetailsLoading: boolean; + readonly moreCount: number; + readonly onMoreClick: (e: React.MouseEvent) => void; + readonly total: number; +}) { + if (isDetailsLoading && displayProfiles.length === 0) { + return Loading reactions...; + } + + if (displayProfiles.length === 0) { + return ( + + {formatLargeNumber(total)} {total === 1 ? "reaction" : "reactions"} + + ); + } + + return ( + + by{" "} + {displayProfiles.map((profile, index) => { + const displayName = profile.handle ?? profile.id; + const isLast = index === displayProfiles.length - 1; + + return ( + + {profile.handle ? ( + e.stopPropagation()} + > + {displayName} + + ) : ( + {displayName} + )} + {isLast ? null : ", "} + + ); + })} + {moreCount > 0 && ( + <> + {" "} + + + )} + + ); +} + const WaveDropReactions: React.FC = ({ drop }) => { const [dialogReaction, setDialogReaction] = useState(null); const [detailedReactionsState, setDetailedReactionsState] = @@ -135,7 +231,7 @@ const WaveDropReactions: React.FC = ({ drop }) => { const handleOpenDialog = useCallback( (reactionKey: string) => { setDialogReaction(reactionKey); - void loadReactionDetails(); + loadReactionDetails()?.catch(() => undefined); }, [loadReactionDetails] ); @@ -500,7 +596,7 @@ function WaveDropReaction({ const handlePointerEnter = useCallback(() => { if (!isTouchDevice) { - void onLoadDetails(); + onLoadDetails()?.catch(() => undefined); } }, [isTouchDevice, onLoadDetails]); @@ -512,77 +608,17 @@ function WaveDropReaction({ [onOpenDetailDialog, reaction.reaction] ); - // styles - const borderStyle = selected ? "tw-border-primary-500" : "tw-border-iron-700"; - const bgStyle = selected ? "tw-bg-primary-500/10" : "tw-bg-iron-900/40"; - let hoverStyle = ""; - if (canReact) { - hoverStyle = selected - ? "hover:tw-border-primary-500 hover:tw-bg-primary-500/10" - : "hover:tw-border-iron-500 hover:tw-bg-iron-900/40"; - } - let animationStyle = ""; - if (animate) { - if (selected) { - animationStyle = styles["reactionSlideUp"]!; - } else { - animationStyle = styles["reactionSlideDown"]!; - } - } - - let tooltipContent: React.ReactNode; - if (isDetailsLoading && tooltipProfiles.displayProfiles.length === 0) { - tooltipContent = ( - Loading reactions... - ); - } else if (tooltipProfiles.displayProfiles.length > 0) { - tooltipContent = ( - - by{" "} - {tooltipProfiles.displayProfiles.map((profile, index) => { - const displayName = profile.handle ?? profile.id; - const isLast = index === tooltipProfiles.displayProfiles.length - 1; - const showComma = !isLast; - - return ( - - {profile.handle ? ( - e.stopPropagation()} - > - {displayName} - - ) : ( - {displayName} - )} - {showComma && ", "} - - ); - })} - {tooltipProfiles.moreCount > 0 && ( - <> - {" "} - - - )} - - ); - } else { - tooltipContent = ( - - {formatLargeNumber(total)} {total === 1 ? "reaction" : "reactions"} - - ); - } + const { animationStyle, bgStyle, borderStyle, hoverStyle } = + getReactionClassNames({ animate, canReact, selected }); + const tooltipContent = ( + + ); if (!emojiNode || total === 0) return null; return ( diff --git a/components/waves/header/options/mute/WaveMute.tsx b/components/waves/header/options/mute/WaveMute.tsx index 3080361665..103e7f3271 100644 --- a/components/waves/header/options/mute/WaveMute.tsx +++ b/components/waves/header/options/mute/WaveMute.tsx @@ -34,9 +34,11 @@ export default function WaveMute({ queryClient.invalidateQueries({ queryKey: [QueryKey.WAVES_OVERVIEW], }); - void queryClient.invalidateQueries({ - queryKey: [QueryKey.WAVES_V2], - }); + queryClient + .invalidateQueries({ + queryKey: [QueryKey.WAVES_V2], + }) + .catch(() => undefined); onSuccess?.(); } catch (error) { const defaultMessage = isMuted diff --git a/contexts/wave/utils/wave-messages-utils.ts b/contexts/wave/utils/wave-messages-utils.ts index 08833f723c..91980d748d 100644 --- a/contexts/wave/utils/wave-messages-utils.ts +++ b/contexts/wave/utils/wave-messages-utils.ts @@ -28,7 +28,7 @@ export async function fetchWaveMessages( limit: WAVE_DROPS_PARAMS.limit, serialNoLimit: serialNo, searchStrategy: - serialNo !== null ? ApiDropSearchStrategy.Older : undefined, + serialNo === null ? undefined : ApiDropSearchStrategy.Older, signal, }); @@ -316,7 +316,7 @@ export async function fetchNewestWaveMessages( limit, serialNoLimit: sinceSerialNo, searchStrategy: - sinceSerialNo !== null ? ApiDropSearchStrategy.Newer : undefined, + sinceSerialNo === null ? undefined : ApiDropSearchStrategy.Newer, signal, withRetry: true, }); diff --git a/hooks/useWavesV2.ts b/hooks/useWavesV2.ts index af51e66590..e6a6e1ed6d 100644 --- a/hooks/useWavesV2.ts +++ b/hooks/useWavesV2.ts @@ -77,11 +77,11 @@ export const useWavesV2 = ({ getNextPageParam: (lastPage) => (lastPage.next ? lastPage.page + 1 : null), placeholderData: (previousData, previousQuery) => { const previousParams = - previousQuery !== undefined - ? (previousQuery.queryKey[1] as + previousQuery === undefined + ? undefined + : (previousQuery.queryKey[1] as | { viewer_identity?: string | null } - | undefined) - : undefined; + | undefined); const previousViewerIdentity = typeof previousParams?.viewer_identity === "string" ? previousParams.viewer_identity @@ -110,11 +110,11 @@ export const useWavesV2 = ({ Date.now() - lastErrorTimestamp < 30000 ) { setTimeout(() => { - void query.fetchNextPage(); + query.fetchNextPage().catch(() => undefined); }, 30000); return; } - void query.fetchNextPage(); + query.fetchNextPage().catch(() => undefined); }, [lastErrorTimestamp, query]); const refetch = useCallback(() => { @@ -123,11 +123,11 @@ export const useWavesV2 = ({ Date.now() - lastErrorTimestamp < 30000 ) { setTimeout(() => { - void query.refetch(); + query.refetch().catch(() => undefined); }, 30000); return; } - void query.refetch(); + query.refetch().catch(() => undefined); }, [lastErrorTimestamp, query]); return useMemo( diff --git a/services/api/drop-api.ts b/services/api/drop-api.ts index dc2e7dc214..a598cd93ac 100644 --- a/services/api/drop-api.ts +++ b/services/api/drop-api.ts @@ -40,19 +40,65 @@ export const orderDropsByIds = ( .filter((drop): drop is ApiDrop => !!drop); }; -export const fetchDropsByIds = async ( +type DropFetchResult = + | { + readonly dropId: string; + readonly status: "fulfilled"; + readonly drop: ApiDrop; + } + | { + readonly dropId: string; + readonly status: "rejected"; + readonly error: unknown; + }; + +type FulfilledDropFetchResult = Extract< + DropFetchResult, + { readonly status: "fulfilled" } +>; + +const fetchDropResultsByIds = async ( dropIds: readonly string[] -): Promise => { +): Promise => { const uniqueDropIds = getUniqueDropIds(dropIds); if (uniqueDropIds.length === 0) { return []; } - const drops = await Promise.all( + const results = await Promise.allSettled( uniqueDropIds.map((dropId) => fetchDropV2ById(dropId)) ); - return orderDropsByIds(uniqueDropIds, drops); + return results.map((result, index) => { + const dropId = uniqueDropIds[index]!; + if (result.status === "fulfilled") { + return { + dropId, + status: "fulfilled", + drop: result.value, + }; + } + + return { + dropId, + status: "rejected", + error: result.reason as unknown, + }; + }); +}; + +export const fetchDropsByIds = async ( + dropIds: readonly string[] +): Promise => { + const results = await fetchDropResultsByIds(dropIds); + const drops = results + .filter( + (result): result is FulfilledDropFetchResult => + result.status === "fulfilled" + ) + .map((result) => result.drop); + + return orderDropsByIds(dropIds, drops); }; export const seedDropCache = ( @@ -80,18 +126,15 @@ const flushPendingDropRequests = async () => { const dropIds = [...currentRequests.keys()]; try { - const drops = await fetchDropsByIds(dropIds); - const dropsById = new Map(drops.map((drop) => [drop.id, drop])); - - for (const dropId of dropIds) { - const drop = dropsById.get(dropId); - const requests = currentRequests.get(dropId) ?? []; - if (!drop) { - const error = new Error(`Drop ${dropId} not found`); - requests.forEach((request) => request.reject(error)); + const results = await fetchDropResultsByIds(dropIds); + + for (const result of results) { + const requests = currentRequests.get(result.dropId) ?? []; + if (result.status === "rejected") { + requests.forEach((request) => request.reject(result.error)); continue; } - requests.forEach((request) => request.resolve(drop)); + requests.forEach((request) => request.resolve(result.drop)); } } catch (error) { currentRequests.forEach((requests) => { diff --git a/services/api/notifications-v2-api.ts b/services/api/notifications-v2-api.ts index f215671e12..40a212ca3c 100644 --- a/services/api/notifications-v2-api.ts +++ b/services/api/notifications-v2-api.ts @@ -25,6 +25,10 @@ type NotificationWaveMin = ApiWaveMin & { readonly is_direct_message?: boolean; }; +const knownNotificationCauses = new Set( + Object.values(ApiNotificationCause) +); + type FetchNotificationsV2Params = { readonly limit: string; readonly cause?: ApiNotificationCause[] | null | undefined; @@ -43,6 +47,13 @@ const mapWaveOverviewToNotificationWaveMin = ( is_direct_message: wave.is_dm_wave, }); +const mapWaveOverviewToNotificationRelatedWave = ( + wave: ApiWaveOverview +): ApiWaveOverview & NotificationWaveMin => ({ + ...wave, + ...mapWaveOverviewToNotificationWaveMin(wave), +}); + const mapDropV2ToApiDrop = ({ drop, wave, @@ -149,6 +160,17 @@ const mapDropReactedNotification = ( })); }; +const handleUnknownNotificationCause = ( + notification: ApiNotificationV2 +): TypedNotification[] => { + const cause = String(notification.cause); + const knownCauses = [...knownNotificationCauses].join(", "); + console.error( + `Unsupported notification cause "${cause}". Known ApiNotificationCause values: ${knownCauses}` + ); + return []; +}; + const mapNotificationV2 = ( notification: ApiNotificationV2 ): TypedNotification[] => { @@ -251,7 +273,11 @@ const mapNotificationV2 = ( ...base, cause: ApiNotificationCause.WaveCreated, ...(notification.related_wave - ? { related_wave: notification.related_wave } + ? { + related_wave: mapWaveOverviewToNotificationRelatedWave( + notification.related_wave + ), + } : {}), additional_context: { wave_id: @@ -281,6 +307,8 @@ const mapNotificationV2 = ( additional_context: { ...context }, }, ]; + default: + return handleUnknownNotificationCause(notification); } }; diff --git a/services/api/quorum-participation-drop-preview-v2-api.ts b/services/api/quorum-participation-drop-preview-v2-api.ts index 548d2506ce..677feb6d87 100644 --- a/services/api/quorum-participation-drop-preview-v2-api.ts +++ b/services/api/quorum-participation-drop-preview-v2-api.ts @@ -37,5 +37,5 @@ export async function fetchQuorumParticipationDropPreviewBySerialNoV2({ return { ...mapLeaderboardDropV2({ drop, wave }), wave, - } as ApiDrop; + }; } diff --git a/services/api/wave-drops-v2-api.ts b/services/api/wave-drops-v2-api.ts index c6c5dcd4d9..bbae88f1b2 100644 --- a/services/api/wave-drops-v2-api.ts +++ b/services/api/wave-drops-v2-api.ts @@ -96,6 +96,32 @@ export type ApiWaveDropsV2PageFeed = ApiWaveDropsFeed & { const getDropEndpointId = (dropId: string): string => encodeURIComponent(dropId); +const isAbortFetchError = (error: unknown): boolean => { + if (error instanceof DOMException && error.name === "AbortError") { + return true; + } + + if (error instanceof Error && error.name === "AbortError") { + return true; + } + + const maybeAbortError = error as + | { readonly code?: unknown; readonly name?: unknown } + | null + | undefined; + + return ( + maybeAbortError?.name === "AbortError" || + maybeAbortError?.code === "ERR_CANCELED" + ); +}; + +const rethrowAbortFetchError = (error: unknown) => { + if (isAbortFetchError(error)) { + throw error; + } +}; + const fetchDropPartV2 = async ({ dropId, partNo, @@ -110,7 +136,8 @@ const fetchDropPartV2 = async ({ endpoint: `v2/drops/${getDropEndpointId(dropId)}/parts/${partNo}`, signal, }); - } catch { + } catch (error) { + rethrowAbortFetchError(error); return null; } }; @@ -159,7 +186,8 @@ export const fetchDropReactionDetailsV2 = async ( reaction: reaction.reaction, profiles: reaction.reactors.map(mapIdentityOverviewToProfileMin), })); - } catch { + } catch (error) { + rethrowAbortFetchError(error); return []; } }; @@ -177,7 +205,8 @@ const fetchDropMetadataV2 = async ( endpoint: `v2/drops/${getDropEndpointId(drop.id)}/metadata`, signal, }); - } catch { + } catch (error) { + rethrowAbortFetchError(error); return []; } }; @@ -206,7 +235,8 @@ const fetchTopRatersV2 = async ( profile: mapIdentityOverviewToProfileMin(voter.voter), rating: voter.vote, })); - } catch { + } catch (error) { + rethrowAbortFetchError(error); return []; } }; @@ -417,7 +447,7 @@ export async function fetchWaveDropsFeedV2({ return { wave, drops, - } as unknown as ApiWaveDropsFeed; + }; } export async function fetchWaveLeaderboardV2({ @@ -519,7 +549,7 @@ export async function fetchDropRepliesV2({ count: response.count, page: response.page, next: response.next, - } as unknown as ApiWaveDropsV2PageFeed; + }; } export async function fetchBoostedDropsV2({ diff --git a/services/api/waves-v2-api.ts b/services/api/waves-v2-api.ts index 9e42c59b19..a5d6fd7f9c 100644 --- a/services/api/waves-v2-api.ts +++ b/services/api/waves-v2-api.ts @@ -55,8 +55,8 @@ export function getWavesV2OverviewQueryKeyParams({ page_size: pageSize, overview_type: overviewType, only_waves_followed_by_authenticated_user: following, - ...(directMessage !== undefined ? { direct_message: directMessage } : {}), - ...(pinned !== undefined ? { pinned } : {}), + ...(directMessage === undefined ? {} : { direct_message: directMessage }), + ...(pinned === undefined ? {} : { pinned }), ...(normalizedViewerIdentityKey ? { viewer_identity: normalizedViewerIdentityKey } : {}), diff --git a/types/feed.types.ts b/types/feed.types.ts index 06c4d8c049..041e34825a 100644 --- a/types/feed.types.ts +++ b/types/feed.types.ts @@ -6,6 +6,10 @@ import type { ApiProfileMin } from "@/generated/models/ApiProfileMin"; import type { ApiWave } from "@/generated/models/ApiWave"; import type { ApiWaveOverview } from "@/generated/models/ApiWaveOverview"; +type NotificationWaveOverview = ApiWaveOverview & { + readonly is_direct_message?: boolean; +}; + type IFeedItemWaveCreated = { readonly serial_no: number; readonly item: ApiWave; @@ -119,7 +123,7 @@ export type INotificationDropReplied = NotificationBase & export type INotificationWaveCreated = NotificationBase & { readonly cause: ApiNotificationCause.WaveCreated; - readonly related_wave?: ApiWaveOverview; + readonly related_wave?: NotificationWaveOverview; readonly additional_context: { readonly wave_id: string; }; From 89f6a029cc1f9f3749b377285c8a9de1b9136dda Mon Sep 17 00:00:00 2001 From: GelatoGenesis Date: Mon, 11 May 2026 14:31:56 +0300 Subject: [PATCH 5/6] wip Signed-off-by: GelatoGenesis --- .../NotificationWaveCreated.test.tsx | 16 +++++ .../waves/drop/useSingleWaveDropData.test.tsx | 4 ++ .../waves/drops/DropAuthorBadges.test.tsx | 40 +++++++++++++ .../services/api/wave-drops-v2-api.test.ts | 37 ++++++++++++ .../wave-created/NotificationWaveCreated.tsx | 35 ++++++----- components/waves/drops/DropAuthorBadges.tsx | 25 ++++++-- helpers/artist-activity.helpers.ts | 31 ++++++++-- services/api/drop-v2-mappers.ts | 58 +++++++++++-------- 8 files changed, 201 insertions(+), 45 deletions(-) diff --git a/__tests__/components/brain/notifications/NotificationWaveCreated.test.tsx b/__tests__/components/brain/notifications/NotificationWaveCreated.test.tsx index 95581516c5..df123e68f5 100644 --- a/__tests__/components/brain/notifications/NotificationWaveCreated.test.tsx +++ b/__tests__/components/brain/notifications/NotificationWaveCreated.test.tsx @@ -56,3 +56,19 @@ it("renders wave data and links", () => { const img = screen.getByRole("img"); expect(img.getAttribute("src")).toContain("scaled.jpg"); }); + +it("renders fallback wave text without a link when wave id is missing", () => { + render( + + ); + + expect(screen.getByText("Unknown wave")).toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "Unknown wave" })).toBeNull(); + expect(screen.queryByTestId("wave-follow")).toBeNull(); +}); diff --git a/__tests__/components/waves/drop/useSingleWaveDropData.test.tsx b/__tests__/components/waves/drop/useSingleWaveDropData.test.tsx index e10f9a2db6..0eeb523500 100644 --- a/__tests__/components/waves/drop/useSingleWaveDropData.test.tsx +++ b/__tests__/components/waves/drop/useSingleWaveDropData.test.tsx @@ -45,6 +45,10 @@ const createDeferred = () => { }; describe("useSingleWaveDropData", () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + it("fetches single-drop detail without eager top raters", async () => { fetchDropV2ByIdMock.mockResolvedValue({ id: "drop-1", diff --git a/__tests__/components/waves/drops/DropAuthorBadges.test.tsx b/__tests__/components/waves/drops/DropAuthorBadges.test.tsx index 03fe0728a2..56dd2efcc0 100644 --- a/__tests__/components/waves/drops/DropAuthorBadges.test.tsx +++ b/__tests__/components/waves/drops/DropAuthorBadges.test.tsx @@ -164,6 +164,46 @@ describe("DropAuthorBadges", () => { expect(screen.queryByTestId("wave-creator-badge")).toBeNull(); }); + it("renders activity and wave creator badges from V2 count-only badges", () => { + const onArtistPreviewOpen = jest.fn(); + + render( + + ); + + const badge = screen.getByTestId("artist-activity-badge"); + expect(badge).toHaveAttribute("data-submission-count", "1"); + expect(badge).toHaveAttribute("data-trophy-count", "1"); + expect(screen.getByTestId("wave-creator-badge")).toBeInTheDocument(); + + fireEvent.click(badge); + + expect(onArtistPreviewOpen).toHaveBeenCalledWith({ + user: expect.objectContaining({ + active_main_stage_submission_ids: [], + artist_of_prevote_cards: [], + badges: { + artist_of_main_stage_submissions: 1, + artist_of_memes: 1, + profile_wave_id: "profile-wave-1", + }, + is_wave_creator: true, + profile_wave_id: "profile-wave-1", + }), + initialTab: "active", + }); + }); + it("renders wave creator badge when profile is wave creator", () => { render( { ]); }); + it("preserves V2 author badges without fabricating legacy artwork ids", async () => { + const badges = { + artist_of_main_stage_submissions: 1, + artist_of_memes: 1, + profile_wave_id: "profile-wave-1", + }; + + commonApiFetchMock.mockResolvedValueOnce({ + wave, + drops: [ + { + ...createDrop(1), + author: { + ...identity, + badges, + }, + }, + ], + }); + + const result = await fetchWaveDropsFeedV2({ + waveId: "wave-1", + limit: 20, + }); + + expect(result.drops[0]?.author).toEqual( + expect.objectContaining({ + active_main_stage_submission_ids: [], + artist_of_prevote_cards: [], + badges, + is_wave_creator: true, + profile_wave_id: "profile-wave-1", + winner_main_stage_drop_ids: [], + }) + ); + }); + it("fetches only additional parts for multi-part drops", async () => { commonApiFetchMock .mockResolvedValueOnce({ diff --git a/components/brain/notifications/wave-created/NotificationWaveCreated.tsx b/components/brain/notifications/wave-created/NotificationWaveCreated.tsx index c48da25549..399d06f434 100644 --- a/components/brain/notifications/wave-created/NotificationWaveCreated.tsx +++ b/components/brain/notifications/wave-created/NotificationWaveCreated.tsx @@ -15,13 +15,16 @@ export default function NotificationWaveCreated({ }) { const { isApp } = useDeviceInfo(); const wave = notification.related_wave; - const waveId = wave?.id ?? notification.additional_context.wave_id; - const invitationHref = getWaveRoute({ - waveId, - isDirectMessage: wave?.is_direct_message ?? wave?.is_dm_wave ?? false, - isApp, - }); - const waveName = wave?.name ?? waveId; + const contextWaveId = notification.additional_context.wave_id || undefined; + const waveId = wave?.id ?? contextWaveId; + const invitationHref = waveId + ? getWaveRoute({ + waveId, + isDirectMessage: wave?.is_direct_message ?? wave?.is_dm_wave ?? false, + isApp, + }) + : null; + const waveName = wave?.name ?? waveId ?? "Unknown wave"; return (
@@ -45,12 +48,18 @@ export default function NotificationWaveCreated({ invited you to a wave: - - {waveName} - + {invitationHref ? ( + + {waveName} + + ) : ( + + {waveName} + + )}
diff --git a/components/waves/drops/DropAuthorBadges.tsx b/components/waves/drops/DropAuthorBadges.tsx index ccab7ede99..077f5084cc 100644 --- a/components/waves/drops/DropAuthorBadges.tsx +++ b/components/waves/drops/DropAuthorBadges.tsx @@ -39,10 +39,19 @@ interface DropAuthorBadgesProfile { readonly artist_of_prevote_cards?: readonly number[] | null; readonly profile_wave_id?: string | null; readonly is_wave_creator?: boolean | null; + readonly badges?: { + readonly artist_of_main_stage_submissions?: number | null; + readonly artist_of_memes?: number | null; + readonly profile_wave_id?: string | null; + } | null; readonly classification: ApiProfileClassification; readonly sub_classification: string | null; } +type ApiProfileMinWithAuthorBadges = ApiProfileMin & { + readonly badges?: DropAuthorBadgesProfile["badges"]; +}; + interface DropAuthorBadgesProps { readonly profile: DropAuthorBadgesProfile; readonly tooltipIdPrefix?: string | undefined; @@ -61,10 +70,16 @@ interface DropAuthorBadgesProps { const DEFAULT_CONTAINER_CLASS = "tw-inline-flex tw-items-center tw-gap-x-1.5"; -const toApiProfileMin = (profile: DropAuthorBadgesProfile): ApiProfileMin => { +const getProfileWaveId = (profile: DropAuthorBadgesProfile): string | null => + profile.profile_wave_id ?? profile.badges?.profile_wave_id ?? null; + +const toApiProfileMin = ( + profile: DropAuthorBadgesProfile +): ApiProfileMinWithAuthorBadges => { const primaryAddress = profile.primary_address ?? profile.primary_wallet ?? ""; const fallbackId = primaryAddress; + const profileWaveId = getProfileWaveId(profile); return { id: profile.id ?? fallbackId, @@ -97,10 +112,11 @@ const toApiProfileMin = (profile: DropAuthorBadgesProfile): ApiProfileMin => { profile.artist_of_prevote_cards !== undefined ? [...profile.artist_of_prevote_cards] : [], - profile_wave_id: profile.profile_wave_id ?? null, - is_wave_creator: profile.is_wave_creator === true, + profile_wave_id: profileWaveId, + is_wave_creator: profile.is_wave_creator === true || profileWaveId !== null, classification: profile.classification, sub_classification: profile.sub_classification, + badges: profile.badges, }; }; @@ -115,7 +131,8 @@ export const DropAuthorBadges: React.FC = ({ const submissionCount = getSubmissionCount(profile); const trophyCount = getTrophyArtworkCount(profile); const hasActivityBadge = submissionCount > 0 || trophyCount > 0; - const isWaveCreator = profile.is_wave_creator === true; + const isWaveCreator = + profile.is_wave_creator === true || getProfileWaveId(profile) !== null; const modalUser = React.useMemo(() => toApiProfileMin(profile), [profile]); diff --git a/helpers/artist-activity.helpers.ts b/helpers/artist-activity.helpers.ts index ad9d336e86..8b05cd4d4a 100644 --- a/helpers/artist-activity.helpers.ts +++ b/helpers/artist-activity.helpers.ts @@ -2,18 +2,41 @@ interface ArtistActivityProfileLike { readonly active_main_stage_submission_ids?: readonly string[] | null; readonly winner_main_stage_drop_ids?: readonly string[] | null; readonly artist_of_prevote_cards?: readonly number[] | null; + readonly badges?: { + readonly artist_of_main_stage_submissions?: number | null; + readonly artist_of_memes?: number | null; + } | null; } +const getNonNegativeCount = (count: number | null | undefined): number => { + if (typeof count !== "number" || !Number.isFinite(count)) { + return 0; + } + return Math.max(0, count); +}; + +const getArrayCount = ( + items: readonly string[] | readonly number[] | null | undefined +): number => items?.length ?? 0; + export const getSubmissionCount = ( profile: ArtistActivityProfileLike -): number => profile.active_main_stage_submission_ids?.length ?? 0; +): number => + Math.max( + getArrayCount(profile.active_main_stage_submission_ids), + getNonNegativeCount(profile.badges?.artist_of_main_stage_submissions) + ); const getWinnerCount = (profile: ArtistActivityProfileLike): number => - profile.winner_main_stage_drop_ids?.length ?? 0; + getArrayCount(profile.winner_main_stage_drop_ids); const getPrevoteArtistCount = (profile: ArtistActivityProfileLike): number => - profile.artist_of_prevote_cards?.length ?? 0; + getArrayCount(profile.artist_of_prevote_cards); export const getTrophyArtworkCount = ( profile: ArtistActivityProfileLike -): number => getWinnerCount(profile) + getPrevoteArtistCount(profile); +): number => + Math.max( + getWinnerCount(profile) + getPrevoteArtistCount(profile), + getNonNegativeCount(profile.badges?.artist_of_memes) + ); diff --git a/services/api/drop-v2-mappers.ts b/services/api/drop-v2-mappers.ts index 3e2bc3570e..17bc3e5df1 100644 --- a/services/api/drop-v2-mappers.ts +++ b/services/api/drop-v2-mappers.ts @@ -6,6 +6,7 @@ import type { ApiDropWithoutWave } from "@/generated/models/ApiDropWithoutWave"; import { ApiDropType } from "@/generated/models/ApiDropType"; import type { ApiDropV2 } from "@/generated/models/ApiDropV2"; import type { ApiIdentityOverview } from "@/generated/models/ApiIdentityOverview"; +import type { ApiIdentityOverviewBadges } from "@/generated/models/ApiIdentityOverviewBadges"; import type { ApiMentionedWave } from "@/generated/models/ApiMentionedWave"; import { ApiProfileClassification } from "@/generated/models/ApiProfileClassification"; import type { ApiProfileMin } from "@/generated/models/ApiProfileMin"; @@ -17,32 +18,41 @@ import type { ApiWaveMin } from "@/generated/models/ApiWaveMin"; import type { ApiWaveOverview } from "@/generated/models/ApiWaveOverview"; import { toApiWaveMin } from "@/helpers/waves/wave.helpers"; +type ApiProfileMinWithBadges = ApiProfileMin & { + readonly badges?: ApiIdentityOverviewBadges; +}; + export const mapIdentityOverviewToProfileMin = ( identity: ApiIdentityOverview -): ApiProfileMin => ({ - id: identity.id, - handle: identity.handle ?? null, - pfp: identity.pfp ?? null, - banner1_color: null, - banner2_color: null, - cic: 0, - rep: 0, - tdh: 0, - tdh_rate: 0, - xtdh: 0, - xtdh_rate: 0, - level: identity.level, - classification: identity.classification, - sub_classification: null, - primary_address: identity.primary_address, - subscribed_actions: [], - archived: false, - active_main_stage_submission_ids: [], - winner_main_stage_drop_ids: [], - artist_of_prevote_cards: [], - profile_wave_id: null, - is_wave_creator: false, -}); +): ApiProfileMinWithBadges => { + const profileWaveId = identity.badges.profile_wave_id ?? null; + + return { + id: identity.id, + handle: identity.handle ?? null, + pfp: identity.pfp ?? null, + banner1_color: null, + banner2_color: null, + cic: 0, + rep: 0, + tdh: 0, + tdh_rate: 0, + xtdh: 0, + xtdh_rate: 0, + level: identity.level, + classification: identity.classification, + sub_classification: null, + primary_address: identity.primary_address, + subscribed_actions: [], + archived: false, + active_main_stage_submission_ids: [], + winner_main_stage_drop_ids: [], + artist_of_prevote_cards: [], + profile_wave_id: profileWaveId, + is_wave_creator: profileWaveId !== null, + badges: identity.badges, + }; +}; export const mapApiWaveOverviewToApiWaveMin = ( wave: ApiWaveOverview From f6e43362990c3bebf5e4591e0d1db95c86e2e68a Mon Sep 17 00:00:00 2001 From: GelatoGenesis Date: Mon, 11 May 2026 15:20:06 +0300 Subject: [PATCH 6/6] Usage of v2/curated-profile-wave-drops Signed-off-by: GelatoGenesis --- .../hooks/useCommunityCurationsDrops.test.ts | 91 ++++++++++++++++++- components/waves/drops/CurationDropFooter.tsx | 3 +- hooks/useCommunityCurationsDrops.ts | 27 +++++- openapi.yaml | 85 +++++++++++++++-- services/api/drop-v2-mappers.ts | 2 +- 5 files changed, 188 insertions(+), 20 deletions(-) diff --git a/__tests__/hooks/useCommunityCurationsDrops.test.ts b/__tests__/hooks/useCommunityCurationsDrops.test.ts index 291014dd02..5c27c258df 100644 --- a/__tests__/hooks/useCommunityCurationsDrops.test.ts +++ b/__tests__/hooks/useCommunityCurationsDrops.test.ts @@ -1,15 +1,24 @@ import { renderHook } from "@testing-library/react"; -import type { ApiCuratedProfileWaveDropsPage } from "@/generated/models/ApiCuratedProfileWaveDropsPage"; import type { ApiDrop } from "@/generated/models/ApiDrop"; +import { ApiDropMainType } from "@/generated/models/ApiDropMainType"; +import type { ApiDropV2 } from "@/generated/models/ApiDropV2"; +import type { ApiDropV2PageWithoutCount } from "@/generated/models/ApiDropV2PageWithoutCount"; +import { ApiProfileClassification } from "@/generated/models/ApiProfileClassification"; import { useCommunityCurationsDrops } from "@/hooks/useCommunityCurationsDrops"; import { commonApiFetch } from "@/services/api/common-api"; +type CommunityCurationsDropsPage = { + readonly data: ApiDrop[]; + readonly page: number; + readonly next: boolean; +}; + type InfiniteQueryOptions = { readonly queryFn: (context: { readonly pageParam: number; }) => Promise; readonly getNextPageParam: ( - lastPage: ApiCuratedProfileWaveDropsPage + lastPage: CommunityCurationsDropsPage ) => number | undefined; readonly enabled?: boolean; readonly initialPageParam?: number; @@ -32,7 +41,7 @@ const commonApiFetchMock = commonApiFetch as jest.MockedFunction< >; const getDefaultQueryResult = ( - pages: ApiCuratedProfileWaveDropsPage[] | undefined = undefined + pages: CommunityCurationsDropsPage[] | undefined = undefined ) => ({ data: pages ? { pages } : undefined, fetchNextPage: jest.fn(), @@ -50,6 +59,47 @@ const buildDrop = ({ id }: { readonly id: string }): ApiDrop => parts: [], }) as unknown as ApiDrop; +const buildDropV2 = ({ id }: { readonly id: string }): ApiDropV2 => + ({ + id, + serial_no: 1, + created_at: 1000, + updated_at: null, + is_signed: false, + hide_link_preview: false, + title: "Curated drop", + content: "Part 1", + media: [], + attachments: [], + parts_count: 1, + author: { + id: "author-id", + handle: "artist", + primary_address: "0xauthor", + pfp: "author.png", + level: 1, + classification: ApiProfileClassification.Pseudonym, + badges: { + artist_of_main_stage_submissions: 0, + artist_of_memes: 0, + profile_wave_id: "profile-wave-1", + }, + }, + drop_type: ApiDropMainType.Chat, + referenced_nfts: [], + mentioned_users: [], + mentioned_groups: [], + mentioned_waves: [], + nft_links: [], + reactions: [{ reaction: "👍", count: 2 }], + boosts: 0, + context_profile_context: { + reaction: null, + boosted: false, + bookmarked: false, + }, + }) as unknown as ApiDropV2; + describe("useCommunityCurationsDrops", () => { beforeEach(() => { jest.clearAllMocks(); @@ -68,15 +118,46 @@ describe("useCommunityCurationsDrops", () => { expect(queryOptions).not.toBeNull(); expect(queryOptions?.initialPageParam).toBe(1); - await queryOptions?.queryFn({ pageParam: 2 }); + commonApiFetchMock.mockResolvedValueOnce({ + data: [buildDropV2({ id: "curated-drop-1" })], + page: 2, + next: true, + } as ApiDropV2PageWithoutCount); + + const response = await queryOptions?.queryFn({ pageParam: 2 }); expect(commonApiFetchMock).toHaveBeenCalledWith({ - endpoint: "curated-profile-wave-drops", + endpoint: "v2/curated-profile-wave-drops", params: { page: "2", page_size: "12", }, }); + expect(response).toEqual({ + data: [ + expect.objectContaining({ + id: "curated-drop-1", + parts: [ + expect.objectContaining({ + content: "Part 1", + part_id: 1, + }), + ], + reactions: [ + expect.objectContaining({ + count: 2, + profiles: [], + reaction: "👍", + }), + ], + wave: expect.objectContaining({ + id: "profile-wave-1", + }), + }), + ], + page: 2, + next: true, + }); expect( queryOptions?.getNextPageParam({ data: [], diff --git a/components/waves/drops/CurationDropFooter.tsx b/components/waves/drops/CurationDropFooter.tsx index 5c7bb01a44..d1369d2d65 100644 --- a/components/waves/drops/CurationDropFooter.tsx +++ b/components/waves/drops/CurationDropFooter.tsx @@ -5,6 +5,7 @@ import clsx from "clsx"; import WaveDropActionsAddReaction from "./WaveDropActionsAddReaction"; import WaveDropActionsCopyLink from "./WaveDropActionsCopyLink"; import WaveDropReactions from "./WaveDropReactions"; +import { getReactionCount } from "./reaction-utils"; interface CurationDropFooterProps { readonly drop: ExtendedDrop; @@ -16,7 +17,7 @@ export default function CurationDropFooter({ className, }: CurationDropFooterProps) { const hasVisibleReactions = drop.reactions.some( - (reaction) => reaction.profiles.length > 0 + (reaction) => getReactionCount(reaction) > 0 ); return ( diff --git a/hooks/useCommunityCurationsDrops.ts b/hooks/useCommunityCurationsDrops.ts index 9a818cac00..1385cf66aa 100644 --- a/hooks/useCommunityCurationsDrops.ts +++ b/hooks/useCommunityCurationsDrops.ts @@ -1,8 +1,12 @@ "use client"; import type { ApiDrop } from "@/generated/models/ApiDrop"; +import type { ApiDropV2 } from "@/generated/models/ApiDropV2"; +import type { ApiDropV2PageWithoutCount } from "@/generated/models/ApiDropV2PageWithoutCount"; import { DropSize, type ExtendedDrop } from "@/helpers/waves/drop.helpers"; import { commonApiFetch } from "@/services/api/common-api"; +import { createFallbackWaveMin } from "@/services/api/drop-v2-mappers"; +import { mapLeaderboardDropV2 } from "@/services/api/wave-drops-v2-api"; import { useInfiniteQuery } from "@tanstack/react-query"; import { useMemo } from "react"; @@ -47,21 +51,34 @@ const getUniqueDrops = ( return drops; }; +const mapCommunityCurationDropV2 = (drop: ApiDropV2): ApiDrop => { + const wave = createFallbackWaveMin(drop.author.badges.profile_wave_id ?? ""); + const mappedDrop = mapLeaderboardDropV2({ drop, wave }); + + return { + ...mappedDrop, + wave, + }; +}; + const fetchCommunityCurationsDrops = ({ limit, page, }: { readonly limit: number; readonly page: number; -}): Promise => { - return commonApiFetch({ - endpoint: "curated-profile-wave-drops", +}): Promise => + commonApiFetch({ + endpoint: "v2/curated-profile-wave-drops", params: { page: `${page}`, page_size: `${limit}`, }, - }); -}; + }).then((response) => ({ + data: response.data.map(mapCommunityCurationDropV2), + page: response.page, + next: response.next, + })); export function useCommunityCurationsDrops({ limit, diff --git a/openapi.yaml b/openapi.yaml index 89b7e11833..dff9c21f39 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -996,11 +996,13 @@ paths: tags: - Drops summary: Get curated profile wave drops + deprecated: true description: >- - Returns drops from effective profile-wave curations across all public - profile waves, newest drops first. If a profile wave has an explicit - profile curation it is used; otherwise the oldest curation in the - profile wave is used. Reply drops are included when curated. + Deprecated. Use GET /v2/curated-profile-wave-drops instead. Returns + drops from effective profile-wave curations across all public profile + waves, newest drops first. If a profile wave has an explicit profile + curation it is used; otherwise the oldest curation in the profile wave + is used. Reply drops are included when curated. operationId: getCuratedProfileWaveDrops parameters: - name: page @@ -1032,6 +1034,8 @@ paths: tags: - Drops summary: Get latest drops. + deprecated: true + description: Deprecated. Use GET /v2/drops instead. operationId: getLatestDrops parameters: - name: limit @@ -1197,6 +1201,8 @@ paths: tags: - Drops summary: Get boosted drops. + deprecated: true + description: Deprecated. Use GET /v2/boosted-drops instead. operationId: getBoostedDrops parameters: - name: author @@ -1277,6 +1283,8 @@ paths: tags: - Drops summary: Get drop boosts by Drop ID. + deprecated: true + description: Deprecated. Use GET /v2/drops/{id}/boosts instead. operationId: getDropBoostsById parameters: - name: dropId @@ -1450,6 +1458,8 @@ paths: tags: - Drops summary: Get drop by ID. + deprecated: true + description: Deprecated. Use GET /v2/drops/{id} instead. operationId: getDropById parameters: - name: dropId @@ -2076,6 +2086,42 @@ paths: application/json: schema: $ref: "#/components/schemas/ApiDropV2Page" + /v2/curated-profile-wave-drops: + get: + tags: + - DropsV2 + summary: Get V2 curated profile wave drops + description: >- + Returns V2 drops from effective profile-wave curations across all public + profile waves, newest drops first. If a profile wave has an explicit + profile curation it is used; otherwise the oldest curation in the + profile wave is used. Reply drops are included when curated. + operationId: getCuratedProfileWaveDropsV2 + parameters: + - name: page + in: query + required: false + schema: + type: integer + format: int64 + minimum: 1 + default: 1 + - name: page_size + in: query + required: false + schema: + type: integer + format: int64 + minimum: 1 + maximum: 2000 + default: 50 + responses: + "200": + description: successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/ApiDropV2PageWithoutCount" /v2/drops: get: tags: @@ -3388,6 +3434,8 @@ paths: tags: - Notifications summary: Get notifications for authenticated user. + deprecated: true + description: Deprecated. Use GET /v2/notifications instead. operationId: getNotifications parameters: - name: limit @@ -5756,6 +5804,8 @@ paths: tags: - Waves summary: Get already decided wave decision outcomes + deprecated: true + description: Deprecated. Use GET /v2/waves/{id}/decisions instead. operationId: getWaveDecisions parameters: - name: id @@ -5871,6 +5921,8 @@ paths: tags: - Waves summary: Search for drops in wave by content + deprecated: true + description: Deprecated. Use GET /v2/waves/{waveId}/search instead. operationId: searchDropsInWave parameters: - name: waveId @@ -5936,6 +5988,8 @@ paths: tags: - Waves summary: Get overview of waves by different criteria. + deprecated: true + description: Deprecated. Use GET /v2/waves?view=OVERVIEW instead. operationId: getWavesOverview parameters: - name: limit @@ -5996,10 +6050,12 @@ paths: tags: - Waves summary: Get waves where a profile has written the most messages. + deprecated: true description: >- - Returns non-DM waves ranked by how many chat messages the resolved - profile has written in them. Results only include waves the requester - can access. + Deprecated. Use GET /v2/waves?view=FAVOURITES&identity={identityKey} + instead. Returns non-DM waves ranked by how many chat messages the + resolved profile has written in them. Results only include waves the + requester can access. operationId: getFavouriteWavesOfIdentity parameters: - name: identityKey @@ -6040,7 +6096,10 @@ paths: tags: - Waves summary: Get hot waves overview. - description: Returns up to 25 public waves ranked by activity in the last 24 hours. + deprecated: true + description: >- + Deprecated. Use GET /v2/waves?view=HOT instead. Returns up to 25 public + waves ranked by activity in the last 24 hours. operationId: getHotWavesOverview parameters: - name: exclude_followed @@ -6065,6 +6124,8 @@ paths: tags: - Waves summary: Get waves. + deprecated: true + description: Deprecated. Use GET /v2/waves?view=SEARCH instead. operationId: getWaves parameters: - name: name @@ -6233,6 +6294,8 @@ paths: tags: - Waves summary: Get drops related to wave or parent drop + deprecated: true + description: Deprecated. Use GET /v2/waves/{id}/drops instead. operationId: getDropsOfWave parameters: - name: id @@ -6449,6 +6512,8 @@ paths: tags: - Waves summary: Get waves leaderboard + deprecated: true + description: Deprecated. Use GET /v2/waves/{id}/leaderboard instead. operationId: getWaveLeaderboard parameters: - name: id @@ -6595,6 +6660,10 @@ paths: tags: - Waves summary: List drops in a curation for wave + deprecated: true + description: >- + Deprecated. Use GET /v2/waves/{id}/curations/{curation_id}/drops + instead. operationId: listWaveCurationDrops parameters: - name: id diff --git a/services/api/drop-v2-mappers.ts b/services/api/drop-v2-mappers.ts index 17bc3e5df1..ee2bc14104 100644 --- a/services/api/drop-v2-mappers.ts +++ b/services/api/drop-v2-mappers.ts @@ -83,7 +83,7 @@ export const mapApiWaveOverviewToApiWaveMin = ( identity_wave: false, }); -const createFallbackWaveMin = (waveId: string): ApiWaveMin => ({ +export const createFallbackWaveMin = (waveId: string): ApiWaveMin => ({ id: waveId, name: waveId, picture: null,