diff --git a/__tests__/components/user/brain/UserPageBrainSidebar.test.tsx b/__tests__/components/user/brain/UserPageBrainSidebar.test.tsx new file mode 100644 index 0000000000..d8ccc1a6f3 --- /dev/null +++ b/__tests__/components/user/brain/UserPageBrainSidebar.test.tsx @@ -0,0 +1,323 @@ +import { fireEvent, render, screen, within } from "@testing-library/react"; +import UserPageBrainSidebar from "@/components/user/brain/UserPageBrainSidebar"; +import { useFavouriteWavesOfIdentity } from "@/hooks/useFavouriteWavesOfIdentity"; +import { useWaves } from "@/hooks/useWaves"; + +jest.mock("next/image", () => ({ + __esModule: true, + default: ({ alt, fill, unoptimized, ...props }: any) => ( + {alt + ), +})); +jest.mock("next/link", () => ({ + __esModule: true, + default: ({ children, href, prefetch, ...props }: any) => ( + + {children} + + ), +})); +jest.mock("@/hooks/useWaves", () => ({ + useWaves: jest.fn(), +})); +jest.mock("@/hooks/useFavouriteWavesOfIdentity", () => ({ + useFavouriteWavesOfIdentity: jest.fn(), +})); +jest.mock("@/components/waves/drops/WaveCreatorPreviewModal", () => ({ + WaveCreatorPreviewModal: ({ isOpen, user }: any) => + isOpen ? ( +
+ ) : null, +})); + +const mockedUseWaves = useWaves as jest.MockedFunction; +const mockedUseFavouriteWavesOfIdentity = + useFavouriteWavesOfIdentity as jest.MockedFunction< + typeof useFavouriteWavesOfIdentity + >; + +const baseProfile = { + handle: "kanetix", + display: "Kanetix", + primary_wallet: "0xabc", +} as any; + +const makeWave = (overrides: Record = {}) => + ({ + id: "wave-1", + name: "TDH Name Vote", + picture: null, + contributors_overview: [], + pinned: false, + author: { + handle: null, + banner1_color: null, + banner2_color: null, + }, + visibility: { + scope: { + group: null, + }, + }, + chat: { scope: { group: { is_direct_message: false } } }, + metrics: { + drops_count: 12, + subscribers_count: 25, + latest_drop_timestamp: Date.now(), + }, + ...overrides, + }) as any; + +describe("UserPageBrainSidebar", () => { + beforeEach(() => { + mockedUseWaves.mockReset(); + mockedUseFavouriteWavesOfIdentity.mockReset(); + mockedUseWaves.mockReturnValue({ + waves: [], + isFetching: false, + isFetchingNextPage: false, + hasNextPage: false, + fetchNextPage: jest.fn(), + status: "success", + error: null, + refetch: jest.fn(), + }); + mockedUseFavouriteWavesOfIdentity.mockReturnValue({ + waves: [], + status: "success", + error: null, + refetch: jest.fn(), + isFetching: false, + }); + }); + + it("renders created waves and most active waves", () => { + mockedUseWaves.mockReturnValue({ + waves: [makeWave()], + isFetching: false, + isFetchingNextPage: false, + hasNextPage: false, + fetchNextPage: jest.fn(), + status: "success", + error: null, + refetch: jest.fn(), + }); + mockedUseFavouriteWavesOfIdentity.mockReturnValue({ + waves: [ + makeWave({ + id: "wave-2", + name: "Meme Card Curation", + }), + ], + status: "success", + error: null, + refetch: jest.fn(), + isFetching: false, + }); + + render(); + const desktopSidebar = screen.getByTestId("brain-sidebar-desktop"); + const mobileStrip = screen.getByTestId("brain-sidebar-mobile-strip"); + + expect( + within(desktopSidebar).getByText("Created Waves") + ).toBeInTheDocument(); + expect( + within(desktopSidebar).getByText("TDH Name Vote") + ).toBeInTheDocument(); + expect( + within(desktopSidebar).getByText("Most Active In") + ).toBeInTheDocument(); + expect( + within(desktopSidebar).getByText("Meme Card Curation") + ).toBeInTheDocument(); + expect(within(mobileStrip).getByText("Created")).toBeInTheDocument(); + expect(within(mobileStrip).getByText("Active In")).toBeInTheDocument(); + expect(mockedUseWaves).toHaveBeenCalledTimes(1); + expect(mockedUseWaves).toHaveBeenCalledWith({ + identity: "kanetix", + waveName: null, + enabled: true, + directMessage: false, + limit: 20, + }); + expect(mockedUseFavouriteWavesOfIdentity).toHaveBeenCalledWith({ + identityKey: "kanetix", + limit: 3, + enabled: true, + }); + }); + + it("does not show a pinned star in the sidebar wave rows", () => { + mockedUseFavouriteWavesOfIdentity.mockReturnValue({ + waves: [ + makeWave({ + id: "wave-2", + name: "Pinned Private Wave", + picture: "https://example.com/wave.png", + pinned: true, + visibility: { + scope: { + group: { + id: "group-1", + }, + }, + }, + }), + ], + status: "success", + error: null, + refetch: jest.fn(), + isFetching: false, + }); + + render(); + const desktopSidebar = screen.getByTestId("brain-sidebar-desktop"); + + expect( + within(desktopSidebar).getByText("Pinned Private Wave") + ).toBeInTheDocument(); + expect(screen.queryByTestId("wave-creator-preview-modal")).toBeNull(); + }); + + it("uses the primary wallet when the profile has no handle", () => { + render( + + ); + + expect(mockedUseWaves).toHaveBeenCalledWith({ + identity: "0xdef", + waveName: null, + enabled: true, + directMessage: false, + limit: 20, + }); + expect(mockedUseFavouriteWavesOfIdentity).toHaveBeenCalledWith({ + identityKey: "0xdef", + limit: 3, + enabled: true, + }); + }); + + it("hides the created section when there are no created waves", () => { + mockedUseFavouriteWavesOfIdentity.mockReturnValue({ + waves: [makeWave({ id: "wave-2", name: "Meme Card Curation" })], + status: "success", + error: null, + refetch: jest.fn(), + isFetching: false, + }); + + render(); + const desktopSidebar = screen.getByTestId("brain-sidebar-desktop"); + + expect(within(desktopSidebar).queryByText("Created Waves")).toBeNull(); + expect( + within(desktopSidebar).getByText("Most Active In") + ).toBeInTheDocument(); + expect(mockedUseWaves).toHaveBeenCalledTimes(1); + }); + + it("hides the most active section when there are no favourite waves", () => { + mockedUseWaves.mockReturnValue({ + waves: [makeWave()], + isFetching: false, + isFetchingNextPage: false, + hasNextPage: false, + fetchNextPage: jest.fn(), + status: "success", + error: null, + refetch: jest.fn(), + }); + + render(); + const desktopSidebar = screen.getByTestId("brain-sidebar-desktop"); + + expect( + within(desktopSidebar).getByText("Created Waves") + ).toBeInTheDocument(); + expect(within(desktopSidebar).queryByText("Most Active In")).toBeNull(); + }); + + it("expands and collapses the created waves list", () => { + mockedUseWaves.mockReturnValue({ + waves: [ + makeWave(), + makeWave({ + id: "wave-2", + name: "Meme Card Curation", + metrics: { + drops_count: 8, + subscribers_count: 10, + latest_drop_timestamp: Date.now(), + }, + }), + ], + isFetching: false, + isFetchingNextPage: false, + hasNextPage: false, + fetchNextPage: jest.fn(), + status: "success", + error: null, + refetch: jest.fn(), + }); + + render(); + const desktopSidebar = screen.getByTestId("brain-sidebar-desktop"); + + expect(within(desktopSidebar).getByText("Show 1 more")).toBeInTheDocument(); + expect(within(desktopSidebar).queryByText("Meme Card Curation")).toBeNull(); + + fireEvent.click(within(desktopSidebar).getByText("Show 1 more")); + expect( + within(desktopSidebar).getByText("Meme Card Curation") + ).toBeInTheDocument(); + expect(within(desktopSidebar).getByText("Show less")).toBeInTheDocument(); + + fireEvent.click(within(desktopSidebar).getByText("Show less")); + expect(within(desktopSidebar).queryByText("Meme Card Curation")).toBeNull(); + }); + + it("opens the created waves modal from the compact strip overflow chip", () => { + mockedUseWaves.mockReturnValue({ + waves: [ + makeWave(), + makeWave({ + id: "wave-2", + name: "Meme Card Curation", + metrics: { + drops_count: 8, + subscribers_count: 10, + latest_drop_timestamp: Date.now(), + }, + }), + ], + isFetching: false, + isFetchingNextPage: false, + hasNextPage: false, + fetchNextPage: jest.fn(), + status: "success", + error: null, + refetch: jest.fn(), + }); + + render(); + const mobileStrip = screen.getByTestId("brain-sidebar-mobile-strip"); + + fireEvent.click( + within(mobileStrip).getByRole("button", { + name: /view all created waves/i, + }) + ); + + expect(screen.getByTestId("wave-creator-preview-modal")).toHaveAttribute( + "data-user-primary-address", + "0xabc" + ); + }); +}); diff --git a/__tests__/components/user/brain/UserPageBrainSidebarWaveItem.test.tsx b/__tests__/components/user/brain/UserPageBrainSidebarWaveItem.test.tsx new file mode 100644 index 0000000000..28f92f169d --- /dev/null +++ b/__tests__/components/user/brain/UserPageBrainSidebarWaveItem.test.tsx @@ -0,0 +1,46 @@ +import { render, screen } from "@testing-library/react"; +import UserPageBrainSidebarWaveItem from "@/components/user/brain/UserPageBrainSidebarWaveItem"; + +jest.mock("next/link", () => ({ + __esModule: true, + default: ({ children, href, prefetch, ...props }: any) => ( + + {children} + + ), +})); + +jest.mock("next/image", () => ({ + __esModule: true, + default: ({ alt, fill, ...props }: any) => {alt, +})); + +describe("UserPageBrainSidebarWaveItem", () => { + it("shows the wave icon fallback when picture is missing", () => { + const { container } = render( + + ); + + expect(screen.queryByRole("img")).toBeNull(); + expect(container.querySelector("svg")).toBeInTheDocument(); + }); +}); diff --git a/__tests__/components/user/brain/UserPageDrops.test.tsx b/__tests__/components/user/brain/UserPageDrops.test.tsx index 4c5df24915..7ab13f3541 100644 --- a/__tests__/components/user/brain/UserPageDrops.test.tsx +++ b/__tests__/components/user/brain/UserPageDrops.test.tsx @@ -1,19 +1,27 @@ -import { render } from '@testing-library/react'; -import UserPageDrops from '@/components/user/brain/UserPageDrops'; +import { render } from "@testing-library/react"; +import UserPageDrops from "@/components/user/brain/UserPageDrops"; -jest.mock('@/components/drops/view/Drops', () => ({ +jest.mock("@/components/drops/view/Drops", () => ({ __esModule: true, default: () =>
, })); +jest.mock("@/components/user/brain/UserPageBrainSidebar", () => ({ + __esModule: true, + default: () =>
, +})); -describe('UserPageDrops', () => { - it('renders Drops when profile has handle', () => { - const { getByTestId } = render(); - expect(getByTestId('drops')).toBeInTheDocument(); +describe("UserPageDrops", () => { + it("renders Drops when profile has handle", () => { + const { getByTestId } = render( + + ); + expect(getByTestId("drops")).toBeInTheDocument(); + expect(getByTestId("brain-sidebar")).toBeInTheDocument(); }); - it('hides Drops when no profile handle', () => { + it("hides Drops when no profile handle", () => { const { queryByTestId } = render(); - expect(queryByTestId('drops')).toBeNull(); + expect(queryByTestId("drops")).toBeNull(); + expect(queryByTestId("brain-sidebar")).toBeNull(); }); }); diff --git a/__tests__/components/waves/drops/WaveCreatorPreviewItem.test.tsx b/__tests__/components/waves/drops/WaveCreatorPreviewItem.test.tsx new file mode 100644 index 0000000000..40de05fbec --- /dev/null +++ b/__tests__/components/waves/drops/WaveCreatorPreviewItem.test.tsx @@ -0,0 +1,40 @@ +import { render, screen } from "@testing-library/react"; +import { WaveCreatorPreviewItem } from "@/components/waves/drops/WaveCreatorPreviewItem"; + +jest.mock("next/link", () => ({ + __esModule: true, + default: ({ children, href, prefetch, ...props }: any) => ( + + {children} + + ), +})); + +jest.mock("next/image", () => ({ + __esModule: true, + default: ({ alt, ...props }: any) => {alt, +})); + +describe("WaveCreatorPreviewItem", () => { + it("shows the wave icon fallback when picture is missing", () => { + const { container } = render( + + ); + + expect(screen.queryByRole("img")).toBeNull(); + expect(container.querySelector("svg")).toBeInTheDocument(); + }); +}); diff --git a/components/react-query-wrapper/ReactQueryWrapper.tsx b/components/react-query-wrapper/ReactQueryWrapper.tsx index cfb7cbc9c8..3a187c50dc 100644 --- a/components/react-query-wrapper/ReactQueryWrapper.tsx +++ b/components/react-query-wrapper/ReactQueryWrapper.tsx @@ -60,6 +60,7 @@ export enum QueryKey { IDENTITY_FOLLOWERS = "IDENTITY_FOLLOWERS", IDENTITY_NOTIFICATIONS = "IDENTITY_NOTIFICATIONS", IDENTITY_SEARCH = "IDENTITY_SEARCH", + IDENTITY_FAVOURITE_WAVES = "IDENTITY_FAVOURITE_WAVES", WALLET_TDH_HISTORY = "WALLET_TDH_HISTORY", REP_CATEGORIES_SEARCH = "REP_CATEGORIES_SEARCH", MEMES_LITE = "MEMES_LITE", diff --git a/components/user/brain/UserPageBrainSidebar.tsx b/components/user/brain/UserPageBrainSidebar.tsx new file mode 100644 index 0000000000..fe82d25859 --- /dev/null +++ b/components/user/brain/UserPageBrainSidebar.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { useMemo } from "react"; +import type { ApiIdentity } from "@/generated/models/ApiIdentity"; +import { useFavouriteWavesOfIdentity } from "@/hooks/useFavouriteWavesOfIdentity"; +import { useWaves } from "@/hooks/useWaves"; +import { useWaveCreatorPreviewModal } from "@/hooks/useWaveCreatorPreviewModal"; +import { WaveCreatorPreviewModal } from "@/components/waves/drops/WaveCreatorPreviewModal"; +import UserPageBrainSidebarCreated from "./UserPageBrainSidebarCreated"; +import UserPageBrainSidebarMobileStrip from "./UserPageBrainSidebarMobileStrip"; +import UserPageBrainSidebarMostActive from "./UserPageBrainSidebarMostActive"; +import { getProfileWaveIdentity } from "./userPageBrainSidebar.helpers"; + +export default function UserPageBrainSidebar({ + profile, +}: { + readonly profile: ApiIdentity; +}) { + const identity = getProfileWaveIdentity(profile); + const hasIdentity = identity.length > 0; + const { waves: createdWaves, status: createdStatus } = useWaves({ + identity, + waveName: null, + enabled: hasIdentity, + directMessage: false, + limit: 20, + }); + const { waves: mostActiveWaves, status: mostActiveStatus } = + useFavouriteWavesOfIdentity({ + identityKey: identity, + limit: 3, + enabled: hasIdentity, + }); + const { isModalOpen, handleBadgeClick, handleModalClose } = + useWaveCreatorPreviewModal(); + const modalUser = useMemo( + () => ({ + handle: profile.handle, + primary_address: profile.primary_wallet, + }), + [profile.handle, profile.primary_wallet] + ); + const shouldShowCreated = + createdStatus === "pending" || createdWaves.length > 0; + const shouldShowMostActive = + mostActiveStatus === "pending" || mostActiveWaves.length > 0; + + if (!shouldShowCreated && !shouldShowMostActive) { + return null; + } + + return ( + + ); +} diff --git a/components/user/brain/UserPageBrainSidebarCreated.tsx b/components/user/brain/UserPageBrainSidebarCreated.tsx new file mode 100644 index 0000000000..368f6ab213 --- /dev/null +++ b/components/user/brain/UserPageBrainSidebarCreated.tsx @@ -0,0 +1,80 @@ +"use client"; + +import type { QueryStatus } from "@tanstack/react-query"; +import { useState } from "react"; +import type { ApiWave } from "@/generated/models/ApiWave"; +import UserPageBrainSidebarWaveItem from "./UserPageBrainSidebarWaveItem"; + +interface UserPageBrainSidebarCreatedProps { + readonly identity: string; + readonly waves: ApiWave[]; + readonly status: QueryStatus; +} + +export default function UserPageBrainSidebarCreated({ + identity, + waves, + status, +}: UserPageBrainSidebarCreatedProps) { + const [expandedIdentity, setExpandedIdentity] = useState(null); + const showAllWaves = expandedIdentity === identity; + const visibleWaves = showAllWaves ? waves : waves.slice(0, 1); + const remainingWavesCount = Math.max(waves.length - 1, 0); + const showMoreLabel = + remainingWavesCount === 1 + ? "Show 1 more" + : `Show ${remainingWavesCount} more`; + const shouldShowLoading = status === "pending" && waves.length === 0; + const shouldShowWaves = waves.length > 0; + if (!shouldShowLoading && !shouldShowWaves) { + return null; + } + + return ( +
+ + Created Waves + +
+ {shouldShowLoading ? ( +
+ {[0, 1, 2].map((key) => ( +
+
+
+
+
+
+
+ ))} +
+ ) : ( + <> + {visibleWaves.map((wave) => ( + + ))} + {waves.length > 1 && ( + + )} + + )} +
+
+ ); +} diff --git a/components/user/brain/UserPageBrainSidebarMobileStrip.tsx b/components/user/brain/UserPageBrainSidebarMobileStrip.tsx new file mode 100644 index 0000000000..1231c1f479 --- /dev/null +++ b/components/user/brain/UserPageBrainSidebarMobileStrip.tsx @@ -0,0 +1,178 @@ +"use client"; + +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 { ChatBubbleLeftRightIcon, PlusIcon } from "@heroicons/react/24/outline"; +import Link from "next/link"; +import Image from "next/image"; +import WavesIcon from "@/components/common/icons/WavesIcon"; + +interface UserPageBrainSidebarMobileStripProps { + readonly createdWaves: ApiWave[]; + readonly createdStatus: QueryStatus; + readonly mostActiveWaves: ApiWave[]; + readonly mostActiveStatus: QueryStatus; + readonly onOpenCreatedWaves: () => void; +} + +function UserPageBrainSidebarMobileWavePill({ + wave, +}: { + readonly wave: ApiWave; +}) { + 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 FallbackIcon = isDirectMessage ? ChatBubbleLeftRightIcon : WavesIcon; + + return ( + +
+ {imageSrc ? ( + {wave.name + ) : ( + + )} +
+ + {wave.name} + + + ); +} + +function MobileWavePillSkeleton({ keyId }: { readonly keyId: string }) { + return ( +
+
+
+
+ ); +} + +export default function UserPageBrainSidebarMobileStrip({ + createdWaves, + createdStatus, + mostActiveWaves, + mostActiveStatus, + onOpenCreatedWaves, +}: UserPageBrainSidebarMobileStripProps) { + const shouldShowCreatedLoading = + createdStatus === "pending" && createdWaves.length === 0; + const shouldShowCreatedWaves = createdWaves.length > 0; + const shouldShowMostActiveLoading = + mostActiveStatus === "pending" && mostActiveWaves.length === 0; + const shouldShowMostActiveWaves = mostActiveWaves.length > 0; + const shouldShowCreatedSection = + shouldShowCreatedLoading || shouldShowCreatedWaves; + const shouldShowMostActiveSection = + shouldShowMostActiveLoading || shouldShowMostActiveWaves; + + if (!shouldShowCreatedSection && !shouldShowMostActiveSection) { + return null; + } + + const firstCreatedWave = createdWaves[0]; + const remainingCreatedCount = Math.max(createdWaves.length - 1, 0); + let createdSectionContent: ReactNode = null; + + if (shouldShowCreatedLoading) { + createdSectionContent = ( + <> + + + + ); + } else if (firstCreatedWave) { + createdSectionContent = ( + <> + + {remainingCreatedCount > 0 && ( + + )} + + ); + } + + return ( +
+
+
+ {shouldShowCreatedSection && ( +
+ + Created + +
+ {createdSectionContent} +
+
+ )} + + {shouldShowCreatedSection && shouldShowMostActiveSection && ( +
+ )} + + {shouldShowMostActiveSection && ( +
+ + Active In + +
+ {shouldShowMostActiveLoading + ? [0, 1, 2].map((key) => ( + + )) + : mostActiveWaves + .slice(0, 3) + .map((wave) => ( + + ))} +
+
+ )} +
+
+
+ ); +} diff --git a/components/user/brain/UserPageBrainSidebarMostActive.tsx b/components/user/brain/UserPageBrainSidebarMostActive.tsx new file mode 100644 index 0000000000..9cfc1634dc --- /dev/null +++ b/components/user/brain/UserPageBrainSidebarMostActive.tsx @@ -0,0 +1,57 @@ +"use client"; + +import type { QueryStatus } from "@tanstack/react-query"; +import type { ApiWave } from "@/generated/models/ApiWave"; +import UserPageBrainSidebarWaveItem from "./UserPageBrainSidebarWaveItem"; + +interface UserPageBrainSidebarMostActiveProps { + readonly waves: ApiWave[]; + readonly status: QueryStatus; +} + +export default function UserPageBrainSidebarMostActive({ + waves, + status, +}: UserPageBrainSidebarMostActiveProps) { + const shouldShowLoading = status === "pending" && waves.length === 0; + const shouldShowWaves = waves.length > 0; + if (!shouldShowLoading && !shouldShowWaves) { + return null; + } + + return ( +
+ + Most Active In + +
+ {shouldShowLoading ? ( +
+ {[0, 1, 2].map((key) => ( +
+
+
+
+
+
+
+ ))} +
+ ) : ( + waves.map((wave) => ( + + )) + )} +
+
+ ); +} diff --git a/components/user/brain/UserPageBrainSidebarWaveItem.tsx b/components/user/brain/UserPageBrainSidebarWaveItem.tsx new file mode 100644 index 0000000000..9fa26f6a90 --- /dev/null +++ b/components/user/brain/UserPageBrainSidebarWaveItem.tsx @@ -0,0 +1,111 @@ +"use client"; + +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 Link from "next/link"; +import Image from "next/image"; +import WavesIcon from "@/components/common/icons/WavesIcon"; + +type BrainSidebarWaveItemDisplay = { + readonly href: string; + readonly isPrivate: boolean; + readonly isDirectMessage: boolean; + readonly dropsCount: string; + readonly lastDropTimestamp: ApiWave["metrics"]["latest_drop_timestamp"]; + 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, +}); + +export default function UserPageBrainSidebarWaveItem({ + wave, +}: { + readonly wave: ApiWave; +}) { + const { + href, + isPrivate, + isDirectMessage, + dropsCount, + lastDropTimestamp, + imageSrc, + } = getBrainSidebarWaveItemDisplay(wave); + const FallbackIcon = isDirectMessage ? ChatBubbleLeftRightIcon : WavesIcon; + let metaContent: ReactNode = No drops yet; + + if (lastDropTimestamp) { + metaContent = ( + <> + {getTimeAgoShort(lastDropTimestamp)} + + {dropsCount} drops + + ); + } + + return ( + +
+
+ {imageSrc ? ( + {wave.name + ) : ( +
+ +
+ )} +
+ +
+
+
+ {isPrivate && ( + + )} + + {wave.name} + +
+
+ +
+ {metaContent} +
+
+ + + + ); +} diff --git a/components/user/brain/UserPageDrops.tsx b/components/user/brain/UserPageDrops.tsx index dda58ad7a5..ab647f7fc8 100644 --- a/components/user/brain/UserPageDrops.tsx +++ b/components/user/brain/UserPageDrops.tsx @@ -1,17 +1,29 @@ import Drops from "@/components/drops/view/Drops"; import type { ApiIdentity } from "@/generated/models/ApiIdentity"; +import type { ReactNode } from "react"; +import UserPageBrainSidebar from "./UserPageBrainSidebar"; export default function UserPageDrops({ profile, }: { readonly profile: ApiIdentity | null; }) { - const haveProfile = !!profile?.handle; - return ( -
-
- {haveProfile && } + let content: ReactNode = null; + + if (profile) { + const haveProfile = Boolean(profile.handle); + + content = ( +
+
+
+ {haveProfile && } +
+ +
-
- ); + ); + } + + return content; } diff --git a/components/user/brain/userPageBrainSidebar.helpers.ts b/components/user/brain/userPageBrainSidebar.helpers.ts new file mode 100644 index 0000000000..f41cc2cdf1 --- /dev/null +++ b/components/user/brain/userPageBrainSidebar.helpers.ts @@ -0,0 +1,4 @@ +import type { ApiIdentity } from "@/generated/models/ApiIdentity"; + +export const getProfileWaveIdentity = (profile: ApiIdentity): string => + profile.handle ?? profile.query ?? profile.primary_wallet; diff --git a/components/waves/drops/WaveCreatorPreviewItem.tsx b/components/waves/drops/WaveCreatorPreviewItem.tsx index 018687be2b..8b3ee8a7bd 100644 --- a/components/waves/drops/WaveCreatorPreviewItem.tsx +++ b/components/waves/drops/WaveCreatorPreviewItem.tsx @@ -9,8 +9,8 @@ import { ArrowTopRightOnSquareIcon, ChatBubbleLeftRightIcon, } from "@heroicons/react/24/outline"; -import Image from "next/image"; import Link from "next/link"; +import Image from "next/image"; import WavesIcon from "@/components/common/icons/WavesIcon"; interface WaveCreatorPreviewItemProps { @@ -47,14 +47,14 @@ export const WaveCreatorPreviewItem: React.FC = ({ }} className="tw-group tw-flex tw-items-center tw-gap-3 tw-rounded-lg tw-border tw-border-iron-800/70 tw-bg-iron-950/60 tw-px-4 tw-py-3 tw-no-underline tw-transition tw-duration-200 focus:tw-outline-none focus-visible:tw-ring-2 focus-visible:tw-ring-primary-400/60 desktop-hover:hover:tw-border-iron-700 desktop-hover:hover:tw-bg-iron-900/60" > -
+
{imageSrc ? ( {wave.name ) : ( diff --git a/components/waves/drops/WaveCreatorPreviewModal.tsx b/components/waves/drops/WaveCreatorPreviewModal.tsx index a8ae1d524f..b22d76cd00 100644 --- a/components/waves/drops/WaveCreatorPreviewModal.tsx +++ b/components/waves/drops/WaveCreatorPreviewModal.tsx @@ -1,6 +1,5 @@ "use client"; -import type { ApiProfileMin } from "@/generated/models/ApiProfileMin"; import useDeviceInfo from "@/hooks/useDeviceInfo"; import { Dialog, @@ -12,11 +11,12 @@ import { Fragment, useEffect } from "react"; import { createPortal } from "react-dom"; import ArtistPreviewAppWrapper from "./ArtistPreviewAppWrapper"; import { WaveCreatorPreviewModalContent } from "./WaveCreatorPreviewModalContent"; +import type { WaveCreatorPreviewUser } from "./waveCreatorPreview.types"; interface WaveCreatorPreviewModalProps { readonly isOpen: boolean; readonly onClose: () => void; - readonly user: ApiProfileMin; + readonly user: WaveCreatorPreviewUser; } export const WaveCreatorPreviewModal = ({ diff --git a/components/waves/drops/WaveCreatorPreviewModalContent.tsx b/components/waves/drops/WaveCreatorPreviewModalContent.tsx index a48435f2cc..b9d93e5c42 100644 --- a/components/waves/drops/WaveCreatorPreviewModalContent.tsx +++ b/components/waves/drops/WaveCreatorPreviewModalContent.tsx @@ -1,7 +1,6 @@ "use client"; import React, { useCallback } from "react"; -import type { ApiProfileMin } from "@/generated/models/ApiProfileMin"; import { useWaves } from "@/hooks/useWaves"; import CircleLoader, { CircleLoaderSize, @@ -12,9 +11,10 @@ import { WaveCreatorPreviewItem } from "./WaveCreatorPreviewItem"; import { shortenAddress } from "@/helpers/address.helpers"; import { useRouter } from "next/navigation"; import type { ApiWave } from "@/generated/models/ApiWave"; +import type { WaveCreatorPreviewUser } from "./waveCreatorPreview.types"; interface WaveCreatorPreviewModalContentProps { - readonly user: ApiProfileMin; + readonly user: WaveCreatorPreviewUser; readonly isOpen: boolean; readonly onClose: () => void; readonly isApp?: boolean | undefined; @@ -73,11 +73,11 @@ export const WaveCreatorPreviewModalContent: React.FC< return (
-
+
Waves by {displayName}
diff --git a/components/waves/drops/waveCreatorPreview.types.ts b/components/waves/drops/waveCreatorPreview.types.ts new file mode 100644 index 0000000000..e0c272377e --- /dev/null +++ b/components/waves/drops/waveCreatorPreview.types.ts @@ -0,0 +1,4 @@ +export interface WaveCreatorPreviewUser { + readonly handle: string | null; + readonly primary_address: string; +} diff --git a/hooks/useFavouriteWavesOfIdentity.ts b/hooks/useFavouriteWavesOfIdentity.ts new file mode 100644 index 0000000000..05f54736f0 --- /dev/null +++ b/hooks/useFavouriteWavesOfIdentity.ts @@ -0,0 +1,49 @@ +"use client"; + +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"; + +interface UseFavouriteWavesOfIdentityProps { + readonly identityKey: string | null; + readonly limit?: number | undefined; + readonly enabled?: boolean | undefined; +} + +export function useFavouriteWavesOfIdentity({ + identityKey, + limit = 3, + enabled = true, +}: UseFavouriteWavesOfIdentityProps) { + const normalizedIdentityKey = identityKey?.trim() ?? null; + const activeIdentityKey = normalizedIdentityKey ?? ""; + + 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", + }, + }), + enabled: enabled && activeIdentityKey.length > 0, + ...getDefaultQueryRetry(), + }); + + return { + waves: query.data ?? [], + status: query.status, + error: query.error, + refetch: query.refetch, + isFetching: query.isFetching, + }; +}