From 7002734184e302a39d51725c569c14c1a1874fd8 Mon Sep 17 00:00:00 2001 From: Simo Date: Mon, 6 Apr 2026 14:07:57 +0300 Subject: [PATCH 1/5] wip Signed-off-by: Simo --- .../waves/drop/SingleWaveDropWrapper.test.tsx | 131 ++++++++ components/brain/ContentTabContext.tsx | 6 + components/brain/my-stream/MyStreamWave.tsx | 10 +- .../brain/my-stream/MyStreamWaveChat.tsx | 22 +- .../my-stream/MyStreamWaveDesktopTabs.tsx | 4 + .../my-stream/MyStreamWaveLeaderboard.tsx | 13 +- .../tabs/MyStreamWaveTabsDefault.tsx | 34 +- .../my-stream/tabs/MyStreamWaveTabsMeme.tsx | 48 +-- .../memes/drops/MemeParticipationDrop.tsx | 50 +-- components/memes/drops/MemeWinnerDrop.tsx | 4 +- .../memes/drops/MemesLeaderboardDrop.tsx | 51 +-- components/shared/WavesMessagesWrapper.tsx | 121 +++---- components/user/layout/userPageVisibility.ts | 2 +- components/waves/WavesDesktop.tsx | 3 + .../drop/MemesSingleWaveDropInfoPanel.tsx | 41 ++- .../waves/drop/SingleWaveDropInfoPanel.tsx | 10 +- .../waves/drop/SingleWaveDropWrapper.tsx | 311 ++++++++++++------ .../waves/drops/DropMobileMenuHandler.tsx | 10 +- components/waves/drops/WaveDrop.tsx | 30 +- components/waves/drops/WaveDropReactions.tsx | 30 +- .../participation/EndedParticipationDrop.tsx | 41 ++- .../OngoingParticipationDrop.tsx | 55 ++-- .../participation/ParticipationDropFooter.tsx | 8 +- .../hooks/useWaveDropsNotificationRead.ts | 13 +- .../waves/drops/wave-drops-all/index.tsx | 12 +- .../subcomponents/WaveDropsContent.tsx | 3 + .../WaveDropsMessageListSection.tsx | 4 +- .../waves/drops/winner/DefaultWinnerDrop.tsx | 27 +- components/waves/layout/WavesLayout.tsx | 3 +- .../drops/DefaultWaveLeaderboardDrop.tsx | 59 ++-- .../gallery/WaveLeaderboardGalleryItem.tsx | 32 +- .../grid/WaveLeaderboardGridItem.tsx | 41 ++- .../submission/utils/buildPreviewDrop.ts | 2 + components/waves/public/LoggedOutSkeleton.tsx | 173 ---------- components/waves/public/PublicWaveShell.tsx | 120 +------ .../waves/public/WaveViewerModeContext.tsx | 26 ++ .../waves/public/usePublicWaveShellState.ts | 29 +- components/waves/utils/getOptimisticDrop.ts | 2 + docs/waves/README.md | 9 +- docs/waves/feature-public-wave-preview.md | 106 +++--- ...bleshooting-wave-navigation-and-posting.md | 24 +- generated/models/ApiDrop.ts | 8 + generated/models/ApiDropWithoutWave.ts | 8 + generated/models/ApiWave.ts | 8 + generated/models/ApiWaveMin.ts | 8 + generated/models/ApiWaveSelection.ts | 44 +++ .../models/ApiWaveSelectionDropRequest.ts | 37 +++ generated/models/ApiWaveSelectionRequest.ts | 37 +++ generated/models/ObjectSerializer.ts | 15 +- hooks/useWaveDropsSearch.ts | 1 + openapi.yaml | 174 ++++++++++ package-lock.json | 57 ---- 52 files changed, 1258 insertions(+), 859 deletions(-) create mode 100644 __tests__/components/waves/drop/SingleWaveDropWrapper.test.tsx delete mode 100644 components/waves/public/LoggedOutSkeleton.tsx create mode 100644 components/waves/public/WaveViewerModeContext.tsx create mode 100644 generated/models/ApiWaveSelection.ts create mode 100644 generated/models/ApiWaveSelectionDropRequest.ts create mode 100644 generated/models/ApiWaveSelectionRequest.ts diff --git a/__tests__/components/waves/drop/SingleWaveDropWrapper.test.tsx b/__tests__/components/waves/drop/SingleWaveDropWrapper.test.tsx new file mode 100644 index 0000000000..e9b3f4c26a --- /dev/null +++ b/__tests__/components/waves/drop/SingleWaveDropWrapper.test.tsx @@ -0,0 +1,131 @@ +import React from "react"; +import "@testing-library/jest-dom"; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { SingleWaveDropWrapper } from "@/components/waves/drop/SingleWaveDropWrapper"; +import { WaveViewerModeProvider } from "@/components/waves/public/WaveViewerModeContext"; + +const mockUseMediaQuery = jest.fn(); +const mockMarkDropOpenReady = jest.fn(); + +jest.mock("@/hooks/useMediaQuery", () => ({ + useMediaQuery: (...args: any[]) => mockUseMediaQuery(...args), +})); + +jest.mock("@/utils/monitoring/dropOpenTiming", () => ({ + markDropOpenReady: (...args: any[]) => mockMarkDropOpenReady(...args), +})); + +jest.mock("@/components/waves/drop/SingleWaveDropChat", () => ({ + SingleWaveDropChat: () =>
, +})); + +jest.mock("@headlessui/react", () => ({ + Transition: ({ children, show = true }: any) => + show ? <>{children} : null, +})); + +const drop = { id: "drop-1" } as any; +const wave = { id: "wave-1" } as any; + +function renderWrapper(isPublicReadOnly = false) { + return ( + + +
Content
+
+
+ ); +} + +describe("SingleWaveDropWrapper", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseMediaQuery.mockReturnValue(true); + document.body.style.overflow = ""; + }); + + afterEach(() => { + document.body.style.overflow = ""; + }); + + it("hard resets chat when switching to public read-only", async () => { + const view = render(renderWrapper(false)); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: "Show chat" }) + ).toBeInTheDocument(); + }); + + expect(screen.getAllByTestId("single-drop-chat")).toHaveLength(1); + + fireEvent.click(screen.getByRole("button", { name: "Show chat" })); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: "Hide chat" }) + ).toBeInTheDocument(); + expect(screen.getAllByTestId("single-drop-chat")).toHaveLength(2); + }); + expect(document.body.style.overflow).toBe("hidden"); + + view.rerender(renderWrapper(true)); + + await waitFor(() => { + expect( + screen.queryByRole("button", { name: /chat/i }) + ).not.toBeInTheDocument(); + expect(screen.queryByTestId("single-drop-chat")).not.toBeInTheDocument(); + }); + expect(document.body.style.overflow).toBe(""); + + view.rerender(renderWrapper(false)); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: "Show chat" }) + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Hide chat" }) + ).not.toBeInTheDocument(); + expect(screen.getAllByTestId("single-drop-chat")).toHaveLength(1); + }); + }); + + it("closes chat from the single-drop close event", async () => { + render(renderWrapper(false)); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: "Show chat" }) + ).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: "Show chat" })); + + await waitFor(() => { + expect(screen.getAllByTestId("single-drop-chat")).toHaveLength(2); + }); + expect(document.body.style.overflow).toBe("hidden"); + + act(() => { + globalThis.window.dispatchEvent( + new CustomEvent("single-drop:close-chat") + ); + }); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: "Show chat" }) + ).toBeInTheDocument(); + expect(screen.getAllByTestId("single-drop-chat")).toHaveLength(1); + }); + expect(document.body.style.overflow).toBe(""); + }); +}); diff --git a/components/brain/ContentTabContext.tsx b/components/brain/ContentTabContext.tsx index 6e4b4ce69b..d0ca3310dc 100644 --- a/components/brain/ContentTabContext.tsx +++ b/components/brain/ContentTabContext.tsx @@ -27,6 +27,7 @@ type WaveTabParams = { isCurationWave: boolean; votingState: WaveVotingState; hasFirstDecisionPassed: boolean; + publicReadOnly?: boolean; }; interface ContentTabContextType { @@ -146,6 +147,7 @@ export const ContentTabProvider: React.FC<{ children: ReactNode }> = ({ isCurationWave, votingState, hasFirstDecisionPassed, + publicReadOnly = false, } = params; let tabs: MyStreamWaveTab[]; @@ -161,6 +163,10 @@ export const ContentTabProvider: React.FC<{ children: ReactNode }> = ({ ); } + if (publicReadOnly) { + tabs = tabs.filter((tab) => tab !== MyStreamWaveTab.MY_VOTES); + } + setAvailableTabs(tabs); currentWaveIdRef.current = waveId ?? null; diff --git a/components/brain/my-stream/MyStreamWave.tsx b/components/brain/my-stream/MyStreamWave.tsx index 324c9f4588..2b26a2660a 100644 --- a/components/brain/my-stream/MyStreamWave.tsx +++ b/components/brain/my-stream/MyStreamWave.tsx @@ -24,6 +24,7 @@ import { useWave } from "@/hooks/useWave"; import type { ApiDrop } from "@/generated/models/ApiDrop"; import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; import useDeviceInfo from "@/hooks/useDeviceInfo"; +import { useWaveViewerMode } from "@/components/waves/public/WaveViewerModeContext"; interface MyStreamWaveProps { readonly waveId: string; @@ -39,6 +40,7 @@ const MyStreamWave: React.FC = ({ waveId }) => { const pathname = usePathname(); const router = useRouter(); const { isApp } = useDeviceInfo(); + const { isPublicReadOnly } = useWaveViewerMode(); const queryClient = useQueryClient(); const { waves, directMessages } = useMyStream(); const { data: wave } = useWaveData({ @@ -80,6 +82,10 @@ const MyStreamWave: React.FC = ({ waveId }) => { // Get the active tab and utilities from global context const { activeContentTab } = useContentTab(); + const resolvedActiveContentTab = + isPublicReadOnly && activeContentTab === MyStreamWaveTab.MY_VOTES + ? MyStreamWaveTab.CHAT + : activeContentTab; // View mode for chat/gallery toggle const { viewMode, toggleViewMode } = useWaveViewMode(waveId); @@ -151,9 +157,9 @@ const MyStreamWave: React.FC = ({ waveId }) => {
- {components[activeContentTab]} + {components[resolvedActiveContentTab]}
); diff --git a/components/brain/my-stream/MyStreamWaveChat.tsx b/components/brain/my-stream/MyStreamWaveChat.tsx index 2a41b0510e..72c7e77d2a 100644 --- a/components/brain/my-stream/MyStreamWaveChat.tsx +++ b/components/brain/my-stream/MyStreamWaveChat.tsx @@ -32,6 +32,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation"; import React, { useContext, useEffect, useMemo, useRef, useState } from "react"; import { useSelector } from "react-redux"; import { useLayout } from "./layout/LayoutContext"; +import { useWaveViewerMode } from "@/components/waves/public/WaveViewerModeContext"; interface InitialDropState { readonly waveId: string; @@ -108,6 +109,7 @@ const MyStreamWaveChat: React.FC = ({ }); const editingDropId = useSelector(selectEditingDropId); const { isApp } = useDeviceInfo(); + const { isPublicReadOnly } = useWaveViewerMode(); const [activeDropState, setActiveDropState] = useState<{ readonly waveId: string; @@ -175,7 +177,9 @@ const MyStreamWaveChat: React.FC = ({ initialDropState?.waveId === wave.id ? initialDropState.serialNo : null; let dividerTarget = firstUnreadSerialNo; - if (initialDropState?.waveId === wave.id) { + if (isPublicReadOnly) { + dividerTarget = null; + } else if (initialDropState?.waveId === wave.id) { dividerTarget = initialDropState.dividerSerialNo; } else if (capturedDividerState.waveId === wave.id) { dividerTarget = capturedDividerState.serialNo; @@ -242,7 +246,7 @@ const MyStreamWaveChat: React.FC = ({ initialSerialNo={dividerTarget ?? null} key={`unread-divider-${wave.id}`} > - + {!isPublicReadOnly && }
= ({ activeDrop={activeDrop} initialDrop={scrollTarget} dividerSerialNo={dividerTarget} - unreadCount={wave.metrics.your_unread_drops_count} + unreadCount={ + isPublicReadOnly ? undefined : wave.metrics.your_unread_drops_count + } dropId={null} isMuted={wave.metrics.muted} + readOnly={isPublicReadOnly} /> - {!(isApp && editingDropId) && ( + {!isPublicReadOnly && !(isApp && editingDropId) && (
= ({
)} - {submissionExperience === WaveSubmissionExperience.MEMES_LEGACY && ( - - )} + {!isPublicReadOnly && + submissionExperience === WaveSubmissionExperience.MEMES_LEGACY && ( + + )}
); diff --git a/components/brain/my-stream/MyStreamWaveDesktopTabs.tsx b/components/brain/my-stream/MyStreamWaveDesktopTabs.tsx index 52a10a3629..da5f41f40c 100644 --- a/components/brain/my-stream/MyStreamWaveDesktopTabs.tsx +++ b/components/brain/my-stream/MyStreamWaveDesktopTabs.tsx @@ -10,6 +10,7 @@ import { useWaveTimers } from "@/hooks/useWaveTimers"; import { ApiWaveType } from "@/generated/models/ApiWaveType"; import { useDecisionPoints } from "@/hooks/waves/useDecisionPoints"; import { Time } from "@/helpers/time"; +import { useWaveViewerMode } from "@/components/waves/public/WaveViewerModeContext"; interface MyStreamWaveDesktopTabsProps { readonly activeTab: MyStreamWaveTab; @@ -46,6 +47,7 @@ const MyStreamWaveDesktopTabs: React.FC = ({ // Use the available tabs from context instead of recalculating const { availableTabs, updateAvailableTabs, setActiveContentTab } = useContentTab(); + const { isPublicReadOnly } = useWaveViewerMode(); const { isChatWave, @@ -144,6 +146,7 @@ const MyStreamWaveDesktopTabs: React.FC = ({ isCurationWave, votingState, hasFirstDecisionPassed: firstDecisionDone, + publicReadOnly: isPublicReadOnly, } : null ); @@ -156,6 +159,7 @@ const MyStreamWaveDesktopTabs: React.FC = ({ isCompleted, isInProgress, firstDecisionDone, + isPublicReadOnly, updateAvailableTabs, ]); diff --git a/components/brain/my-stream/MyStreamWaveLeaderboard.tsx b/components/brain/my-stream/MyStreamWaveLeaderboard.tsx index a4deeb2e76..59eb44882d 100644 --- a/components/brain/my-stream/MyStreamWaveLeaderboard.tsx +++ b/components/brain/my-stream/MyStreamWaveLeaderboard.tsx @@ -36,6 +36,7 @@ import { resolveWaveSubmissionExperience, WaveSubmissionExperience, } from "@/helpers/waves/wave-submission-experience.helpers"; +import { useWaveViewerMode } from "@/components/waves/public/WaveViewerModeContext"; interface MyStreamWaveLeaderboardProps { readonly wave: ApiWave; @@ -50,6 +51,7 @@ const MyStreamWaveLeaderboard: React.FC = ({ const pathname = usePathname(); const searchParams = useSearchParams(); const { connectedProfile, activeProfileProxy } = useContext(AuthContext); + const { isPublicReadOnly } = useWaveViewerMode(); const { isMemesWave, isCurationWave, participation } = useWave(wave); const { leaderboardViewStyle } = useLayout(); // Get pre-calculated style from context const submissionExperience = resolveWaveSubmissionExperience({ @@ -88,6 +90,7 @@ const MyStreamWaveLeaderboard: React.FC = ({ [activeProfileProxy, isCurationWave, isLoggedIn, participation] ); const showToggleableDropInput = + !isPublicReadOnly && submissionExperience !== WaveSubmissionExperience.MEMES_LEGACY && submissionExperience !== WaveSubmissionExperience.CURATION_LEGACY && isCreateDropOpen; @@ -241,7 +244,7 @@ const MyStreamWaveLeaderboard: React.FC = ({ minPrice={minPrice} maxPrice={maxPrice} priceCurrency={priceCurrency} - onCreateDrop={onCreateDrop} + onCreateDrop={isPublicReadOnly ? undefined : onCreateDrop} /> ); } else if (!isMemesWave) { @@ -282,7 +285,7 @@ const MyStreamWaveLeaderboard: React.FC = ({ viewMode={effectiveViewMode} sort={sort} onViewModeChange={(mode) => setViewMode(mode)} - onCreateDrop={onCreateDrop} + onCreateDrop={isPublicReadOnly ? undefined : onCreateDrop} onSortChange={(s) => setSort(s)} curationGroups={curationGroups} curatedByGroupId={curatedByGroupId ?? null} @@ -322,7 +325,8 @@ const MyStreamWaveLeaderboard: React.FC = ({ )} - {submissionExperience === WaveSubmissionExperience.MEMES_LEGACY && + {!isPublicReadOnly && + submissionExperience === WaveSubmissionExperience.MEMES_LEGACY && isMemesCreateOpen && ( = ({ onClose={() => setIsMemesCreateOpen(false)} /> )} - {submissionExperience === WaveSubmissionExperience.CURATION_LEGACY && + {!isPublicReadOnly && + submissionExperience === WaveSubmissionExperience.CURATION_LEGACY && isCurationDropModalOpen && ( = ({ }) => { const { activeContentTab, setActiveContentTab } = useContentTab(); const { toggleRightSidebar, isRightSidebarOpen } = useSidebarState(); + const { isPublicReadOnly } = useWaveViewerMode(); const router = useRouter(); const searchParams = useSearchParams(); @@ -192,21 +194,23 @@ const MyStreamWaveTabsDefault: React.FC = ({ > - + {!isPublicReadOnly && ( + + )}
diff --git a/components/brain/my-stream/tabs/MyStreamWaveTabsMeme.tsx b/components/brain/my-stream/tabs/MyStreamWaveTabsMeme.tsx index 47ea39d46b..b17883dc2d 100644 --- a/components/brain/my-stream/tabs/MyStreamWaveTabsMeme.tsx +++ b/components/brain/my-stream/tabs/MyStreamWaveTabsMeme.tsx @@ -32,6 +32,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 { useWaveViewerMode } from "@/components/waves/public/WaveViewerModeContext"; const useBreakpoint = createBreakpoint({ LG: 1024, MD: 768, S: 0 }); @@ -45,6 +46,7 @@ const MyStreamWaveTabsMeme: React.FC = ({ const { activeContentTab, setActiveContentTab } = useContentTab(); const { toggleRightSidebar, isRightSidebarOpen } = useSidebarState(); const [isMemesModalOpen, setIsMemesModalOpen] = useState(false); + const { isPublicReadOnly } = useWaveViewerMode(); const router = useRouter(); const searchParams = useSearchParams(); @@ -211,7 +213,7 @@ const MyStreamWaveTabsMeme: React.FC = ({ )}
- {!isCompact && ( + {!isPublicReadOnly && !isCompact && ( = ({ > - + {!isPublicReadOnly && ( + + )}
@@ -268,11 +272,13 @@ const MyStreamWaveTabsMeme: React.FC = ({ )}
- setIsMemesModalOpen(false)} - /> + {!isPublicReadOnly && ( + setIsMemesModalOpen(false)} + /> + )} setIsSearchOpen(false)} diff --git a/components/memes/drops/MemeParticipationDrop.tsx b/components/memes/drops/MemeParticipationDrop.tsx index 884c48534e..6543b0c44e 100644 --- a/components/memes/drops/MemeParticipationDrop.tsx +++ b/components/memes/drops/MemeParticipationDrop.tsx @@ -20,6 +20,7 @@ import MemeDropDescription from "./meme-participation-drop/MemeDropDescription"; import MemeDropHeader from "./meme-participation-drop/MemeDropHeader"; import MemeDropVoteStats from "./meme-participation-drop/MemeDropVoteStats"; import MemeDropTraits from "./MemeDropTraits"; +import { useWaveViewerMode } from "@/components/waves/public/WaveViewerModeContext"; interface MemeParticipationDropProps { readonly drop: ExtendedDrop; @@ -64,7 +65,9 @@ export default function MemeParticipationDrop({ onReply, }: MemeParticipationDropProps) { const [isVotingModalOpen, setIsVotingModalOpen] = useState(false); - const { canShowVote } = useDropInteractionRules(drop); + const { canShowVote: canShowVoteByRules } = useDropInteractionRules(drop); + const { isPublicReadOnly } = useWaveViewerMode(); + const canShowVote = !isPublicReadOnly && canShowVoteByRules; const isActiveDrop = activeDrop?.drop.id === drop.id; const isMobile = useIsMobileDevice(); const isMobileScreen = useIsMobileScreen(); @@ -162,29 +165,32 @@ export default function MemeParticipationDrop({ -
- -
+ {!isPublicReadOnly && ( +
+ +
+ )} - {isMobileScreen ? ( - setIsVotingModalOpen(false)} - /> - ) : ( - setIsVotingModalOpen(false)} - /> - )} + {!isPublicReadOnly && + (isMobileScreen ? ( + setIsVotingModalOpen(false)} + /> + ) : ( + setIsVotingModalOpen(false)} + /> + ))} ); diff --git a/components/memes/drops/MemeWinnerDrop.tsx b/components/memes/drops/MemeWinnerDrop.tsx index e56ab0f3b2..4097e1ebe0 100644 --- a/components/memes/drops/MemeWinnerDrop.tsx +++ b/components/memes/drops/MemeWinnerDrop.tsx @@ -18,6 +18,7 @@ import DropMobileMenuHandler from "@/components/waves/drops/DropMobileMenuHandle import DropListItemContentMedia from "@/components/drops/view/item/content/media/DropListItemContentMedia"; import { useDropContext } from "@/components/waves/drops/DropContext"; import { WaveWinnerIdentity } from "@/components/waves/winners/identity/WaveWinnerIdentity"; +import { useWaveViewerMode } from "@/components/waves/public/WaveViewerModeContext"; interface MemeWinnerDropProps { readonly drop: ExtendedDrop; @@ -35,6 +36,7 @@ export default function MemeWinnerDrop({ onReply, }: MemeWinnerDropProps) { const isMobile = useIsMobileDevice(); + const { isPublicReadOnly } = useWaveViewerMode(); const { location } = useDropContext(); // Extract metadata @@ -107,7 +109,7 @@ export default function MemeWinnerDrop({ - {!isMobile && showReplyAndQuote && ( + {!isMobile && !isPublicReadOnly && showReplyAndQuote && (
= ({ const isMobileScreen = useIsMobileScreen(); const isTabletOrSmaller = useMediaQuery("(max-width: 1023px)"); const { canDelete } = useDropInteractionRules(drop); + const { isPublicReadOnly } = useWaveViewerMode(); + const canDeleteInView = !isPublicReadOnly && canDelete; const [isVotingModalOpen, setIsVotingModalOpen] = useState(false); // Get device info from useDeviceInfo hook @@ -77,7 +80,7 @@ export const MemesLeaderboardDrop: React.FC = ({
{ - if (hasTouchScreen) return; + if (hasTouchScreen && !isPublicReadOnly) return; startDropOpen({ dropId: drop.id, waveId: drop.wave.id, @@ -88,7 +91,7 @@ export const MemesLeaderboardDrop: React.FC = ({ }} >
-
+
{/* Artist info section with border */} @@ -99,7 +102,9 @@ export const MemesLeaderboardDrop: React.FC = ({ {!hasTouchScreen && ( <> - {canDelete && } + {canDeleteInView && ( + + )} )}
@@ -187,33 +192,37 @@ export const MemesLeaderboardDrop: React.FC = ({
- setIsVotingModalOpen(true)} - /> + {!isPublicReadOnly && ( + setIsVotingModalOpen(true)} + /> + )}
- {isMobileScreen ? ( - setIsVotingModalOpen(false)} - /> - ) : ( - setIsVotingModalOpen(false)} - /> - )} + {!isPublicReadOnly && + (isMobileScreen ? ( + setIsVotingModalOpen(false)} + /> + ) : ( + setIsVotingModalOpen(false)} + /> + ))} {/* Touch slide-up menu for leaderboard */} {hasTouchScreen && + !isPublicReadOnly && createPortal( = ({ /> {/* Delete option - only if user can delete */} - {canDelete && ( + {canDeleteInView && ( setIsActive(false)} diff --git a/components/shared/WavesMessagesWrapper.tsx b/components/shared/WavesMessagesWrapper.tsx index ca882c47b1..2de25968eb 100644 --- a/components/shared/WavesMessagesWrapper.tsx +++ b/components/shared/WavesMessagesWrapper.tsx @@ -23,6 +23,7 @@ import { QueryKey } from "../react-query-wrapper/ReactQueryWrapper"; import CreateWaveModal from "../waves/create-wave/CreateWaveModal"; import { WaveChatScrollProvider } from "@/contexts/wave/WaveChatScrollContext"; import { useClosingDropId } from "@/hooks/useClosingDropId"; +import { WaveViewerModeProvider } from "../waves/public/WaveViewerModeContext"; const useBreakpoint = createBreakpoint({ XL: 1400, LG: 1024, S: 0 }); @@ -32,6 +33,7 @@ interface WavesMessagesWrapperProps { readonly showLeftSidebar?: boolean | undefined; readonly allowRightSidebar?: boolean | undefined; readonly allowDropOverlay?: boolean | undefined; + readonly isPublicReadOnly?: boolean | undefined; } const WavesMessagesWrapper: React.FC = ({ @@ -40,6 +42,7 @@ const WavesMessagesWrapper: React.FC = ({ showLeftSidebar = true, allowRightSidebar = true, allowDropOverlay = true, + isPublicReadOnly = false, }) => { const searchParams = useSearchParams(); const pathname = usePathname(); @@ -142,67 +145,71 @@ const WavesMessagesWrapper: React.FC = ({ } return ( - -
-
-
-
- {shouldShowLeftSidebar && ( - - )} - {shouldShowMainContent && ( -
- {children} - {shouldShowDropOverlay && ( -
- -
- )} -
- )} - {rightVariant === "inline" && ( -
- + +
+
+
+
+ {shouldShowLeftSidebar && ( + -
- )} + )} + {shouldShowMainContent && ( +
+ {children} + {shouldShowDropOverlay && ( +
+ +
+ )} +
+ )} + {rightVariant === "inline" && ( +
+ +
+ )} +
-
- - {rightVariant === "overlay" && ( - - )} - - {connectedProfile && ( - - )} - + + {rightVariant === "overlay" && ( + + )} + + {connectedProfile && ( + + )} + + ); }; diff --git a/components/user/layout/userPageVisibility.ts b/components/user/layout/userPageVisibility.ts index f33cfabc82..2a326d85a6 100644 --- a/components/user/layout/userPageVisibility.ts +++ b/components/user/layout/userPageVisibility.ts @@ -1,6 +1,6 @@ "use client"; -export const normalizeCountry = ( +const normalizeCountry = ( country: string | null | undefined ): string | null => { if (typeof country !== "string") { diff --git a/components/waves/WavesDesktop.tsx b/components/waves/WavesDesktop.tsx index 823d85ee3d..e9ca3b776d 100644 --- a/components/waves/WavesDesktop.tsx +++ b/components/waves/WavesDesktop.tsx @@ -10,6 +10,7 @@ interface Props { readonly showLeftSidebar?: boolean | undefined; readonly allowRightSidebar?: boolean | undefined; readonly allowDropOverlay?: boolean | undefined; + readonly isPublicReadOnly?: boolean | undefined; } const WavesDesktop: React.FC = ({ @@ -17,6 +18,7 @@ const WavesDesktop: React.FC = ({ showLeftSidebar = true, allowRightSidebar = true, allowDropOverlay = true, + isPublicReadOnly = false, }) => { return ( = ({ showLeftSidebar={showLeftSidebar} allowRightSidebar={allowRightSidebar} allowDropOverlay={allowDropOverlay} + isPublicReadOnly={isPublicReadOnly} > {children} diff --git a/components/waves/drop/MemesSingleWaveDropInfoPanel.tsx b/components/waves/drop/MemesSingleWaveDropInfoPanel.tsx index 5e7aed1e5e..57b3df3ed4 100644 --- a/components/waves/drop/MemesSingleWaveDropInfoPanel.tsx +++ b/components/waves/drop/MemesSingleWaveDropInfoPanel.tsx @@ -28,6 +28,7 @@ import { SingleWaveDropVotes } from "./SingleWaveDropVotes"; import { WaveDropAdditionalInfo } from "./WaveDropAdditionalInfo"; import { WaveDropMetaRow } from "./WaveDropMetaRow"; import { WaveDropVoteSummary } from "./WaveDropVoteSummary"; +import { useWaveViewerMode } from "../public/WaveViewerModeContext"; interface MemesSingleWaveDropInfoPanelProps { readonly drop: ExtendedDrop; @@ -41,8 +42,11 @@ export const MemesSingleWaveDropInfoPanel = ({ const [isFullscreen, setIsFullscreen] = useState(false); const [isVotingOpen, setIsVotingOpen] = useState(false); const isMobileScreen = useIsMobileScreen(); + const { isPublicReadOnly } = useWaveViewerMode(); const { isWinner, canDelete, canShowVote, isVotingEnded } = useDropInteractionRules(drop); + const canDeleteInView = !isPublicReadOnly && canDelete; + const canShowVoteInView = !isPublicReadOnly && canShowVote; const { nicTotal, repTotal, manualOutcomes } = useWaveRankReward({ waveId: drop.wave.id, @@ -138,7 +142,7 @@ export const MemesSingleWaveDropInfoPanel = ({ drop={drop} isWinner={isWinner} isVotingEnded={isVotingEnded} - canShowVote={canShowVote} + canShowVote={canShowVoteInView} onVoteClick={() => setIsVotingOpen(true)} />
@@ -154,7 +158,11 @@ export const MemesSingleWaveDropInfoPanel = ({ )}
- + {manualOutcomes.length > 0 && ( <> ยท @@ -265,7 +273,7 @@ export const MemesSingleWaveDropInfoPanel = ({
) : null} - {canDelete && drop.drop_type !== ApiDropType.Winner && ( + {canDeleteInView && drop.drop_type !== ApiDropType.Winner && ( )} @@ -320,19 +328,20 @@ export const MemesSingleWaveDropInfoPanel = ({ )} - {isMobileScreen ? ( - setIsVotingOpen(false)} - /> - ) : ( - setIsVotingOpen(false)} - /> - )} + {!isPublicReadOnly && + (isMobileScreen ? ( + setIsVotingOpen(false)} + /> + ) : ( + setIsVotingOpen(false)} + /> + ))} ); }; diff --git a/components/waves/drop/SingleWaveDropInfoPanel.tsx b/components/waves/drop/SingleWaveDropInfoPanel.tsx index 565a1482a8..fa868f4448 100644 --- a/components/waves/drop/SingleWaveDropInfoPanel.tsx +++ b/components/waves/drop/SingleWaveDropInfoPanel.tsx @@ -11,6 +11,7 @@ import { SingleWaveDropInfoContainer } from "./SingleWaveDropInfoContainer"; import { SingleWaveDropInfoDetails } from "./SingleWaveDropInfoDetails"; import { WaveDropMetaRow } from "./WaveDropMetaRow"; import { WaveDropVoteSummary } from "./WaveDropVoteSummary"; +import { useWaveViewerMode } from "../public/WaveViewerModeContext"; interface SingleWaveDropInfoPanelProps { readonly drop: ExtendedDrop; @@ -20,9 +21,12 @@ export const SingleWaveDropInfoPanel = ({ drop, }: SingleWaveDropInfoPanelProps) => { const [isVotingOpen, setIsVotingOpen] = useState(false); + const { isPublicReadOnly } = useWaveViewerMode(); const { canDelete, canShowVote, isVotingEnded, isWinner } = useDropInteractionRules(drop); const isChatWave = drop.drop_type === ApiDropType.Chat; + const canDeleteInView = !isPublicReadOnly && canDelete; + const canShowVoteInView = !isPublicReadOnly && canShowVote; return ( <> @@ -39,7 +43,7 @@ export const SingleWaveDropInfoPanel = ({ drop={drop} isWinner={isWinner} isVotingEnded={isVotingEnded} - canShowVote={canShowVote} + canShowVote={canShowVoteInView} onVoteClick={() => setIsVotingOpen(true)} /> @@ -52,7 +56,7 @@ export const SingleWaveDropInfoPanel = ({ )} - {canDelete && drop.drop_type !== ApiDropType.Winner && ( + {canDeleteInView && drop.drop_type !== ApiDropType.Winner && (
@@ -61,7 +65,7 @@ export const SingleWaveDropInfoPanel = ({ - {!isChatWave && ( + {!isChatWave && !isPublicReadOnly && ( void; } -export const SingleWaveDropWrapper: React.FC = ({ - drop, - wave, - children, - onClose, -}) => { - const [isChatOpen, setIsChatOpen] = useState(false); - const isSmallScreen = useMediaQuery("(max-width: 1023px)"); - const readyKeyRef = useRef(null); - - const toggleChat = useCallback(() => { - setIsChatOpen((prev) => !prev); - }, []); - - useEffect(() => { - const browserWindow = globalThis.window; - if (browserWindow === undefined) return; - const handleClose = () => setIsChatOpen(false); - browserWindow.addEventListener("single-drop:close-chat", handleClose); - return () => - browserWindow.removeEventListener("single-drop:close-chat", handleClose); - }, []); - - useEffect(() => { - if (!isChatOpen || !isSmallScreen) return; - const previousOverflow = document.body.style.overflow; - document.body.style.overflow = "hidden"; - return () => { - document.body.style.overflow = previousOverflow; - }; - }, [isChatOpen, isSmallScreen]); +interface SingleWaveDropShellProps { + readonly children: React.ReactNode; + readonly onClose: () => void; + readonly setHeaderActionsContainer: (node: HTMLDivElement | null) => void; + readonly setTrailingContentContainer: (node: HTMLDivElement | null) => void; +} - useEffect(() => { - const readyKey = `${drop.id}:${wave.id}`; - if (readyKeyRef.current === readyKey) return; - readyKeyRef.current = readyKey; - markDropOpenReady({ dropId: drop.id, waveId: wave.id }); - }, [drop.id, wave.id]); +interface AuthenticatedSingleWaveDropChatControllerProps { + readonly drop: ApiDrop; + readonly wave: ApiWave; + readonly headerActionsContainer: HTMLDivElement | null; + readonly trailingContentContainer: HTMLDivElement | null; +} +function SingleWaveDropShell({ + children, + onClose, + setHeaderActionsContainer, + setTrailingContentContainer, +}: SingleWaveDropShellProps) { return (
@@ -84,20 +67,7 @@ export const SingleWaveDropWrapper: React.FC = ({
- +
@@ -114,66 +84,193 @@ export const SingleWaveDropWrapper: React.FC = ({
- -
+
+ ); +} + +function AuthenticatedSingleWaveDropChatController({ + drop, + wave, + headerActionsContainer, + trailingContentContainer, +}: AuthenticatedSingleWaveDropChatControllerProps) { + const [isChatOpen, setIsChatOpen] = useState(false); + const isSmallScreen = useMediaQuery("(max-width: 1023px)"); + + const toggleChat = useCallback(() => { + setIsChatOpen((prev) => !prev); + }, []); + + useEffect(() => { + const browserWindow = globalThis.window; + if (browserWindow === undefined) return; + const handleClose = () => setIsChatOpen(false); + browserWindow.addEventListener("single-drop:close-chat", handleClose); + return () => + browserWindow.removeEventListener("single-drop:close-chat", handleClose); + }, []); + + useEffect(() => { + if (!isChatOpen || !isSmallScreen) return; + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = previousOverflow; + }; + }, [isChatOpen, isSmallScreen]); + + if (!headerActionsContainer || !trailingContentContainer) { + return null; + } + + return ( + <> + {createPortal( + , + headerActionsContainer + )} + + {createPortal( + <> + +
-
-
-
+ + + + +
+ +
- - Close - -
- -
- -
+ /> +
+ +
+
+ +
+ +
+ +
+
+
- -
- - -
+ +
+ , + trailingContentContainer + )} + + ); +} + +export const SingleWaveDropWrapper: React.FC = ({ + drop, + wave, + children, + onClose, +}) => { + const readyKeyRef = useRef(null); + const [headerActionsContainer, setHeaderActionsContainer] = + useState(null); + const [trailingContentContainer, setTrailingContentContainer] = + useState(null); + const { isPublicReadOnly } = useWaveViewerMode(); + + const handleHeaderActionsContainer = useCallback( + (node: HTMLDivElement | null) => { + setHeaderActionsContainer(node); + }, + [] + ); + + const handleTrailingContentContainer = useCallback( + (node: HTMLDivElement | null) => { + setTrailingContentContainer(node); + }, + [] + ); + + useEffect(() => { + const readyKey = `${drop.id}:${wave.id}`; + if (readyKeyRef.current === readyKey) return; + readyKeyRef.current = readyKey; + markDropOpenReady({ dropId: drop.id, waveId: wave.id }); + }, [drop.id, wave.id]); + + return ( + <> + + {children} + + + {!isPublicReadOnly && ( + + )} + ); }; diff --git a/components/waves/drops/DropMobileMenuHandler.tsx b/components/waves/drops/DropMobileMenuHandler.tsx index 399f47f106..1f0f6b2177 100644 --- a/components/waves/drops/DropMobileMenuHandler.tsx +++ b/components/waves/drops/DropMobileMenuHandler.tsx @@ -5,6 +5,7 @@ import WaveDropMobileMenu from "./WaveDropMobileMenu"; import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; import useIsMobileDevice from "@/hooks/isMobileDevice"; import useIsTouchDevice from "@/hooks/useIsTouchDevice"; +import { useWaveViewerMode } from "../public/WaveViewerModeContext"; interface DropMobileMenuHandlerProps { readonly drop: ExtendedDrop; @@ -27,16 +28,17 @@ export default function DropMobileMenuHandler({ const [isSlideUp, setIsSlideUp] = useState(false); const isMobile = useIsMobileDevice(); const hasTouch = useIsTouchDevice() || isMobile; + const { isPublicReadOnly } = useWaveViewerMode(); const longPressTimeout = useRef(null); const touchStartX = useRef(0); const touchStartY = useRef(0); const handleLongPress = useCallback(() => { - if (!hasTouch) return; + if (!hasTouch || isPublicReadOnly) return; setLongPressTriggered(true); setIsSlideUp(true); - }, [hasTouch]); + }, [hasTouch, isPublicReadOnly]); const handleTouchStart = (e: React.TouchEvent) => { if (isTemporaryDrop) return; @@ -93,6 +95,10 @@ export default function DropMobileMenuHandler({ setIsSlideUp(false); }, []); + if (isPublicReadOnly) { + return <>{children}; + } + return (
(null); const touchStartPosition = useRef<{ x: number; y: number } | null>(null); const dropUpdateMutation = useDropUpdateMutation(); + const { isPublicReadOnly } = useWaveViewerMode(); const isActiveDrop = activeDrop?.drop.id === drop.id; const isStorm = drop.parts.length > 1; const isDrop = drop.drop_type === ApiDropType.Participatory; @@ -184,7 +186,7 @@ const WaveDrop = ({ const hasTouch = useHasTouchInput() || isMobile; const breakpoint = useBreakpoint(); const isMdUp = breakpoint === "MD"; - const allowLongPress = hasTouch && !isMdUp; + const allowLongPress = hasTouch && !isMdUp && !isPublicReadOnly; const compact = useCompactMode(); const hasActiveLinkCardActions = activeLinkCardActionIds.length > 0; @@ -454,7 +456,7 @@ const WaveDrop = ({
- {!isMobile && showReplyAndQuote && !isEditing && ( + {!isMobile && !isPublicReadOnly && showReplyAndQuote && !isEditing && ( {wrapContentOnly ? wrapContentOnly(contentBlock) : contentBlock} {reactionsRow} - + {!isPublicReadOnly && ( + + )} = ({ drop }) => { + const { isPublicReadOnly } = useWaveViewerMode(); const [dialogReaction, setDialogReaction] = useState(null); const isTouchDevice = useIsTouchDevice(); @@ -59,14 +61,17 @@ const WaveDropReactions: React.FC = ({ drop }) => { reaction={reaction} onOpenDetailDialog={handleOpenDialog} isTouchDevice={isTouchDevice} + readOnly={isPublicReadOnly} /> ))} - + {!isPublicReadOnly && ( + + )} ); }; @@ -76,11 +81,13 @@ function WaveDropReaction({ reaction, onOpenDetailDialog, isTouchDevice, + readOnly, }: { readonly drop: ApiDrop; readonly reaction: ApiDropReaction; readonly onOpenDetailDialog: (reactionKey: string) => void; readonly isTouchDevice: boolean; + readonly readOnly: boolean; }) { const { setToast, connectedProfile } = useAuth(); const { emojiMap, findNativeEmoji } = useEmoji(); @@ -321,6 +328,10 @@ function WaveDropReaction({ ); const handleClick = useCallback(async () => { + if (readOnly) { + return; + } + if (longPressTriggered) { return; } @@ -363,6 +374,7 @@ function WaveDropReaction({ drop.id, longPressTriggered, reaction.reaction, + readOnly, selected, setToast, ]); @@ -400,16 +412,18 @@ function WaveDropReaction({ return ( <> - {!isPublicReadOnly && ( - - )} +
diff --git a/components/brain/my-stream/tabs/MyStreamWaveTabsMeme.tsx b/components/brain/my-stream/tabs/MyStreamWaveTabsMeme.tsx index b17883dc2d..e6d0b0cc84 100644 --- a/components/brain/my-stream/tabs/MyStreamWaveTabsMeme.tsx +++ b/components/brain/my-stream/tabs/MyStreamWaveTabsMeme.tsx @@ -239,23 +239,21 @@ const MyStreamWaveTabsMeme: React.FC = ({ > - {!isPublicReadOnly && ( - - )} +
diff --git a/components/shared/WavesMessagesWrapper.tsx b/components/shared/WavesMessagesWrapper.tsx index 2de25968eb..007d1b67f3 100644 --- a/components/shared/WavesMessagesWrapper.tsx +++ b/components/shared/WavesMessagesWrapper.tsx @@ -31,7 +31,6 @@ interface WavesMessagesWrapperProps { readonly children: ReactNode; readonly defaultPath?: string | undefined; // "/waves" or "/messages" readonly showLeftSidebar?: boolean | undefined; - readonly allowRightSidebar?: boolean | undefined; readonly allowDropOverlay?: boolean | undefined; readonly isPublicReadOnly?: boolean | undefined; } @@ -40,7 +39,6 @@ const WavesMessagesWrapper: React.FC = ({ children, defaultPath = "/waves", showLeftSidebar = true, - allowRightSidebar = true, allowDropOverlay = true, isPublicReadOnly = false, }) => { @@ -79,12 +77,6 @@ const WavesMessagesWrapper: React.FC = ({ } }, [waveId, isRightSidebarOpen, closeRightSidebar]); - useEffect(() => { - if (!allowRightSidebar && isRightSidebarOpen) { - closeRightSidebar(); - } - }, [allowRightSidebar, isRightSidebarOpen, closeRightSidebar]); - const { data: drop, error: dropError } = useQuery({ queryKey: [QueryKey.DROP, { drop_id: effectiveDropId }], queryFn: async () => { @@ -131,7 +123,7 @@ const WavesMessagesWrapper: React.FC = ({ drop !== undefined && shouldShowMainContent; const shouldShowRightSidebar = Boolean( - allowRightSidebar && isRightSidebarOpen && waveId && !isDropOpen + isRightSidebarOpen && waveId && !isDropOpen ); const canInlineRight = !isMobile && (isLargeDesktop || breakpoint === "LG"); let rightVariant: "inline" | "overlay" | null = null; diff --git a/components/waves/WavesDesktop.tsx b/components/waves/WavesDesktop.tsx index e9ca3b776d..17d5627b50 100644 --- a/components/waves/WavesDesktop.tsx +++ b/components/waves/WavesDesktop.tsx @@ -8,7 +8,6 @@ import WavesMessagesWrapper from "../shared/WavesMessagesWrapper"; interface Props { readonly children: ReactNode; readonly showLeftSidebar?: boolean | undefined; - readonly allowRightSidebar?: boolean | undefined; readonly allowDropOverlay?: boolean | undefined; readonly isPublicReadOnly?: boolean | undefined; } @@ -16,7 +15,6 @@ interface Props { const WavesDesktop: React.FC = ({ children, showLeftSidebar = true, - allowRightSidebar = true, allowDropOverlay = true, isPublicReadOnly = false, }) => { @@ -24,7 +22,6 @@ const WavesDesktop: React.FC = ({ diff --git a/components/waves/drop/SingleWaveDropWrapper.tsx b/components/waves/drop/SingleWaveDropWrapper.tsx index 8f62a90ccb..76865363e9 100644 --- a/components/waves/drop/SingleWaveDropWrapper.tsx +++ b/components/waves/drop/SingleWaveDropWrapper.tsx @@ -146,72 +146,76 @@ function AuthenticatedSingleWaveDropChatController({ {createPortal( <> - -
-
-
- + {!isSmallScreen && ( + +
+
+
+ +
-
-
+ + )} - - -
- -
- - -
-
- -
+ {isSmallScreen && isChatOpen && ( + + +
+ +
+ + +
+
+ +
-
- +
+ +
-
-
-
-
- + +
+
+
+ )} , trailingContentContainer )} diff --git a/components/waves/layout/WavesLayout.tsx b/components/waves/layout/WavesLayout.tsx index 50595724e9..c5909f5ddd 100644 --- a/components/waves/layout/WavesLayout.tsx +++ b/components/waves/layout/WavesLayout.tsx @@ -60,7 +60,6 @@ function getNotAuthenticatedContent({
From 273fc7d4e09d5610bfa3a25b91306bbe0f5d72b6 Mon Sep 17 00:00:00 2001 From: Simo Date: Tue, 7 Apr 2026 08:05:07 +0300 Subject: [PATCH 4/5] wip Signed-off-by: Simo --- .../shared/WavesMessagesWrapper.test.tsx | 51 +++++++++- .../waves/drop/SingleWaveDropWrapper.test.tsx | 43 +++++++-- components/shared/WavesMessagesWrapper.tsx | 9 +- .../waves/drop/SingleWaveDropWrapper.tsx | 93 ++++++++----------- 4 files changed, 130 insertions(+), 66 deletions(-) diff --git a/__tests__/components/shared/WavesMessagesWrapper.test.tsx b/__tests__/components/shared/WavesMessagesWrapper.test.tsx index bf354db22c..204102ce4f 100644 --- a/__tests__/components/shared/WavesMessagesWrapper.test.tsx +++ b/__tests__/components/shared/WavesMessagesWrapper.test.tsx @@ -4,6 +4,7 @@ import WavesMessagesWrapper from "@/components/shared/WavesMessagesWrapper"; const mockUseQuery = jest.fn(); const mockCloseRightSidebar = jest.fn(); +const mockUsePublicWaveShellState = jest.fn(); jest.mock("@tanstack/react-query", () => ({ keepPreviousData: {}, @@ -39,6 +40,11 @@ jest.mock("@/hooks/useSidebarState", () => ({ }), })); +jest.mock("@/components/waves/public/usePublicWaveShellState", () => ({ + usePublicWaveShellState: (...args: unknown[]) => + mockUsePublicWaveShellState(...args), +})); + jest.mock("@/components/auth/Auth", () => ({ useAuth: () => ({ connectedProfile: null }), })); @@ -84,15 +90,58 @@ describe("WavesMessagesWrapper", () => { beforeEach(() => { mockUseQuery.mockReturnValue({ data: undefined, error: undefined }); mockCloseRightSidebar.mockClear(); + mockUsePublicWaveShellState.mockReturnValue({ status: "ready" }); + }); + + it("renders the right sidebar in public read-only mode when the shell is ready", () => { + render( + +
content
+
+ ); + + expect(screen.getByTestId("right-sidebar")).toHaveAttribute( + "data-wave-id", + "wave-1" + ); + expect(mockCloseRightSidebar).not.toHaveBeenCalled(); }); - it("renders the right sidebar in public read-only mode when it is open", () => { + it("does not render the right sidebar in public read-only mode while loading", () => { + mockUsePublicWaveShellState.mockReturnValue({ status: "loading" }); + render(
content
); + expect(screen.queryByTestId("right-sidebar")).not.toBeInTheDocument(); + expect(mockCloseRightSidebar).not.toHaveBeenCalled(); + }); + + it("does not render the right sidebar in public read-only mode when the wave is unavailable", () => { + mockUsePublicWaveShellState.mockReturnValue({ status: "unavailable" }); + + render( + +
content
+
+ ); + + expect(screen.queryByTestId("right-sidebar")).not.toBeInTheDocument(); + expect(mockCloseRightSidebar).not.toHaveBeenCalled(); + }); + + it("keeps authenticated flows using the right sidebar even if the public shell is not ready", () => { + mockUsePublicWaveShellState.mockReturnValue({ status: "unavailable" }); + + render( + +
content
+
+ ); + expect(screen.getByTestId("right-sidebar")).toHaveAttribute( "data-wave-id", "wave-1" diff --git a/__tests__/components/waves/drop/SingleWaveDropWrapper.test.tsx b/__tests__/components/waves/drop/SingleWaveDropWrapper.test.tsx index 6ce8d4aec8..f852a87d9d 100644 --- a/__tests__/components/waves/drop/SingleWaveDropWrapper.test.tsx +++ b/__tests__/components/waves/drop/SingleWaveDropWrapper.test.tsx @@ -25,11 +25,6 @@ jest.mock("@/components/waves/drop/SingleWaveDropChat", () => ({ SingleWaveDropChat: () =>
, })); -jest.mock("@headlessui/react", () => ({ - Transition: ({ children, show = true }: any) => - show ? <>{children} : null, -})); - const drop = { id: "drop-1" } as any; const wave = { id: "wave-1" } as any; @@ -61,10 +56,9 @@ describe("SingleWaveDropWrapper", () => { expect( screen.getByRole("button", { name: "Show chat" }) ).toBeInTheDocument(); + expect(screen.getAllByTestId("single-drop-chat")).toHaveLength(1); }); - expect(screen.queryByTestId("single-drop-chat")).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: "Show chat" })); await waitFor(() => { @@ -94,7 +88,7 @@ describe("SingleWaveDropWrapper", () => { expect( screen.queryByRole("button", { name: "Hide chat" }) ).not.toBeInTheDocument(); - expect(screen.queryByTestId("single-drop-chat")).not.toBeInTheDocument(); + expect(screen.getAllByTestId("single-drop-chat")).toHaveLength(1); }); }); @@ -105,6 +99,7 @@ describe("SingleWaveDropWrapper", () => { expect( screen.getByRole("button", { name: "Show chat" }) ).toBeInTheDocument(); + expect(screen.getAllByTestId("single-drop-chat")).toHaveLength(1); }); fireEvent.click(screen.getByRole("button", { name: "Show chat" })); @@ -124,7 +119,37 @@ describe("SingleWaveDropWrapper", () => { expect( screen.getByRole("button", { name: "Show chat" }) ).toBeInTheDocument(); - expect(screen.queryByTestId("single-drop-chat")).not.toBeInTheDocument(); + expect(screen.getAllByTestId("single-drop-chat")).toHaveLength(1); + }); + expect(document.body.style.overflow).toBe(""); + }); + + it("keeps exactly one mobile chat tree mounted while closed and open", async () => { + render(renderWrapper(false)); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: "Show chat" }) + ).toBeInTheDocument(); + expect(screen.getAllByTestId("single-drop-chat")).toHaveLength(1); + }); + + fireEvent.click(screen.getByRole("button", { name: "Show chat" })); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: "Hide chat" }) + ).toBeInTheDocument(); + expect(screen.getAllByTestId("single-drop-chat")).toHaveLength(1); + }); + + fireEvent.click(screen.getByRole("button", { name: "Hide chat" })); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: "Show chat" }) + ).toBeInTheDocument(); + expect(screen.getAllByTestId("single-drop-chat")).toHaveLength(1); }); expect(document.body.style.overflow).toBe(""); }); diff --git a/components/shared/WavesMessagesWrapper.tsx b/components/shared/WavesMessagesWrapper.tsx index 007d1b67f3..0cb225fc48 100644 --- a/components/shared/WavesMessagesWrapper.tsx +++ b/components/shared/WavesMessagesWrapper.tsx @@ -24,6 +24,7 @@ import CreateWaveModal from "../waves/create-wave/CreateWaveModal"; import { WaveChatScrollProvider } from "@/contexts/wave/WaveChatScrollContext"; import { useClosingDropId } from "@/hooks/useClosingDropId"; import { WaveViewerModeProvider } from "../waves/public/WaveViewerModeContext"; +import { usePublicWaveShellState } from "../waves/public/usePublicWaveShellState"; const useBreakpoint = createBreakpoint({ XL: 1400, LG: 1024, S: 0 }); @@ -113,6 +114,12 @@ const WavesMessagesWrapper: React.FC = ({ ), [effectiveDropId, drop?.id] ); + const publicWaveShellState = usePublicWaveShellState( + isPublicReadOnly ? (waveId ?? null) : null, + { enabled: isPublicReadOnly } + ); + const isPublicWaveShellReady = + !isPublicReadOnly || publicWaveShellState.status === "ready"; // Clear logic for when to show each part const shouldShowLeftSidebar = showLeftSidebar && (!isMobile || !waveId); @@ -123,7 +130,7 @@ const WavesMessagesWrapper: React.FC = ({ drop !== undefined && shouldShowMainContent; const shouldShowRightSidebar = Boolean( - isRightSidebarOpen && waveId && !isDropOpen + isRightSidebarOpen && waveId && !isDropOpen && isPublicWaveShellReady ); const canInlineRight = !isMobile && (isLargeDesktop || breakpoint === "LG"); let rightVariant: "inline" | "overlay" | null = null; diff --git a/components/waves/drop/SingleWaveDropWrapper.tsx b/components/waves/drop/SingleWaveDropWrapper.tsx index 76865363e9..ffb9e3f6bb 100644 --- a/components/waves/drop/SingleWaveDropWrapper.tsx +++ b/components/waves/drop/SingleWaveDropWrapper.tsx @@ -4,20 +4,13 @@ import { CompactModeProvider } from "@/contexts/CompactModeContext"; import type { ApiDrop } from "@/generated/models/ApiDrop"; import type { ApiWave } from "@/generated/models/ApiWave"; import { useMediaQuery } from "@/hooks/useMediaQuery"; -import { Transition } from "@headlessui/react"; import { markDropOpenReady } from "@/utils/monitoring/dropOpenTiming"; import { ArrowLeftIcon, ChatBubbleLeftRightIcon, } from "@heroicons/react/24/outline"; import { createPortal } from "react-dom"; -import React, { - Fragment, - useCallback, - useEffect, - useRef, - useState, -} from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { SingleWaveDropChat } from "./SingleWaveDropChat"; import { useWaveViewerMode } from "../public/WaveViewerModeContext"; @@ -163,57 +156,47 @@ function AuthenticatedSingleWaveDropChatController({ )} - {isSmallScreen && isChatOpen && ( + {isSmallScreen && ( - -
- -
+ -
+ className="tw-flex tw-items-center tw-gap-2 tw-rounded-lg tw-border-0 tw-bg-transparent tw-px-3 tw-py-2 tw-text-white/70 tw-transition-colors desktop-hover:hover:tw-text-white" + > + + Close + +
-
- -
-
- +
+ +
- +
)} , From c322b23953e9eede8cb482fa3d757fc61bf01a93 Mon Sep 17 00:00:00 2001 From: Simo Date: Tue, 7 Apr 2026 09:13:23 +0300 Subject: [PATCH 5/5] wip Signed-off-by: Simo --- .../waves/drop/SingleWaveDropWrapper.test.tsx | 14 +-- .../waves/drop/SingleWaveDropWrapper.tsx | 92 ++++++++++++------- 2 files changed, 64 insertions(+), 42 deletions(-) diff --git a/__tests__/components/waves/drop/SingleWaveDropWrapper.test.tsx b/__tests__/components/waves/drop/SingleWaveDropWrapper.test.tsx index f852a87d9d..9c11aaaade 100644 --- a/__tests__/components/waves/drop/SingleWaveDropWrapper.test.tsx +++ b/__tests__/components/waves/drop/SingleWaveDropWrapper.test.tsx @@ -56,7 +56,7 @@ describe("SingleWaveDropWrapper", () => { expect( screen.getByRole("button", { name: "Show chat" }) ).toBeInTheDocument(); - expect(screen.getAllByTestId("single-drop-chat")).toHaveLength(1); + expect(screen.queryByTestId("single-drop-chat")).not.toBeInTheDocument(); }); fireEvent.click(screen.getByRole("button", { name: "Show chat" })); @@ -88,7 +88,7 @@ describe("SingleWaveDropWrapper", () => { expect( screen.queryByRole("button", { name: "Hide chat" }) ).not.toBeInTheDocument(); - expect(screen.getAllByTestId("single-drop-chat")).toHaveLength(1); + expect(screen.queryByTestId("single-drop-chat")).not.toBeInTheDocument(); }); }); @@ -99,7 +99,7 @@ describe("SingleWaveDropWrapper", () => { expect( screen.getByRole("button", { name: "Show chat" }) ).toBeInTheDocument(); - expect(screen.getAllByTestId("single-drop-chat")).toHaveLength(1); + expect(screen.queryByTestId("single-drop-chat")).not.toBeInTheDocument(); }); fireEvent.click(screen.getByRole("button", { name: "Show chat" })); @@ -119,19 +119,19 @@ describe("SingleWaveDropWrapper", () => { expect( screen.getByRole("button", { name: "Show chat" }) ).toBeInTheDocument(); - expect(screen.getAllByTestId("single-drop-chat")).toHaveLength(1); + expect(screen.queryByTestId("single-drop-chat")).not.toBeInTheDocument(); }); expect(document.body.style.overflow).toBe(""); }); - it("keeps exactly one mobile chat tree mounted while closed and open", async () => { + it("mounts the mobile chat tree only while open", async () => { render(renderWrapper(false)); await waitFor(() => { expect( screen.getByRole("button", { name: "Show chat" }) ).toBeInTheDocument(); - expect(screen.getAllByTestId("single-drop-chat")).toHaveLength(1); + expect(screen.queryByTestId("single-drop-chat")).not.toBeInTheDocument(); }); fireEvent.click(screen.getByRole("button", { name: "Show chat" })); @@ -149,7 +149,7 @@ describe("SingleWaveDropWrapper", () => { expect( screen.getByRole("button", { name: "Show chat" }) ).toBeInTheDocument(); - expect(screen.getAllByTestId("single-drop-chat")).toHaveLength(1); + expect(screen.queryByTestId("single-drop-chat")).not.toBeInTheDocument(); }); expect(document.body.style.overflow).toBe(""); }); diff --git a/components/waves/drop/SingleWaveDropWrapper.tsx b/components/waves/drop/SingleWaveDropWrapper.tsx index ffb9e3f6bb..0c40b8824e 100644 --- a/components/waves/drop/SingleWaveDropWrapper.tsx +++ b/components/waves/drop/SingleWaveDropWrapper.tsx @@ -1,5 +1,6 @@ "use client"; +import { Transition, TransitionChild } from "@headlessui/react"; import { CompactModeProvider } from "@/contexts/CompactModeContext"; import type { ApiDrop } from "@/generated/models/ApiDrop"; import type { ApiWave } from "@/generated/models/ApiWave"; @@ -10,7 +11,13 @@ import { ChatBubbleLeftRightIcon, } from "@heroicons/react/24/outline"; import { createPortal } from "react-dom"; -import React, { useCallback, useEffect, useRef, useState } from "react"; +import React, { + Fragment, + useCallback, + useEffect, + useRef, + useState, +} from "react"; import { SingleWaveDropChat } from "./SingleWaveDropChat"; import { useWaveViewerMode } from "../public/WaveViewerModeContext"; @@ -121,6 +128,7 @@ function AuthenticatedSingleWaveDropChatController({ <> {createPortal( -
+ className="tw-absolute tw-inset-0 tw-bg-black/60" + /> + + + +
+
+ +
-
- -
+
+ +
+
+
-
+ )} ,