diff --git a/.mcp.json b/.mcp.json index ee5d245dcb..28fa0cb286 100644 --- a/.mcp.json +++ b/.mcp.json @@ -1,7 +1,7 @@ { "mcpServers": { "next-devtools": { - "command": "npx", + "command": "/opt/homebrew/bin/npx", "args": ["-y", "next-devtools-mcp@latest"] } } diff --git a/__tests__/components/utils/select/dropdown/CommonDropdownItemsMobileWrapper.test.tsx b/__tests__/components/utils/select/dropdown/CommonDropdownItemsMobileWrapper.test.tsx index 159d11e60e..2b63aafd03 100644 --- a/__tests__/components/utils/select/dropdown/CommonDropdownItemsMobileWrapper.test.tsx +++ b/__tests__/components/utils/select/dropdown/CommonDropdownItemsMobileWrapper.test.tsx @@ -1,25 +1,78 @@ -import { render, screen, fireEvent } from '@testing-library/react'; -import React from 'react'; -import CommonDropdownItemsMobileWrapper from '@/components/utils/select/dropdown/CommonDropdownItemsMobileWrapper'; +import { fireEvent, render, screen } from "@testing-library/react"; +import React from "react"; +import CommonDropdownItemsMobileWrapper from "@/components/utils/select/dropdown/CommonDropdownItemsMobileWrapper"; + +jest.mock("@headlessui/react", () => { + const Dialog = ({ children, className }: any) => ( +
+ {children} +
+ ); + const DialogPanel = ({ children, className }: any) => ( +
+ {children} +
+ ); + const DialogTitle = ({ children, className }: any) => ( +
{children}
+ ); + const Transition = ({ show, children }: any) => + show ?
{children}
: null; + const TransitionChild = ({ children }: any) =>
{children}
; -jest.mock('@headlessui/react', () => { - const Comp = (p: any) =>
{p.children}
; return { - Dialog: Object.assign(Comp, { Panel: Comp, Title: Comp }), - Transition: { Root: ({ show, children }: any) => (show ?
{children}
: null), Child: Comp }, + Dialog, + DialogPanel, + DialogTitle, + Transition, + TransitionChild, }; }); -describe('CommonDropdownItemsMobileWrapper', () => { - it('renders when open and closes on button click', () => { +describe("CommonDropdownItemsMobileWrapper", () => { + it("uses the default z-index class when none is provided", () => { const setOpen = jest.fn(); + + render( + +
  • child
  • +
    + ); + + expect(screen.getByTestId("dialog")).toHaveClass("tw-z-[1000]"); + }); + + it("applies a custom z-index class when provided", () => { + const setOpen = jest.fn(); + render( - +
  • child
  • ); - expect(screen.getByText('test')).toBeInTheDocument(); - fireEvent.click(screen.getByLabelText('Close panel')); + + expect(screen.getByTestId("dialog")).toHaveClass("tw-z-[1020]"); + }); + + it("renders when open and closes on button click", () => { + const setOpen = jest.fn(); + + render( + +
  • child
  • +
    + ); + + expect(screen.getByText("test")).toBeInTheDocument(); + fireEvent.click(screen.getByLabelText("Close panel")); expect(setOpen).toHaveBeenCalledWith(false); }); }); diff --git a/__tests__/components/waves/drops/WaveDropActionsAddReaction.test.tsx b/__tests__/components/waves/drops/WaveDropActionsAddReaction.test.tsx index eed39484fe..84e16961d9 100644 --- a/__tests__/components/waves/drops/WaveDropActionsAddReaction.test.tsx +++ b/__tests__/components/waves/drops/WaveDropActionsAddReaction.test.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import WaveDropActionsAddReaction from "@/components/waves/drops/WaveDropActionsAddReaction"; import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; import { DropSize } from "@/helpers/waves/drop.helpers"; @@ -7,6 +7,14 @@ import { ApiDropType } from "@/generated/models/ApiDropType"; const applyOptimisticDropUpdateMock = jest.fn(() => ({ rollback: jest.fn() })); const setToastMock = jest.fn(); +const mobileWrapperDialogMock = jest.fn( + ({ isOpen, children, zIndexClassName }: any) => + isOpen ? ( +
    + {children} +
    + ) : null +); jest.mock("@/contexts/wave/MyStreamContext", () => ({ useMyStream: jest.fn(() => ({ @@ -39,7 +47,11 @@ jest.mock("@/services/api/common-api", () => ({ commonApiPost: jest.fn(() => Promise.resolve({})), })); -// Mock emoji-mart/react Picker and emoji-mart/data +jest.mock("@/components/mobile-wrapper-dialog/MobileWrapperDialog", () => ({ + __esModule: true, + default: (props: any) => mobileWrapperDialogMock(props), +})); + jest.mock("@emoji-mart/react", () => ({ __esModule: true, default: ({ onEmojiSelect }: any) => ( @@ -54,7 +66,6 @@ jest.mock("@emoji-mart/data", () => ({ default: {}, })); -// Mock useEmoji jest.mock("@/contexts/EmojiContext", () => ({ useEmoji: jest.fn(() => ({ emojiMap: [], @@ -66,7 +77,6 @@ jest.mock("@/contexts/EmojiContext", () => ({ })), })); -// Mock drop object const baseDrop = { id: "12345", wave: { id: "wave-1" }, @@ -92,6 +102,7 @@ const tempDrop = { stableKey: "temp-001", stableHash: "hash-temp-001", } as ExtendedDrop; + describe("WaveDropActionsAddReaction", () => { beforeEach(() => { jest.clearAllMocks(); @@ -124,7 +135,6 @@ describe("WaveDropActionsAddReaction", () => { fireEvent.click(button); expect(await screen.findByTestId("mock-picker")).toBeInTheDocument(); - // Simulate Escape key fireEvent.keyDown(document, { key: "Escape" }); await waitFor(() => { expect(screen.queryByTestId("mock-picker")).not.toBeInTheDocument(); @@ -138,7 +148,6 @@ describe("WaveDropActionsAddReaction", () => { fireEvent.click(button); expect(await screen.findByTestId("mock-picker")).toBeInTheDocument(); - // Simulate outside click fireEvent.mouseDown(document.body); await waitFor(() => { expect(screen.queryByTestId("mock-picker")).not.toBeInTheDocument(); @@ -171,4 +180,21 @@ describe("WaveDropActionsAddReaction", () => { fireEvent.click(button); expect(await screen.findByTestId("mock-picker")).toBeInTheDocument(); }); + + it("forwards custom dialog z-index to the mobile wrapper", async () => { + render( + + ); + + fireEvent.click(screen.getByRole("button", { name: /add reaction/i })); + + expect(await screen.findByTestId("mobile-dialog")).toHaveAttribute( + "data-z-index", + "tw-z-[1030]" + ); + }); }); diff --git a/__tests__/components/waves/drops/WaveDropMobileMenu.test.tsx b/__tests__/components/waves/drops/WaveDropMobileMenu.test.tsx index 3da5ea033e..a5765307f1 100644 --- a/__tests__/components/waves/drops/WaveDropMobileMenu.test.tsx +++ b/__tests__/components/waves/drops/WaveDropMobileMenu.test.tsx @@ -1,5 +1,6 @@ import { AuthContext } from "@/components/auth/Auth"; import WaveDropMobileMenu from "@/components/waves/drops/WaveDropMobileMenu"; +import { WaveDropLayerProvider } from "@/components/waves/drops/WaveDropLayerContext"; import { ApiDropType } from "@/generated/models/ApiDropType"; import { useDropInteractionRules } from "@/hooks/drops/useDropInteractionRules"; import { render, screen } from "@testing-library/react"; @@ -7,6 +8,19 @@ import userEvent from "@testing-library/user-event"; const mockIsMemesWave = jest.fn(); const writeText = jest.fn().mockResolvedValue(undefined); +const addReactionMock = jest.fn((props: any) => ( +
    +)); +const mobileWrapperMock = jest.fn((props: any) => + props.isOpen ? ( +
    + {props.children} +
    + ) : null +); jest.mock("@/hooks/drops/useDropInteractionRules", () => ({ useDropInteractionRules: jest.fn(), @@ -30,16 +44,19 @@ jest.mock("@/components/waves/drops/WaveDropActionsMarkUnread", () => () => ( jest.mock("@/components/waves/drops/WaveDropActionsRate", () => () => (
    )); -jest.mock("@/components/waves/drops/WaveDropActionsAddReaction", () => () => ( -
    -)); +jest.mock("@/components/waves/drops/WaveDropActionsAddReaction", () => ({ + __esModule: true, + default: (props: any) => addReactionMock(props), +})); jest.mock("@/components/waves/drops/WaveDropActionsQuickReact", () => () => (
    )); jest.mock( "@/components/utils/select/dropdown/CommonDropdownItemsMobileWrapper", - () => (props: any) => - props.isOpen ?
    {props.children}
    : null + () => ({ + __esModule: true, + default: (props: any) => mobileWrapperMock(props), + }) ); jest.mock("@/contexts/SeizeSettingsContext", () => ({ @@ -70,6 +87,8 @@ const mockedUseDropInteractionRules = jest.mocked(useDropInteractionRules); beforeEach(() => { writeText.mockClear(); + addReactionMock.mockClear(); + mobileWrapperMock.mockClear(); mockIsMemesWave.mockReturnValue(false); mockedUseDropInteractionRules.mockReturnValue({ canShowVote: true, @@ -107,7 +126,6 @@ test("copies serial jump links for non-memes drops", async () => { longPressTriggered={false} setOpen={jest.fn()} onReply={jest.fn()} - onQuote={jest.fn()} onAddReaction={jest.fn()} /> @@ -142,7 +160,6 @@ test("copies canonical drop links for memes submissions", async () => { longPressTriggered={false} setOpen={jest.fn()} onReply={jest.fn()} - onQuote={jest.fn()} onAddReaction={jest.fn()} /> @@ -177,7 +194,6 @@ test("hides follow and clap when author and memes wave", () => { longPressTriggered={false} setOpen={jest.fn()} onReply={jest.fn()} - onQuote={jest.fn()} onAddReaction={jest.fn()} /> @@ -223,7 +239,6 @@ test("shows pinned-drop action in the mobile menu for admins", () => { longPressTriggered={false} setOpen={jest.fn()} onReply={jest.fn()} - onQuote={jest.fn()} onAddReaction={jest.fn()} /> @@ -269,7 +284,6 @@ test("does not show pinned-drop action in the mobile menu for non-admins", () => longPressTriggered={false} setOpen={jest.fn()} onReply={jest.fn()} - onQuote={jest.fn()} onAddReaction={jest.fn()} /> @@ -358,3 +372,97 @@ test("shows only copy link in the mobile menu for guests", () => { expect(screen.queryByTestId("set-pinned-drop")).toBeNull(); expect(screen.queryByTestId("delete")).toBeNull(); }); + +test("uses single-drop layer overrides when provided by context", () => { + const drop = { + id: "1", + serial_no: 1, + wave: { id: "w" }, + drop_type: ApiDropType.Chat, + author: { handle: "alice" }, + } as any; + + render( + + + + + + ); + + expect(screen.getByTestId("wrapper")).toHaveAttribute( + "data-z-index", + "tw-z-[1020]" + ); + expect(screen.getByTestId("add-reaction")).toHaveAttribute( + "data-dialog-z-index", + "tw-z-[1030]" + ); +}); + +test("preserves default layer values when context overrides are undefined", () => { + const drop = { + id: "1", + serial_no: 1, + wave: { id: "w" }, + drop_type: ApiDropType.Chat, + author: { handle: "alice" }, + } as any; + + render( + + + + + + ); + + expect(screen.getByTestId("wrapper")).toHaveAttribute( + "data-z-index", + "tw-z-[1000]" + ); + expect(screen.getByTestId("add-reaction")).toHaveAttribute( + "data-dialog-z-index", + "tw-z-[1030]" + ); +}); diff --git a/components/mobile-wrapper-dialog/MobileWrapperDialog.tsx b/components/mobile-wrapper-dialog/MobileWrapperDialog.tsx index 8904f30afa..65d312c13a 100644 --- a/components/mobile-wrapper-dialog/MobileWrapperDialog.tsx +++ b/components/mobile-wrapper-dialog/MobileWrapperDialog.tsx @@ -107,6 +107,7 @@ export default function MobileWrapperDialog({ showScrollbar, allowOverflow, maxWidthClass, + zIndexClassName = "tw-z-[1010]", dismissible = true, }: { readonly title?: string | undefined; @@ -122,6 +123,7 @@ export default function MobileWrapperDialog({ readonly showScrollbar?: boolean | undefined; readonly allowOverflow?: boolean | undefined; readonly maxWidthClass?: string | undefined; + readonly zIndexClassName?: string | undefined; readonly dismissible?: boolean | undefined; }) { const { isCapacitor, isIos } = useCapacitor(); @@ -163,7 +165,7 @@ export default function MobileWrapperDialog({ void; readonly label?: string | undefined; readonly hideOnDesktopHover?: boolean | undefined; + readonly zIndexClassName?: string | undefined; readonly children: ReactNode; }) { const hasTouchInput = useHasTouchInput(); @@ -31,7 +33,7 @@ export default function CommonDropdownItemsMobileWrapper({ = ({ }; return ( -
    -
    -
    -
    + +
    +
    +
    -
    - - handleDropAction({ - targetDrop: repliedDrop, +
    +
    + -
    -
    - - + handleDropAction({ + targetDrop: repliedDrop, + partId, + action: ActiveDropAction.REPLY, + }) + } activeDrop={activeDrop} - onCancelReplyQuote={resetActiveDrop} - onDropAddedToQueue={resetActiveDrop} - wave={wave} + initialDrop={null} + unreadCount={wave.metrics.your_unread_drops_count} dropId={drop.id} - fixedDropMode={DropMode.BOTH} + isMuted={wave.metrics.muted} /> - +
    +
    + + + +
    -
    +
    ); }; diff --git a/components/waves/drops/WaveDropActionsAddReaction.tsx b/components/waves/drops/WaveDropActionsAddReaction.tsx index bbc6941df7..1b9c3a57ef 100644 --- a/components/waves/drops/WaveDropActionsAddReaction.tsx +++ b/components/waves/drops/WaveDropActionsAddReaction.tsx @@ -14,7 +14,8 @@ const WaveDropActionsAddReaction: React.FC<{ readonly drop: ExtendedDrop; readonly isMobile?: boolean | undefined; readonly onAddReaction?: (() => void) | undefined; -}> = ({ drop, isMobile = false, onAddReaction }) => { + readonly dialogZIndexClassName?: string | undefined; +}> = ({ drop, isMobile = false, onAddReaction, dialogZIndexClassName }) => { const { react, canReact } = useDropReaction(drop, onAddReaction); const [showPicker, setShowPicker] = useState(false); const buttonRef = useRef(null); @@ -181,6 +182,7 @@ const WaveDropActionsAddReaction: React.FC<{ setShowPicker(false)} + zIndexClassName={dialogZIndexClassName} >
    ( + DEFAULT_WAVE_DROP_LAYER_CONTEXT +); + +export function WaveDropLayerProvider({ + children, + value, +}: { + readonly children: React.ReactNode; + readonly value?: Partial | undefined; +}) { + const mergedValue = useMemo( + () => ({ + mobileMenuZIndexClassName: + value?.mobileMenuZIndexClassName ?? + DEFAULT_WAVE_DROP_LAYER_CONTEXT.mobileMenuZIndexClassName, + mobileDialogZIndexClassName: + value?.mobileDialogZIndexClassName ?? + DEFAULT_WAVE_DROP_LAYER_CONTEXT.mobileDialogZIndexClassName, + }), + [value] + ); + + return ( + + {children} + + ); +} + +export function useWaveDropLayers(): WaveDropLayerContextValue { + return useContext(WaveDropLayerContext); +} diff --git a/components/waves/drops/WaveDropMobileMenu.tsx b/components/waves/drops/WaveDropMobileMenu.tsx index 136f8482a7..623556bccc 100644 --- a/components/waves/drops/WaveDropMobileMenu.tsx +++ b/components/waves/drops/WaveDropMobileMenu.tsx @@ -21,6 +21,7 @@ import WaveDropMobileMenuEdit from "./WaveDropMobileMenuEdit"; import WaveDropMobileMenuSetPinnedDrop from "./WaveDropMobileMenuSetPinnedDrop"; import WaveDropMobileMenuOpen from "./WaveDropMobileMenuOpen"; import WaveDropActionsQuickReact from "./WaveDropActionsQuickReact"; +import { useWaveDropLayers } from "./WaveDropLayerContext"; interface WaveDropMobileMenuProps { readonly drop: ApiDrop; @@ -53,6 +54,8 @@ const WaveDropMobileMenu: FC = ({ const { isMemesWave } = useSeizeSettings(); const isTemporaryDrop = drop.id.startsWith("temp-"); const { canDelete, canSetPinnedDrop } = useDropInteractionRules(drop); + const { mobileMenuZIndexClassName, mobileDialogZIndexClassName } = + useWaveDropLayers(); const extendedDrop = useMemo( () => ({ @@ -135,7 +138,11 @@ const WaveDropMobileMenu: FC = ({ const showGuestCopyOnly = !connectedProfile?.handle; return createPortal( - +
    = ({ drop={extendedDrop} isMobile={true} onAddReaction={onAddReaction} + dialogZIndexClassName={mobileDialogZIndexClassName} /> {showReplyAndQuote && (