diff --git a/__tests__/components/ChatItemHrefButtons.test.tsx b/__tests__/components/ChatItemHrefButtons.test.tsx index bba32bad02..e72b416a68 100644 --- a/__tests__/components/ChatItemHrefButtons.test.tsx +++ b/__tests__/components/ChatItemHrefButtons.test.tsx @@ -1,13 +1,27 @@ -import { render, screen, fireEvent } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import ChatItemHrefButtons from "@/components/waves/ChatItemHrefButtons"; import { LinkPreviewProvider } from "@/components/waves/LinkPreviewContext"; +import useHasTouchInput from "@/hooks/useHasTouchInput"; +import useIsMobileDevice from "@/hooks/isMobileDevice"; + +jest.mock("@/hooks/useHasTouchInput"); +jest.mock("@/hooks/isMobileDevice"); const writeText = jest.fn().mockResolvedValue(undefined); Object.assign(navigator, { clipboard: { writeText }, }); +const useHasTouchInputMock = useHasTouchInput as jest.Mock; +const useIsMobileDeviceMock = useIsMobileDevice as jest.Mock; + describe("ChatItemHrefButtons", () => { + beforeEach(() => { + useHasTouchInputMock.mockReturnValue(false); + useIsMobileDeviceMock.mockReturnValue(false); + }); + afterEach(() => { jest.clearAllMocks(); }); @@ -52,4 +66,395 @@ describe("ChatItemHrefButtons", () => { fireEvent.click(screen.getByRole("button", { name: "Hide link previews" })); expect(onToggle).toHaveBeenCalledTimes(1); }); + + it("opens the overlay menu and keeps copy action available", () => { + render( +
+ +
+ ); + + fireEvent.click(screen.getByRole("button", { name: "Link actions" })); + fireEvent.click(screen.getByRole("button", { name: "Copy link" })); + + expect(writeText).toHaveBeenCalledWith("https://a"); + }); + + it("dismisses the overlay menu without activating the underlying card", () => { + const onCardClick = jest.fn(); + + render( +
+ + +
+ ); + + fireEvent.click(screen.getByRole("button", { name: "Link actions" })); + fireEvent.click( + screen.getByRole("button", { name: "Dismiss link actions" }) + ); + + expect( + screen.queryByRole("button", { name: "Copy link" }) + ).not.toBeInTheDocument(); + expect(onCardClick).not.toHaveBeenCalled(); + }); + + it("reports overlay action activity when the menu opens and closes", () => { + const onCardActionsActiveChange = jest.fn(); + + render( +
+ + + +
+ ); + + const trigger = screen.getByRole("button", { name: "Link actions" }); + + fireEvent.click(trigger); + + const actionSurfaceId = onCardActionsActiveChange.mock.calls[0]?.[0]; + expect(onCardActionsActiveChange).toHaveBeenNthCalledWith( + 1, + actionSurfaceId, + true + ); + + fireEvent.click(trigger); + + expect(onCardActionsActiveChange).toHaveBeenNthCalledWith( + 2, + actionSurfaceId, + false + ); + }); + + it("reports overlay action activity when focus enters and leaves the trigger", () => { + const onCardActionsActiveChange = jest.fn(); + + render( +
+
+ + + +
+ +
+ ); + + const trigger = screen.getByRole("button", { name: "Link actions" }); + const outsideButton = screen.getByRole("button", { name: "Outside" }); + + fireEvent.focus(trigger); + + const actionSurfaceId = onCardActionsActiveChange.mock.calls[0]?.[0]; + expect(onCardActionsActiveChange).toHaveBeenNthCalledWith( + 1, + actionSurfaceId, + true + ); + + fireEvent.blur(trigger, { relatedTarget: outsideButton }); + + expect(onCardActionsActiveChange).toHaveBeenNthCalledWith( + 2, + actionSurfaceId, + false + ); + }); + + it("reports overlay action activity when the trigger is hovered", () => { + const onCardActionsActiveChange = jest.fn(); + + render( +
+ + + +
+ ); + + const trigger = screen.getByRole("button", { name: "Link actions" }); + + fireEvent.mouseEnter(trigger); + + const actionSurfaceId = onCardActionsActiveChange.mock.calls[0]?.[0]; + expect(onCardActionsActiveChange).toHaveBeenNthCalledWith( + 1, + actionSurfaceId, + true + ); + + fireEvent.mouseLeave(trigger); + + expect(onCardActionsActiveChange).toHaveBeenNthCalledWith( + 2, + actionSurfaceId, + false + ); + }); + + it("reports overlay actions as inactive when the component unmounts while active", () => { + const onCardActionsActiveChange = jest.fn(); + + const { unmount } = render( +
+ + + +
+ ); + + fireEvent.focus(screen.getByRole("button", { name: "Link actions" })); + + const actionSurfaceId = onCardActionsActiveChange.mock.calls[0]?.[0]; + + unmount(); + + expect(onCardActionsActiveChange).toHaveBeenNthCalledWith( + 2, + actionSurfaceId, + false + ); + }); + + it("moves focus to the first overlay action when opened from keyboard", async () => { + const user = userEvent.setup(); + + render( +
+ + + +
+ ); + + const trigger = screen.getByRole("button", { name: "Link actions" }); + trigger.focus(); + + await user.keyboard("{Enter}"); + + expect( + screen.getByRole("button", { name: "Hide link previews" }) + ).toHaveFocus(); + }); + + it("moves pointer-opened overlay menus into the tab sequence", async () => { + const user = userEvent.setup(); + + render( +
+
+ +
+ +
+ ); + + await user.click(screen.getByRole("button", { name: "Link actions" })); + + expect(screen.getByRole("button", { name: "Copy link" })).toHaveFocus(); + + await user.tab(); + expect(screen.getByRole("link", { name: "Open link" })).toHaveFocus(); + + await user.tab(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Outside" })).toHaveFocus(); + expect( + screen.queryByRole("button", { name: "Copy link" }) + ).not.toBeInTheDocument(); + }); + }); + + it("skips disabled overlay actions when moving keyboard focus into the menu", async () => { + const user = userEvent.setup(); + + render( +
+ + + +
+ ); + + const trigger = screen.getByRole("button", { name: "Link actions" }); + trigger.focus(); + + await user.keyboard("{Enter}"); + + expect( + screen.getByRole("button", { name: "Hide link previews" }) + ).toBeDisabled(); + expect(screen.getByRole("button", { name: "Copy link" })).toHaveFocus(); + }); + + it("reports overlay actions as inactive after pointer-driven trigger close", () => { + const onCardActionsActiveChange = jest.fn(); + + render( +
+ + + +
+ ); + + const trigger = screen.getByRole("button", { name: "Link actions" }); + + fireEvent.pointerDown(trigger); + fireEvent.mouseDown(trigger); + fireEvent.focus(trigger); + fireEvent.click(trigger); + + const actionSurfaceId = onCardActionsActiveChange.mock.calls[0]?.[0]; + expect(onCardActionsActiveChange).toHaveBeenNthCalledWith( + 1, + actionSurfaceId, + true + ); + + fireEvent.pointerDown(trigger); + fireEvent.mouseDown(trigger); + fireEvent.focus(trigger); + fireEvent.click(trigger); + + expect(onCardActionsActiveChange).toHaveBeenNthCalledWith( + 2, + actionSurfaceId, + false + ); + }); + + it("returns focus to the trigger after a keyboard-activated overlay action", async () => { + const user = userEvent.setup(); + + render( +
+ +
+ ); + + const trigger = screen.getByRole("button", { name: "Link actions" }); + trigger.focus(); + + await user.keyboard("{Enter}"); + expect(screen.getByRole("button", { name: "Copy link" })).toHaveFocus(); + + await user.keyboard("{Enter}"); + + await waitFor(() => { + expect(trigger).toHaveFocus(); + }); + }); + + it("returns focus to the trigger when escape closes the overlay menu", async () => { + const user = userEvent.setup(); + + render( +
+ +
+ ); + + const trigger = screen.getByRole("button", { name: "Link actions" }); + trigger.focus(); + + await user.keyboard("{Enter}"); + expect(screen.getByRole("button", { name: "Copy link" })).toHaveFocus(); + + fireEvent.keyDown(window, { key: "Escape" }); + + await waitFor(() => { + expect(trigger).toHaveFocus(); + }); + }); + + it("closes the overlay menu when keyboard focus tabs past the last action", async () => { + const user = userEvent.setup(); + const onCardActionsActiveChange = jest.fn(); + + render( +
+
+ + + +
+ +
+ ); + + const trigger = screen.getByRole("button", { name: "Link actions" }); + trigger.focus(); + + const actionSurfaceId = onCardActionsActiveChange.mock.calls[0]?.[0]; + + await user.keyboard("{Enter}"); + expect(screen.getByRole("button", { name: "Copy link" })).toHaveFocus(); + + await user.tab(); + expect(screen.getByRole("link", { name: "Open link" })).toHaveFocus(); + + await user.tab(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Outside" })).toHaveFocus(); + expect( + screen.queryByRole("button", { name: "Copy link" }) + ).not.toBeInTheDocument(); + }); + + expect(onCardActionsActiveChange).toHaveBeenLastCalledWith( + actionSurfaceId, + false + ); + }); + + it("keeps the overlay trigger visible on touch devices", () => { + useHasTouchInputMock.mockReturnValue(true); + + render( +
+ +
+ ); + + expect(screen.getByRole("button", { name: "Link actions" })).toBeVisible(); + }); }); diff --git a/__tests__/components/utils/select/dropdown/CommonDropdownItemsDefaultWrapper.test.tsx b/__tests__/components/utils/select/dropdown/CommonDropdownItemsDefaultWrapper.test.tsx index 2c4d57c9b3..9133c357b5 100644 --- a/__tests__/components/utils/select/dropdown/CommonDropdownItemsDefaultWrapper.test.tsx +++ b/__tests__/components/utils/select/dropdown/CommonDropdownItemsDefaultWrapper.test.tsx @@ -1,50 +1,222 @@ -import { render, fireEvent, waitFor } from '@testing-library/react'; -import React from 'react'; -import CommonDropdownItemsDefaultWrapper from '@/components/utils/select/dropdown/CommonDropdownItemsDefaultWrapper'; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import React from "react"; +import CommonDropdownItemsDefaultWrapper from "@/components/utils/select/dropdown/CommonDropdownItemsDefaultWrapper"; -jest.mock('framer-motion', () => ({ +jest.mock("framer-motion", () => ({ AnimatePresence: ({ children }: any) =>
{children}
, motion: { div: (props: any) =>
}, })); -jest.mock('react-use', () => ({ +jest.mock("react-use", () => ({ useClickAway: (ref: React.RefObject, handler: () => void) => { React.useEffect(() => { const listener = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) handler(); }; - document.addEventListener('mousedown', listener); - return () => document.removeEventListener('mousedown', listener); + document.addEventListener("mousedown", listener); + return () => document.removeEventListener("mousedown", listener); }, [ref, handler]); }, useKeyPressEvent: (key: string, cb: () => void) => { React.useEffect(() => { const handler = (e: KeyboardEvent) => e.key === key && cb(); - window.addEventListener('keydown', handler); - return () => window.removeEventListener('keydown', handler); + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); }, [key, cb]); }, })); -test('closes on escape press', () => { +const createRect = ({ + top, + bottom, + left, + right, +}: { + readonly top: number; + readonly bottom: number; + readonly left: number; + readonly right: number; +}) => ({ + top, + bottom, + left, + right, + width: right - left, + height: bottom - top, + x: left, + y: top, + toJSON: () => ({}), +}); + +const originalInnerWidth = window.innerWidth; +const originalInnerHeight = window.innerHeight; + +afterEach(() => { + Object.defineProperty(window, "innerWidth", { + configurable: true, + value: originalInnerWidth, + }); + Object.defineProperty(window, "innerHeight", { + configurable: true, + value: originalInnerHeight, + }); + jest.restoreAllMocks(); +}); + +test("closes on escape press", () => { const setOpen = jest.fn(); render( - +
  • item
  • ); - fireEvent.keyDown(window, { key: 'Escape' }); + fireEvent.keyDown(window, { key: "Escape" }); expect(setOpen).toHaveBeenCalledWith(false); }); -test('positions dropdown when buttonPosition provided', async () => { - jest.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(40); - const { container } = render( - {}} buttonRef={{ current: null }} buttonPosition={{ right:100 }}> +test("positions the dropdown below the trigger when there is space", async () => { + Object.defineProperty(window, "innerWidth", { + configurable: true, + value: 1280, + }); + Object.defineProperty(window, "innerHeight", { + configurable: true, + value: 720, + }); + jest.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockReturnValue(160); + jest.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockReturnValue(120); + + const button = document.createElement("button"); + Object.defineProperty(button, "getBoundingClientRect", { + configurable: true, + value: () => + createRect({ + top: 200, + bottom: 232, + left: 100, + right: 132, + }), + }); + + render( + {}} + buttonRef={{ current: button }} + >
  • item
  • ); + await waitFor(() => { - expect((container.firstChild as HTMLElement).style.left).toBe('60px'); + expect(screen.getByRole("menu").parentElement).toHaveStyle({ + left: "100px", + top: "240px", + }); + }); +}); + +test("positions the dropdown above the trigger near the viewport bottom", async () => { + Object.defineProperty(window, "innerWidth", { + configurable: true, + value: 1280, + }); + Object.defineProperty(window, "innerHeight", { + configurable: true, + value: 500, }); + jest.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockReturnValue(160); + jest.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockReturnValue(120); + + const button = document.createElement("button"); + Object.defineProperty(button, "getBoundingClientRect", { + configurable: true, + value: () => + createRect({ + top: 440, + bottom: 472, + left: 100, + right: 132, + }), + }); + + render( + {}} + buttonRef={{ current: button }} + > +
  • item
  • +
    + ); + + await waitFor(() => { + expect(screen.getByRole("menu").parentElement).toHaveStyle({ + left: "100px", + top: "312px", + }); + }); +}); + +test("closes when focus moves outside the trigger and portaled menu by default", async () => { + const setOpen = jest.fn(); + const buttonRef = React.createRef(); + + render( + <> + + + +
  • + +
  • +
    + + ); + + await screen.findByRole("menu"); + + fireEvent.focusIn(screen.getByRole("button", { name: "Outside" })); + + expect(setOpen).toHaveBeenCalledWith(false); +}); + +test("does not close when opted out and focus moves outside the trigger and portaled menu", async () => { + const setOpen = jest.fn(); + const buttonRef = React.createRef(); + + render( + <> + + + +
  • + +
  • +
    + + ); + + await screen.findByRole("menu"); + + fireEvent.focusIn(screen.getByRole("button", { name: "Outside" })); + + expect(setOpen).not.toHaveBeenCalled(); }); diff --git a/__tests__/components/waves/LinkHandlerFrame.test.tsx b/__tests__/components/waves/LinkHandlerFrame.test.tsx new file mode 100644 index 0000000000..4e35bbb5a1 --- /dev/null +++ b/__tests__/components/waves/LinkHandlerFrame.test.tsx @@ -0,0 +1,106 @@ +import { render, screen } from "@testing-library/react"; + +import LinkHandlerFrame from "@/components/waves/LinkHandlerFrame"; + +let mockHideActions = false; + +jest.mock("@/components/waves/ChatItemHrefButtons", () => ({ + __esModule: true, + default: ({ href, layout }: { href: string; layout: string }) => ( +
    + ), +})); + +jest.mock("@/components/waves/LinkPreviewContext", () => ({ + useLinkPreviewContext: () => ({ hideActions: mockHideActions }), +})); + +describe("LinkHandlerFrame", () => { + beforeEach(() => { + mockHideActions = false; + }); + + it("uses the full-width frame wrapper by default", () => { + render( + +
    + Preview +
    +
    + ); + + const actions = screen.getByTestId("chat-item-href-buttons"); + const anchor = actions.parentElement; + const outer = anchor?.parentElement; + + expect(anchor).not.toBeNull(); + expect(anchor?.className).toContain("tw-group/link-card"); + expect(anchor?.className).toContain("tw-relative"); + expect(anchor?.className).toContain("tw-w-full"); + expect(anchor?.className).toContain("tw-max-w-full"); + expect(anchor?.className).not.toContain("tw-inline-flex"); + expect(anchor?.className).not.toContain("tw-w-fit"); + expect(outer).not.toBeNull(); + expect(actions).toHaveAttribute("data-layout", "overlay"); + }); + + it("supports content-width overlay anchoring when explicitly requested", () => { + render( + +
    + Preview +
    +
    + ); + + const actions = screen.getByTestId("chat-item-href-buttons"); + const anchor = actions.parentElement; + const outer = anchor?.parentElement; + + expect(anchor).not.toBeNull(); + expect(anchor?.className).toContain("tw-group/link-card"); + expect(anchor?.className).toContain("tw-relative"); + expect(anchor?.className).toContain("tw-inline-flex"); + expect(anchor?.className).toContain("tw-w-fit"); + expect(anchor?.className).toContain("tw-max-w-full"); + + expect(outer).not.toBeNull(); + expect(outer?.className).toContain("tw-flex"); + expect(outer?.className).toContain("tw-w-full"); + expect(actions).toHaveAttribute("data-layout", "overlay"); + }); + + it("falls back to the full-width frame wrapper when actions are hidden", () => { + mockHideActions = true; + + render( + +
    + Preview +
    +
    + ); + + const preview = screen.getByTestId("preview-card"); + const anchor = preview.parentElement?.parentElement; + + expect( + screen.queryByTestId("chat-item-href-buttons") + ).not.toBeInTheDocument(); + expect(anchor).not.toBeNull(); + expect(anchor?.className).toContain("tw-group/link-card"); + expect(anchor?.className).toContain("tw-w-full"); + expect(anchor?.className).not.toContain("tw-inline-flex"); + expect(anchor?.className).not.toContain("tw-w-fit"); + }); +}); diff --git a/__tests__/components/waves/drops/WaveDrop.test.tsx b/__tests__/components/waves/drops/WaveDrop.test.tsx index 92577a62be..025839bc89 100644 --- a/__tests__/components/waves/drops/WaveDrop.test.tsx +++ b/__tests__/components/waves/drops/WaveDrop.test.tsx @@ -1,19 +1,27 @@ import React from "react"; -import { render } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { Provider } from "react-redux"; import { configureStore } from "@reduxjs/toolkit"; import WaveDrop from "@/components/waves/drops/WaveDrop"; import useIsMobileDevice from "@/hooks/isMobileDevice"; import { editSlice } from "@/store/editSlice"; -jest.mock("@/components/waves/drops/WaveDropActions", () => (props: any) => ( -
    -)); +const mockWaveDropActions = jest.fn(); +jest.mock("@/components/waves/drops/WaveDropActions", () => (props: any) => { + mockWaveDropActions(props); + return
    ; +}); jest.mock("@/components/waves/drops/WaveDropReply", () => () => (
    )); -jest.mock("@/components/waves/drops/WaveDropContent", () => () => ( -
    +jest.mock("@/components/waves/drops/WaveDropContent", () => (props: any) => ( + + ), +})); + +jest.mock("@/components/waves/drops/WaveDropActionsQuickReact", () => ({ + __esModule: true, + default: () =>
    , +})); + +jest.mock("@/components/waves/drops/WaveDropActionsRate", () => ({ + __esModule: true, + default: () =>
    , +})); + +jest.mock("@/components/waves/drops/WaveDropActionsReply", () => ({ + __esModule: true, + default: () =>
    , +})); + +jest.mock("@/contexts/SeizeSettingsContext", () => ({ + useSeizeSettings: jest.fn(), })); -const rulesMock = useDropInteractionRules as jest.Mock; const settingsMock = useSeizeSettings as jest.Mock; -const auth = { connectedProfile: { handle: 'alice' } } as any; -const wrapper = ({ children }: any) => {children}; +const baseDrop: any = { + id: "drop-1", + wave: { id: "wave-1" }, + drop_type: ApiDropType.Chat, +}; -const baseDrop: any = { id: 'drop1', author: { handle: 'bob' }, wave: { id: 'w1' }, drop_type: ApiDropType.Chat }; +describe("WaveDropActions", () => { + beforeEach(() => { + settingsMock.mockReturnValue({ isMemesWave: () => false }); + }); -beforeEach(() => { - rulesMock.mockReturnValue({ canDelete: true }); - settingsMock.mockReturnValue({ isMemesWave: () => false }); -}); + it("keeps hidden actions non-interactive while closed", () => { + const { container } = render( + {}} /> + ); -describe('WaveDropActions', () => { - it('renders follow button and voting and options', () => { - render( {}} onQuote={() => {}} />, { wrapper }); - expect(screen.getByTestId('follow')).toBeInTheDocument(); - expect(screen.getByTestId('rate')).toBeInTheDocument(); - expect(screen.getByTestId('options')).toBeInTheDocument(); + expect(container.firstElementChild).toHaveClass( + "tw-pointer-events-none", + "tw-opacity-0" + ); }); - it('hides follow when author is current profile', () => { + it("keeps actions interactive when the more dropdown is open", () => { + const { container } = render( + {}} + suppressed={true} + /> + ); + + fireEvent.click(screen.getByRole("button", { name: "Open more actions" })); + + expect(container.firstElementChild).toHaveClass( + "tw-pointer-events-auto", + "tw-opacity-100" + ); + }); + + it("renders the voting control for regular drops", () => { render( - - {}} onQuote={() => {}} /> - + {}} /> ); - expect(screen.queryByTestId('follow')).toBeNull(); + + expect(screen.getByTestId("rate")).toBeInTheDocument(); }); - it('hides voting when participation drop in memes wave', () => { + it("hides voting for participation drops in memes waves", () => { settingsMock.mockReturnValue({ isMemesWave: () => true }); - const drop = { ...baseDrop, drop_type: ApiDropType.Participatory }; - render( {}} onQuote={() => {}} />, { wrapper }); - expect(screen.queryByTestId('rate')).toBeNull(); - }); - it('does not render options when cannot delete', () => { - rulesMock.mockReturnValue({ canDelete: false }); - render( {}} onQuote={() => {}} />, { wrapper }); - expect(screen.queryByTestId('options')).toBeNull(); + render( + {}} + /> + ); + + expect(screen.queryByTestId("rate")).toBeNull(); }); }); diff --git a/__tests__/components/waves/drops/WaveDropPartContentMarkdown.test.tsx b/__tests__/components/waves/drops/WaveDropPartContentMarkdown.test.tsx index 511c140b55..d1715e091f 100644 --- a/__tests__/components/waves/drops/WaveDropPartContentMarkdown.test.tsx +++ b/__tests__/components/waves/drops/WaveDropPartContentMarkdown.test.tsx @@ -55,6 +55,7 @@ it("renders quoted drop", () => { quoted_drop: { drop_id: "d", drop_part_id: 1, drop: null }, } as any; const drop = { id: "root-drop", serial_no: 7 } as any; + const onLinkCardActionsActiveChange = jest.fn(); render( { wave={wave} drop={drop} onQuoteClick={jest.fn()} + onLinkCardActionsActiveChange={onLinkCardActionsActiveChange} /> ); expect(screen.getByTestId("quote")).toHaveAttribute("data-id", "d"); @@ -70,6 +72,9 @@ it("renders quoted drop", () => { expect(quoteProps.embedPath).toEqual(["root-drop"]); expect(quoteProps.quotePath).toEqual(["w:7"]); expect(quoteProps.embedDepth).toBe(1); + expect(quoteProps.onLinkCardActionsActiveChange).toBe( + onLinkCardActionsActiveChange + ); }); it("passes link preview toggle control for author drops with links", () => { diff --git a/__tests__/components/waves/drops/WaveDropQuote.test.tsx b/__tests__/components/waves/drops/WaveDropQuote.test.tsx index d407f59f4a..8dbb917bec 100644 --- a/__tests__/components/waves/drops/WaveDropQuote.test.tsx +++ b/__tests__/components/waves/drops/WaveDropQuote.test.tsx @@ -1,6 +1,7 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import WaveDropQuote from "@/components/waves/drops/WaveDropQuote"; +import { LinkPreviewProvider } from "@/components/waves/LinkPreviewContext"; let markdownProps: any; @@ -107,3 +108,54 @@ test("clears quoted part content when drop becomes null", () => { rerender(); expect(screen.getByTestId("markdown")).toHaveTextContent(""); }); + +test("passes explicit link-card suppression callback into nested markdown", () => { + const drop = { + id: "d1", + serial_no: 42, + wave: { id: "w1", name: "wave" }, + author: { handle: "a", level: 1, cic: "BRONZE", pfp: null }, + parts: [{ part_id: 5, content: "text" }], + created_at: "2020-01-01", + mentioned_users: [], + referenced_nfts: [], + } as any; + const onLinkCardActionsActiveChange = jest.fn(); + + render( + + ); + + expect(markdownProps.onLinkCardActionsActiveChange).toBe( + onLinkCardActionsActiveChange + ); +}); + +test("falls back to link preview context for nested markdown suppression", () => { + const drop = { + id: "d1", + serial_no: 42, + wave: { id: "w1", name: "wave" }, + author: { handle: "a", level: 1, cic: "BRONZE", pfp: null }, + parts: [{ part_id: 5, content: "text" }], + created_at: "2020-01-01", + mentioned_users: [], + referenced_nfts: [], + } as any; + const onCardActionsActiveChange = jest.fn(); + + render( + + + + ); + + expect(markdownProps.onLinkCardActionsActiveChange).toBe( + onCardActionsActiveChange + ); +}); diff --git a/components/drops/view/part/DropPartMarkdown.tsx b/components/drops/view/part/DropPartMarkdown.tsx index c664425621..3e1730ebe7 100644 --- a/components/drops/view/part/DropPartMarkdown.tsx +++ b/components/drops/view/part/DropPartMarkdown.tsx @@ -259,6 +259,9 @@ export interface DropPartMarkdownProps { readonly embedDepth?: number | undefined; readonly maxEmbedDepth?: number | undefined; readonly linkPreviewToggleControl?: LinkPreviewToggleControl | undefined; + readonly onLinkCardActionsActiveChange?: + | ((href: string, active: boolean) => void) + | undefined; } function DropPartMarkdown({ @@ -276,6 +279,7 @@ function DropPartMarkdown({ embedDepth = 0, maxEmbedDepth = DEFAULT_MAX_EMBED_DEPTH, linkPreviewToggleControl, + onLinkCardActionsActiveChange, }: DropPartMarkdownProps) { const queryClient = useQueryClient(); const isMobile = useIsMobileScreen(); @@ -430,6 +434,7 @@ function DropPartMarkdown({ ; return ( - +
    renderFallback()}> { }; return ( -
    +
    {card()}
    - {!hideActions && } + {!hideActions && }
    ); }; diff --git a/components/utils/select/dropdown/CommonDropdownItemsDefaultWrapper.tsx b/components/utils/select/dropdown/CommonDropdownItemsDefaultWrapper.tsx index 5594083173..7eaaf33b02 100644 --- a/components/utils/select/dropdown/CommonDropdownItemsDefaultWrapper.tsx +++ b/components/utils/select/dropdown/CommonDropdownItemsDefaultWrapper.tsx @@ -12,17 +12,22 @@ import { } from "react"; import { useClickAway, useKeyPressEvent } from "react-use"; +const VIEWPORT_PADDING = 16; +const MENU_GAP = 8; + export default function CommonDropdownItemsDefaultWrapper({ isOpen, setOpen, buttonRef, dynamicPosition = true, + closeOnFocusOutside = true, children, }: { readonly isOpen: boolean; readonly setOpen: (isOpen: boolean) => void; readonly buttonRef: RefObject; readonly dynamicPosition?: boolean | undefined; + readonly closeOnFocusOutside?: boolean | undefined; readonly children: ReactNode; }) { const listRef = useRef(null); @@ -49,25 +54,40 @@ export default function CommonDropdownItemsDefaultWrapper({ const dropdownEl = dropdownRef.current; // Get width from the list element if possible, otherwise the wrapper - const width = listRef.current?.offsetWidth ?? dropdownEl.offsetWidth ?? 288; + const width = listRef.current?.offsetWidth ?? dropdownEl.offsetWidth; + const height = listRef.current?.offsetHeight ?? dropdownEl.offsetHeight; const scrollX = window.scrollX; const scrollY = window.scrollY; - - // Vertical position: below the button - const top = buttonRect.bottom + scrollY; + const viewportTop = scrollY + VIEWPORT_PADDING; + const viewportLeft = scrollX + VIEWPORT_PADDING; + const viewportRight = scrollX + window.innerWidth - VIEWPORT_PADDING; + const viewportBottom = scrollY + window.innerHeight - VIEWPORT_PADDING; + const availableAbove = buttonRect.top - VIEWPORT_PADDING; + const availableBelow = + window.innerHeight - buttonRect.bottom - VIEWPORT_PADDING; + const shouldOpenAbove = + height > 0 && + availableBelow < height + MENU_GAP && + availableAbove > availableBelow; + + const unclampedTop = shouldOpenAbove + ? buttonRect.top + scrollY - height - MENU_GAP + : buttonRect.bottom + scrollY + MENU_GAP; + const maxTop = Math.max(viewportTop, viewportBottom - height); + const top = Math.min(Math.max(unclampedTop, viewportTop), maxTop); // Horizontal position: default to left align let left = buttonRect.left + scrollX; // Check if it overflows right edge of viewport - if (buttonRect.left + width > window.innerWidth - 16) { + if (buttonRect.left + width > window.innerWidth - VIEWPORT_PADDING) { // Switch to right align (align right edge of dropdown with right edge of button) left = buttonRect.right + scrollX - width; } - // Ensure it doesn't overflow left edge - left = Math.max(16, left); + const maxLeft = Math.max(viewportLeft, viewportRight - width); + left = Math.min(Math.max(left, viewportLeft), maxLeft); dropdownEl.style.top = `${top}px`; dropdownEl.style.left = `${left}px`; @@ -88,6 +108,33 @@ export default function CommonDropdownItemsDefaultWrapper({ }; }, [dynamicPosition, isOpen, position]); + useEffect(() => { + if (!isOpen || !closeOnFocusOutside) { + return; + } + + const handleFocusIn = (event: FocusEvent) => { + if (!(event.target instanceof Node)) { + return; + } + + if ( + buttonRef.current?.contains(event.target) || + listRef.current?.contains(event.target) + ) { + return; + } + + setOpen(false); + }; + + document.addEventListener("focusin", handleFocusIn); + + return () => { + document.removeEventListener("focusin", handleFocusIn); + }; + }, [buttonRef, closeOnFocusOutside, isOpen, setOpen]); + const [mounted, setMounted] = useState(false); useEffect(() => { setMounted(true); @@ -107,7 +154,7 @@ export default function CommonDropdownItemsDefaultWrapper({ ref={listRef} role="menu" tabIndex={-1} - className="tw-mt-2 tw-w-56 tw-rounded-lg tw-bg-iron-900 tw-py-1 tw-shadow-lg tw-ring-1 tw-ring-white/10 focus-visible:tw-outline-none focus-visible:tw-ring-2 focus-visible:tw-ring-white/20" + className="tw-w-56 tw-rounded-lg tw-bg-iron-900 tw-py-1 tw-shadow-lg tw-ring-1 tw-ring-white/10 focus-visible:tw-outline-none focus-visible:tw-ring-2 focus-visible:tw-ring-white/20" initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} diff --git a/components/waves/ChatItemHrefButtons.tsx b/components/waves/ChatItemHrefButtons.tsx index 3ca29fd110..869c3cce0d 100644 --- a/components/waves/ChatItemHrefButtons.tsx +++ b/components/waves/ChatItemHrefButtons.tsx @@ -2,152 +2,415 @@ import Link from "next/link"; import { + ArrowTopRightOnSquareIcon, + DocumentDuplicateIcon, + EllipsisHorizontalIcon, +} from "@heroicons/react/24/outline"; +import { + useEffect, + useId, + useRef, useState, + type KeyboardEvent, type MouseEvent, type PointerEvent, type TouchEvent, } from "react"; + +import CommonDropdownItemsDefaultWrapper from "@/components/utils/select/dropdown/CommonDropdownItemsDefaultWrapper"; +import useHasTouchInput from "@/hooks/useHasTouchInput"; +import useIsMobileDevice from "@/hooks/isMobileDevice"; + import { useLinkPreviewContext } from "./LinkPreviewContext"; type StopEvent = | MouseEvent + | KeyboardEvent | PointerEvent | TouchEvent; +type ChatItemHrefButtonsLayout = "overlay" | "rail"; + const stopPropagation = (event: StopEvent) => { event.stopPropagation(); - event.nativeEvent.stopImmediatePropagation?.(); + event.nativeEvent.stopImmediatePropagation(); }; +const OVERLAY_TRIGGER_BUTTON_CLASSES = + "tw-flex tw-h-8 tw-w-8 tw-items-center tw-justify-center tw-rounded-full tw-border-0 tw-bg-black/70 tw-text-iron-100 tw-shadow-lg tw-shadow-black/40 tw-backdrop-blur-sm tw-transition tw-duration-200 hover:tw-bg-black/85 hover:tw-text-white focus-visible:tw-outline focus-visible:tw-outline-2 focus-visible:tw-outline-offset-2 focus-visible:tw-outline-primary-400"; + +const MENU_ITEM_CLASSES = + "tw-flex tw-w-full tw-items-center tw-gap-x-2 tw-rounded-md tw-border-0 tw-bg-transparent tw-px-3 tw-py-2 tw-text-left tw-text-sm tw-font-medium tw-text-iron-100 tw-transition-colors hover:tw-bg-iron-800 focus-visible:tw-outline-none focus-visible:tw-bg-iron-800"; + +const RAIL_BUTTON_CLASSES = + "tw-flex tw-items-center tw-gap-x-2 tw-rounded-xl tw-border-0 tw-bg-iron-900 tw-p-2 hover:tw-text-iron-400"; + +function PreviewToggleIcon({ isLoading }: { readonly isLoading: boolean }) { + if (isLoading) { + return ( + + ); + } + + return ( + + ); +} + +function ExternalLinkIcon() { + return ( + + ); +} + export default function ChatItemHrefButtons({ href, relativeHref, hideLink = false, + layout = "rail", }: { href: string; relativeHref?: string | undefined; hideLink?: boolean | undefined; + layout?: ChatItemHrefButtonsLayout | undefined; }) { - const { previewToggle } = useLinkPreviewContext(); + const { previewToggle, onCardActionsActiveChange } = useLinkPreviewContext(); + const hasTouchInput = useHasTouchInput(); + const isMobileDevice = useIsMobileDevice(); const [copied, setCopied] = useState(false); + const [isMenuOpen, setIsMenuOpen] = useState(false); + const actionSurfaceId = useId(); + const buttonRef = useRef(null); + const previewToggleButtonRef = useRef(null); + const copyButtonRef = useRef(null); + const openLinkButtonRef = useRef(null); + const lastReportedActiveRef = useRef(false); + const shouldFocusFirstMenuItemRef = useRef(false); + const shouldIgnoreNextPointerFocusRef = useRef(false); + const shouldRestoreTriggerFocusRef = useRef(false); + const actionSurfaceStateRef = useRef({ + isMenuOpen: false, + isHovered: false, + isFocused: false, + }); const showPreviewToggle = Boolean(previewToggle && !previewToggle.isHidden); + const isOverlay = layout === "overlay"; + const showPersistentOverlayTrigger = + isOverlay && (hasTouchInput || isMobileDevice); + const isPreviewToggleDisabled = Boolean( + previewToggle && (previewToggle.isLoading || !previewToggle.canToggle) + ); + + useEffect(() => { + return () => { + if (lastReportedActiveRef.current && onCardActionsActiveChange) { + onCardActionsActiveChange(actionSurfaceId, false); + lastReportedActiveRef.current = false; + } + }; + }, [actionSurfaceId, onCardActionsActiveChange]); + + useEffect(() => { + if (isMenuOpen || !shouldRestoreTriggerFocusRef.current) { + return; + } + + const timeoutId = globalThis.window.setTimeout(() => { + shouldRestoreTriggerFocusRef.current = false; + buttonRef.current?.focus(); + }, 0); + + return () => { + globalThis.window.clearTimeout(timeoutId); + }; + }, [isMenuOpen]); + + useEffect(() => { + if (!isOverlay || !isMenuOpen) { + return; + } + + const handleKeyDown = (event: globalThis.KeyboardEvent) => { + if (event.key !== "Escape") { + return; + } + + shouldRestoreTriggerFocusRef.current = true; + }; + + globalThis.window.addEventListener("keydown", handleKeyDown, true); + + return () => { + globalThis.window.removeEventListener("keydown", handleKeyDown, true); + }; + }, [isMenuOpen, isOverlay]); + + const updateActionSurfaceState = ( + nextStatePatch: Partial + ) => { + const currentState = actionSurfaceStateRef.current; + const nextState = { ...currentState, ...nextStatePatch }; + + if ( + nextState.isMenuOpen === currentState.isMenuOpen && + nextState.isHovered === currentState.isHovered && + nextState.isFocused === currentState.isFocused + ) { + return; + } + + actionSurfaceStateRef.current = nextState; + + if (nextState.isMenuOpen !== currentState.isMenuOpen) { + setIsMenuOpen(nextState.isMenuOpen); + } + + if (!isOverlay || !onCardActionsActiveChange) { + return; + } + + const isActionSurfaceActive = + nextState.isMenuOpen || nextState.isHovered || nextState.isFocused; + + if (lastReportedActiveRef.current === isActionSurfaceActive) { + return; + } + + onCardActionsActiveChange(actionSurfaceId, isActionSurfaceActive); + lastReportedActiveRef.current = isActionSurfaceActive; + }; + + const focusFirstMenuItemIfRequested = () => { + if (!shouldFocusFirstMenuItemRef.current) { + return; + } + + const firstEnabledMenuItem = [ + previewToggleButtonRef.current, + copyButtonRef.current, + openLinkButtonRef.current, + ].find((element): element is HTMLButtonElement | HTMLAnchorElement => { + return Boolean(element) && !element?.hasAttribute("disabled"); + }); + + if (!firstEnabledMenuItem) { + return; + } + + firstEnabledMenuItem.focus(); + shouldFocusFirstMenuItemRef.current = false; + }; + + const handlePreviewToggleButtonRef = (element: HTMLButtonElement | null) => { + previewToggleButtonRef.current = element; + focusFirstMenuItemIfRequested(); + }; + + const handleCopyButtonRef = (element: HTMLButtonElement | null) => { + copyButtonRef.current = element; + focusFirstMenuItemIfRequested(); + }; + + const handleOpenLinkButtonRef = (element: HTMLAnchorElement | null) => { + openLinkButtonRef.current = element; + focusFirstMenuItemIfRequested(); + }; + + const closeMenu = ({ + restoreFocusToTrigger = false, + }: { + readonly restoreFocusToTrigger?: boolean; + } = {}) => { + shouldFocusFirstMenuItemRef.current = false; + shouldRestoreTriggerFocusRef.current = restoreFocusToTrigger; + updateActionSurfaceState({ isMenuOpen: false }); + }; + + const handleMenuOpenChange = (nextIsOpen: boolean) => { + if (!nextIsOpen) { + shouldFocusFirstMenuItemRef.current = false; + } + + updateActionSurfaceState({ isMenuOpen: nextIsOpen }); + }; const toggleLinkPreviews = (event: MouseEvent) => { stopPropagation(event); - if (!previewToggle || previewToggle.isLoading || !previewToggle.canToggle) { + if (!previewToggle || isPreviewToggleDisabled) { return; } + previewToggle.onToggle(); + closeMenu({ restoreFocusToTrigger: event.detail === 0 }); }; const copyToClipboard = (event: MouseEvent) => { stopPropagation(event); - navigator.clipboard.writeText(href).then(() => { + closeMenu({ restoreFocusToTrigger: event.detail === 0 }); + void navigator.clipboard.writeText(href).then(() => { setCopied(true); setTimeout(() => setCopied(false), 500); }); }; - return ( -
    - {showPreviewToggle && ( - - )} - + ) : null; + + const copyButton = ( + - {!hideLink && ( - Copy link} + + ); + + const openLinkButton = hideLink ? null : ( + { + stopPropagation(event); + closeMenu({ restoreFocusToTrigger: event.detail === 0 }); + }} + onPointerDown={stopPropagation} + onMouseDown={stopPropagation} + onTouchStart={stopPropagation} + > + {isOverlay ? ( + + ) : ( + + )} + {isOverlay && Open link} + + ); + + if (!isOverlay) { + return ( +
    + {previewToggleButton} + {copyButton} + {openLinkButton} +
    + ); + } + + return ( + <> + {isMenuOpen && ( + +
    + + {previewToggleButton && ( +
  • {previewToggleButton}
  • + )} +
  • {copyButton}
  • + {!hideLink &&
  • {openLinkButton}
  • } +
    +
    + ); } diff --git a/components/waves/LinkHandlerFrame.tsx b/components/waves/LinkHandlerFrame.tsx index 812e58e566..905ad967b1 100644 --- a/components/waves/LinkHandlerFrame.tsx +++ b/components/waves/LinkHandlerFrame.tsx @@ -10,6 +10,7 @@ interface LinkHandlerFrameProps { readonly children: ReactNode; readonly hideLink?: boolean | undefined; readonly relativeHref?: string | undefined; + readonly overlayAnchor?: "frame" | "content" | undefined; } export default function LinkHandlerFrame({ @@ -17,27 +18,45 @@ export default function LinkHandlerFrame({ children, hideLink = false, relativeHref, + overlayAnchor = "frame", }: LinkHandlerFrameProps) { const { hideActions } = useLinkPreviewContext(); const effectiveRelativeHref = relativeHref ?? (() => { const relative = removeBaseEndpoint(href); - return relative?.startsWith("/") ? relative : undefined; + return relative.startsWith("/") ? relative : undefined; })(); + const actionButtons = hideActions ? null : ( + + ); + const shouldAnchorOverlayToContent = + overlayAnchor === "content" && actionButtons !== null; + + if (shouldAnchorOverlayToContent) { + return ( +
    +
    +
    + {children} +
    + {actionButtons} +
    +
    + ); + } return ( -
    -
    +
    +
    {children}
    - {!hideActions && ( - - )} + {actionButtons}
    ); } diff --git a/components/waves/LinkPreviewContext.tsx b/components/waves/LinkPreviewContext.tsx index a565eee9f3..3a5c657e89 100644 --- a/components/waves/LinkPreviewContext.tsx +++ b/components/waves/LinkPreviewContext.tsx @@ -22,6 +22,9 @@ type LinkPreviewContextValue = { readonly hideActions: boolean; readonly previewToggle?: LinkPreviewToggleControl | undefined; readonly inlineShowControl?: LinkPreviewInlineShowControl | undefined; + readonly onCardActionsActiveChange?: + | ((href: string, active: boolean) => void) + | undefined; }; const DEFAULT_CONTEXT: LinkPreviewContextValue = { @@ -36,11 +39,15 @@ export const LinkPreviewProvider = ({ variant = "chat", previewToggle, inlineShowControl, + onCardActionsActiveChange, children, }: { readonly variant?: LinkPreviewVariant | undefined; readonly previewToggle?: LinkPreviewToggleControl | undefined; readonly inlineShowControl?: LinkPreviewInlineShowControl | undefined; + readonly onCardActionsActiveChange?: + | ((href: string, active: boolean) => void) + | undefined; readonly children: ReactNode; }) => { const value = useMemo( @@ -49,8 +56,9 @@ export const LinkPreviewProvider = ({ hideActions: variant === "home", previewToggle, inlineShowControl, + onCardActionsActiveChange, }), - [variant, previewToggle, inlineShowControl] + [variant, previewToggle, inlineShowControl, onCardActionsActiveChange] ); return ( diff --git a/components/waves/OpenGraphPreview.tsx b/components/waves/OpenGraphPreview.tsx index 034e3226f3..0d21d49545 100644 --- a/components/waves/OpenGraphPreview.tsx +++ b/components/waves/OpenGraphPreview.tsx @@ -260,12 +260,16 @@ export function LinkPreviewCardLayout({ } return ( -
    -
    +
    +
    {children}
    {!hideActions && ( - + )}
    ); diff --git a/components/waves/drops/WaveDrop.tsx b/components/waves/drops/WaveDrop.tsx index f06ab5269f..ef3694d655 100644 --- a/components/waves/drops/WaveDrop.tsx +++ b/components/waves/drops/WaveDrop.tsx @@ -158,6 +158,9 @@ const WaveDrop = ({ const [activePartIndex, setActivePartIndex] = useState(0); const [isSlideUp, setIsSlideUp] = useState(false); const [longPressTriggered, setLongPressTriggered] = useState(false); + const [activeLinkCardActionIds, setActiveLinkCardActionIds] = useState< + string[] + >([]); const [boostAnimation, setBoostAnimation] = useState(null); const dropRef = useRef(null); @@ -183,6 +186,7 @@ const WaveDrop = ({ const isMdUp = breakpoint === "MD"; const allowLongPress = hasTouch && !isMdUp; const compact = useCompactMode(); + const hasActiveLinkCardActions = activeLinkCardActionIds.length > 0; const isProfileView = location === DropLocation.PROFILE; const showAuthorInfo = !shouldGroupWithPreviousDrop || isProfileView; @@ -343,6 +347,22 @@ const WaveDrop = ({ setBoostAnimation(null); }, []); + const handleLinkCardActionsActiveChange = useCallback( + (actionId: string, active: boolean) => { + setActiveLinkCardActionIds((current) => { + const hasActionId = current.includes(actionId); + if (active) { + return hasActionId ? current : [...current, actionId]; + } + + return hasActionId + ? current.filter((item) => item !== actionId) + : current; + }); + }, + [] + ); + // Handler for mobile menu boost animation const handleMobileBoostAnimation = useCallback(() => { if (!dropRef.current) return; @@ -429,6 +449,7 @@ const WaveDrop = ({ onSave={handleEditSave} onCancel={handleEditCancel} hasTouch={allowLongPress} + onLinkCardActionsActiveChange={handleLinkCardActionsActiveChange} />
    @@ -439,6 +460,7 @@ const WaveDrop = ({ activePartIndex={activePartIndex} onReply={handleOnReply} onEdit={handleOnEdit} + suppressed={hasActiveLinkCardActions} /> )} diff --git a/components/waves/drops/WaveDropActions.tsx b/components/waves/drops/WaveDropActions.tsx index 22a4e1c890..2e55ecfec6 100644 --- a/components/waves/drops/WaveDropActions.tsx +++ b/components/waves/drops/WaveDropActions.tsx @@ -19,6 +19,7 @@ interface WaveDropActionsProps { readonly showVoting?: boolean | undefined; readonly onReply: () => void; readonly onEdit?: (() => void) | undefined; + readonly suppressed?: boolean | undefined; } export default function WaveDropActions({ @@ -27,6 +28,7 @@ export default function WaveDropActions({ showVoting = true, onReply, onEdit, + suppressed = false, }: WaveDropActionsProps) { const { isMemesWave } = useSeizeSettings(); const compact = useCompactMode(); @@ -38,16 +40,20 @@ export default function WaveDropActions({ !( drop.drop_type === ApiDropType.Participatory && isMemesWave(drop.wave.id) ); + let visibilityClasses = + "tw-pointer-events-none tw-opacity-0 desktop-hover:group-hover:tw-pointer-events-auto desktop-hover:group-hover:tw-opacity-100 desktop-hover:hover:tw-pointer-events-auto desktop-hover:hover:tw-opacity-100"; + + if (isMoreDropdownOpen) { + visibilityClasses = "tw-pointer-events-auto tw-opacity-100"; + } else if (suppressed) { + visibilityClasses = "tw-pointer-events-none tw-opacity-0"; + } return (
    diff --git a/components/waves/drops/WaveDropContent.tsx b/components/waves/drops/WaveDropContent.tsx index 7923c36511..11785ee53b 100644 --- a/components/waves/drops/WaveDropContent.tsx +++ b/components/waves/drops/WaveDropContent.tsx @@ -28,6 +28,9 @@ interface WaveDropContentProps { readonly isCompetitionDrop?: boolean | undefined; readonly mediaImageScale?: ImageScale | undefined; readonly hasTouch?: boolean | undefined; + readonly onLinkCardActionsActiveChange?: + | ((href: string, active: boolean) => void) + | undefined; } const WaveDropContent: React.FC = ({ @@ -45,6 +48,7 @@ const WaveDropContent: React.FC = ({ isCompetitionDrop = false, mediaImageScale = ImageScale.AUTOx450, hasTouch, + onLinkCardActionsActiveChange, }) => { const isTouchDevice = useIsTouchDevice(); const effectiveHasTouch = hasTouch ?? isTouchDevice; @@ -65,6 +69,7 @@ const WaveDropContent: React.FC = ({ isCompetitionDrop={isCompetitionDrop} mediaImageScale={mediaImageScale} hasTouch={effectiveHasTouch} + onLinkCardActionsActiveChange={onLinkCardActionsActiveChange} /> ); }; diff --git a/components/waves/drops/WaveDropPart.tsx b/components/waves/drops/WaveDropPart.tsx index e1d3dc4271..eaf9498aa4 100644 --- a/components/waves/drops/WaveDropPart.tsx +++ b/components/waves/drops/WaveDropPart.tsx @@ -29,6 +29,9 @@ interface WaveDropPartProps { readonly isCompetitionDrop?: boolean | undefined; readonly mediaImageScale?: ImageScale | undefined; readonly hasTouch?: boolean | undefined; + readonly onLinkCardActionsActiveChange?: + | ((href: string, active: boolean) => void) + | undefined; } const LONG_PRESS_DURATION = 500; // milliseconds @@ -50,6 +53,7 @@ const WaveDropPart: React.FC = memo( isCompetitionDrop = false, mediaImageScale = ImageScale.AUTOx450, hasTouch = false, + onLinkCardActionsActiveChange, }) => { const activePart = drop.parts[activePartIndex]; @@ -146,6 +150,7 @@ const WaveDropPart: React.FC = memo( onCancel={onCancel} isCompetitionDrop={isCompetitionDrop} mediaImageScale={mediaImageScale} + onLinkCardActionsActiveChange={onLinkCardActionsActiveChange} />
    diff --git a/components/waves/drops/WaveDropPartContent.tsx b/components/waves/drops/WaveDropPartContent.tsx index 679a47e9be..ff815da201 100644 --- a/components/waves/drops/WaveDropPartContent.tsx +++ b/components/waves/drops/WaveDropPartContent.tsx @@ -36,6 +36,9 @@ interface WaveDropPartContentProps { readonly drop?: ApiDrop | undefined; readonly isCompetitionDrop?: boolean | undefined; readonly mediaImageScale?: ImageScale | undefined; + readonly onLinkCardActionsActiveChange?: + | ((href: string, active: boolean) => void) + | undefined; } const WaveDropPartContent: React.FC = ({ @@ -57,6 +60,7 @@ const WaveDropPartContent: React.FC = ({ drop, isCompetitionDrop = false, mediaImageScale = ImageScale.AUTOx450, + onLinkCardActionsActiveChange, }) => { const contentRef = React.useRef(null); @@ -146,6 +150,7 @@ const WaveDropPartContent: React.FC = ({ onSave={onSave} onCancel={onCancel} drop={drop} + onLinkCardActionsActiveChange={onLinkCardActionsActiveChange} />
    {!!activePart.media.length && ( diff --git a/components/waves/drops/WaveDropPartContentMarkdown.tsx b/components/waves/drops/WaveDropPartContentMarkdown.tsx index 5952cf6da1..1081733052 100644 --- a/components/waves/drops/WaveDropPartContentMarkdown.tsx +++ b/components/waves/drops/WaveDropPartContentMarkdown.tsx @@ -28,6 +28,9 @@ interface WaveDropPartContentMarkdownProps { | undefined; readonly onCancel?: (() => void) | undefined; readonly drop?: ApiDrop | undefined; // Add drop to check for edited status + readonly onLinkCardActionsActiveChange?: + | ((href: string, active: boolean) => void) + | undefined; } const WaveDropPartContentMarkdown: React.FC< @@ -44,6 +47,7 @@ const WaveDropPartContentMarkdown: React.FC< onSave, onCancel, drop, + onLinkCardActionsActiveChange, }) => { const linkPreviewToggleControl = useDropLinkPreviewToggleControl(drop); const currentQuotePath = @@ -89,6 +93,7 @@ const WaveDropPartContentMarkdown: React.FC< hideLinkPreviews={drop?.hide_link_preview} quotePath={currentQuotePath} linkPreviewToggleControl={linkPreviewToggleControl} + onLinkCardActionsActiveChange={onLinkCardActionsActiveChange} /> {typeof drop?.updated_at === "number" && drop.updated_at !== drop.created_at && ( @@ -111,6 +116,7 @@ const WaveDropPartContentMarkdown: React.FC< embedPath={drop?.id ? [drop.id] : []} quotePath={currentQuotePath} embedDepth={1} + onLinkCardActionsActiveChange={onLinkCardActionsActiveChange} />
    )} diff --git a/components/waves/drops/WaveDropPartDrop.tsx b/components/waves/drops/WaveDropPartDrop.tsx index 1a957f5107..b4049f1f4d 100644 --- a/components/waves/drops/WaveDropPartDrop.tsx +++ b/components/waves/drops/WaveDropPartDrop.tsx @@ -28,6 +28,9 @@ interface WaveDropPartDropProps { readonly onCancel?: (() => void) | undefined; isCompetitionDrop?: boolean | undefined; mediaImageScale?: ImageScale | undefined; + readonly onLinkCardActionsActiveChange?: + | ((href: string, active: boolean) => void) + | undefined; } const WaveDropPartDrop: React.FC = ({ @@ -45,6 +48,7 @@ const WaveDropPartDrop: React.FC = ({ onCancel, isCompetitionDrop = false, mediaImageScale = ImageScale.AUTOx450, + onLinkCardActionsActiveChange, }) => { return (
    @@ -70,6 +74,7 @@ const WaveDropPartDrop: React.FC = ({ drop={drop} isCompetitionDrop={isCompetitionDrop} mediaImageScale={mediaImageScale} + onLinkCardActionsActiveChange={onLinkCardActionsActiveChange} />
    diff --git a/components/waves/drops/WaveDropQuote.tsx b/components/waves/drops/WaveDropQuote.tsx index aeb1fc6330..40bfdb132e 100644 --- a/components/waves/drops/WaveDropQuote.tsx +++ b/components/waves/drops/WaveDropQuote.tsx @@ -12,6 +12,7 @@ import WaveDropTime from "./time/WaveDropTime"; import { getWaveRoute } from "@/helpers/navigation.helpers"; import Image from "next/image"; import { resolveIpfsUrlSync } from "@/components/ipfs/IPFSContext"; +import { useLinkPreviewContext } from "@/components/waves/LinkPreviewContext"; interface WaveDropQuoteProps { readonly drop: ApiDrop | null; @@ -21,6 +22,9 @@ interface WaveDropQuoteProps { readonly quotePath?: readonly string[] | undefined; readonly embedDepth?: number | undefined; readonly maxEmbedDepth?: number | undefined; + readonly onLinkCardActionsActiveChange?: + | ((href: string, active: boolean) => void) + | undefined; } const WaveDropQuote: React.FC = ({ @@ -31,7 +35,9 @@ const WaveDropQuote: React.FC = ({ quotePath, embedDepth, maxEmbedDepth, + onLinkCardActionsActiveChange, }) => { + const { onCardActionsActiveChange } = useLinkPreviewContext(); const quotedPart = useMemo(() => { if (!drop) { return null; @@ -115,6 +121,9 @@ const WaveDropQuote: React.FC = ({ return path; }, [drop, quotePath]); + const resolvedOnLinkCardActionsActiveChange = + onLinkCardActionsActiveChange ?? onCardActionsActiveChange; + return (
    = ({ quotePath={effectiveQuotePath} embedDepth={embedDepth} maxEmbedDepth={maxEmbedDepth} + onLinkCardActionsActiveChange={ + resolvedOnLinkCardActionsActiveChange + } />
    diff --git a/components/waves/drops/WaveDropQuoteWithDropId.tsx b/components/waves/drops/WaveDropQuoteWithDropId.tsx index e04cbed8ae..12f7c2206e 100644 --- a/components/waves/drops/WaveDropQuoteWithDropId.tsx +++ b/components/waves/drops/WaveDropQuoteWithDropId.tsx @@ -17,6 +17,9 @@ interface WaveDropQuoteWithDropIdProps { readonly quotePath?: readonly string[] | undefined; readonly embedDepth?: number | undefined; readonly maxEmbedDepth?: number | undefined; + readonly onLinkCardActionsActiveChange?: + | ((href: string, active: boolean) => void) + | undefined; } const WaveDropQuoteWithDropId: React.FC = ({ @@ -28,6 +31,7 @@ const WaveDropQuoteWithDropId: React.FC = ({ quotePath, embedDepth, maxEmbedDepth, + onLinkCardActionsActiveChange, }) => { const { connectedProfile } = useContext(AuthContext); const { data: drop } = useQuery({ @@ -58,6 +62,7 @@ const WaveDropQuoteWithDropId: React.FC = ({ quotePath={quotePath} embedDepth={embedDepth} maxEmbedDepth={maxEmbedDepth} + onLinkCardActionsActiveChange={onLinkCardActionsActiveChange} /> ); }; diff --git a/components/waves/drops/WaveDropQuoteWithSerialNo.tsx b/components/waves/drops/WaveDropQuoteWithSerialNo.tsx index ac724cb5a7..925d5e6ad7 100644 --- a/components/waves/drops/WaveDropQuoteWithSerialNo.tsx +++ b/components/waves/drops/WaveDropQuoteWithSerialNo.tsx @@ -17,6 +17,9 @@ interface WaveDropQuoteWithSerialNoProps { readonly quotePath?: readonly string[] | undefined; readonly embedDepth?: number | undefined; readonly maxEmbedDepth?: number | undefined; + readonly onLinkCardActionsActiveChange?: + | ((href: string, active: boolean) => void) + | undefined; } const WaveDropQuoteWithSerialNo: React.FC = ({ @@ -27,6 +30,7 @@ const WaveDropQuoteWithSerialNo: React.FC = ({ quotePath, embedDepth, maxEmbedDepth, + onLinkCardActionsActiveChange, }) => { const { data } = useQuery({ queryKey: [ @@ -70,6 +74,7 @@ const WaveDropQuoteWithSerialNo: React.FC = ({ quotePath={quotePath} embedDepth={embedDepth} maxEmbedDepth={maxEmbedDepth} + onLinkCardActionsActiveChange={onLinkCardActionsActiveChange} /> ); };