diff --git a/__tests__/components/brain/BrainMobile.test.tsx b/__tests__/components/brain/BrainMobile.test.tsx index 406eb63874..1051c101ed 100644 --- a/__tests__/components/brain/BrainMobile.test.tsx +++ b/__tests__/components/brain/BrainMobile.test.tsx @@ -35,23 +35,31 @@ jest.mock("@/hooks/useMemesQuickVoteDialogController", () => ({ const [quickVoteSessionId, setQuickVoteSessionId] = React.useState(0); const nextSessionIdRef = React.useRef(1); const reservedSessionIdRef = React.useRef(null as number | null); + const closeQuickVote = () => setIsQuickVoteOpen(false); + const openQuickVote = () => { + const sessionId = + reservedSessionIdRef.current ?? nextSessionIdRef.current; + reservedSessionIdRef.current = null; + nextSessionIdRef.current = sessionId + 1; + setQuickVoteSessionId(sessionId); + setIsQuickVoteOpen(true); + }; + const prefetchQuickVote = () => { + if (reservedSessionIdRef.current === null) { + reservedSessionIdRef.current = nextSessionIdRef.current; + } + }; return { - closeQuickVote: () => setIsQuickVoteOpen(false), - isQuickVoteOpen, - openQuickVote: () => { - const sessionId = - reservedSessionIdRef.current ?? nextSessionIdRef.current; - reservedSessionIdRef.current = null; - nextSessionIdRef.current = sessionId + 1; - setQuickVoteSessionId(sessionId); - setIsQuickVoteOpen(true); - }, - prefetchQuickVote: () => { - if (reservedSessionIdRef.current === null) { - reservedSessionIdRef.current = nextSessionIdRef.current; - } + closeQuickVote, + dialogState: { + isOpen: isQuickVoteOpen, + onClose: closeQuickVote, + sessionId: quickVoteSessionId, }, + isQuickVoteOpen, + openQuickVote, + prefetchQuickVote, quickVoteSessionId, }; }, @@ -76,11 +84,6 @@ jest.mock("@/hooks/useWave", () => ({ useWave: (...args: any[]) => mockUseWave(...args), })); -const mockUseMemesWaveFooterStats = jest.fn(); -jest.mock("@/hooks/useMemesWaveFooterStats", () => ({ - useMemesWaveFooterStats: () => mockUseMemesWaveFooterStats(), -})); - jest.mock("@/hooks/useWaveTimers", () => ({ useWaveTimers: () => ({ voting: { isCompleted: mockIsCompleted }, @@ -134,28 +137,6 @@ jest.mock("@/components/brain/mobile/BrainMobileMessages", () => ({ default: () =>
, })); -jest.mock( - "@/components/brain/left-sidebar/waves/MemesWaveQuickVoteTrigger", - () => ({ - __esModule: true, - default: ({ - leftThisRoundCount, - onOpenQuickVote, - }: { - readonly leftThisRoundCount: number; - readonly onOpenQuickVote: () => void; - }) => ( - - ), - }) -); - let mockDialogMountCount = 0; jest.mock( "@/components/brain/left-sidebar/waves/memes-quick-vote/MemesQuickVoteDialog", @@ -262,14 +243,6 @@ describe("BrainMobile", () => { isRankWave: true, isDm: incomingWave?.chat?.scope?.group?.is_direct_message ?? false, })); - mockUseMemesWaveFooterStats.mockReturnValue({ - isAvailable: true, - isReady: true, - leftThisRoundCount: 3, - uncastPower: 5000, - unratedCount: 9, - votingLabel: "TDH", - }); }); it("renders BrainDesktopDrop when drop is open", () => { @@ -292,7 +265,6 @@ describe("BrainMobile", () => { isApp = true; render(child); expect(screen.queryByTestId("tabs")).toBeNull(); - expect(mockUseMemesWaveFooterStats).not.toHaveBeenCalled(); expect(mockDialogMountCount).toBe(0); mockSearchParams.set("wave", "1"); @@ -315,23 +287,23 @@ describe("BrainMobile", () => { expect(screen.getByTestId(testId)).toBeInTheDocument(); }); - expect(mockUseMemesWaveFooterStats).not.toHaveBeenCalled(); expect(mockDialogMountCount).toBe(0); } ); - it("shows the floating quick-vote trigger for non-DM wave chat in app", async () => { + it("keeps quick vote out of the default wave chat view in app", async () => { mockSearchParams.set("wave", "1"); waveData = createWave(false); render(child); await waitFor(() => { - expect(screen.getByTestId("quick-vote-trigger")).toBeInTheDocument(); + expect(screen.getByTestId("tabs")).toBeInTheDocument(); }); - expect(mockUseMemesWaveFooterStats).toHaveBeenCalled(); - expect(mockDialogMountCount).toBe(1); + expect(screen.queryByTestId("waves")).toBeNull(); + expect(screen.queryByTestId("quick-vote-dialog")).toBeNull(); + expect(mockDialogMountCount).toBe(0); }); it("keeps My Votes unavailable for guests on memes waves", async () => { @@ -361,40 +333,6 @@ describe("BrainMobile", () => { }); }); - it("keeps the floating quick-vote trigger inside the flex pane wrapper", async () => { - mockSearchParams.set("wave", "1"); - waveData = createWave(false); - - const { container } = render(child); - - await waitFor(() => { - expect(screen.getByTestId("quick-vote-trigger")).toBeInTheDocument(); - }); - - const activePane = container.querySelector( - ".tw-relative.tw-min-w-0.tw-flex-1" - ); - - expect(activePane).not.toBeNull(); - expect(activePane).toContainElement( - screen.getByTestId("quick-vote-trigger") - ); - }); - - it("hides the floating quick-vote trigger for DM wave chat", async () => { - mockSearchParams.set("wave", "1"); - waveData = createWave(true); - - render(child); - - await waitFor(() => { - expect(screen.queryByTestId("quick-vote-trigger")).toBeNull(); - }); - - expect(mockUseMemesWaveFooterStats).not.toHaveBeenCalled(); - expect(mockDialogMountCount).toBe(0); - }); - it("mounts the quick-vote dialog owner on the waves shell", async () => { mockPathname = "/waves"; @@ -408,27 +346,6 @@ describe("BrainMobile", () => { expect(screen.queryByTestId("quick-vote-dialog")).toBeNull(); }); - it("hides the floating quick-vote trigger when leaving chat view", async () => { - mockSearchParams.set("wave", "1"); - waveData = createWave(false); - - render(child); - - await waitFor(() => { - expect(screen.getByTestId("quick-vote-trigger")).toBeInTheDocument(); - expect(screen.getByTestId("tabs")).toBeInTheDocument(); - }); - - act(() => { - latestTabsProps.onViewChange(BrainView.ABOUT); - }); - - await waitFor(() => { - expect(screen.queryByTestId("quick-vote-trigger")).toBeNull(); - expect(screen.getByTestId("about")).toBeInTheDocument(); - }); - }); - it("drops a stale local tab selection when navigation context changes", async () => { mockSearchParams.set("wave", "1"); waveData = createWave(false); @@ -528,33 +445,26 @@ describe("BrainMobile", () => { }); }); - it("reuses the page-owned quick-vote dialog across floating and waves entry points", async () => { - mockSearchParams.set("wave", "1"); - waveData = createWave(false); + it("reuses the page-owned quick-vote dialog across repeated waves footer openings", async () => { + mockPathname = "/waves"; - const { rerender } = render(child); + render(child); await waitFor(() => { - expect(screen.getByTestId("quick-vote-trigger")).toBeInTheDocument(); + expect(screen.getByTestId("waves")).toBeInTheDocument(); }); expect(mockDialogMountCount).toBe(1); - fireEvent.click(screen.getByTestId("quick-vote-trigger")); - expect(screen.getByText("Session 1")).toBeInTheDocument(); + fireEvent.click( + screen.getByRole("button", { + name: "Open quick vote from waves footer", + }) + ); + expect(screen.getByText("Session 1")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Close Quick Vote" })); expect(screen.queryByTestId("quick-vote-dialog")).not.toBeInTheDocument(); - expect(mockDialogMountCount).toBe(1); - - mockSearchParams.delete("wave"); - mockPathname = "/waves"; - waveData = null; - rerender(child); - - await waitFor(() => { - expect(screen.getByTestId("waves")).toBeInTheDocument(); - }); fireEvent.click( screen.getByRole("button", { diff --git a/__tests__/components/brain/mobile/FloatingMemesQuickVoteTrigger.test.tsx b/__tests__/components/brain/mobile/FloatingMemesQuickVoteTrigger.test.tsx deleted file mode 100644 index 22647af9c0..0000000000 --- a/__tests__/components/brain/mobile/FloatingMemesQuickVoteTrigger.test.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { fireEvent, render, screen } from "@testing-library/react"; -import FloatingMemesQuickVoteTrigger from "@/components/brain/mobile/FloatingMemesQuickVoteTrigger"; -import { useMemesWaveFooterStats } from "@/hooks/useMemesWaveFooterStats"; - -jest.mock("@/hooks/useMemesWaveFooterStats", () => ({ - useMemesWaveFooterStats: jest.fn(), -})); - -jest.mock( - "@/components/brain/left-sidebar/waves/MemesWaveQuickVoteTrigger", - () => ({ - __esModule: true, - default: ({ - leftThisRoundCount, - onOpenQuickVote, - onPrefetchQuickVote, - unratedCount: _unratedCount, - }: { - readonly leftThisRoundCount: number; - readonly onOpenQuickVote: () => void; - readonly onPrefetchQuickVote?: (() => void) | undefined; - readonly unratedCount: number; - }) => ( - - ), - }) -); - -const useMemesWaveFooterStatsMock = - useMemesWaveFooterStats as jest.MockedFunction< - typeof useMemesWaveFooterStats - >; - -describe("FloatingMemesQuickVoteTrigger", () => { - const onOpenQuickVote = jest.fn(); - const onPrefetchQuickVote = jest.fn(); - - beforeEach(() => { - jest.clearAllMocks(); - useMemesWaveFooterStatsMock.mockReturnValue({ - isAvailable: false, - isReady: false, - leftThisRoundCount: 0, - uncastPower: null, - unratedCount: 0, - votingLabel: null, - }); - }); - - it("stays hidden when footer stats are unavailable", () => { - render( - - ); - - expect(screen.queryByTestId("floating-quick-vote-trigger")).toBeNull(); - }); - - it("passes hover and focus prefetch intent through to the floating trigger", () => { - useMemesWaveFooterStatsMock.mockReturnValue({ - isAvailable: true, - isReady: true, - leftThisRoundCount: 3, - uncastPower: 5000, - unratedCount: 9, - votingLabel: "TDH", - }); - - render( - - ); - - const trigger = screen.getByTestId("floating-quick-vote-trigger"); - - fireEvent.mouseEnter(trigger); - fireEvent.focus(trigger); - fireEvent.click(trigger); - - expect(onPrefetchQuickVote).toHaveBeenCalledTimes(2); - expect(onOpenQuickVote).toHaveBeenCalledTimes(1); - }); -}); diff --git a/__tests__/components/brain/my-stream/MyStreamWaveLeaderboard.test.tsx b/__tests__/components/brain/my-stream/MyStreamWaveLeaderboard.test.tsx index a2049a8110..f61db5cf45 100644 --- a/__tests__/components/brain/my-stream/MyStreamWaveLeaderboard.test.tsx +++ b/__tests__/components/brain/my-stream/MyStreamWaveLeaderboard.test.tsx @@ -9,7 +9,7 @@ import { WaveDropsLeaderboardSort } from "@/hooks/useWaveDropsLeaderboard"; const useWave = jest.fn(); const useLayout = jest.fn(); const useLocalPreference = jest.fn(); -const useWaveCurationGroups = jest.fn(); +const useWaveCurations = jest.fn(); const replace = jest.fn(); let searchParamsString = ""; let dropsProps: any; @@ -33,8 +33,8 @@ jest.mock( (...args: any[]) => useLocalPreference(...args) ); -jest.mock("@/hooks/waves/useWaveCurationGroups", () => ({ - useWaveCurationGroups: (...args: any[]) => useWaveCurationGroups(...args), +jest.mock("@/hooks/waves/useWaveCurations", () => ({ + useWaveCurations: (...args: any[]) => useWaveCurations(...args), })); jest.mock("next/navigation", () => ({ useRouter: () => ({ replace }), @@ -130,7 +130,7 @@ describe("MyStreamWaveLeaderboard", () => { createDropProps = []; curationModalProps = undefined; useLayout.mockReturnValue({ leaderboardViewStyle: {} }); - useWaveCurationGroups.mockReturnValue({ + useWaveCurations.mockReturnValue({ data: [], isLoading: false, isError: false, @@ -273,7 +273,7 @@ describe("MyStreamWaveLeaderboard", () => { }); it("reads curation group from URL and keeps price filters local", () => { - searchParamsString = "curated_by_group=group-1&min_price=1.5&max_price=4.2"; + searchParamsString = "curation_id=group-1&min_price=1.5&max_price=4.2"; useWave.mockReturnValue({ isMemesWave: false, isCurationWave: true, @@ -283,7 +283,7 @@ describe("MyStreamWaveLeaderboard", () => { hasReachedLimit: false, }, }); - useWaveCurationGroups.mockReturnValue({ + useWaveCurations.mockReturnValue({ data: [{ id: "group-1", name: "Curators", group_id: "g1" }], isLoading: false, isError: false, @@ -343,7 +343,7 @@ describe("MyStreamWaveLeaderboard", () => { hasReachedLimit: false, }, }); - useWaveCurationGroups.mockReturnValue({ + useWaveCurations.mockReturnValue({ data: [{ id: "group-1", name: "Curators", group_id: "g1" }], isLoading: false, isError: false, @@ -357,7 +357,7 @@ describe("MyStreamWaveLeaderboard", () => { renderLeaderboard(); headerProps.onCurationGroupChange("group-1"); - expect(replace).toHaveBeenCalledWith("/waves?curated_by_group=group-1", { + expect(replace).toHaveBeenCalledWith("/waves?curation_id=group-1", { scroll: false, }); @@ -366,7 +366,7 @@ describe("MyStreamWaveLeaderboard", () => { }); it("updates local price filters without touching URL", () => { - searchParamsString = "curated_by_group=group-1"; + searchParamsString = "curation_id=group-1"; useWave.mockReturnValue({ isMemesWave: false, isCurationWave: true, @@ -376,7 +376,7 @@ describe("MyStreamWaveLeaderboard", () => { hasReachedLimit: false, }, }); - useWaveCurationGroups.mockReturnValue({ + useWaveCurations.mockReturnValue({ data: [{ id: "group-1", name: "Curators", group_id: "g1" }], isLoading: false, isError: false, diff --git a/__tests__/components/waves/groups/WaveGroups.test.tsx b/__tests__/components/waves/groups/WaveGroups.test.tsx index 4b45b4bb42..b029bef58a 100644 --- a/__tests__/components/waves/groups/WaveGroups.test.tsx +++ b/__tests__/components/waves/groups/WaveGroups.test.tsx @@ -4,7 +4,7 @@ import { ApiWaveType } from "@/generated/models/ApiWaveType"; // Capture props passed to the mocked WaveGroup component const captured: any[] = []; -const capturedCuration: any[] = []; +const capturedActiveCuration: any[] = []; jest.mock("@/components/waves/specs/groups/group/WaveGroup", () => { const { WaveGroupType } = jest.requireActual( "../../../../components/waves/specs/groups/group/WaveGroup.types" @@ -20,12 +20,12 @@ jest.mock("@/components/waves/specs/groups/group/WaveGroup", () => { }); jest.mock( - "@/components/waves/groups/curation/WaveCurationGroupsSection", + "@/components/waves/groups/curation/WaveActiveCurationSection", () => ({ __esModule: true, default: (props: any) => { - capturedCuration.push(props); - return
; + capturedActiveCuration.push(props); + return
Curation
; }, }) ); @@ -45,7 +45,7 @@ describe("WaveGroups", () => { beforeEach(() => { captured.length = 0; - capturedCuration.length = 0; + capturedActiveCuration.length = 0; }); it("renders all groups with ring by default", () => { @@ -53,11 +53,11 @@ describe("WaveGroups", () => { ); expect(screen.getByText("General")).toBeInTheDocument(); - expect(screen.getByText("Curation Groups")).toBeInTheDocument(); + expect(screen.getByText("Curation")).toBeInTheDocument(); expect(getAllByTestId(/group-/)).toHaveLength(5); expect(getByTestId("curation-section")).toBeInTheDocument(); - expect(capturedCuration).toHaveLength(1); - expect(capturedCuration[0].wave).toBe(baseWave); + expect(capturedActiveCuration).toHaveLength(1); + expect(capturedActiveCuration[0].wave).toBe(baseWave); expect(captured.map((c) => c.type)).toEqual([ "VIEW", "DROP", @@ -79,8 +79,8 @@ describe("WaveGroups", () => { ); expect(getAllByTestId(/group-/)).toHaveLength(3); expect(getByTestId("curation-section")).toBeInTheDocument(); - expect(capturedCuration).toHaveLength(1); - expect(capturedCuration[0].wave).toBe(wave); + expect(capturedActiveCuration).toHaveLength(1); + expect(capturedActiveCuration[0].wave).toBe(wave); expect(captured.map((c) => c.type)).toEqual(["VIEW", "CHAT", "ADMIN"]); const inner = container.querySelector(".tw-h-full") as HTMLElement; expect(inner.className).toContain("tw-rounded-b-xl"); diff --git a/__tests__/components/waves/groups/curation/WaveCurationGroupsSection.test.tsx b/__tests__/components/waves/groups/curation/WaveCurationGroupsSection.test.tsx deleted file mode 100644 index 27f2bd8307..0000000000 --- a/__tests__/components/waves/groups/curation/WaveCurationGroupsSection.test.tsx +++ /dev/null @@ -1,407 +0,0 @@ -import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import WaveCurationGroupsSection from "@/components/waves/groups/curation/WaveCurationGroupsSection"; -import { AuthContext } from "@/components/auth/Auth"; -import { - ReactQueryWrapperContext, - QueryKey, -} from "@/components/react-query-wrapper/ReactQueryWrapper"; -import { ApiWaveType } from "@/generated/models/ApiWaveType"; -import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; -import { commonApiDelete, commonApiPost } from "@/services/api/common-api"; -import { createPublishedGroupForIdentityChange } from "@/components/waves/specs/groups/group/edit/buttons/utils/identityGroupWorkflow"; - -jest.mock("@tanstack/react-query", () => ({ - useQueries: jest.fn(), - useQuery: jest.fn(), - useQueryClient: jest.fn(), -})); - -jest.mock("@/services/api/common-api", () => ({ - commonApiFetch: jest.fn(), - commonApiPost: jest.fn(), - commonApiDelete: jest.fn(), -})); - -jest.mock( - "@/components/waves/specs/groups/group/edit/buttons/utils/identityGroupWorkflow", - () => ({ - createPublishedGroupForIdentityChange: jest.fn(), - IdentityGroupWorkflowMode: { - INCLUDE: "include", - EXCLUDE: "exclude", - }, - }) -); - -jest.mock("@/components/distribution-plan-tool/common/CircleLoader", () => ({ - __esModule: true, - default: () =>
, -})); - -jest.mock("@/components/compact-menu", () => ({ - CompactMenu: ({ - items, - }: { - items: Array<{ id: string; label: string; onSelect: () => void }>; - }) => ( -
- {items.map((item) => ( - - ))} -
- ), -})); - -jest.mock("@/components/utils/select-group/SelectGroupModalWrapper", () => ({ - __esModule: true, - default: ({ - isOpen, - onGroupSelect, - }: { - isOpen: boolean; - onGroupSelect: (group: any) => void; - }) => - isOpen ? ( - - ) : null, -})); - -jest.mock( - "@/components/waves/specs/groups/group/edit/WaveGroupRemoveModal", - () => ({ - __esModule: true, - default: ({ removeGroup }: { removeGroup: () => void }) => ( - - ), - }) -); - -jest.mock( - "@/components/waves/specs/groups/group/edit/WaveGroupManageIdentitiesModal", - () => ({ - __esModule: true, - WaveGroupManageIdentitiesMode: { - INCLUDE: "INCLUDE", - EXCLUDE: "EXCLUDE", - }, - default: ({ - mode, - onConfirm, - }: { - mode: "INCLUDE" | "EXCLUDE"; - onConfirm: (event: { - identity: string; - mode: "INCLUDE" | "EXCLUDE"; - }) => void; - }) => ( - - ), - }) -); - -const mockUseQuery = useQuery as jest.Mock; -const mockUseQueries = useQueries as jest.Mock; -const mockUseQueryClient = useQueryClient as jest.Mock; -const mockCommonApiPost = commonApiPost as jest.Mock; -const mockCommonApiDelete = commonApiDelete as jest.Mock; -const mockCreatePublishedGroupForIdentityChange = - createPublishedGroupForIdentityChange as jest.Mock; - -const invalidateQueries = jest.fn(); -const onWaveCreated = jest.fn(); -const setToast = jest.fn(); -const requestAuth = jest.fn().mockResolvedValue({ success: true }); - -const baseWave = { - id: "wave-1", - name: "Wave One", - author: { handle: "simo" }, - wave: { - type: ApiWaveType.Rank, - authenticated_user_eligible_for_admin: true, - }, -} as any; - -const renderSection = (wave = baseWave) => - render( - - - - - - ); - -beforeEach(() => { - jest.clearAllMocks(); - mockUseQueryClient.mockReturnValue({ - invalidateQueries, - }); - mockUseQuery.mockReturnValue({ - data: [], - isLoading: false, - isError: false, - }); - mockUseQueries.mockReturnValue([]); - mockCommonApiPost.mockResolvedValue({}); - mockCommonApiDelete.mockResolvedValue(undefined); - mockCreatePublishedGroupForIdentityChange.mockResolvedValue("group-new"); -}); - -describe("WaveCurationGroupsSection", () => { - it("does not render for chat waves", () => { - const chatWave = { - ...baseWave, - wave: { ...baseWave.wave, type: ApiWaveType.Chat }, - }; - - const { container } = renderSection(chatWave); - expect(container).toBeEmptyDOMElement(); - }); - - it("creates curation group from Add group action", async () => { - const user = userEvent.setup(); - renderSection(); - - expect(screen.queryByText("None")).not.toBeInTheDocument(); - - await user.click(screen.getByRole("button", { name: "Add group" })); - await user.click(screen.getByRole("button", { name: "Select group" })); - - await waitFor(() => { - expect(mockCommonApiPost).toHaveBeenCalledWith( - expect.objectContaining({ - endpoint: "waves/wave-1/curation-groups", - body: { - name: "Group Two", - group_id: "group-2", - }, - }) - ); - }); - - expect(requestAuth).toHaveBeenCalled(); - expect(invalidateQueries).toHaveBeenCalledWith({ - queryKey: [QueryKey.WAVE_CURATION_GROUPS, { wave_id: "wave-1" }], - }); - expect(onWaveCreated).toHaveBeenCalled(); - }); - - it("keeps add action as bottom button only", () => { - mockUseQuery.mockReturnValue({ - data: [ - { - id: "curation-1", - name: "Curators One", - wave_id: "wave-1", - group_id: "group-1", - created_at: 1, - updated_at: 1, - }, - ], - isLoading: false, - isError: false, - }); - - renderSection(); - - expect(screen.getAllByRole("button", { name: "Add group" })).toHaveLength( - 1 - ); - }); - - it("renders curation group as clickable link when group details are available", () => { - mockUseQuery.mockReturnValue({ - data: [ - { - id: "curation-1", - name: "Curators One", - wave_id: "wave-1", - group_id: "group-1", - created_at: 1, - updated_at: 1, - }, - ], - isLoading: false, - isError: false, - }); - mockUseQueries.mockReturnValue([ - { - data: { - id: "group-1", - name: "Curators One", - group: {}, - created_at: 1, - created_by: { handle: "simo", pfp: "https://example.com/pfp.png" }, - visible: true, - is_private: false, - }, - }, - ]); - - renderSection(); - - expect( - screen.getByRole("link", { name: /Curators One/i }) - ).toBeInTheDocument(); - }); - - it("renders plain curation text when group details are unavailable", () => { - mockUseQuery.mockReturnValue({ - data: [ - { - id: "curation-1", - name: "Curators One", - wave_id: "wave-1", - group_id: "group-1", - created_at: 1, - updated_at: 1, - }, - ], - isLoading: false, - isError: false, - }); - mockUseQueries.mockReturnValue([ - { - isError: true, - }, - ]); - - renderSection(); - - expect(screen.getByText("Curators One")).toBeInTheDocument(); - expect( - screen.queryByRole("link", { name: /Curators One/i }) - ).not.toBeInTheDocument(); - }); - - it("removes an existing curation group", async () => { - const user = userEvent.setup(); - mockUseQuery.mockReturnValue({ - data: [ - { - id: "curation-1", - name: "Curators One", - wave_id: "wave-1", - group_id: "group-1", - created_at: 1, - updated_at: 1, - }, - ], - isLoading: false, - isError: false, - }); - - renderSection(); - await user.click(screen.getByRole("button", { name: "Remove group" })); - await user.click(screen.getByRole("button", { name: "Confirm remove" })); - - await waitFor(() => { - expect(mockCommonApiDelete).toHaveBeenCalledWith({ - endpoint: "waves/wave-1/curation-groups/curation-1", - }); - }); - }); - - it("includes identity and relinks curation group", async () => { - const user = userEvent.setup(); - mockUseQuery.mockReturnValue({ - data: [ - { - id: "curation-1", - name: "Curators One", - wave_id: "wave-1", - group_id: "group-1", - created_at: 1, - updated_at: 1, - }, - ], - isLoading: false, - isError: false, - }); - - renderSection(); - await user.click(screen.getByRole("button", { name: "Include identity" })); - await user.click( - screen.getByRole("button", { name: "Confirm identity update" }) - ); - - await waitFor(() => { - expect(mockCreatePublishedGroupForIdentityChange).toHaveBeenCalledWith( - expect.objectContaining({ - waveId: "wave-1", - waveName: "Wave One", - groupLabel: "Curation", - scopedGroupId: "group-1", - identity: "0xabc", - mode: "include", - }) - ); - }); - - expect(mockCommonApiPost).toHaveBeenCalledWith( - expect.objectContaining({ - endpoint: "waves/wave-1/curation-groups/curation-1", - body: { - name: "Curators One", - group_id: "group-new", - }, - }) - ); - }); -}); diff --git a/components/brain/BrainMobile.tsx b/components/brain/BrainMobile.tsx index 9e29768512..6c521918f4 100644 --- a/components/brain/BrainMobile.tsx +++ b/components/brain/BrainMobile.tsx @@ -30,7 +30,6 @@ import { useMyStreamOptional } from "@/contexts/wave/MyStreamContext"; import { useClosingDropId } from "@/hooks/useClosingDropId"; import { useMemesQuickVoteDialogController } from "@/hooks/useMemesQuickVoteDialogController"; import BrainMobileViewContent from "./mobile/BrainMobileViewContent"; -import FloatingMemesQuickVoteTrigger from "./mobile/FloatingMemesQuickVoteTrigger"; import { BrainView } from "./mobile/brainMobileViews"; import { useBrainMobileActiveView } from "./mobile/useBrainMobileActiveView"; @@ -91,7 +90,7 @@ const BrainMobile: React.FC = ({ children }) => { }, }); - const { isMemesWave, isCurationWave, isRankWave, isDm } = useWave(wave); + const { isMemesWave, isCurationWave, isRankWave } = useWave(wave); const { voting: { isCompleted }, @@ -174,17 +173,8 @@ const BrainMobile: React.FC = ({ children }) => { return null; }, [isApp, searchParams, connectedProfile, closeCreateOverlay]); - const shouldMountFloatingQuickVoteEntry = - isApp && - hasWave && - !!wave && - activeView === BrainView.DEFAULT && - !isDropOpen && - !isDm; const shouldMountQuickVoteDialog = - quickVote.isQuickVoteOpen || - shouldMountFloatingQuickVoteEntry || - activeView === BrainView.WAVES; + quickVote.isQuickVoteOpen || activeView === BrainView.WAVES; const dropOverlayClass = isApp ? "tw-fixed tw-inset-0 tw-z-[1010] tw-bg-black tailwind-scope" @@ -226,12 +216,6 @@ const BrainMobile: React.FC = ({ children }) => { transition={{ duration: 0.2, ease: "easeInOut" }} className="tw-relative tw-min-w-0 tw-flex-1" > - {shouldMountFloatingQuickVoteEntry && ( - - )} + `${BASE_TAB_BUTTON_CLASS_NAME} ${ + isActive ? ACTIVE_TAB_BACKGROUND : INACTIVE_TAB_BACKGROUND + }`; + +const getTabTextClassName = ({ + isActive, + additionalClasses, +}: { + readonly isActive: boolean; + readonly additionalClasses?: string | undefined; +}): string => { + const additionalClassName = additionalClasses ? ` ${additionalClasses}` : ""; + const textColorClassName = isActive ? ACTIVE_TAB_TEXT : INACTIVE_TAB_TEXT; + + return `${BASE_TAB_TEXT_CLASS_NAME}${additionalClassName} ${textColorClassName}`; +}; interface BrainMobileTabsProps { readonly activeView: BrainView; @@ -33,9 +62,22 @@ const BrainMobileTabs: React.FC = ({ isApp, }) => { const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); const { registerRef } = useLayout(); const { connectedProfile } = useAuth(); const hasAuthenticatedProfile = Boolean(connectedProfile?.handle); + const [isCreateCurationOpen, setIsCreateCurationOpen] = useState(false); + const shouldShowCurationTabs = Boolean(isApp && waveActive && wave?.id); + const activeCurationId = shouldShowCurationTabs + ? searchParams.get("curation") + : null; + const { data: curations = [] } = useWaveCurations({ + waveId: wave?.id ?? "", + enabled: shouldShowCurationTabs, + }); + const canManageCurations = + wave?.wave.authenticated_user_eligible_for_admin === true; // Local ref for component-specific needs const mobileTabsRef = useRef(null); @@ -65,116 +107,104 @@ const BrainMobileTabs: React.FC = ({ connectedProfile?.handle ?? null ); - const tabRefs = useRef>({ - [BrainView.DEFAULT]: null, - [BrainView.ABOUT]: null, - [BrainView.LEADERBOARD]: null, - [BrainView.SALES]: null, - [BrainView.WINNERS]: null, - [BrainView.OUTCOME]: null, - [BrainView.MY_VOTES]: null, - [BrainView.FAQ]: null, - [BrainView.WAVES]: null, - [BrainView.MESSAGES]: null, - [BrainView.NOTIFICATIONS]: null, - }); - - React.useEffect(() => { - const activeTabEl = tabRefs.current[activeView]; - if (activeTabEl) { - activeTabEl.scrollIntoView({ + const scrollActiveButtonIntoView = useCallback( + (element: HTMLButtonElement | null) => { + element?.scrollIntoView({ behavior: "smooth", inline: "center", block: "nearest", }); - } - }, [activeView]); - - const aboutButtonClasses = `tw-border-none tw-no-underline tw-flex tw-justify-center tw-items-center tw-px-2 tw-py-1.5 tw-gap-1 tw-flex-1 tw-rounded-md ${ - activeView === BrainView.ABOUT ? "tw-bg-iron-800" : "tw-bg-iron-950" - }`; - - const aboutButtonTextClasses = `tw-font-semibold tw-text-xs sm:tw-text-sm tw-whitespace-nowrap ${ - activeView === BrainView.ABOUT ? "tw-text-iron-300" : "tw-text-iron-400" - }`; - - const outcomeButtonClasses = `tw-border-none tw-no-underline tw-flex tw-justify-center tw-items-center tw-px-2 tw-py-1.5 tw-gap-1 tw-flex-1 tw-rounded-md ${ - activeView === BrainView.OUTCOME ? "tw-bg-iron-800" : "tw-bg-iron-950" - }`; - const otucomeButtonTextClasses = `tw-font-semibold tw-text-xs sm:tw-text-sm tw-whitespace-nowrap ${ - activeView === BrainView.OUTCOME ? "tw-text-iron-300" : "tw-text-iron-400" - }`; - - const myVotesButtonClasses = `tw-border-none tw-no-underline tw-flex tw-justify-center tw-items-center tw-px-2 tw-py-1.5 tw-gap-1 tw-flex-1 tw-rounded-md ${ - activeView === BrainView.MY_VOTES ? "tw-bg-iron-800" : "tw-bg-iron-950" - }`; - - const myVotesButtonTextClasses = `tw-font-semibold tw-text-xs sm:tw-text-sm tw-whitespace-nowrap ${ - activeView === BrainView.MY_VOTES ? "tw-text-iron-300" : "tw-text-iron-400" - }`; + }, + [] + ); - const salesButtonClasses = `tw-border-none tw-no-underline tw-flex tw-justify-center tw-items-center tw-px-2 tw-py-1.5 tw-gap-1 tw-flex-1 tw-rounded-md ${ - activeView === BrainView.SALES ? "tw-bg-iron-800" : "tw-bg-iron-950" - }`; + const getActiveButtonRef = useCallback( + (isActive: boolean) => (element: HTMLButtonElement | null) => { + if (isActive) { + scrollActiveButtonIntoView(element); + } + }, + [scrollActiveButtonIntoView] + ); - const salesButtonTextClasses = `tw-font-semibold tw-text-xs sm:tw-text-sm tw-whitespace-nowrap ${ - activeView === BrainView.SALES ? "tw-text-iron-300" : "tw-text-iron-400" - }`; + const updateSelectedCuration = useCallback( + (curationId: string | null) => { + const params = new URLSearchParams(searchParams.toString() || ""); - const chatButtonClasses = `tw-border-none tw-no-underline tw-flex tw-justify-center tw-items-center tw-px-2 tw-py-1.5 tw-gap-1 tw-flex-1 tw-rounded-md ${ - activeView === BrainView.DEFAULT ? "tw-bg-iron-800" : "tw-bg-iron-950" - }`; + if (curationId) { + params.set("curation", curationId); + } else { + params.delete("curation"); + } - const chatButtonTextClasses = `tw-font-semibold tw-text-xs sm:tw-text-sm tw-whitespace-nowrap ${ - activeView === BrainView.DEFAULT ? "tw-text-iron-300" : "tw-text-iron-400" - }`; - - const wavesButtonClasses = `tw-border-none tw-no-underline tw-flex tw-justify-center tw-items-center tw-px-2 tw-py-1.5 tw-gap-1 tw-flex-1 tw-rounded-md ${ - activeView === BrainView.WAVES ? "tw-bg-iron-800" : "tw-bg-iron-950" - }`; + const nextQuery = params.toString(); + const nextUrl = nextQuery ? `${pathname}?${nextQuery}` : pathname; + router.replace(nextUrl, { scroll: false }); + }, + [pathname, router, searchParams] + ); - const wavesButtonTextClasses = `tw-font-semibold tw-text-xs sm:tw-text-sm tw-whitespace-nowrap ${ - activeView === BrainView.WAVES ? "tw-text-iron-300" : "tw-text-iron-400" - }`; + const isChatActive = + activeView === BrainView.DEFAULT && activeCurationId === null; + const backButtonClasses = `tw-border-none tw-no-underline tw-flex tw-justify-center tw-items-center tw-px-2 tw-py-1.5 tw-gap-1 tw-flex-1 tw-rounded-md tw-bg-iron-950`; - const messagesButtonClasses = `tw-border-none tw-no-underline tw-flex tw-justify-center tw-items-center tw-px-2 tw-py-1.5 tw-gap-1 tw-flex-1 tw-rounded-md ${ - activeView === BrainView.MESSAGES ? "tw-bg-iron-800" : "tw-bg-iron-950" - }`; + const curationTabs = React.useMemo< + Array<{ id: string; name: string }> + >(() => { + const mappedCurations = curations.map((curation) => ({ + id: curation.id, + name: curation.name, + })); - const messagesButtonTextClasses = `tw-font-semibold tw-text-xs sm:tw-text-sm tw-whitespace-nowrap tw-relative ${ - activeView === BrainView.MESSAGES ? "tw-text-iron-300" : "tw-text-iron-400" - }`; + if ( + !activeCurationId || + mappedCurations.some((curation) => curation.id === activeCurationId) + ) { + return mappedCurations; + } - const notificationsButtonClasses = `tw-border-none tw-no-underline tw-flex tw-justify-center tw-items-center tw-px-2 tw-py-1.5 tw-gap-1 tw-flex-1 tw-rounded-md ${ - activeView === BrainView.NOTIFICATIONS ? "tw-bg-iron-800" : "tw-bg-iron-950" - }`; + return [{ id: activeCurationId, name: "Curation" }, ...mappedCurations]; + }, [activeCurationId, curations]); + const showCreateFirstCurationCallout = + isApp && canManageCurations && curations.length === 0; - const notificationsButtonTextClasses = `tw-font-semibold tw-text-xs sm:tw-text-sm tw-whitespace-nowrap tw-relative ${ - activeView === BrainView.NOTIFICATIONS - ? "tw-text-iron-300" - : "tw-text-iron-400" - }`; + const handleWaveViewChange = (view: BrainView) => { + const shouldPreserveSelectedCuration = + isApp && view === BrainView.ABOUT && activeCurationId !== null; - const backButtonClasses = `tw-border-none tw-no-underline tw-flex tw-justify-center tw-items-center tw-px-2 tw-py-1.5 tw-gap-1 tw-flex-1 tw-rounded-md tw-bg-iron-950`; + if (!shouldPreserveSelectedCuration) { + updateSelectedCuration(null); + } + onViewChange(view); + }; const onChatClick = () => { - onViewChange(BrainView.DEFAULT); + handleWaveViewChange(BrainView.DEFAULT); }; const onNotificationsClick = () => { - onViewChange(BrainView.NOTIFICATIONS); + handleWaveViewChange(BrainView.NOTIFICATIONS); + }; + + const onCurationClick = (curationId: string) => { + onViewChange(BrainView.DEFAULT); + updateSelectedCuration(curationId); }; const salesTabButton = waveActive && wave && isCurationWave ? ( ) : null; @@ -206,24 +236,31 @@ const BrainMobileTabs: React.FC = ({ )} {!waveActive && showWavesTab && ( )} {!isApp && !waveActive && ( )} {waveActive && ( )} {!isRankWave && salesTabButton} @@ -259,52 +298,53 @@ const BrainMobileTabs: React.FC = ({ { - tabRefs.current[view] = el; - }} + onViewChange={handleWaveViewChange} renderAfterLeaderboard={salesTabButton} /> {(isCurationWave || (isMemesWave && hasAuthenticatedProfile)) && ( <> )} {isMemesWave && ( + ); + })} + {isApp && canManageCurations && ( + + )} {!isApp && !waveActive && ( )}
+ {wave && isCreateCurationOpen && ( + setIsCreateCurationOpen(false)} + onSaved={(curation) => { + onCurationClick(curation.id); + setIsCreateCurationOpen(false); + }} + /> + )}
); }; diff --git a/components/brain/mobile/FloatingMemesQuickVoteTrigger.tsx b/components/brain/mobile/FloatingMemesQuickVoteTrigger.tsx deleted file mode 100644 index 4ea6cfa7b0..0000000000 --- a/components/brain/mobile/FloatingMemesQuickVoteTrigger.tsx +++ /dev/null @@ -1,33 +0,0 @@ -"use client"; - -import MemesWaveQuickVoteTrigger from "../left-sidebar/waves/MemesWaveQuickVoteTrigger"; -import { useMemesWaveFooterStats } from "@/hooks/useMemesWaveFooterStats"; - -interface FloatingMemesQuickVoteTriggerProps { - readonly onOpenQuickVote: () => void; - readonly onPrefetchQuickVote?: (() => void) | undefined; -} - -export default function FloatingMemesQuickVoteTrigger({ - onOpenQuickVote, - onPrefetchQuickVote, -}: FloatingMemesQuickVoteTriggerProps) { - const { isAvailable, leftThisRoundCount, unratedCount } = - useMemesWaveFooterStats(); - - if (!isAvailable) { - return null; - } - - return ( -
- -
- ); -} diff --git a/components/brain/my-stream/MyStreamActionTooltip.tsx b/components/brain/my-stream/MyStreamActionTooltip.tsx new file mode 100644 index 0000000000..e96b19279b --- /dev/null +++ b/components/brain/my-stream/MyStreamActionTooltip.tsx @@ -0,0 +1,37 @@ +"use client"; + +import type { ComponentProps } from "react"; +import { Tooltip } from "react-tooltip"; + +interface MyStreamActionTooltipProps { + readonly id: string; + readonly place?: ComponentProps["place"]; +} + +const tooltipStyle = { + padding: "4px 8px", + background: "#37373E", + color: "white", + fontSize: "13px", + fontWeight: 500, + borderRadius: "6px", + boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.25)", + zIndex: 99999, + pointerEvents: "none", +} as const; + +export default function MyStreamActionTooltip({ + id, + place = "top", +}: MyStreamActionTooltipProps) { + return ( + + ); +} diff --git a/components/brain/my-stream/MyStreamWave.tsx b/components/brain/my-stream/MyStreamWave.tsx index fb26223771..95c498c9c2 100644 --- a/components/brain/my-stream/MyStreamWave.tsx +++ b/components/brain/my-stream/MyStreamWave.tsx @@ -6,6 +6,7 @@ import { useSetWaveData } from "@/contexts/TitleContext"; import { useContentTab } from "../ContentTabContext"; import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; import MyStreamWaveChat from "./MyStreamWaveChat"; +import MyStreamWaveCurationContent from "./curations/MyStreamWaveCurationContent"; import { useWaveData } from "@/hooks/useWaveData"; import MyStreamWaveLeaderboard from "./MyStreamWaveLeaderboard"; import MyStreamWaveOutcome from "./MyStreamWaveOutcome"; @@ -80,6 +81,7 @@ const MyStreamWave: React.FC = ({ waveId }) => { // Get the active tab and utilities from global context const { activeContentTab } = useContentTab(); + const activeCurationId = searchParams.get("curation"); // View mode for chat/gallery toggle const { viewMode, setViewMode, toggleViewMode } = useWaveViewMode(waveId); @@ -116,6 +118,20 @@ const MyStreamWave: React.FC = ({ waveId }) => { router.push(`${pathname}?${params.toString()}`, { scroll: false }); }; + const onSelectCuration = (curationId: string | null) => { + const params = new URLSearchParams(searchParams.toString() || ""); + + if (curationId) { + params.set("curation", curationId); + } else { + params.delete("curation"); + } + + const nextQuery = params.toString(); + const nextUrl = nextQuery ? `${pathname}?${nextQuery}` : pathname; + router.replace(nextUrl, { scroll: false }); + }; + // Early return if no wave data - all hooks must be called before this if (!wave) { return null; @@ -160,14 +176,29 @@ const MyStreamWave: React.FC = ({ waveId }) => { viewMode={viewMode} onToggleViewMode={toggleViewMode} showGalleryToggle={showGalleryToggle} + activeCurationId={activeCurationId} + onSelectCuration={onSelectCuration} />
- {components[activeContentTab]} + {activeCurationId ? ( + + ) : ( + components[activeContentTab] + )}
); diff --git a/components/brain/my-stream/MyStreamWaveDesktopTabs.tsx b/components/brain/my-stream/MyStreamWaveDesktopTabs.tsx index ffcd8a14ec..64e90177f8 100644 --- a/components/brain/my-stream/MyStreamWaveDesktopTabs.tsx +++ b/components/brain/my-stream/MyStreamWaveDesktopTabs.tsx @@ -1,37 +1,49 @@ "use client"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { EllipsisVerticalIcon, PlusIcon } from "@heroicons/react/24/outline"; +import { CompactMenu, type CompactMenuItem } from "@/components/compact-menu"; import { TabToggle } from "@/components/common/TabToggle"; import { useSearchParams } from "next/navigation"; import type { ApiWave } from "@/generated/models/ApiWave"; +import { ApiWaveType } from "@/generated/models/ApiWaveType"; +import { useWaveCurations } from "@/hooks/waves/useWaveCurations"; +import { useWave } from "@/hooks/useWave"; +import { useDecisionPoints } from "@/hooks/waves/useDecisionPoints"; +import { useWaveTimers } from "@/hooks/useWaveTimers"; +import { Time } from "@/helpers/time"; +import { useAuth } from "@/components/auth/Auth"; import { MyStreamWaveTab } from "@/types/waves.types"; import { useContentTab, WaveVotingState, type SetActiveContentTab, } from "../ContentTabContext"; -import { useWave } from "@/hooks/useWave"; -import { useWaveTimers } from "@/hooks/useWaveTimers"; -import { useDecisionPoints } from "@/hooks/waves/useDecisionPoints"; -import { Time } from "@/helpers/time"; -import { useAuth } from "@/components/auth/Auth"; +import MyStreamWaveCurationCreateDialog from "./tabs/MyStreamWaveCurationCreateDialog"; +import MyStreamActionTooltip from "./MyStreamActionTooltip"; interface MyStreamWaveDesktopTabsProps { readonly activeTab: MyStreamWaveTab; readonly wave: ApiWave; readonly setActiveTab: SetActiveContentTab; + readonly activeCurationId: string | null; + readonly onSelectCuration: (curationId: string | null) => void; } interface TabOption { - key: MyStreamWaveTab; - label: string; - panelId: string; + readonly key: string; + readonly label: string; + readonly panelId: string; } const getContentTabPanelId = (tab: MyStreamWaveTab): string => `my-stream-wave-tabpanel-${tab.toLowerCase()}`; +const getCurationPanelId = (curationId: string): string => + `my-stream-wave-tabpanel-curation-${curationId}`; + const AUTO_EXPAND_LIMIT = 5; +const MOBILE_INLINE_CURATION_LIMIT = 1; const TAB_LABELS: Record = { [MyStreamWaveTab.CHAT]: "Chat", @@ -43,17 +55,36 @@ const TAB_LABELS: Record = { [MyStreamWaveTab.FAQ]: "FAQ", }; +const getWaveVotingState = ({ + isUpcoming, + isCompleted, +}: { + readonly isUpcoming: boolean; + readonly isCompleted: boolean; +}): WaveVotingState => { + if (isUpcoming) { + return WaveVotingState.NOT_STARTED; + } + + if (isCompleted) { + return WaveVotingState.ENDED; + } + + return WaveVotingState.ONGOING; +}; + const MyStreamWaveDesktopTabs: React.FC = ({ activeTab, wave, setActiveTab, + activeCurationId, + onSelectCuration, }) => { const searchParams = useSearchParams(); - // Use the available tabs from context instead of recalculating - const { availableTabs, updateAvailableTabs } = useContentTab(); + const { availableTabs, updateAvailableTabs, setActiveContentTab } = + useContentTab(); const { connectedProfile } = useAuth(); const hasAuthenticatedProfile = Boolean(connectedProfile?.handle); - const { isChatWave, isMemesWave, @@ -64,8 +95,6 @@ const MyStreamWaveDesktopTabs: React.FC = ({ voting: { isUpcoming, isCompleted }, decisions: { firstDecisionDone }, } = useWaveTimers(wave); - - // For next decision countdown const { allDecisions, hasMoreFuture, loadMoreFuture } = useDecisionPoints( wave, { @@ -73,106 +102,91 @@ const MyStreamWaveDesktopTabs: React.FC = ({ initialFutureWindow: 10, } ); + const { data: curations = [] } = useWaveCurations({ + waveId: wave.id, + }); + const canManageCurations = + wave.wave.authenticated_user_eligible_for_admin === true; - // Filter out decisions that occur during pause periods using the helper from useWave - const filteredDecisions = React.useMemo(() => { - // Convert DecisionPoint[] to ApiWaveDecision[] format for the filter function + const filteredDecisions = useMemo(() => { const decisionsAsApiFormat = allDecisions.map((decision) => ({ decision_time: decision.timestamp, })); - - // Apply the filter const filtered = filterDecisionsDuringPauses(decisionsAsApiFormat); - // Convert back to DecisionPoint[] format return allDecisions.filter((decision) => - filtered.some((f) => f.decision_time === decision.timestamp) + filtered.some((item) => item.decision_time === decision.timestamp) ); }, [allDecisions, filterDecisionsDuringPauses]); - // Get the next valid decision time (excluding paused decisions) const nextDecisionTime = filteredDecisions.find( (decision) => decision.timestamp > Time.currentMillis() )?.timestamp ?? null; - const [autoExpandFutureAttempts, setAutoExpandFutureAttempts] = useState(0); + const autoExpandFutureAttemptsRef = useRef(0); + const desktopTabsScrollerRef = useRef(null); + const mobileTabsScrollerRef = useRef(null); + const [isCreateCurationOpen, setIsCreateCurationOpen] = useState(false); useEffect(() => { - const hasUpcoming = !!nextDecisionTime; - - if (hasUpcoming) { - if (autoExpandFutureAttempts !== 0) { - setAutoExpandFutureAttempts(0); - } - return; - } + const hasUpcoming = typeof nextDecisionTime === "number"; - if (!hasMoreFuture) { - if (autoExpandFutureAttempts !== 0) { - setAutoExpandFutureAttempts(0); - } + if (hasUpcoming || !hasMoreFuture) { + autoExpandFutureAttemptsRef.current = 0; return; } - if (autoExpandFutureAttempts >= AUTO_EXPAND_LIMIT) { + if (autoExpandFutureAttemptsRef.current >= AUTO_EXPAND_LIMIT) { return; } const timeoutId = globalThis.setTimeout(() => { - setAutoExpandFutureAttempts((prev) => prev + 1); + autoExpandFutureAttemptsRef.current += 1; loadMoreFuture(); }, 50); return () => { clearTimeout(timeoutId); }; - }, [ - nextDecisionTime, - hasMoreFuture, - loadMoreFuture, - autoExpandFutureAttempts, - ]); + }, [nextDecisionTime, hasMoreFuture, loadMoreFuture]); + + const votingState = getWaveVotingState({ + isUpcoming, + isCompleted, + }); - // Calculate time left for next decision - // Update available tabs when wave changes useEffect(() => { const hasSerialTarget = searchParams.get("serialNo") !== null; - const votingState = isUpcoming - ? WaveVotingState.NOT_STARTED - : isCompleted - ? WaveVotingState.ENDED - : WaveVotingState.ONGOING; - updateAvailableTabs( - wave - ? { - waveId: wave.id, - isMemesWave, - isChatWave, - hasAuthenticatedProfile, - isCurationWave, - votingState, - hasFirstDecisionPassed: firstDecisionDone, - transientPreferredTab: hasSerialTarget - ? MyStreamWaveTab.CHAT - : null, - } - : null - ); + updateAvailableTabs({ + waveId: wave.id, + isMemesWave, + isChatWave, + hasAuthenticatedProfile, + isCurationWave, + votingState, + hasFirstDecisionPassed: firstDecisionDone, + transientPreferredTab: hasSerialTarget ? MyStreamWaveTab.CHAT : null, + }); }, [ wave, isMemesWave, isChatWave, hasAuthenticatedProfile, isCurationWave, - isUpcoming, - isCompleted, + votingState, firstDecisionDone, searchParams, updateAvailableTabs, ]); - const options: TabOption[] = React.useMemo( + useEffect(() => { + if (wave.wave.type === ApiWaveType.Chat && !activeCurationId) { + setActiveContentTab(MyStreamWaveTab.CHAT); + } + }, [wave.wave.type, activeCurationId, setActiveContentTab]); + + const standardOptions: TabOption[] = useMemo( () => availableTabs .filter((tab) => { @@ -195,29 +209,105 @@ const MyStreamWaveDesktopTabs: React.FC = ({ [availableTabs, hasAuthenticatedProfile, isMemesWave, isCurationWave] ); - useEffect(() => { - const isMyVotesHidden = - activeTab === MyStreamWaveTab.MY_VOTES && - ((isMemesWave && !hasAuthenticatedProfile) || - (!isMemesWave && !isCurationWave)); - const isSalesHidden = - activeTab === MyStreamWaveTab.SALES && !isCurationWave; - const isFaqHidden = activeTab === MyStreamWaveTab.FAQ && !isMemesWave; - - if ( - (isMyVotesHidden || isSalesHidden || isFaqHidden) && - options.length > 0 - ) { - setActiveTab(options[0]?.key!); + const curationOptions: TabOption[] = useMemo( + () => + curations.map((curation) => ({ + key: `curation:${curation.id}`, + label: curation.name, + panelId: getCurationPanelId(curation.id), + })), + [curations] + ); + + const options: TabOption[] = useMemo( + () => [...standardOptions, ...curationOptions], + [curationOptions, standardOptions] + ); + + const activeKey = activeCurationId + ? `curation:${activeCurationId}` + : activeTab; + + const mobileVisibleCurationOptions = useMemo(() => { + if (curationOptions.length <= MOBILE_INLINE_CURATION_LIMIT) { + return curationOptions; } - }, [ - hasAuthenticatedProfile, - isMemesWave, - isCurationWave, - activeTab, - options, - setActiveTab, - ]); + + const activeCurationOption = + curationOptions.find((option) => option.key === activeKey) ?? null; + const visibleOptions = activeCurationOption ? [activeCurationOption] : []; + + for (const option of curationOptions) { + if (visibleOptions.length >= MOBILE_INLINE_CURATION_LIMIT) { + break; + } + + if (option.key === activeCurationOption?.key) { + continue; + } + + visibleOptions.push(option); + } + + return visibleOptions; + }, [activeKey, curationOptions]); + + const mobileOverflowCurationOptions = useMemo(() => { + const visibleKeys = new Set( + mobileVisibleCurationOptions.map((option) => option.key) + ); + + return curationOptions.filter((option) => !visibleKeys.has(option.key)); + }, [curationOptions, mobileVisibleCurationOptions]); + + const mobileOptions: TabOption[] = useMemo( + () => [...standardOptions, ...mobileVisibleCurationOptions], + [mobileVisibleCurationOptions, standardOptions] + ); + + const mobileOverflowItems: CompactMenuItem[] = useMemo( + () => + mobileOverflowCurationOptions.map((option) => ({ + id: option.key, + label: option.label, + onSelect: () => onSelectCuration(option.key.replace("curation:", "")), + })), + [mobileOverflowCurationOptions, onSelectCuration] + ); + + const createCurationTooltipId = `my-stream-create-curation-${wave.id}`; + const showCreateFirstCurationCallout = + canManageCurations && curations.length === 0; + const createButtonTooltipProps = showCreateFirstCurationCallout + ? {} + : { + "data-tooltip-id": createCurationTooltipId, + "data-tooltip-content": "Create curation", + }; + + useEffect(() => { + const frameId = globalThis.window.requestAnimationFrame(() => { + [desktopTabsScrollerRef.current, mobileTabsScrollerRef.current].forEach( + (scroller) => { + if (!scroller) { + return; + } + + const activeTabElement = scroller.querySelector( + '[role="tab"][aria-selected="true"]' + ); + activeTabElement?.scrollIntoView({ + block: "nearest", + inline: "nearest", + }); + } + ); + }); + + return () => { + globalThis.window.cancelAnimationFrame(frameId); + }; + }, [activeKey, options]); // For simple waves, don't render any tabs if (isChatWave) { @@ -225,13 +315,93 @@ const MyStreamWaveDesktopTabs: React.FC = ({ } return ( -
- setActiveTab(key as MyStreamWaveTab)} - /> -
+ <> +
+
+
+
+ { + if (key.startsWith("curation:")) { + onSelectCuration(key.replace("curation:", "")); + return; + } + + onSelectCuration(null); + setActiveTab(key as MyStreamWaveTab); + }} + /> + {mobileOverflowItems.length > 0 && ( + } + aria-label="More curations" + items={mobileOverflowItems} + menuWidthClassName="tw-w-52" + /> + )} +
+
+
+
+ { + if (key.startsWith("curation:")) { + onSelectCuration(key.replace("curation:", "")); + return; + } + + onSelectCuration(null); + setActiveTab(key as MyStreamWaveTab); + }} + /> +
+ {canManageCurations && ( +
+ +
+ )} +
+ + + {isCreateCurationOpen && ( + setIsCreateCurationOpen(false)} + onSaved={(curation) => onSelectCuration(curation.id)} + /> + )} + ); }; diff --git a/components/brain/my-stream/MyStreamWaveLeaderboard.tsx b/components/brain/my-stream/MyStreamWaveLeaderboard.tsx index ef65beafce..7c80718e4e 100644 --- a/components/brain/my-stream/MyStreamWaveLeaderboard.tsx +++ b/components/brain/my-stream/MyStreamWaveLeaderboard.tsx @@ -30,7 +30,7 @@ import { WaveDropsLeaderboardSort } from "@/hooks/useWaveDropsLeaderboard"; import useLocalPreference from "@/hooks/useLocalPreference"; import MemesArtSubmissionModal from "@/components/waves/memes/MemesArtSubmissionModal"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { useWaveCurationGroups } from "@/hooks/waves/useWaveCurationGroups"; +import { useWaveCurations } from "@/hooks/waves/useWaveCurations"; import { getWaveDropEligibility } from "@/components/waves/leaderboard/dropEligibility"; import { resolveWaveSubmissionExperience, @@ -144,12 +144,12 @@ const MyStreamWaveLeaderboard: React.FC = ({ data: curationGroups = [], isLoading: isLoadingCurationGroups, isError: isCurationGroupsError, - } = useWaveCurationGroups({ + } = useWaveCurations({ waveId: wave.id, enabled: wave.wave.type !== ApiWaveType.Chat, }); - const rawCuratedByGroupId = searchParams.get("curated_by_group"); + const rawCuratedByGroupId = searchParams.get("curation_id"); const curationGroupIdSet = useMemo( () => new Set(curationGroups.map((group) => group.id)), @@ -195,9 +195,9 @@ const MyStreamWaveLeaderboard: React.FC = ({ const nextParams = new URLSearchParams(searchParams.toString()); if (groupId) { - nextParams.set("curated_by_group", groupId); + nextParams.set("curation_id", groupId); } else { - nextParams.delete("curated_by_group"); + nextParams.delete("curation_id"); } const nextQuery = nextParams.toString(); diff --git a/components/brain/my-stream/curations/MyStreamWaveCurationContent.tsx b/components/brain/my-stream/curations/MyStreamWaveCurationContent.tsx new file mode 100644 index 0000000000..861ff4a944 --- /dev/null +++ b/components/brain/my-stream/curations/MyStreamWaveCurationContent.tsx @@ -0,0 +1,229 @@ +"use client"; + +import CircleLoader, { + CircleLoaderSize, +} from "@/components/distribution-plan-tool/common/CircleLoader"; +import { Spinner } from "@/components/dotLoader/DotLoader"; +import CommonIntersectionElement from "@/components/utils/CommonIntersectionElement"; +import Drop, { DropLocation } from "@/components/waves/drops/Drop"; +import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; +import { useDropCurationMembershipMutation } from "@/hooks/drops/useDropCurationMembershipMutation"; +import { useDropCurations } from "@/hooks/drops/useDropCurations"; +import useDeviceInfo from "@/hooks/useDeviceInfo"; +import useIsTouchDevice from "@/hooks/useIsTouchDevice"; +import { useWaveDrops } from "@/hooks/useWaveDrops"; +import type { ApiWave } from "@/generated/models/ApiWave"; +import { XMarkIcon } from "@heroicons/react/24/outline"; +import { useCallback, useMemo, type ReactNode } from "react"; +import { useLayout } from "../layout/LayoutContext"; + +interface MyStreamWaveCurationContentProps { + readonly wave: ApiWave; + readonly curationId: string; + readonly curationName?: string | null | undefined; + readonly onDropClick: (drop: ExtendedDrop) => void; +} + +function MyStreamWaveCurationDropItem({ + drop, + previousDrop, + nextDrop, + curationId, + canManageActiveCuration, + onDropClick, +}: { + readonly drop: ExtendedDrop; + readonly previousDrop: ExtendedDrop | null; + readonly nextDrop: ExtendedDrop | null; + readonly curationId: string; + readonly canManageActiveCuration: boolean; + readonly onDropClick: (drop: ExtendedDrop) => void; +}) { + const { hasTouchScreen, isApp } = useDeviceInfo(); + const isTouchDevice = useIsTouchDevice(); + const { updateMembership, isPending } = useDropCurationMembershipMutation({ + dropId: drop.id, + }); + const shouldUseDetachedRemoveButton = + isTouchDevice || (isApp && hasTouchScreen); + + const handleRemove = () => { + updateMembership(curationId, "remove"); + }; + + return ( +
+ {canManageActiveCuration && shouldUseDetachedRemoveButton && ( + + )} + + {}} + onReplyClick={() => {}} + onQuoteClick={() => {}} + onDropContentClick={onDropClick} + /> + + {canManageActiveCuration && !shouldUseDetachedRemoveButton && ( + + )} +
+ ); +} + +export default function MyStreamWaveCurationContent({ + wave, + curationId, + curationName, + onDropClick, +}: MyStreamWaveCurationContentProps) { + const { leaderboardViewStyle } = useLayout(); + const { drops, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage } = + useWaveDrops({ + waveId: wave.id, + curationId, + }); + const permissionProbeDropId = drops[0]?.id ?? ""; + const { data: permissionProbeCurations = [] } = useDropCurations({ + dropId: permissionProbeDropId, + enabled: Boolean(permissionProbeDropId), + }); + + const isInitialLoading = isFetching && drops.length === 0; + + const handleBottomIntersection = useCallback( + (isIntersecting: boolean) => { + if (!isIntersecting || !hasNextPage || isFetchingNextPage) { + return; + } + + void fetchNextPage(); + }, + [fetchNextPage, hasNextPage, isFetchingNextPage] + ); + + const curationTitle = curationName?.trim() ?? "Curation"; + const canManageActiveCuration = + permissionProbeCurations.find((curation) => curation.id === curationId) + ?.authenticated_user_can_curate ?? false; + + const renderedDrops = useMemo( + () => + drops.map((drop, index) => ( + 0 ? (drops[index - 1] ?? null) : null} + nextDrop={drops[index + 1] ?? null} + curationId={curationId} + canManageActiveCuration={canManageActiveCuration} + onDropClick={onDropClick} + /> + )), + [canManageActiveCuration, curationId, drops, onDropClick] + ); + + let content: ReactNode; + + if (isInitialLoading) { + content = ( +
+ +
+ ); + } else if (drops.length === 0) { + content = ( +
+
+

+ {curationTitle} is empty +

+

+ This tab will show the drops added to this curation. +

+
+
+ ); + } else { + content = ( +
+ {renderedDrops} + {(hasNextPage || isFetchingNextPage) && ( +
+ {isFetchingNextPage ? ( +
+ +
+ ) : ( + + )} +
+ )} +
+ ); + } + + return ( +
+ {content} +
+ ); +} diff --git a/components/brain/my-stream/tabs/MyStreamWaveCurationCreateDialog.tsx b/components/brain/my-stream/tabs/MyStreamWaveCurationCreateDialog.tsx new file mode 100644 index 0000000000..1a4a2717df --- /dev/null +++ b/components/brain/my-stream/tabs/MyStreamWaveCurationCreateDialog.tsx @@ -0,0 +1,776 @@ +"use client"; + +import { useAuth } from "@/components/auth/Auth"; +import CircleLoader, { + CircleLoaderSize, +} from "@/components/distribution-plan-tool/common/CircleLoader"; +import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; +import MobileWrapperDialog from "@/components/mobile-wrapper-dialog/MobileWrapperDialog"; +import PrimaryButton from "@/components/utils/button/PrimaryButton"; +import SecondaryButton from "@/components/utils/button/SecondaryButton"; +import SelectGroupModalSearchName from "@/components/utils/select-group/SelectGroupModalSearchName"; +import { getWaveCurationsQueryKey } from "@/hooks/waves/useWaveCurations"; +import type { ApiGroupFull } from "@/generated/models/ApiGroupFull"; +import type { ApiWave } from "@/generated/models/ApiWave"; +import type { ApiWaveCuration } from "@/generated/models/ApiWaveCuration"; +import type { ApiWaveCurationRequest } from "@/generated/models/ApiWaveCurationRequest"; +import type { ApiWaveMin } from "@/generated/models/ApiWaveMin"; +import { getRandomColorWithSeed, getTimeAgo } from "@/helpers/Helpers"; +import { ImageScale, getScaledImageUri } from "@/helpers/image.helpers"; +import { commonApiFetch, commonApiPost } from "@/services/api/common-api"; +import { + keepPreviousData, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import clsx from "clsx"; +import Image from "next/image"; +import { useRef, useState, type ReactNode, type ChangeEvent } from "react"; + +const CURATION_NAME_PRESETS = [ + "Art", + "Favourites", + "Marketplace", + "Important", +] as const; + +const getMatchingPresetLabel = ( + value: string | null | undefined +): string | null => { + const normalizedValue = value?.trim().toLowerCase(); + if (!normalizedValue) { + return null; + } + + const matchingPreset = CURATION_NAME_PRESETS.find( + (preset) => preset.toLowerCase() === normalizedValue + ); + + return matchingPreset ?? null; +}; + +interface MyStreamWaveCurationCreateDialogProps { + readonly wave: + | Pick + | Pick; + readonly isOpen: boolean; + readonly onClose: () => void; + readonly onSaved: (curation: ApiWaveCuration) => Promise | void; + readonly showSuccessToast?: boolean | undefined; + readonly curation?: ApiWaveCuration | null | undefined; + readonly initialGroup?: ApiGroupFull | null | undefined; +} + +const getWaveAdminGroupId = ( + wave: Pick | Pick +): string | null => { + if ("wave" in wave) { + return wave.wave.admin_group.group?.id ?? null; + } + + return wave.admin_group_id ?? null; +}; + +const getGroupCreatorLabel = (group: ApiGroupFull): string => + group.created_by.handle + ? `@${group.created_by.handle}` + : group.created_by.primary_address; + +const getErrorMessage = (error: unknown, fallback: string): string => { + if (typeof error === "string" && error.trim().length > 0) { + return error; + } + + if (error instanceof Error && error.message.trim().length > 0) { + return error.message; + } + + return fallback; +}; + +const getCurationSaveErrorMessage = ({ + error, + fallbackErrorMessage, + isEditMode, +}: { + readonly error: unknown; + readonly fallbackErrorMessage: string; + readonly isEditMode: boolean; +}): string => { + const errorMessage = getErrorMessage(error, fallbackErrorMessage); + const normalizedMessage = errorMessage.toLowerCase(); + + if ( + normalizedMessage.includes("already exists") || + normalizedMessage.includes("duplicate") || + normalizedMessage.includes("unique constraint") + ) { + return "A curation with this name already exists in this wave. Choose another name."; + } + + if ( + normalizedMessage.includes("permission") || + normalizedMessage.includes("forbidden") || + normalizedMessage.includes("not authorized") || + normalizedMessage.includes("not allowed") + ) { + return isEditMode + ? "You don't have permission to update this curation." + : "You don't have permission to create a curation with the selected group."; + } + + return errorMessage; +}; + +const getInitialSelectedGroupId = ({ + wave, + curation, + initialGroup, + isEditMode, +}: { + readonly wave: + | Pick + | Pick; + readonly curation?: ApiWaveCuration | null | undefined; + readonly initialGroup?: ApiGroupFull | null | undefined; + readonly isEditMode: boolean; +}): string | null => { + if (initialGroup?.id) { + return initialGroup.id; + } + + if (curation?.group_id) { + return curation.group_id; + } + + if (isEditMode) { + return null; + } + + return getWaveAdminGroupId(wave); +}; + +const getSubmitButtonLabel = ( + isPending: boolean, + isEditMode: boolean +): string => { + if (isPending) { + return isEditMode ? "Saving..." : "Creating..."; + } + + if (isEditMode) { + return "Save"; + } + + return "Create"; +}; + +function CurationGroupRow({ + group, + onSelect, + trailingContent, + isSelected = false, +}: { + readonly group: ApiGroupFull; + readonly onSelect?: ((group: ApiGroupFull) => void) | undefined; + readonly trailingContent?: ReactNode | undefined; + readonly isSelected?: boolean | undefined; +}) { + const isInteractive = onSelect !== undefined; + const avatarAccentStart = + group.created_by.banner1_color ?? + getRandomColorWithSeed(group.created_by.handle ?? ""); + const avatarAccentEnd = + group.created_by.banner2_color ?? + getRandomColorWithSeed(group.created_by.handle ?? ""); + const timeAgo = getTimeAgo(new Date(group.created_at).getTime()); + const avatarFallbackLabel = getGroupCreatorLabel(group) + .charAt(0) + .toUpperCase(); + const hasTrailingContent = + trailingContent !== undefined && trailingContent !== null; + const avatarAlt = group.created_by.handle + ? `${group.created_by.handle} profile picture` + : "Group creator profile picture"; + const handleSelect = () => { + if (onSelect === undefined) { + return; + } + + onSelect(group); + }; + let rowClassName = "tw-border-white/[0.06] tw-bg-iron-900/40"; + + if (isInteractive) { + rowClassName = isSelected + ? "tw-cursor-pointer tw-border-white/20 tw-bg-iron-900 tw-shadow-[0_0_0_1px_rgba(255,255,255,0.04),0_10px_24px_rgba(0,0,0,0.28),0_0_18px_rgba(255,255,255,0.06)]" + : "desktop-hover:hover:tw-border-white/12 tw-cursor-pointer tw-border-white/[0.06] tw-bg-iron-950/70 desktop-hover:hover:tw-bg-iron-900/70"; + } + + const selectionIndicatorClassName = isSelected + ? "tw-border-iron-100 tw-bg-iron-100 tw-shadow-[0_0_10px_rgba(255,255,255,0.2)]" + : "tw-border-white/10 tw-bg-transparent desktop-hover:group-hover:tw-border-white/30"; + const rowContent = ( + <> +
+
+ {group.created_by.pfp ? ( + {avatarAlt} + ) : ( + <> + + {avatarFallbackLabel} + + )} +
+
+

+ {group.name} +

+
+ + {getGroupCreatorLabel(group)} + + {timeAgo && ( + <> + + · + + Created {timeAgo} + + )} +
+
+
+ {hasTrailingContent && ( +
+ {trailingContent} +
+ )} + {!hasTrailingContent && ( + + )} + + ); + const rowClassNames = clsx( + "tw-group tw-flex tw-items-center tw-justify-between tw-gap-3 tw-rounded-xl tw-border tw-border-solid tw-p-4 tw-transition-all", + rowClassName + ); + + if (isInteractive) { + return ( + + ); + } + + return
{rowContent}
; +} + +function CurationGroupSearchState({ + canCloseGroupSearch, + onCloseGroupSearch, + shouldShowDefaultGroupError, + groupSearchName, + setGroupSearchName, + isSearchingGroups, + isSearchFiltered, + searchedGroups, + selectedGroupId, + onGroupSelect, +}: { + readonly canCloseGroupSearch: boolean; + readonly onCloseGroupSearch: () => void; + readonly shouldShowDefaultGroupError: boolean; + readonly groupSearchName: string | null; + readonly setGroupSearchName: (value: string | null) => void; + readonly isSearchingGroups: boolean; + readonly isSearchFiltered: boolean; + readonly searchedGroups: ApiGroupFull[]; + readonly selectedGroupId: string | null; + readonly onGroupSelect: (group: ApiGroupFull) => void; +}) { + const loadingMessage = isSearchFiltered + ? "Searching groups..." + : "Loading groups..."; + + let content: ReactNode; + + if (isSearchingGroups) { + content = ( +
+
+ + {loadingMessage} +
+
+ ); + } else if (searchedGroups.length > 0) { + content = ( +
+ {searchedGroups.map((group) => ( + + ))} +
+ ); + } else { + content = ( +
+

No groups found.

+
+ ); + } + + return ( +
+
+

+ Choose another group +

+ {canCloseGroupSearch && ( + + )} +
+ + {shouldShowDefaultGroupError && ( +
+

+ Failed to load the current curator group. Choose another group + below. +

+
+ )} + + + + {content} +
+ ); +} + +function CurationGroupSummaryState({ + selectedGroup, + isInitialGroupLoading, + onOpenGroupSearch, +}: { + readonly selectedGroup: ApiGroupFull | null; + readonly isInitialGroupLoading: boolean; + readonly onOpenGroupSearch: () => void; +}) { + if (selectedGroup === null && isInitialGroupLoading) { + return ( +
+

+ Loading selected group... +

+
+ ); + } + + if (selectedGroup !== null) { + return ( + + Change group + + } + /> + ); + } + + return ( +
+
+

+ No group selected yet. +

+ + Choose group + +
+
+ ); +} + +export default function MyStreamWaveCurationCreateDialog({ + wave, + isOpen, + onClose, + onSaved, + showSuccessToast = true, + curation, + initialGroup, +}: MyStreamWaveCurationCreateDialogProps) { + const queryClient = useQueryClient(); + const { requestAuth, setToast } = useAuth(); + const isEditMode = !!curation; + const initialName = curation?.name ?? ""; + const dialogTitle = isEditMode ? "Edit curation" : "Create curation"; + const fallbackErrorMessage = isEditMode + ? "Failed to update curation." + : "Failed to create curation."; + const initialSelectedGroupId = getInitialSelectedGroupId({ + wave, + curation, + initialGroup, + isEditMode, + }); + const [name, setName] = useState(() => initialName); + const [selectedGroupOverride, setSelectedGroupOverride] = + useState(initialGroup ?? null); + const [isGroupSearchOpen, setIsGroupSearchOpen] = useState( + () => !initialGroup && !initialSelectedGroupId + ); + const [groupSearchName, setGroupSearchName] = useState(null); + const nameInputRef = useRef(null); + const { + data: resolvedInitialGroup, + isLoading: isInitialGroupLoading, + isError: isInitialGroupError, + } = useQuery({ + queryKey: [QueryKey.GROUP, initialSelectedGroupId], + queryFn: async () => { + if (!initialSelectedGroupId) { + throw new Error("No default group selected."); + } + + return await commonApiFetch({ + endpoint: `groups/${initialSelectedGroupId}`, + }); + }, + enabled: !!initialSelectedGroupId && !initialGroup, + staleTime: 5 * 60 * 1000, + }); + + const selectedGroup = + selectedGroupOverride ?? initialGroup ?? resolvedInitialGroup ?? null; + const trimmedName = name.trim(); + const trimmedGroupSearchName = groupSearchName?.trim() ?? ""; + const isSearchFiltered = trimmedGroupSearchName.length > 0; + const selectedPresetLabel = getMatchingPresetLabel(name); + const isSubmitDisabled = selectedGroup === null || trimmedName.length === 0; + const shouldShowDefaultGroupError = + selectedGroup === null && !isInitialGroupLoading && isInitialGroupError; + const canCloseGroupSearch = selectedGroup !== null; + const isGroupSearchVisible = isGroupSearchOpen || shouldShowDefaultGroupError; + const groupSearchParams = isSearchFiltered + ? { + group_name: trimmedGroupSearchName, + } + : undefined; + const { data: searchedGroups = [], isFetching: isSearchingGroups } = useQuery< + ApiGroupFull[] + >({ + queryKey: [QueryKey.GROUPS, { group_name: trimmedGroupSearchName }], + queryFn: async () => + await commonApiFetch({ + endpoint: "groups", + params: groupSearchParams, + }), + enabled: isGroupSearchVisible, + placeholderData: keepPreviousData, + }); + + const handleGroupSelect = (group: ApiGroupFull) => { + setSelectedGroupOverride(group); + setGroupSearchName(null); + setIsGroupSearchOpen(false); + }; + + const handleOpenGroupSearch = () => { + setGroupSearchName(null); + setIsGroupSearchOpen(true); + }; + + const handleCloseGroupSearch = () => { + if (selectedGroup === null) { + return; + } + + setGroupSearchName(null); + setIsGroupSearchOpen(false); + }; + + const handlePresetSelect = (presetLabel: string) => { + setName(presetLabel); + globalThis.window.requestAnimationFrame(() => { + const input = nameInputRef.current; + if (input === null) { + return; + } + + input.focus(); + const caretPosition = presetLabel.length; + input.setSelectionRange(caretPosition, caretPosition); + }); + }; + + const handleNameChange = (event: ChangeEvent) => { + setName(event.target.value); + }; + + const handleSubmit = () => { + if (selectedGroup === null || trimmedName.length === 0) { + return; + } + + saveMutation.mutate({ + name: trimmedName, + group_id: selectedGroup.id, + }); + }; + + const saveMutation = useMutation({ + mutationFn: async (body: ApiWaveCurationRequest) => { + const auth = await requestAuth(); + if (!auth.success) { + throw new Error("Authentication was cancelled."); + } + + return await commonApiPost({ + endpoint: isEditMode + ? `waves/${wave.id}/curations/${curation.id}` + : `waves/${wave.id}/curations`, + body, + errorMode: "structured", + }); + }, + onSuccess: async (saved) => { + queryClient.setQueryData( + getWaveCurationsQueryKey(wave.id), + (current) => { + if (!current) { + return isEditMode ? current : [saved]; + } + + const hasSavedCuration = current.some((item) => item.id === saved.id); + + if (!hasSavedCuration) { + return [...current, saved]; + } + + return current.map((item) => (item.id === saved.id ? saved : item)); + } + ); + await queryClient.invalidateQueries({ + queryKey: getWaveCurationsQueryKey(wave.id), + }); + if (showSuccessToast) { + setToast({ + type: "success", + message: isEditMode ? "Curation updated." : "Curation created.", + }); + } + await onSaved(saved); + onClose(); + }, + onError: (error) => { + setToast({ + type: "error", + message: getCurationSaveErrorMessage({ + error, + fallbackErrorMessage, + isEditMode, + }), + }); + }, + }); + const submitButtonLabel = getSubmitButtonLabel( + saveMutation.isPending, + isEditMode + ); + let groupPickerContent: ReactNode; + + if (isGroupSearchVisible) { + groupPickerContent = ( + + ); + } else { + groupPickerContent = ( + + ); + } + + return ( + +
+
+
+
+ +
+ {CURATION_NAME_PRESETS.map((preset) => { + const isSelected = selectedPresetLabel === preset; + + return ( + + ); + })} +
+
+ +
+
+ +
+ + Who can curate? + +
{groupPickerContent}
+
+
+
+ +
+
+ + Cancel + + + {submitButtonLabel} + +
+
+
+
+ ); +} diff --git a/components/brain/my-stream/tabs/MyStreamWaveTabs.tsx b/components/brain/my-stream/tabs/MyStreamWaveTabs.tsx index d3efd1c3e2..7fa1e10ee3 100644 --- a/components/brain/my-stream/tabs/MyStreamWaveTabs.tsx +++ b/components/brain/my-stream/tabs/MyStreamWaveTabs.tsx @@ -14,6 +14,8 @@ interface MyStreamWaveTabsProps { readonly viewMode: WaveViewMode; readonly onToggleViewMode: () => void; readonly showGalleryToggle: boolean; + readonly activeCurationId: string | null; + readonly onSelectCuration: (curationId: string | null) => void; } export const MyStreamWaveTabs: React.FC = ({ @@ -21,6 +23,8 @@ export const MyStreamWaveTabs: React.FC = ({ viewMode, onToggleViewMode, showGalleryToggle, + activeCurationId, + onSelectCuration, }) => { const { isMemesWave } = useWave(wave); const { registerRef } = useLayout(); @@ -56,13 +60,19 @@ export const MyStreamWaveTabs: React.FC = ({
{isMemesWave ? ( - + ) : ( )}
diff --git a/components/brain/my-stream/tabs/MyStreamWaveTabsDefault.tsx b/components/brain/my-stream/tabs/MyStreamWaveTabsDefault.tsx index bb6971dedf..e32173dba7 100644 --- a/components/brain/my-stream/tabs/MyStreamWaveTabsDefault.tsx +++ b/components/brain/my-stream/tabs/MyStreamWaveTabsDefault.tsx @@ -25,6 +25,7 @@ import { getWaveHomeRoute } from "@/helpers/navigation.helpers"; import { useWaveShareCopyAction } from "@/hooks/waves/useWaveShareCopyAction"; import WaveDescriptionPopover from "@/components/waves/header/WaveDescriptionPopover"; import { getWaveDescriptionPreviewText } from "@/helpers/waves/waveDescriptionPreview"; +import MyStreamActionTooltip from "../MyStreamActionTooltip"; const useBreakpoint = createBreakpoint({ LG: 1024, MD: 768, S: 0 }); interface MyStreamWaveTabsDefaultProps { @@ -32,6 +33,8 @@ interface MyStreamWaveTabsDefaultProps { readonly viewMode: WaveViewMode; readonly onToggleViewMode: () => void; readonly showGalleryToggle: boolean; + readonly activeCurationId: string | null; + readonly onSelectCuration: (curationId: string | null) => void; } const MyStreamWaveTabsDefault: React.FC = ({ @@ -39,6 +42,8 @@ const MyStreamWaveTabsDefault: React.FC = ({ viewMode, onToggleViewMode, showGalleryToggle, + activeCurationId, + onSelectCuration, }) => { const { activeContentTab, setActiveContentTab } = useContentTab(); const { toggleRightSidebar, isRightSidebarOpen } = useSidebarState(); @@ -72,6 +77,7 @@ const MyStreamWaveTabsDefault: React.FC = ({ params.delete("serialNo"); params.delete("divider"); params.delete("drop"); + params.delete("curation"); const basePath = getWaveHomeRoute({ isDirectMessage: wave.chat.scope.group?.is_direct_message ?? false, isApp, @@ -83,6 +89,7 @@ const MyStreamWaveTabsDefault: React.FC = ({ }; const handleSearchSelect = (serialNo: number) => { + onSelectCuration(null); setActiveContentTab(MyStreamWaveTab.CHAT); if (waveChatScroll) { waveChatScroll.requestScrollToSerialNo({ waveId: wave.id, serialNo }); @@ -90,6 +97,7 @@ const MyStreamWaveTabsDefault: React.FC = ({ } const params = new URLSearchParams(searchParams.toString() || ""); + params.delete("curation"); params.set("serialNo", String(serialNo)); router.replace(`${pathname}?${params.toString()}`, { scroll: false }); }; @@ -98,6 +106,13 @@ const MyStreamWaveTabsDefault: React.FC = ({ waveLinkActionFeedbackState === "idle" ? "tw-text-iron-200" : "tw-text-emerald-300"; + const headerActionsTooltipId = `my-stream-wave-header-actions-${wave.id}`; + const galleryToggleLabel = + viewMode === "chat" ? "Switch to gallery view" : "Switch to chat view"; + const searchMessagesLabel = "Search messages in this wave"; + const rightSidebarActionLabel = isRightSidebarOpen + ? "Hide right sidebar" + : "Show right sidebar"; const renderWaveLinkActionIcon = () => { if (waveLinkActionFeedbackState !== "idle") { return ; @@ -154,15 +169,13 @@ const MyStreamWaveTabsDefault: React.FC = ({ )}
- {showGalleryToggle && ( + {showGalleryToggle && !activeCurationId && (
+
void; } const MyStreamWaveTabsMeme: React.FC = ({ wave, + activeCurationId, + onSelectCuration, }) => { const { activeContentTab, setActiveContentTab } = useContentTab(); const { toggleRightSidebar, isRightSidebarOpen } = useSidebarState(); @@ -73,6 +85,11 @@ const MyStreamWaveTabsMeme: React.FC = ({ waveLinkActionFeedbackState === "idle" ? "tw-text-iron-200" : "tw-text-emerald-300"; + const headerActionsTooltipId = `my-stream-wave-meme-header-actions-${wave.id}`; + const searchMessagesLabel = "Search messages in this wave"; + const rightSidebarActionLabel = isRightSidebarOpen + ? "Hide right sidebar" + : "Show right sidebar"; const renderWaveLinkActionIcon = () => { if (waveLinkActionFeedbackState !== "idle") { return ; @@ -108,17 +125,11 @@ const MyStreamWaveTabsMeme: React.FC = ({ (decision) => decision.timestamp > Time.currentMillis() )?.timestamp ?? null; - const [timeLeft, setTimeLeft] = useState({ - days: 0, - hours: 0, - minutes: 0, - seconds: 0, - }); + const [timeLeft, setTimeLeft] = useState(EMPTY_TIME_LEFT); useEffect(() => { if (typeof nextDecisionTime !== "number") return; - setTimeLeft(calculateTimeLeft(nextDecisionTime)); const intervalId = setInterval(() => { const newTimeLeft = calculateTimeLeft(nextDecisionTime); setTimeLeft(newTimeLeft); @@ -134,6 +145,9 @@ const MyStreamWaveTabsMeme: React.FC = ({ return () => clearInterval(intervalId); }, [nextDecisionTime]); + const displayedTimeLeft = + typeof nextDecisionTime === "number" ? timeLeft : EMPTY_TIME_LEFT; + const handleMemesSubmit = () => { setIsMemesModalOpen(true); }; @@ -144,6 +158,7 @@ const MyStreamWaveTabsMeme: React.FC = ({ params.delete("serialNo"); params.delete("divider"); params.delete("drop"); + params.delete("curation"); const basePath = getWaveHomeRoute({ isDirectMessage: wave.chat.scope.group?.is_direct_message ?? false, isApp, @@ -155,6 +170,7 @@ const MyStreamWaveTabsMeme: React.FC = ({ }; const handleSearchSelect = (serialNo: number) => { + onSelectCuration(null); setActiveContentTab(MyStreamWaveTab.CHAT); if (waveChatScroll) { waveChatScroll.requestScrollToSerialNo({ waveId: wave.id, serialNo }); @@ -162,6 +178,7 @@ const MyStreamWaveTabsMeme: React.FC = ({ } const params = new URLSearchParams(searchParams.toString() || ""); + params.delete("curation"); params.set("serialNo", String(serialNo)); router.replace(`${pathname}?${params.toString()}`, { scroll: false }); }; @@ -222,7 +239,8 @@ const MyStreamWaveTabsMeme: React.FC = ({ type="button" onClick={handleWaveLinkActionClick} aria-label={waveLinkActionLabel} - title={waveLinkActionLabel} + data-tooltip-id={headerActionsTooltipId} + data-tooltip-content={waveLinkActionLabel} data-wave-link-action-mode={waveLinkActionMode} className={`tw-flex tw-h-8 tw-w-8 tw-items-center tw-justify-center tw-rounded-xl tw-border tw-border-solid tw-border-iron-700 tw-bg-iron-900 tw-transition tw-duration-150 hover:tw-border-iron-500 hover:tw-bg-iron-800 hover:tw-text-white ${waveLinkActionIconColor}`} > @@ -232,7 +250,9 @@ const MyStreamWaveTabsMeme: React.FC = ({ +
{(isMemesWave || isRankWave) && typeof nextDecisionTime === "number" && (
- +
)}
diff --git a/components/compact-menu/subcomponents/CompactMenuTrigger.tsx b/components/compact-menu/subcomponents/CompactMenuTrigger.tsx index b601bf6b34..ab02537224 100644 --- a/components/compact-menu/subcomponents/CompactMenuTrigger.tsx +++ b/components/compact-menu/subcomponents/CompactMenuTrigger.tsx @@ -1,5 +1,3 @@ -import { cloneElement, isValidElement } from "react"; -import type { ReactElement } from "react"; import { MenuButton } from "@headlessui/react"; import clsx from "clsx"; import type { CompactMenuProps } from "../types"; @@ -29,10 +27,6 @@ export function CompactMenuTrigger({ return trigger({ isOpen, close }); } - if (isCustomTriggerElement(trigger)) { - return cloneElement(trigger, { isOpen, close }); - } - return trigger; }; @@ -43,17 +37,10 @@ export function CompactMenuTrigger({ disabled={disabled} className={clsx( unstyledTrigger ? undefined : DEFAULT_TRIGGER_CLASSES, - triggerClassName, + triggerClassName )} > {renderTrigger()} ); } - -type TriggerRenderProps = { isOpen: boolean; close: () => void }; - -const isCustomTriggerElement = ( - element: CompactMenuProps["trigger"], -): element is ReactElement => - isValidElement(element) && typeof element.type !== "string"; diff --git a/components/mobile-wrapper-dialog/MobileWrapperDialog.tsx b/components/mobile-wrapper-dialog/MobileWrapperDialog.tsx index 65d312c13a..d09a48ddb2 100644 --- a/components/mobile-wrapper-dialog/MobileWrapperDialog.tsx +++ b/components/mobile-wrapper-dialog/MobileWrapperDialog.tsx @@ -22,7 +22,7 @@ function DialogCloseButton({ title="Close panel" aria-label="Close panel" className={clsx( - "tw-inline-flex tw-items-center tw-justify-center tw-rounded-full tw-border-none tw-bg-transparent tw-p-2.5 tw-text-iron-400 tw-transition tw-duration-300 tw-ease-out hover:tw-bg-white/[0.04] hover:tw-text-iron-50 focus:tw-outline-none focus:tw-ring-2 focus:tw-ring-white/20", + "-tw-mr-2 -tw-mt-2.5 tw-inline-flex tw-items-center tw-justify-center tw-rounded-full tw-border-none tw-bg-transparent tw-p-2.5 tw-text-iron-400 tw-transition tw-duration-300 tw-ease-out hover:tw-bg-white/[0.04] hover:tw-text-iron-50 focus:tw-outline-none focus:tw-ring-2 focus:tw-ring-white/20", className )} onClick={onClick} @@ -50,14 +50,16 @@ function DialogHeader({ title, showDesktopCloseButton, onClose, + className, }: { readonly title: string | undefined; readonly showDesktopCloseButton: boolean; readonly onClose: () => void; + readonly className?: string | undefined; }) { return ( -
-
+
+
{title && ( {title} @@ -93,6 +95,28 @@ function getSlideTransition(tabletModal?: boolean) { }; } +function getBottomPadding(noPadding?: boolean): string { + return noPadding + ? "env(safe-area-inset-bottom,0px)" + : "calc(env(safe-area-inset-bottom,0px) + 1.5rem)"; +} + +function getDialogHeight({ + tall, + isCapacitor, +}: { + readonly tall?: boolean | undefined; + readonly isCapacitor: boolean; +}): string { + const viewportHeight = "min(100vh, 100svh)"; + + if (tall && !isCapacitor) { + return `calc(${viewportHeight} - 4rem)`; + } + + return `calc(${viewportHeight} - 10rem)`; +} + export default function MobileWrapperDialog({ title, isOpen, @@ -108,6 +132,8 @@ export default function MobileWrapperDialog({ allowOverflow, maxWidthClass, zIndexClassName = "tw-z-[1010]", + headerClassName, + mobileCloseButtonClassName, dismissible = true, }: { readonly title?: string | undefined; @@ -124,6 +150,8 @@ export default function MobileWrapperDialog({ readonly allowOverflow?: boolean | undefined; readonly maxWidthClass?: string | undefined; readonly zIndexClassName?: string | undefined; + readonly headerClassName?: string | undefined; + readonly mobileCloseButtonClassName?: string | undefined; readonly dismissible?: boolean | undefined; }) { const { isCapacitor, isIos } = useCapacitor(); @@ -133,17 +161,11 @@ export default function MobileWrapperDialog({ } }; - const bottomPadding = noPadding - ? "env(safe-area-inset-bottom,0px)" - : "calc(env(safe-area-inset-bottom,0px) + 1.5rem)"; - - const viewportHeight = "min(100vh, 100svh)"; - const getHeight = () => { - if (tall && !isCapacitor) { - return `calc(${viewportHeight} - 4rem)`; - } - return `calc(${viewportHeight} - 10rem)`; - }; + const bottomPadding = getBottomPadding(noPadding); + const dialogHeight = getDialogHeight({ + tall, + isCapacitor, + }); const panelClassNames = clsx( "mobile-wrapper-dialog tw-pointer-events-auto tw-relative tw-w-screen", @@ -216,7 +238,12 @@ export default function MobileWrapperDialog({ tabletModal && "md:tw-hidden" )} > - +
)} @@ -230,8 +257,8 @@ export default function MobileWrapperDialog({ )} style={{ ...(fixedHeight - ? { height: getHeight() } - : { maxHeight: getHeight() }), + ? { height: dialogHeight } + : { maxHeight: dialogHeight }), }} >
{children}
diff --git a/components/react-query-wrapper/ReactQueryWrapper.tsx b/components/react-query-wrapper/ReactQueryWrapper.tsx index cdf0a7d694..314dc763a2 100644 --- a/components/react-query-wrapper/ReactQueryWrapper.tsx +++ b/components/react-query-wrapper/ReactQueryWrapper.tsx @@ -95,7 +95,7 @@ export enum QueryKey { WAVES_PUBLIC = "WAVES_PUBLIC", WAVES_SEARCH = "WAVES_SEARCH", WAVE = "WAVE", - WAVE_CURATION_GROUPS = "WAVE_CURATION_GROUPS", + WAVE_CURATIONS = "WAVE_CURATIONS", WAVE_LOGS = "WAVE_LOGS", WAVE_VOTERS = "WAVE_VOTERS", WAVE_FOLLOWERS = "WAVE_FOLLOWERS", diff --git a/components/utils/button/PrimaryButton.tsx b/components/utils/button/PrimaryButton.tsx index c6a1644a5e..75d00dbeb2 100644 --- a/components/utils/button/PrimaryButton.tsx +++ b/components/utils/button/PrimaryButton.tsx @@ -21,11 +21,15 @@ export default function PrimaryButton({ disabled={disabled || loading} type="button" title={title} - className={`tw-whitespace-nowrap tw-text-sm tw-font-semibold tw-flex tw-items-center tw-rounded-lg tw-bg-iron-200 ${padding} tw-text-iron-950 focus:tw-outline-none focus:tw-ring-1 focus:tw-ring-inset tw-border-0 tw-ring-1 tw-ring-inset tw-ring-white hover:tw-bg-iron-300 hover:tw-ring-iron-300 focus:tw-z-10 tw-transition tw-duration-300 tw-ease-out tw-justify-center tw-gap-x-1.5 ${ + className={`tw-flex tw-items-center tw-whitespace-nowrap tw-rounded-lg tw-bg-iron-200 tw-text-sm tw-font-semibold ${padding} tw-justify-center tw-gap-x-1.5 tw-border-0 tw-text-iron-950 tw-ring-1 tw-ring-inset tw-ring-white tw-transition tw-duration-300 tw-ease-out hover:tw-bg-iron-300 hover:tw-ring-iron-300 focus:tw-z-10 focus:tw-outline-none focus:tw-ring-1 focus:tw-ring-inset ${ disabled || loading ? "tw-cursor-not-allowed tw-opacity-50" : "" }`} > - {loading && } + {loading && ( + + + + )} {children} ); diff --git a/components/utils/input/identity/IdentitySearch.tsx b/components/utils/input/identity/IdentitySearch.tsx index 58a80df719..60970d0397 100644 --- a/components/utils/input/identity/IdentitySearch.tsx +++ b/components/utils/input/identity/IdentitySearch.tsx @@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import type { KeyboardEvent } from "react"; -import { useEffect, useRef, useState, useId } from "react"; +import { useEffect, useMemo, useRef, useState, useId } from "react"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faCircleExclamation, @@ -71,9 +71,10 @@ export default function IdentitySearch({ [IdentitySearchSize.MD]: "tw-text-md", }; - const ICON_CLASSES: Record = { - [IdentitySearchSize.SM]: "tw-top-3", - [IdentitySearchSize.MD]: "tw-top-3.5", + const ICON_TOP_CLASS = "tw-top-3.5"; + const SEARCH_ICON_SIZE_CLASSES: Record = { + [IdentitySearchSize.SM]: "tw-h-4 tw-w-4", + [IdentitySearchSize.MD]: "tw-h-5 tw-w-5", }; const inputId = useId(); @@ -112,13 +113,45 @@ export default function IdentitySearch({ }), enabled: !!debouncedValue && debouncedValue.length >= MIN_SEARCH_LENGTH, }); + const searchResults = useMemo(() => data ?? [], [data]); const [isOpen, setIsOpen] = useState(false); - const [highlightedIndex, setHighlightedIndex] = useState(null); + const [manualHighlightedIndex, setManualHighlightedIndex] = useState< + number | null + >(null); const [highlightedOptionId, setHighlightedOptionId] = useState< string | undefined >(undefined); - const [shouldSubmit, setShouldSubmit] = useState(false); + const selectedResultIndex = useMemo(() => { + if (searchResults.length === 0 || !identity) { + return null; + } + + const matchingIndex = searchResults.findIndex((profile) => { + const value = getSelectableIdentity(profile); + return value?.toLowerCase() === identity.toLowerCase(); + }); + + return matchingIndex >= 0 ? matchingIndex : null; + }, [identity, searchResults]); + + const effectiveHighlightedIndex = useMemo(() => { + if (!isOpen || searchResults.length === 0) { + return null; + } + + if (manualHighlightedIndex !== null) { + return Math.min(manualHighlightedIndex, searchResults.length - 1); + } + + return selectedResultIndex; + }, [isOpen, manualHighlightedIndex, searchResults.length, selectedResultIndex]); + + const closeDropdown = () => { + setIsOpen(false); + setManualHighlightedIndex(null); + }; + const onValueChange = ( newValue: string | null, options?: { @@ -134,8 +167,7 @@ export default function IdentitySearch({ baseResolvedDisplayValue: resolvedDisplayValue, preservedResolvedValues: [newValue, options?.displayValue ?? null], }); - setIsOpen(false); - setHighlightedIndex(null); + closeDropdown(); }; const onFocusChange = (newV: boolean) => { @@ -144,8 +176,7 @@ export default function IdentitySearch({ setIsOpen(len >= MIN_SEARCH_LENGTH); return; } - setIsOpen(false); - setHighlightedIndex(null); + closeDropdown(); }; const onSearchCriteriaChange = (newV: string | null) => { @@ -160,7 +191,7 @@ export default function IdentitySearch({ setIdentity(null); onSelectionChange?.(null); } - setHighlightedIndex(null); + setManualHighlightedIndex(null); }; const selectProfile = (profile: CommunityMemberMinimal) => { @@ -177,8 +208,8 @@ export default function IdentitySearch({ }; const wrapperRef = useRef(null); - useClickAway(wrapperRef, () => setIsOpen(false)); - useKeyPressEvent("Escape", () => setIsOpen(false)); + useClickAway(wrapperRef, closeDropdown); + useKeyPressEvent("Escape", closeDropdown); const inputRef = useRef(null); const shouldAutoFocus = useRef(autoFocus); @@ -188,89 +219,46 @@ export default function IdentitySearch({ } }, []); - useEffect(() => { - if (!shouldSubmit) { - return; - } - - const formElement = inputRef.current?.form; - if (formElement) { - formElement.requestSubmit(); - } - setShouldSubmit(false); - }, [shouldSubmit]); - const handleArrowNavigation = (event: KeyboardEvent) => { - if (!data?.length) { + const resultCount = searchResults.length; + if (resultCount === 0) { return; } - const maxIndex = data.length - 1; + const maxIndex = resultCount - 1; + const currentHighlightIndex = effectiveHighlightedIndex; if (event.key === "ArrowDown") { event.preventDefault(); setIsOpen(true); - setHighlightedIndex((current) => { - if (current === null || current >= maxIndex) { - return 0; - } - return current + 1; - }); + setManualHighlightedIndex( + currentHighlightIndex === null || currentHighlightIndex >= maxIndex + ? 0 + : currentHighlightIndex + 1 + ); return; } if (event.key === "ArrowUp") { event.preventDefault(); setIsOpen(true); - setHighlightedIndex((current) => { - if (current === null || current <= 0) { - return maxIndex; - } - return current - 1; - }); + setManualHighlightedIndex( + currentHighlightIndex === null || currentHighlightIndex <= 0 + ? maxIndex + : currentHighlightIndex - 1 + ); return; } - if (event.key === "Enter" && highlightedIndex !== null) { + if (event.key === "Enter" && effectiveHighlightedIndex !== null) { event.preventDefault(); - const profile = data[highlightedIndex]; - if (profile) { - if (selectProfile(profile)) { - setShouldSubmit(true); - } + const profile = searchResults[effectiveHighlightedIndex]; + if (profile !== undefined && selectProfile(profile)) { + inputRef.current?.form?.requestSubmit(); } } }; - useEffect(() => { - if (!isOpen) { - setHighlightedIndex(null); - } - }, [isOpen]); - - useEffect(() => { - if (!data?.length) { - setHighlightedIndex(null); - return; - } - - if (identity) { - const matchingIndex = data.findIndex((profile) => { - const value = getSelectableIdentity(profile); - return value?.toLowerCase() === identity.toLowerCase(); - }); - - if (matchingIndex >= 0) { - setHighlightedIndex(matchingIndex); - return; - } - } - - setHighlightedIndex((current) => - current === null ? null : Math.min(current, data.length - 1) - ); - }, [data, identity]); - const hasIdentity = identity !== null && identity.length > 0; return ( @@ -301,7 +289,7 @@ export default function IdentitySearch({ error ? "tw-caret-error tw-ring-error focus:tw-border-error focus:tw-ring-error" : "tw-caret-primary-400 tw-ring-iron-700 hover:tw-ring-iron-650 focus:tw-border-blue-500 focus:tw-ring-primary-400" - } tw-peer tw-form-input tw-block tw-w-full tw-appearance-none tw-rounded-lg tw-border-0 tw-border-iron-700 tw-bg-iron-900 tw-pl-10 tw-pr-4 tw-text-base tw-font-medium tw-shadow-sm tw-ring-1 tw-ring-inset tw-transition tw-duration-300 tw-ease-out placeholder:tw-text-iron-500 focus:tw-outline-none focus:tw-ring-1 focus:tw-ring-inset ${ + } tw-peer tw-form-input tw-block tw-w-full tw-appearance-none tw-rounded-lg tw-border-0 tw-border-iron-700 tw-bg-iron-900 tw-pl-9 tw-pr-4 tw-text-base tw-font-medium tw-shadow-sm tw-ring-1 tw-ring-inset tw-transition tw-duration-300 tw-ease-out placeholder:tw-text-iron-500 focus:tw-outline-none focus:tw-ring-1 focus:tw-ring-inset ${ searchCriteria ? "tw-text-primary-400 focus:tw-text-white" : "tw-text-white" @@ -309,7 +297,7 @@ export default function IdentitySearch({ placeholder=" " />