diff --git a/__tests__/components/brain/my-stream/MyStreamWaveChat.test.tsx b/__tests__/components/brain/my-stream/MyStreamWaveChat.test.tsx index c353f492a0..9a9026fc74 100644 --- a/__tests__/components/brain/my-stream/MyStreamWaveChat.test.tsx +++ b/__tests__/components/brain/my-stream/MyStreamWaveChat.test.tsx @@ -1,58 +1,68 @@ -import { render, screen, act } from '@testing-library/react'; -import React from 'react'; -import { Provider } from 'react-redux'; -import { configureStore } from '@reduxjs/toolkit'; -import { editSlice } from '@/store/editSlice'; -import MyStreamWaveChat from '@/components/brain/my-stream/MyStreamWaveChat'; +import { render, screen, act } from "@testing-library/react"; +import React from "react"; +import { Provider } from "react-redux"; +import { configureStore } from "@reduxjs/toolkit"; +import { editSlice } from "@/store/editSlice"; +import MyStreamWaveChat from "@/components/brain/my-stream/MyStreamWaveChat"; const replaceMock = jest.fn(); const searchParamsMock = { get: jest.fn() }; -jest.mock('next/navigation', () => ({ +jest.mock("next/navigation", () => ({ useRouter: () => ({ replace: replaceMock }), useSearchParams: () => searchParamsMock, usePathname: jest.fn(), })); let mockIsMemesWave = false; -jest.mock('@/hooks/useWave', () => ({ useWave: () => ({ isMemesWave: mockIsMemesWave }) })); +jest.mock("@/hooks/useWave", () => ({ + useWave: () => ({ isMemesWave: mockIsMemesWave }), +})); -jest.mock('@/components/brain/my-stream/layout/LayoutContext', () => ({ - useLayout: () => ({ waveViewStyle: { height: '1px' } }) +jest.mock("@/components/brain/my-stream/layout/LayoutContext", () => ({ + useLayout: () => ({ waveViewStyle: { height: "1px" } }), })); let capturedProps: any = {}; -jest.mock('@/components/waves/drops/wave-drops-all', () => ({ +jest.mock("@/components/waves/drops/wave-drops-all", () => ({ __esModule: true, default: (props: any) => { capturedProps = props; return
; - } + }, })); -jest.mock('@/components/waves/CreateDropWaveWrapper', () => ({ - CreateDropWaveWrapper: ({ children }: any) =>
{children}
+jest.mock("@/components/waves/CreateDropWaveWrapper", () => ({ + CreateDropWaveWrapper: ({ children }: any) =>
{children}
, })); -jest.mock('@/components/waves/PrivilegedDropCreator', () => ({ +jest.mock("@/components/waves/PrivilegedDropCreator", () => ({ __esModule: true, default: () =>
, - DropMode: { BOTH: 'BOTH' } + DropMode: { BOTH: "BOTH" }, })); -jest.mock('@/components/waves/memes/submission/MobileMemesArtSubmissionBtn', () => ({ - __esModule: true, - default: () =>
-})); +jest.mock( + "@/components/waves/memes/submission/MobileMemesArtSubmissionBtn", + () => ({ + __esModule: true, + default: () =>
, + }) +); -jest.mock('@/hooks/useDeviceInfo', () => ({ +jest.mock("@/hooks/useDeviceInfo", () => ({ __esModule: true, default: () => ({ isApp: false }), })); -const wave = { id: '10' } as any; +jest.mock("@/components/waves/gallery", () => ({ + WaveGallery: () =>
, +})); + +const wave = { id: "10" } as any; +const mockOnDropClick = jest.fn(); -describe('MyStreamWaveChat', () => { +describe("MyStreamWaveChat", () => { let store: any; beforeEach(() => { @@ -66,31 +76,41 @@ describe('MyStreamWaveChat', () => { }); const renderWithProvider = (component: React.ReactElement) => { - return render( - - {component} - - ); + return render({component}); }; - it('handles serialNo param and shows memes button', async () => { - searchParamsMock.get.mockReturnValueOnce('5').mockReturnValue(null); + it("handles serialNo param and shows memes button", async () => { + searchParamsMock.get.mockReturnValueOnce("5").mockReturnValue(null); mockIsMemesWave = true; await act(async () => { - renderWithProvider(); + renderWithProvider( + + ); }); expect(replaceMock).toHaveBeenCalled(); expect(capturedProps.initialDrop).toBe(5); - expect(screen.getByTestId('memes-btn')).toBeInTheDocument(); + expect(screen.getByTestId("memes-btn")).toBeInTheDocument(); }); - it('sets initialDrop null when no param', async () => { + it("sets initialDrop null when no param", async () => { searchParamsMock.get.mockReturnValue(null); await act(async () => { - renderWithProvider(); + renderWithProvider( + + ); }); expect(replaceMock).not.toHaveBeenCalled(); expect(capturedProps.initialDrop).toBeNull(); - expect(screen.queryByTestId('memes-btn')).toBeNull(); + expect(screen.queryByTestId("memes-btn")).toBeNull(); }); }); diff --git a/__tests__/components/brain/my-stream/tabs/MyStreamWaveTabs.test.tsx b/__tests__/components/brain/my-stream/tabs/MyStreamWaveTabs.test.tsx index a9b07a5688..bf240be5e7 100644 --- a/__tests__/components/brain/my-stream/tabs/MyStreamWaveTabs.test.tsx +++ b/__tests__/components/brain/my-stream/tabs/MyStreamWaveTabs.test.tsx @@ -1,50 +1,65 @@ -import { render, screen } from '@testing-library/react'; -import React from 'react'; +import { render, screen } from "@testing-library/react"; +import React from "react"; const registerRef = jest.fn(); -jest.mock('@/components/brain/my-stream/layout/LayoutContext', () => ({ +jest.mock("@/components/brain/my-stream/layout/LayoutContext", () => ({ useLayout: () => ({ registerRef }), })); -jest.mock('@/hooks/useWave', () => ({ +jest.mock("@/hooks/useWave", () => ({ useWave: jest.fn(), })); -jest.mock('@/components/brain/my-stream/tabs/MyStreamWaveTabsMeme', () => ({ +jest.mock("@/components/brain/my-stream/tabs/MyStreamWaveTabsMeme", () => ({ __esModule: true, default: () =>
, })); -jest.mock('@/components/brain/my-stream/tabs/MyStreamWaveTabsDefault', () => ({ +jest.mock("@/components/brain/my-stream/tabs/MyStreamWaveTabsDefault", () => ({ __esModule: true, default: () =>
, })); -import { MyStreamWaveTabs } from '@/components/brain/my-stream/tabs/MyStreamWaveTabs'; -import type { ApiWave } from '@/generated/models/ApiWave'; -const { useWave } = require('@/hooks/useWave'); +import { MyStreamWaveTabs } from "@/components/brain/my-stream/tabs/MyStreamWaveTabs"; +import type { ApiWave } from "@/generated/models/ApiWave"; +const { useWave } = require("@/hooks/useWave"); -describe('MyStreamWaveTabs', () => { - const wave: ApiWave = { id: 'w1', name: 'Wave 1' } as ApiWave; +describe("MyStreamWaveTabs", () => { + const wave: ApiWave = { id: "w1", name: "Wave 1" } as ApiWave; + const mockToggleViewMode = jest.fn(); beforeEach(() => { jest.clearAllMocks(); }); - it('renders meme tabs when wave is a memes wave and registers ref', () => { + it("renders meme tabs when wave is a memes wave and registers ref", () => { (useWave as jest.Mock).mockReturnValue({ isMemesWave: true }); - render(); - const container = document.getElementById('tabs-container'); - expect(screen.getByTestId('meme')).toBeInTheDocument(); - expect(screen.queryByTestId('default')).toBeNull(); - expect(registerRef).toHaveBeenCalledWith('tabs', container); + render( + + ); + const container = document.getElementById("tabs-container"); + expect(screen.getByTestId("meme")).toBeInTheDocument(); + expect(screen.queryByTestId("default")).toBeNull(); + expect(registerRef).toHaveBeenCalledWith("tabs", container); }); - it('renders default tabs when wave is not a memes wave', () => { + it("renders default tabs when wave is not a memes wave", () => { (useWave as jest.Mock).mockReturnValue({ isMemesWave: false }); - render(); - expect(screen.getByTestId('default')).toBeInTheDocument(); - expect(screen.queryByTestId('meme')).toBeNull(); + render( + + ); + expect(screen.getByTestId("default")).toBeInTheDocument(); + expect(screen.queryByTestId("meme")).toBeNull(); }); }); diff --git a/__tests__/components/brain/my-stream/tabs/MyStreamWaveTabsDefault.test.tsx b/__tests__/components/brain/my-stream/tabs/MyStreamWaveTabsDefault.test.tsx index d0ce525092..93373a3955 100644 --- a/__tests__/components/brain/my-stream/tabs/MyStreamWaveTabsDefault.test.tsx +++ b/__tests__/components/brain/my-stream/tabs/MyStreamWaveTabsDefault.test.tsx @@ -1,65 +1,79 @@ -import { render, screen, fireEvent } from '@testing-library/react'; -import React from 'react'; -import MyStreamWaveTabsDefault from '@/components/brain/my-stream/tabs/MyStreamWaveTabsDefault'; -import { SidebarProvider } from '@/hooks/useSidebarState'; +import { render, screen, fireEvent } from "@testing-library/react"; +import React from "react"; +import MyStreamWaveTabsDefault from "@/components/brain/my-stream/tabs/MyStreamWaveTabsDefault"; +import { SidebarProvider } from "@/hooks/useSidebarState"; const mockPush = jest.fn(); -const mockUseBreakpoint = jest.fn(() => 'LG'); +const mockUseBreakpoint = jest.fn(() => "LG"); -jest.mock('next/navigation', () => ({ +jest.mock("next/navigation", () => ({ useRouter: () => ({ push: mockPush, replace: jest.fn(), back: jest.fn() }), - useSearchParams: () => new URLSearchParams('wave=w1'), - usePathname: () => '/waves', + useSearchParams: () => new URLSearchParams("wave=w1"), + usePathname: () => "/waves", })); -jest.mock('react-use', () => { - const actual = jest.requireActual('react-use'); +jest.mock("react-use", () => { + const actual = jest.requireActual("react-use"); return { __esModule: true, ...actual, - createBreakpoint: () => (...args: any[]) => mockUseBreakpoint(...args), + createBreakpoint: + () => + (...args: any[]) => + mockUseBreakpoint(...args), }; }); const useContentTab = jest.fn(); -jest.mock('@/components/brain/my-stream/MyStreamWaveDesktopTabs', () => ({ +jest.mock("@/components/brain/my-stream/MyStreamWaveDesktopTabs", () => ({ __esModule: true, default: ({ activeTab, setActiveTab }: any) => (
{activeTab} - +
- ) + ), })); -jest.mock('@/components/brain/ContentTabContext', () => ({ - useContentTab: (...args: any[]) => useContentTab(...args) +jest.mock("@/components/brain/ContentTabContext", () => ({ + useContentTab: (...args: any[]) => useContentTab(...args), })); -describe('MyStreamWaveTabsDefault', () => { +describe("MyStreamWaveTabsDefault", () => { + const mockToggleViewMode = jest.fn(); + beforeEach(() => { mockPush.mockClear(); - mockUseBreakpoint.mockReturnValue('LG'); + mockUseBreakpoint.mockReturnValue("LG"); useContentTab.mockReset(); + mockToggleViewMode.mockClear(); }); - it('passes active tab and handles tab change', () => { + it("passes active tab and handles tab change", () => { const setActiveContentTab = jest.fn(); - useContentTab.mockReturnValue({ activeContentTab: 'CHAT', setActiveContentTab }); + useContentTab.mockReturnValue({ + activeContentTab: "CHAT", + setActiveContentTab, + }); const wave = { - id: 'w1', - name: 'Wave', + id: "w1", + name: "Wave", picture: null, contributors_overview: [], } as any; render( - + ); - expect(screen.getByTestId('active')).toHaveTextContent('CHAT'); - fireEvent.click(screen.getByText('change')); - expect(setActiveContentTab).toHaveBeenCalledWith('NEW'); + expect(screen.getByTestId("active")).toHaveTextContent("CHAT"); + fireEvent.click(screen.getByText("change")); + expect(setActiveContentTab).toHaveBeenCalledWith("NEW"); }); }); diff --git a/components/brain/my-stream/MyStreamWave.tsx b/components/brain/my-stream/MyStreamWave.tsx index 94d14e5649..8a4bba3557 100644 --- a/components/brain/my-stream/MyStreamWave.tsx +++ b/components/brain/my-stream/MyStreamWave.tsx @@ -17,6 +17,8 @@ import MyStreamWaveFAQ from "./MyStreamWaveFAQ"; import { useMyStream } from "@/contexts/wave/MyStreamContext"; import { createBreakpoint } from "react-use"; import { getHomeFeedRoute } from "@/helpers/navigation.helpers"; +import { useWaveViewMode } from "@/hooks/useWaveViewMode"; +import { useWave } from "@/hooks/useWave"; interface MyStreamWaveProps { readonly waveId: string; @@ -68,6 +70,14 @@ const MyStreamWave: React.FC = ({ waveId }) => { // Get the active tab and utilities from global context const { activeContentTab } = useContentTab(); + // View mode for chat/gallery toggle + const { viewMode, toggleViewMode } = useWaveViewMode(waveId); + + // Get wave type info to determine if gallery toggle should be shown + // Show for CHAT type waves (normal waves), hide for RANK, MEMES, and DMs + const { isRankWave, isMemesWave, isDm } = useWave(wave); + const showGalleryToggle = !isRankWave && !isMemesWave && !isDm; + useBreakpoint(); // For handling clicks on drops @@ -88,6 +98,8 @@ const MyStreamWave: React.FC = ({ waveId }) => { ), [MyStreamWaveTab.LEADERBOARD]: ( @@ -109,7 +121,12 @@ const MyStreamWave: React.FC = ({ waveId }) => { key={stableWaveKey} > {/* Always render tab container (hidden on app inside MyStreamWaveTabs) */} - +
void; } const MyStreamWaveChat: React.FC = ({ wave, firstUnreadSerialNo, + viewMode, + onDropClick, }) => { const router = useRouter(); const searchParams = useSearchParams(); const pathname = usePathname(); const containerRef = useRef(null); - const [initialDropState, setInitialDropState] = - useState(null); const { isMemesWave } = useWave(wave); const editingDropId = useSelector(selectEditingDropId); const { isApp } = useDeviceInfo(); - const [activeDrop, setActiveDrop] = useState(null); - - const scrollTarget = - initialDropState?.waveId === wave.id ? initialDropState.serialNo : null; - - const dividerTarget = - initialDropState?.waveId === wave.id - ? initialDropState.dividerSerialNo - : firstUnreadSerialNo; + const [activeDropState, setActiveDropState] = useState<{ + readonly waveId: string; + readonly activeDrop: ActiveDropState | null; + }>({ + waveId: wave.id, + activeDrop: null, + }); + + const activeDrop = + activeDropState.waveId === wave.id ? activeDropState.activeDrop : null; + + const setActiveDropForWave = (nextActiveDrop: ActiveDropState | null) => { + setActiveDropState({ waveId: wave.id, activeDrop: nextActiveDrop }); + }; - useEffect(() => { - const dropParam = searchParams?.get("serialNo"); + const initialDropState = useMemo(() => { + const dropParam = searchParams.get("serialNo"); if (!dropParam) { - return; + return null; } const parsed = Number.parseInt(dropParam, 10); if (!Number.isFinite(parsed)) { - return; + return null; } - const dividerParam = searchParams?.get("divider"); + const dividerParam = searchParams.get("divider"); const dividerParsed = dividerParam ? Number.parseInt(dividerParam, 10) : null; @@ -76,20 +83,37 @@ const MyStreamWaveChat: React.FC = ({ ? dividerParsed : firstUnreadSerialNo; - setInitialDropState({ + return { waveId: wave.id, serialNo: parsed, dividerSerialNo, - }); + }; + }, [searchParams, wave.id, firstUnreadSerialNo]); + + const scrollTarget = + initialDropState?.waveId === wave.id ? initialDropState.serialNo : null; + + const dividerTarget = + initialDropState?.waveId === wave.id + ? initialDropState.dividerSerialNo + : firstUnreadSerialNo; - const params = new URLSearchParams(searchParams?.toString() || ""); + useEffect(() => { + if (!initialDropState) { + return; + } + + const params = new URLSearchParams(searchParams.toString() || ""); + if (!params.has("serialNo") && !params.has("divider")) { + return; + } params.delete("serialNo"); params.delete("divider"); const href = params.toString() ? `${pathname}?${params.toString()}` : pathname || getHomeFeedRoute(); router.replace(href, { scroll: false }); - }, [searchParams, router, pathname, wave.id, firstUnreadSerialNo]); + }, [initialDropState, searchParams, router, pathname]); const { waveViewStyle } = useLayout(); @@ -104,12 +128,8 @@ const MyStreamWaveChat: React.FC = ({ return `${baseStyles} ${heightClass}`; }, []); - const containerStyle = waveViewStyle || {}; - - useEffect(() => setActiveDrop(null), [wave]); - const onReply = (drop: ApiDrop, partId: number) => { - setActiveDrop({ + setActiveDropForWave({ action: ActiveDropAction.REPLY, drop, partId, @@ -117,7 +137,7 @@ const MyStreamWaveChat: React.FC = ({ }; const onQuote = (drop: ApiDrop, partId: number) => { - setActiveDrop({ + setActiveDropForWave({ action: ActiveDropAction.QUOTE, drop, partId, @@ -133,15 +153,27 @@ const MyStreamWaveChat: React.FC = ({ }; const onCancelReplyQuote = () => { - setActiveDrop(null); + setActiveDropForWave(null); }; + if (viewMode === "gallery") { + return ( +
+ +
+ ); + } + return (
- + style={waveViewStyle} + > = ({ initialDrop={scrollTarget} dividerSerialNo={dividerTarget} dropId={null} - isMuted={wave.metrics?.muted ?? false} + isMuted={wave.metrics.muted} /> {!(isApp && editingDropId) && (
diff --git a/components/brain/my-stream/tabs/MyStreamWaveTabs.tsx b/components/brain/my-stream/tabs/MyStreamWaveTabs.tsx index 5d31d68ec3..d3efd1c3e2 100644 --- a/components/brain/my-stream/tabs/MyStreamWaveTabs.tsx +++ b/components/brain/my-stream/tabs/MyStreamWaveTabs.tsx @@ -7,12 +7,21 @@ import type { ApiWave } from "@/generated/models/ApiWave"; import MyStreamWaveTabsMeme from "./MyStreamWaveTabsMeme"; import MyStreamWaveTabsDefault from "./MyStreamWaveTabsDefault"; import useDeviceInfo from "../../../../hooks/useDeviceInfo"; +import type { WaveViewMode } from "@/hooks/useWaveViewMode"; interface MyStreamWaveTabsProps { readonly wave: ApiWave; + readonly viewMode: WaveViewMode; + readonly onToggleViewMode: () => void; + readonly showGalleryToggle: boolean; } -export const MyStreamWaveTabs: React.FC = ({ wave }) => { +export const MyStreamWaveTabs: React.FC = ({ + wave, + viewMode, + onToggleViewMode, + showGalleryToggle, +}) => { const { isMemesWave } = useWave(wave); const { registerRef } = useLayout(); const { isApp } = useDeviceInfo(); @@ -45,11 +54,16 @@ export const MyStreamWaveTabs: React.FC = ({ wave }) => { return (
-
+
{isMemesWave ? ( ) : ( - + )}
diff --git a/components/brain/my-stream/tabs/MyStreamWaveTabsDefault.tsx b/components/brain/my-stream/tabs/MyStreamWaveTabsDefault.tsx index ec5fa0b77e..ce24695b26 100644 --- a/components/brain/my-stream/tabs/MyStreamWaveTabsDefault.tsx +++ b/components/brain/my-stream/tabs/MyStreamWaveTabsDefault.tsx @@ -7,6 +7,8 @@ import { ChevronDoubleLeftIcon, ArrowLeftIcon, MagnifyingGlassIcon, + ChatBubbleLeftIcon, + Squares2X2Icon, } from "@heroicons/react/24/outline"; import WavePicture from "../../../waves/WavePicture"; import { useRouter, useSearchParams, usePathname } from "next/navigation"; @@ -14,14 +16,21 @@ import { createBreakpoint } from "react-use"; import WaveDropsSearchModal from "@/components/waves/drops/search/WaveDropsSearchModal"; import { MyStreamWaveTab } from "@/types/waves.types"; import { useWaveChatScrollOptional } from "@/contexts/wave/WaveChatScrollContext"; +import type { WaveViewMode } from "@/hooks/useWaveViewMode"; const useBreakpoint = createBreakpoint({ LG: 1024, S: 0 }); interface MyStreamWaveTabsDefaultProps { readonly wave: ApiWave; + readonly viewMode: WaveViewMode; + readonly onToggleViewMode: () => void; + readonly showGalleryToggle: boolean; } const MyStreamWaveTabsDefault: React.FC = ({ wave, + viewMode, + onToggleViewMode, + showGalleryToggle, }) => { const { activeContentTab, setActiveContentTab } = useContentTab(); const { toggleRightSidebar, isRightSidebarOpen } = useSidebarState(); @@ -35,7 +44,7 @@ const MyStreamWaveTabsDefault: React.FC = ({ const waveChatScroll = useWaveChatScrollOptional(); const handleMobileBack = () => { - const params = new URLSearchParams(searchParams?.toString() || ""); + const params = new URLSearchParams(searchParams.toString() || ""); params.delete("wave"); const newUrl = params.toString() ? `${pathname}?${params.toString()}` @@ -50,22 +59,22 @@ const MyStreamWaveTabsDefault: React.FC = ({ return; } - const params = new URLSearchParams(searchParams?.toString() || ""); + const params = new URLSearchParams(searchParams.toString() || ""); params.set("serialNo", String(serialNo)); router.replace(`${pathname}?${params.toString()}`, { scroll: false }); }; return ( -
-
-
+
+
+
{isMobile && ( )} {isMobile ? ( @@ -73,9 +82,9 @@ const MyStreamWaveTabsDefault: React.FC = ({ type="button" onClick={() => setIsSearchOpen(true)} aria-label="Search messages in this wave" - className="tw-flex tw-items-center tw-bg-transparent tw-border-0 tw-p-0 tw-text-left tw-min-w-0" + className="tw-flex tw-min-w-0 tw-items-center tw-border-0 tw-bg-transparent tw-p-0 tw-text-left" > -
+
= ({ }))} />
-

+

{wave.name}

) : ( <> -
+
= ({ }))} />
-

+

{wave.name}

+ {showGalleryToggle && ( + + )} @@ -117,19 +144,21 @@ const MyStreamWaveTabsDefault: React.FC = ({
-
+
- Tired of bot replies? + Tired of bot replies? Join the most interesting chats in crypto -

Most active waves

+

+ Most active waves +

m.data_key === "title")?.data_value; + + // Compare with nowMinting name (case-insensitive, trimmed) + // Only treat as same when both values exist; otherwise treat as not equal + const isNextMintSameAsNowMinting = + !!nowMinting?.name && + !!nextMintTitle && + nowMinting.name.toLowerCase().trim() === nextMintTitle.toLowerCase().trim(); + + // Determine what to show + const showNextMint = nextMint && !isNextMintSameAsNowMinting; + const leadingCount = showNextMint ? 2 : 3; + + // Get top drops from leaderboard + const leading = drops.slice(0, leadingCount); - const isFetching = isWinnersFetching || isLeaderboardFetching; + const isFetching = + isNowMintingFetching || isWinnersFetching || isLeaderboardFetching; if (!isLoaded || !waveId) { return null; @@ -61,7 +81,7 @@ export function NextMintLeadingSection() {
); - if (isFetching && !nextMint && leading.length === 0) { + if (isFetching && !showNextMint && leading.length === 0) { return (
@@ -74,7 +94,7 @@ export function NextMintLeadingSection() { ); } - if (!nextMint && leading.length === 0) { + if (!showNextMint && leading.length === 0) { return null; } @@ -83,7 +103,7 @@ export function NextMintLeadingSection() {
{header}
- {nextMint && } + {showNextMint && } {leading.map((drop, index) => ( ))} diff --git a/components/home/now-minting/NowMintingHeader.tsx b/components/home/now-minting/NowMintingHeader.tsx index 5ecf37b3b6..404d53f1ac 100644 --- a/components/home/now-minting/NowMintingHeader.tsx +++ b/components/home/now-minting/NowMintingHeader.tsx @@ -14,17 +14,63 @@ interface NowMintingHeaderProps { readonly artistName: string; } +function NowMintingArtistHandlePill({ + artistHandle, +}: { + readonly artistHandle: string; +}) { + const { profile } = useIdentity({ + handleOrWallet: artistHandle, + initialProfile: null, + }); + + return ( + + {profile?.pfp ? ( + {artistHandle} + ) : ( +
+ )} + + + + + ); +} + +function NowMintingArtistNamePill({ + artistName, +}: { + readonly artistName: string; +}) { + return ( + +
+ + {artistName} + + + ); +} + export default function NowMintingHeader({ cardNumber, title, artistHandle, artistName, }: NowMintingHeaderProps) { - const { profile } = useIdentity({ - handleOrWallet: artistHandle, - initialProfile: null, - }); - const hasHandle = Boolean(artistHandle.trim()); + const artistHandles = artistHandle + .split(",") + .map((handle) => handle.trim()) + .filter(Boolean); return (
@@ -39,28 +85,13 @@ export default function NowMintingHeader({ Card #{cardNumber} - - {profile?.pfp ? ( - {artistName} - ) : ( -
- )} - - {hasHandle ? ( - - ) : ( - artistName - )} - - + {artistHandles.length > 0 ? ( + artistHandles.map((handle) => ( + + )) + ) : ( + + )}
); diff --git a/components/waves/drop/SingleWaveDropInfoPanel.tsx b/components/waves/drop/SingleWaveDropInfoPanel.tsx index 1fb56febb7..e81f783b7b 100644 --- a/components/waves/drop/SingleWaveDropInfoPanel.tsx +++ b/components/waves/drop/SingleWaveDropInfoPanel.tsx @@ -1,16 +1,16 @@ "use client"; -import { useState } from "react"; -import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; +import WaveDropDeleteButton from "@/components/utils/button/WaveDropDeleteButton"; +import VotingModal from "@/components/voting/VotingModal"; import { ApiDropType } from "@/generated/models/ApiDropType"; +import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; +import { useDropInteractionRules } from "@/hooks/drops/useDropInteractionRules"; +import { useState } from "react"; +import { SingleWaveDropContent } from "./SingleWaveDropContent"; import { SingleWaveDropInfoContainer } from "./SingleWaveDropInfoContainer"; import { SingleWaveDropInfoDetails } from "./SingleWaveDropInfoDetails"; -import { SingleWaveDropContent } from "./SingleWaveDropContent"; -import WaveDropDeleteButton from "@/components/utils/button/WaveDropDeleteButton"; -import { useDropInteractionRules } from "@/hooks/drops/useDropInteractionRules"; -import VotingModal from "@/components/voting/VotingModal"; -import { WaveDropVoteSummary } from "./WaveDropVoteSummary"; import { WaveDropMetaRow } from "./WaveDropMetaRow"; +import { WaveDropVoteSummary } from "./WaveDropVoteSummary"; interface SingleWaveDropInfoPanelProps { readonly drop: ExtendedDrop; @@ -22,33 +22,38 @@ export const SingleWaveDropInfoPanel = ({ const [isVotingOpen, setIsVotingOpen] = useState(false); const { canDelete, canShowVote, isVotingEnded, isWinner } = useDropInteractionRules(drop); + const isChatWave = drop.drop_type === ApiDropType.Chat; return ( <> -
-
+
+
-
- setIsVotingOpen(true)} - /> -
+ {!isChatWave && ( +
+ setIsVotingOpen(true)} + /> +
+ )} -
- -
+ {!isChatWave && ( +
+ +
+ )} {canDelete && drop.drop_type !== ApiDropType.Winner && ( -
+
)} @@ -56,11 +61,13 @@ export const SingleWaveDropInfoPanel = ({
- setIsVotingOpen(false)} - /> + {!isChatWave && ( + setIsVotingOpen(false)} + /> + )} ); }; diff --git a/components/waves/gallery/WaveGallery.tsx b/components/waves/gallery/WaveGallery.tsx new file mode 100644 index 0000000000..e2c654c7be --- /dev/null +++ b/components/waves/gallery/WaveGallery.tsx @@ -0,0 +1,95 @@ +"use client"; + +import React, { useCallback } from "react"; +import type { ApiWave } from "@/generated/models/ApiWave"; +import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; +import { WaveGalleryItem } from "./WaveGalleryItem"; +import { useWaveGalleryDrops } from "@/hooks/useWaveGalleryDrops"; +import InfiniteScrollTrigger from "@/components/utils/infinite-scroll/InfiniteScrollTrigger"; + +interface WaveGalleryProps { + readonly wave: ApiWave; + readonly onDropClick: (drop: ExtendedDrop) => void; +} + +export const WaveGallery: React.FC = ({ + wave, + onDropClick, +}) => { + const { drops, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage } = + useWaveGalleryDrops(wave.id); + + const handleIntersection = useCallback( + (isIntersecting: boolean) => { + if (isIntersecting && hasNextPage && !isFetchingNextPage) { + void fetchNextPage().catch(() => { + // Error surfaced via query state + }); + } + }, + [hasNextPage, isFetchingNextPage, fetchNextPage] + ); + + if (isFetching && drops.length === 0) { + return ( +
+
+
+ + Loading gallery... + +
+
+ ); + } + + if (drops.length === 0) { + return ( +
+
+ + + + No media drops yet +
+
+ ); + } + + return ( +
+
+
+ {drops.map((drop) => ( + + ))} +
+ + {hasNextPage && ( +
+ {isFetchingNextPage ? ( +
+ ) : ( + + )} +
+ )} +
+
+ ); +}; diff --git a/components/waves/gallery/WaveGalleryItem.tsx b/components/waves/gallery/WaveGalleryItem.tsx new file mode 100644 index 0000000000..7dc28f6afa --- /dev/null +++ b/components/waves/gallery/WaveGalleryItem.tsx @@ -0,0 +1,113 @@ +"use client"; + +import MediaTypeBadge from "@/components/drops/media/MediaTypeBadge"; +import MediaDisplay from "@/components/drops/view/item/content/media/MediaDisplay"; +import UserProfileTooltipWrapper from "@/components/utils/tooltip/UserProfileTooltipWrapper"; +import { ImageScale } from "@/helpers/image.helpers"; +import { + type ExtendedDrop, + getDropPreviewImageUrl, +} from "@/helpers/waves/drop.helpers"; +import { useMediaQuery } from "@/hooks/useMediaQuery"; +import useDeviceInfo from "@/hooks/useDeviceInfo"; +import Link from "next/link"; +import { memo, useMemo } from "react"; + +interface WaveGalleryItemProps { + readonly drop: ExtendedDrop; + readonly onDropClick: (drop: ExtendedDrop) => void; +} + +export const WaveGalleryItem = memo( + ({ drop, onDropClick }) => { + const isTabletOrSmaller = useMediaQuery("(max-width: 1023px)"); + const { hasTouchScreen } = useDeviceInfo(); + const mediaImageScale = isTabletOrSmaller + ? ImageScale.AUTOx450 + : ImageScale.AUTOx1080; + + const media = drop.parts[0]?.media[0]; + + const previewImageUrl = useMemo( + () => getDropPreviewImageUrl(drop.metadata), + [drop.metadata] + ); + + const handleImageClick = () => { + onDropClick(drop); + }; + + const transitionClasses = !hasTouchScreen + ? "tw-transition-all tw-duration-300 tw-ease-out" + : ""; + + const containerClass = `tw-group ${transitionClasses} tw-relative tw-bg-iron-950/50 tw-rounded-xl tw-overflow-hidden desktop-hover:hover:tw-ring-1 desktop-hover:hover:tw-ring-iron-700`; + + const imageScaleClasses = hasTouchScreen + ? "" + : "tw-transform tw-duration-500 tw-ease-out group-hover:tw-scale-105"; + + return ( +
+ +
+ +
+ {drop.author.handle && ( +
+ + e.stopPropagation()} + href={`/${drop.author.handle}`} + className="tw-rounded-lg tw-bg-black/60 tw-px-2 tw-py-1 tw-text-xs tw-text-white/90 tw-no-underline tw-backdrop-blur-sm tw-transition-colors desktop-hover:hover:tw-bg-black/80" + > + {drop.author.handle} + + +
+ )} +
+ ); + } +); + +WaveGalleryItem.displayName = "WaveGalleryItem"; diff --git a/components/waves/gallery/index.ts b/components/waves/gallery/index.ts new file mode 100644 index 0000000000..c7bbec4279 --- /dev/null +++ b/components/waves/gallery/index.ts @@ -0,0 +1,2 @@ +export { WaveGallery } from "./WaveGallery"; +export { WaveGalleryItem } from "./WaveGalleryItem"; diff --git a/hooks/useWaveGalleryDrops.ts b/hooks/useWaveGalleryDrops.ts new file mode 100644 index 0000000000..c31496e354 --- /dev/null +++ b/hooks/useWaveGalleryDrops.ts @@ -0,0 +1,197 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { keepPreviousData, useInfiniteQuery } 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"; +import { DropSize, type ExtendedDrop } from "@/helpers/waves/drop.helpers"; +import type { WsDropUpdateMessage } from "@/helpers/Types"; +import { WsMessageType } from "@/helpers/Types"; +import { useWebSocketMessage } from "@/services/websocket/useWebSocketMessage"; + +const GALLERY_DROPS_LIMIT = 20; + +const processDrops = (pages: ApiDrop[][] | undefined) => { + if (!pages) return []; + // Flatten all pages into single array and convert to ExtendedDrop + const allDrops = pages.flat(); + const extendedDrops: ExtendedDrop[] = allDrops.map((drop) => ({ + ...drop, + type: DropSize.FULL, + stableKey: drop.id, + stableHash: drop.id, + })); + const keyCount = new Map(); + return extendedDrops.map((drop) => { + const count = (keyCount.get(drop.stableKey) ?? 0) + 1; + keyCount.set(drop.stableKey, count); + if (count > 1) { + return { ...drop, stableKey: `${drop.stableKey}-${count}` }; + } + return drop; + }); +}; + +export function useWaveGalleryDrops(waveId: string) { + const queryKey = [ + QueryKey.DROPS, + { + waveId, + limit: GALLERY_DROPS_LIMIT, + containsMedia: true, + }, + ]; + + const { + data, + fetchNextPage: onFetchNextPage, + hasNextPage, + isFetching, + isFetchingNextPage, + refetch, + } = useInfiniteQuery({ + queryKey, + queryFn: async ({ pageParam }: { pageParam: number | null }) => { + const params: Record = { + wave_id: waveId, + limit: GALLERY_DROPS_LIMIT.toString(), + contains_media: "true", + }; + + if (typeof pageParam === "number") { + params["serial_no_less_than"] = `${pageParam}`; + } + + return await commonApiFetch({ + endpoint: "drops", + params, + }); + }, + enabled: !!waveId, + initialPageParam: null, + getNextPageParam: (lastPage) => lastPage.at(-1)?.serial_no ?? null, + placeholderData: keepPreviousData, + staleTime: 60000, + refetchOnWindowFocus: true, + refetchOnMount: true, + refetchOnReconnect: true, + }); + + const fetchNextPage = useCallback(async () => { + if (hasNextPage && !isFetchingNextPage) { + await onFetchNextPage(); + } + }, [hasNextPage, isFetchingNextPage, onFetchNextPage]); + + const drops = useMemo(() => processDrops(data?.pages), [data]); + + const lastRefetchTimeRef = useRef(0); + const pendingRefetchRef = useRef(false); + const timeoutRef = useRef(null); + const isFetchingRef = useRef(isFetching); + const isFetchingNextPageRef = useRef(isFetchingNextPage); + + useEffect(() => { + isFetchingRef.current = isFetching; + isFetchingNextPageRef.current = isFetchingNextPage; + }, [isFetching, isFetchingNextPage]); + + const onRefetch = useCallback(() => { + const now = Date.now(); + const timeSinceLastRefetch = now - lastRefetchTimeRef.current; + const minDebounceTime = 1000; + + const executeRefetch = () => { + lastRefetchTimeRef.current = Date.now(); + pendingRefetchRef.current = false; + void refetch().catch(() => { + // Error surfaced via query state + }); + }; + + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + + if (isFetching || isFetchingNextPage) { + pendingRefetchRef.current = true; + return; + } + + if (timeSinceLastRefetch < minDebounceTime) { + pendingRefetchRef.current = true; + timeoutRef.current = setTimeout(() => { + timeoutRef.current = null; + if (isFetchingRef.current || isFetchingNextPageRef.current) { + return; + } + executeRefetch(); + }, minDebounceTime - timeSinceLastRefetch); + return; + } + + executeRefetch(); + }, [refetch, isFetching, isFetchingNextPage]); + + useEffect(() => { + if (!isFetching && !isFetchingNextPage && pendingRefetchRef.current) { + const now = Date.now(); + const timeSinceLastRefetch = now - lastRefetchTimeRef.current; + const minDebounceTime = 1000; + + if (timeSinceLastRefetch >= minDebounceTime) { + lastRefetchTimeRef.current = now; + pendingRefetchRef.current = false; + void refetch().catch(() => { + // Error surfaced via query state + }); + } else { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + timeoutRef.current = setTimeout(() => { + timeoutRef.current = null; + if (pendingRefetchRef.current) { + lastRefetchTimeRef.current = Date.now(); + pendingRefetchRef.current = false; + void refetch().catch(() => { + // Error surfaced via query state + }); + } + }, minDebounceTime - timeSinceLastRefetch); + } + } + + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }; + }, [isFetching, isFetchingNextPage, refetch]); + + useWebSocketMessage( + WsMessageType.DROP_UPDATE, + useCallback( + (message) => { + if (waveId !== message.wave.id) { + return; + } + onRefetch(); + }, + [waveId, onRefetch] + ) + ); + + return { + drops, + fetchNextPage, + hasNextPage, + isFetching, + isFetchingNextPage, + refetch, + }; +} diff --git a/hooks/useWaveViewMode.ts b/hooks/useWaveViewMode.ts new file mode 100644 index 0000000000..218d5e375a --- /dev/null +++ b/hooks/useWaveViewMode.ts @@ -0,0 +1,133 @@ +"use client"; + +import { useCallback, useSyncExternalStore } from "react"; + +export type WaveViewMode = "chat" | "gallery"; + +const STORAGE_KEY = "waveViewModes"; +const STORAGE_EVENT = "waveViewModesChange"; + +type WaveViewModes = Record; + +const EMPTY_MODES: WaveViewModes = {}; + +let memoryModes: WaveViewModes = {}; +let useMemoryStore = false; + +// Cache for useSyncExternalStore - must return same reference when data unchanged +let cachedModes: WaveViewModes = EMPTY_MODES; +let cachedRaw: string | null = null; + +const readStoredModes = (): WaveViewModes => { + if (typeof window === "undefined") { + return EMPTY_MODES; + } + + if (useMemoryStore) { + return memoryModes; + } + + try { + const stored = window.localStorage.getItem(STORAGE_KEY); + + // Return cached if raw string unchanged + if (stored === cachedRaw) { + return cachedModes; + } + + if (!stored) { + cachedRaw = null; + cachedModes = EMPTY_MODES; + return EMPTY_MODES; + } + + try { + cachedRaw = stored; + cachedModes = JSON.parse(stored) as WaveViewModes; + return cachedModes; + } catch { + cachedRaw = null; + cachedModes = EMPTY_MODES; + return EMPTY_MODES; + } + } catch { + useMemoryStore = true; + return memoryModes; + } +}; + +const writeStoredModes = (next: WaveViewModes) => { + memoryModes = next; + + if (typeof window === "undefined") { + return; + } + + if (!useMemoryStore) { + try { + const serialized = JSON.stringify(next); + window.localStorage.setItem(STORAGE_KEY, serialized); + // Sync cache + cachedRaw = serialized; + cachedModes = next; + } catch (e) { + useMemoryStore = true; + console.warn("Error saving wave view modes to localStorage:", e); + } + } + + window.dispatchEvent(new Event(STORAGE_EVENT)); +}; + +const subscribeToModes = (onStoreChange: () => void) => { + if (typeof window === "undefined") { + return () => {}; + } + + const handler = (event: Event) => { + if ( + event instanceof StorageEvent && + event.key !== null && + event.key !== STORAGE_KEY + ) { + return; + } + + onStoreChange(); + }; + + window.addEventListener("storage", handler); + window.addEventListener(STORAGE_EVENT, handler); + + return () => { + window.removeEventListener("storage", handler); + window.removeEventListener(STORAGE_EVENT, handler); + }; +}; + +export function useWaveViewMode(waveId: string) { + const allModes = useSyncExternalStore( + subscribeToModes, + readStoredModes, + () => EMPTY_MODES + ); + + const viewMode: WaveViewMode = allModes[waveId] ?? "chat"; + + const setViewMode = useCallback( + (mode: WaveViewMode | ((prev: WaveViewMode) => WaveViewMode)) => { + const prev = readStoredModes(); + const current = prev[waveId] ?? "chat"; + const next = typeof mode === "function" ? mode(current) : mode; + + writeStoredModes({ ...prev, [waveId]: next }); + }, + [waveId] + ); + + const toggleViewMode = useCallback(() => { + setViewMode((prev) => (prev === "chat" ? "gallery" : "chat")); + }, [setViewMode]); + + return { viewMode, setViewMode, toggleViewMode }; +}