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) => (
+
+ ),
+}));
+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) =>
,
+}));
+
+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) =>
,
+}));
+
+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}
+
+
+ );
+}
+
+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 ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+
+ {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 ? (
) : (
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,
+ };
+}