diff --git a/__tests__/components/drops/create/lexical/nodes/ImageComponent.test.tsx b/__tests__/components/drops/create/lexical/nodes/ImageComponent.test.tsx index b0bf23ec8c..e150aecd45 100644 --- a/__tests__/components/drops/create/lexical/nodes/ImageComponent.test.tsx +++ b/__tests__/components/drops/create/lexical/nodes/ImageComponent.test.tsx @@ -1,39 +1,60 @@ -import { render, screen, fireEvent } from '@testing-library/react'; -import ImageComponent from '@/components/drops/create/lexical/nodes/ImageComponent'; +import { render, screen, fireEvent } from "@testing-library/react"; +import ImageComponent from "@/components/drops/create/lexical/nodes/ImageComponent"; +import { URL_PREVIEW_IMAGE_ALT_TEXT } from "@/components/drops/create/lexical/nodes/urlPreviewImage.constants"; +import { CHAT_GIF_PREVIEW_HEIGHT_PX } from "@/components/waves/drops/gifPreview"; -jest.mock('@/components/distribution-plan-tool/common/CircleLoader', () => ({ +jest.mock("@/components/distribution-plan-tool/common/CircleLoader", () => ({ __esModule: true, default: jest.fn(() =>
), - CircleLoaderSize: { MEDIUM: 'MEDIUM' } + CircleLoaderSize: { MEDIUM: "MEDIUM" }, })); -describe('ImageComponent', () => { +describe("ImageComponent", () => { it('renders loader when src is "loading"', () => { render(); - expect(screen.getByTestId('loader')).toBeInTheDocument(); + expect(screen.getByTestId("loader")).toBeInTheDocument(); }); - it('calculates dimensions when width and height are not provided', () => { + it("calculates dimensions when width and height are not provided", () => { render(); - const img = screen.getByRole('img', { name: 'img' }) as HTMLImageElement; + const img = screen.getByRole("img", { name: "img" }) as HTMLImageElement; - Object.defineProperty(img, 'naturalWidth', { value: 1200 }); - Object.defineProperty(img, 'naturalHeight', { value: 600 }); + Object.defineProperty(img, "naturalWidth", { value: 1200 }); + Object.defineProperty(img, "naturalHeight", { value: 600 }); fireEvent.load(img); - expect(img).toHaveAttribute('width', '800'); - expect(img).toHaveAttribute('height', '400'); + expect(img).toHaveAttribute("width", "800"); + expect(img).toHaveAttribute("height", "400"); }); - it('respects provided width and computes height', () => { + it("respects provided width and computes height", () => { render(); - const img = screen.getByRole('img', { name: 'foo' }) as HTMLImageElement; + const img = screen.getByRole("img", { name: "foo" }) as HTMLImageElement; - Object.defineProperty(img, 'naturalWidth', { value: 600 }); - Object.defineProperty(img, 'naturalHeight', { value: 300 }); + Object.defineProperty(img, "naturalWidth", { value: 600 }); + Object.defineProperty(img, "naturalHeight", { value: 300 }); fireEvent.load(img); - expect(img).toHaveAttribute('width', '200'); - expect(img).toHaveAttribute('height', '100'); + expect(img).toHaveAttribute("width", "200"); + expect(img).toHaveAttribute("height", "100"); + }); + + it("uses markdown-matching fixed height for URL-preview Tenor GIFs", () => { + const { container } = render( + + ); + const img = container.querySelector("img") as HTMLImageElement; + expect(img).toBeTruthy(); + expect(img.getAttribute("alt")).toBe(""); + + Object.defineProperty(img, "naturalWidth", { value: 1200 }); + Object.defineProperty(img, "naturalHeight", { value: 600 }); + fireEvent.load(img); + + expect(img).toHaveStyle(`height: ${CHAT_GIF_PREVIEW_HEIGHT_PX}px`); + expect(img).toHaveStyle("width: auto"); }); }); diff --git a/__tests__/components/drops/create/lexical/nodes/ImageNode.test.tsx b/__tests__/components/drops/create/lexical/nodes/ImageNode.test.tsx index a93f194806..5550351523 100644 --- a/__tests__/components/drops/create/lexical/nodes/ImageNode.test.tsx +++ b/__tests__/components/drops/create/lexical/nodes/ImageNode.test.tsx @@ -1,29 +1,61 @@ -import { ImageNode, $createImageNode, $isImageNode } from '@/components/drops/create/lexical/nodes/ImageNode'; +import { + ImageNode, + $createImageNode, + $isImageNode, +} from "@/components/drops/create/lexical/nodes/ImageNode"; -jest.mock('lexical', () => { +jest.mock("lexical", () => { class DecoratorNode { constructor() {} - createDOM() { return document.createElement('span'); } - updateDOM() { return false; } - exportJSON() { return {}; } + createDOM() { + return document.createElement("span"); + } + updateDOM() { + return false; + } + exportJSON() { + return {}; + } } return { DecoratorNode, $applyNodeReplacement: (n: any) => n }; }); -describe('ImageNode', () => { - it('exports and imports JSON', () => { - const node = $createImageNode({ src: 'img.png', altText: 'alt', width: 10, height: 20 }); +describe("ImageNode", () => { + it("exports and imports JSON", () => { + const node = $createImageNode({ + src: "img.png", + altText: "alt", + width: 10, + height: 20, + }); const json = node.exportJSON(); - expect(json).toEqual({ type: 'image', version: 1, src: 'img.png', altText: 'alt', width: 10, height: 20 }); + expect(json).toEqual({ + type: "image", + version: 1, + src: "img.png", + altText: "alt", + width: 10, + height: 20, + }); const imported = ImageNode.importJSON(json); - expect(imported.getSrc()).toBe('img.png'); + expect(imported.getSrc()).toBe("img.png"); expect($isImageNode(imported)).toBe(true); }); - it('createDOM adds class from theme', () => { - const node = $createImageNode({ src: 'a.png' }); - const span = node.createDOM({ theme: { image: 'cls' } } as any); - expect(span.className).toBe('cls'); + it("createDOM adds class from theme", () => { + const node = $createImageNode({ src: "a.png" }); + const span = node.createDOM({ theme: { image: "cls" } } as any); + expect(span.className).toBe("cls"); expect(node.updateDOM(node, span, {} as any)).toBe(false); }); + + it("uses loading fallback only for loading placeholder src", () => { + const loadingNode = $createImageNode({ src: "loading" }); + const loadingDecorated = loadingNode.decorate() as any; + expect(loadingDecorated.props.fallback).toBeTruthy(); + + const regularNode = $createImageNode({ src: "a.png" }); + const regularDecorated = regularNode.decorate() as any; + expect(regularDecorated.props.fallback).toBeNull(); + }); }); diff --git a/__tests__/components/drops/create/lexical/plugins/InsertTextPlugin.test.tsx b/__tests__/components/drops/create/lexical/plugins/InsertTextPlugin.test.tsx new file mode 100644 index 0000000000..db345dd301 --- /dev/null +++ b/__tests__/components/drops/create/lexical/plugins/InsertTextPlugin.test.tsx @@ -0,0 +1,225 @@ +import { render } from "@testing-library/react"; +import React, { createRef } from "react"; +import InsertTextPlugin, { + type InsertTextPluginHandles, +} from "@/components/drops/create/lexical/plugins/InsertTextPlugin"; +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; + +jest.mock("@lexical/react/LexicalComposerContext"); + +const selectEndMock = jest.fn(); +const getRootMock = jest.fn(() => ({ + selectEnd: selectEndMock, +})); +const getSelectionMock = jest.fn(); +const getNodeByKeyMock = jest.fn(); +const insertNodesMock = jest.fn(); +const isRangeSelectionMock = jest.fn((selection: unknown) => + Boolean( + (selection as { readonly __isRangeSelection?: boolean } | null) + ?.__isRangeSelection + ) +); +const isTextNodeMock = jest.fn((node: unknown) => + Boolean((node as { readonly __isTextNode?: boolean } | null)?.__isTextNode) +); + +jest.mock("lexical", () => ({ + $createTextNode: jest.fn((text: string) => ({ + __isTextNode: true, + text, + insertAfter: jest.fn(), + selectEnd: jest.fn(), + })), + $getNodeByKey: (key: string) => getNodeByKeyMock(key), + $getRoot: () => getRootMock(), + $getSelection: () => getSelectionMock(), + $insertNodes: (...args: unknown[]) => insertNodesMock(...args), + $isRangeSelection: (selection: unknown) => isRangeSelectionMock(selection), + $isTextNode: (node: unknown) => isTextNodeMock(node), +})); + +let imageNodeCounter = 0; +const createImageNodeMock = jest.fn( + (opts: { src: string; altText: string }) => { + const key = `image-${++imageNodeCounter}`; + return { + __isImageNode: true, + ...opts, + getSrc: () => opts.src, + getKey: () => key, + replace: jest.fn(), + insertAfter: jest.fn(), + selectNext: jest.fn(), + }; + } +); + +jest.mock("@/components/drops/create/lexical/nodes/ImageNode", () => ({ + $isImageNode: (node: unknown) => + Boolean( + (node as { readonly __isImageNode?: boolean } | null)?.__isImageNode + ), + $createImageNode: (opts: { src: string; altText: string }) => + createImageNodeMock(opts), +})); + +const useCtx = useLexicalComposerContext as jest.Mock; + +type MockSelection = { + readonly __isRangeSelection: true; + readonly anchor: { + readonly getNode: () => { + readonly __isTextNode: true; + getTextContent: () => string; + }; + readonly offset: number; + }; + readonly isCollapsed: () => boolean; + readonly insertRawText: jest.Mock; +}; + +const createSelection = ({ + text, + offset, + collapsed = true, +}: { + readonly text: string; + readonly offset: number; + readonly collapsed?: boolean; +}): MockSelection => { + const node = { + __isTextNode: true as const, + getTextContent: () => text, + }; + + return { + __isRangeSelection: true as const, + anchor: { + getNode: () => node, + offset, + }, + isCollapsed: () => collapsed, + insertRawText: jest.fn(), + }; +}; + +describe("InsertTextPlugin", () => { + beforeEach(() => { + jest.clearAllMocks(); + imageNodeCounter = 0; + }); + + it("inserts text with smart spacing when needed", () => { + const selection = createSelection({ text: "helloworld", offset: 5 }); + getSelectionMock.mockReturnValue(selection); + + const update = jest.fn((fn: () => void) => fn()); + useCtx.mockReturnValue([{ update }]); + + const ref = createRef(); + render(); + + ref.current?.insertTextAtCursor("https://media.tenor.com/abc/tenor.gif", { + smartSpacing: true, + }); + + expect(selection.insertRawText).toHaveBeenCalledWith( + " https://media.tenor.com/abc/tenor.gif " + ); + }); + + it("falls back to end selection when no cursor selection exists", () => { + const fallbackSelection = createSelection({ text: "hello", offset: 5 }); + getSelectionMock + .mockReturnValueOnce(null) + .mockReturnValueOnce(fallbackSelection); + + selectEndMock.mockImplementation(() => { + return undefined; + }); + + const update = jest.fn((fn: () => void) => fn()); + useCtx.mockReturnValue([{ update }]); + + const ref = createRef(); + render(); + + ref.current?.insertTextAtCursor("https://media.tenor.com/abc/tenor.gif"); + + expect(selectEndMock).toHaveBeenCalled(); + expect(fallbackSelection.insertRawText).toHaveBeenCalledWith( + "https://media.tenor.com/abc/tenor.gif" + ); + }); + + it("inserts preview image node for URL insertion", () => { + const selection = createSelection({ text: "hello", offset: 5 }); + getSelectionMock.mockReturnValue(selection); + + const update = jest.fn((fn: () => void) => fn()); + useCtx.mockReturnValue([{ update }]); + + const ref = createRef(); + render(); + + ref.current?.insertImagePreviewFromUrl("https://example.com/cat.gif", { + smartSpacing: true, + }); + + expect(createImageNodeMock).toHaveBeenCalledWith( + expect.objectContaining({ src: "https://example.com/cat.gif" }) + ); + expect(insertNodesMock).toHaveBeenCalled(); + }); + + it("inserts loading placeholder for Tenor GIF and swaps to preview URL", () => { + const OriginalImage = global.Image; + class MockImage { + onload: null | (() => void) = null; + onerror: null | (() => void) = null; + set src(_value: string) { + this.onload?.(); + } + } + (global as typeof globalThis & { Image: typeof Image }).Image = + MockImage as unknown as typeof Image; + + try { + const selection = createSelection({ text: "hello", offset: 5 }); + getSelectionMock.mockReturnValue(selection); + + const placeholderNode = { + __isImageNode: true as const, + getSrc: () => "loading", + replace: jest.fn(), + }; + getNodeByKeyMock.mockReturnValue(placeholderNode); + + const update = jest.fn((fn: () => void) => fn()); + useCtx.mockReturnValue([{ update }]); + + const ref = createRef(); + render(); + + ref.current?.insertImagePreviewFromUrl( + "https://media.tenor.com/abc/tenor.gif", + { smartSpacing: true } + ); + + expect(createImageNodeMock).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ src: "loading" }) + ); + expect(getNodeByKeyMock).toHaveBeenCalled(); + expect(placeholderNode.replace).toHaveBeenCalledWith( + expect.objectContaining({ + src: "https://media.tenor.com/abc/tenor.gif", + }) + ); + } finally { + (global as typeof globalThis & { Image: typeof Image }).Image = + OriginalImage; + } + }); +}); diff --git a/__tests__/components/drops/create/lexical/plugins/StandaloneImageUrlPreviewPlugin.test.tsx b/__tests__/components/drops/create/lexical/plugins/StandaloneImageUrlPreviewPlugin.test.tsx new file mode 100644 index 0000000000..6666309833 --- /dev/null +++ b/__tests__/components/drops/create/lexical/plugins/StandaloneImageUrlPreviewPlugin.test.tsx @@ -0,0 +1,156 @@ +import { render } from "@testing-library/react"; +import StandaloneImageUrlPreviewPlugin from "@/components/drops/create/lexical/plugins/StandaloneImageUrlPreviewPlugin"; +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { + COMMAND_PRIORITY_CRITICAL, + KEY_SPACE_COMMAND, + PASTE_COMMAND, +} from "lexical"; +import { parseStandaloneMediaUrl } from "@/components/waves/drops/media-utils"; + +jest.mock("@lexical/react/LexicalComposerContext"); + +const getSelectionMock = jest.fn(); +const isRangeSelectionMock = jest.fn((selection: unknown) => + Boolean(selection) +); +const isTextNodeMock = jest.fn((node: unknown) => + Boolean((node as { readonly __isTextNode?: boolean } | null)?.__isTextNode) +); +const createTextNodeMock = jest.fn((text: string) => ({ + __isTextNode: true, + text, + insertAfter: jest.fn(), + selectEnd: jest.fn(), + selectStart: jest.fn(), +})); +const getRootMock = jest.fn(() => ({ selectEnd: jest.fn() })); + +jest.mock("lexical", () => ({ + $createTextNode: (text: string) => createTextNodeMock(text), + $getRoot: () => getRootMock(), + $getSelection: () => getSelectionMock(), + $isRangeSelection: (selection: unknown) => isRangeSelectionMock(selection), + $isTextNode: (node: unknown) => isTextNodeMock(node), + COMMAND_PRIORITY_CRITICAL: 4, + COMMAND_PRIORITY_NORMAL: 2, + KEY_ENTER_COMMAND: "KEY_ENTER_COMMAND", + KEY_SPACE_COMMAND: "KEY_SPACE_COMMAND", + PASTE_COMMAND: "PASTE_COMMAND", +})); + +const createImageNodeMock = jest.fn( + (opts: { src: string; altText: string }) => ({ + ...opts, + insertAfter: jest.fn(), + selectNext: jest.fn(), + }) +); + +jest.mock("@/components/drops/create/lexical/nodes/ImageNode", () => ({ + $createImageNode: (opts: { src: string; altText: string }) => + createImageNodeMock(opts), +})); + +jest.mock("@/components/waves/drops/media-utils", () => ({ + parseStandaloneMediaUrl: jest.fn(), +})); + +const useLexicalComposerContextMock = useLexicalComposerContext as jest.Mock; +const parseStandaloneMediaUrlMock = parseStandaloneMediaUrl as jest.Mock; + +describe("StandaloneImageUrlPreviewPlugin", () => { + beforeEach(() => { + jest.clearAllMocks(); + parseStandaloneMediaUrlMock.mockImplementation((token: string) => + token.endsWith(".png") || token.endsWith(".gif") + ? { type: "image", url: token, alt: "Media" } + : null + ); + }); + + it("converts standalone image URL token on space key", () => { + const commandHandlers = new Map boolean>(); + const editor = { + registerCommand: jest.fn( + ( + command: string, + handler: (event?: any) => boolean, + priority: number + ) => { + if (priority === COMMAND_PRIORITY_CRITICAL) { + commandHandlers.set(command, handler); + } + return () => {}; + } + ), + update: (fn: () => void) => fn(), + }; + useLexicalComposerContextMock.mockReturnValue([editor]); + + const urlTextNode = { + __isTextNode: true as const, + getTextContent: () => "https://example.com/cat.gif", + replace: jest.fn(), + }; + getSelectionMock.mockReturnValue({ + isCollapsed: () => true, + anchor: { + getNode: () => urlTextNode, + offset: "https://example.com/cat.gif".length, + }, + }); + + render(); + + const preventDefault = jest.fn(); + const handled = commandHandlers.get(KEY_SPACE_COMMAND)?.({ + preventDefault, + }); + + expect(handled).toBe(true); + expect(preventDefault).toHaveBeenCalled(); + expect(urlTextNode.replace).toHaveBeenCalled(); + expect(createImageNodeMock).toHaveBeenCalledWith( + expect.objectContaining({ src: "https://example.com/cat.gif" }) + ); + }); + + it("converts pasted standalone image URLs into preview image node", () => { + const commandHandlers = new Map boolean>(); + const editor = { + registerCommand: jest.fn( + (command: string, handler: (event?: any) => boolean) => { + commandHandlers.set(command, handler); + return () => {}; + } + ), + update: (fn: () => void) => fn(), + }; + useLexicalComposerContextMock.mockReturnValue([editor]); + + const insertNodes = jest.fn(); + getSelectionMock.mockReturnValue({ + isCollapsed: () => true, + insertNodes, + anchor: { getNode: () => null, offset: 0 }, + }); + + render(); + + const preventDefault = jest.fn(); + const handled = commandHandlers.get(PASTE_COMMAND)?.({ + preventDefault, + clipboardData: { + files: [], + getData: () => "https://example.com/banner.png", + }, + }); + + expect(handled).toBe(true); + expect(preventDefault).toHaveBeenCalled(); + expect(insertNodes).toHaveBeenCalledWith([ + expect.objectContaining({ src: "https://example.com/banner.png" }), + ]); + }); +}); diff --git a/__tests__/components/drops/create/lexical/transformers/UrlPreviewImageTransformer.test.ts b/__tests__/components/drops/create/lexical/transformers/UrlPreviewImageTransformer.test.ts new file mode 100644 index 0000000000..5b591f722a --- /dev/null +++ b/__tests__/components/drops/create/lexical/transformers/UrlPreviewImageTransformer.test.ts @@ -0,0 +1,55 @@ +import { URL_PREVIEW_IMAGE_TRANSFORMER } from "@/components/drops/create/lexical/transformers/UrlPreviewImageTransformer"; +import { URL_PREVIEW_IMAGE_ALT_TEXT } from "@/components/drops/create/lexical/nodes/urlPreviewImage.constants"; + +jest.mock("@/components/drops/create/lexical/nodes/ImageNode", () => ({ + $isImageNode: jest.fn((n) => n && n.type === "image"), + ImageNode: class {}, +})); + +describe("URL_PREVIEW_IMAGE_TRANSFORMER", () => { + it("exports plain URL for preview-marked image nodes", () => { + const node: any = { + type: "image", + getAltText: () => URL_PREVIEW_IMAGE_ALT_TEXT, + getSrc: () => "https://media.tenor.com/abc/tenor.gif", + }; + + expect(URL_PREVIEW_IMAGE_TRANSFORMER.export?.(node)).toBe( + "https://media.tenor.com/abc/tenor.gif" + ); + }); + + it("returns null for non-preview image nodes", () => { + const node: any = { + type: "image", + getAltText: () => "Seize", + getSrc: () => "https://example.com/image.png", + }; + + expect(URL_PREVIEW_IMAGE_TRANSFORMER.export?.(node)).toBeNull(); + }); + + it("uses a non-matching defensive regex for import-side behavior", () => { + expect(URL_PREVIEW_IMAGE_TRANSFORMER.regExp.test("")).toBe(false); + expect( + URL_PREVIEW_IMAGE_TRANSFORMER.regExp.test( + "https://media.tenor.com/abc/tenor.gif" + ) + ).toBe(false); + expect( + URL_PREVIEW_IMAGE_TRANSFORMER.regExp.test("![Seize](https://x.com/a.png)") + ).toBe(false); + }); + + it("has a safe no-op replace handler", () => { + const children: any[] = [{ id: "node-1" }]; + const parentNode: any = { type: "paragraph" }; + const match = ["token"]; + + expect(() => + URL_PREVIEW_IMAGE_TRANSFORMER.replace?.(parentNode, children, match, true) + ).not.toThrow(); + expect(children).toHaveLength(1); + expect(children[0]).toEqual({ id: "node-1" }); + }); +}); diff --git a/__tests__/components/waves/CreateDropContent.gifInsert.test.tsx b/__tests__/components/waves/CreateDropContent.gifInsert.test.tsx new file mode 100644 index 0000000000..052b6624c9 --- /dev/null +++ b/__tests__/components/waves/CreateDropContent.gifInsert.test.tsx @@ -0,0 +1,188 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import CreateDropContent from "@/components/waves/CreateDropContent"; +import { ApiWaveType } from "@/generated/models/ApiWaveType"; + +const mockInsertTextAtCursor = jest.fn(); +const mockInsertImagePreviewFromUrl = jest.fn(); +const mockFocus = jest.fn(); +const mockSubmitDrop = jest.fn(); +const mockSend = jest.fn(); + +global.ResizeObserver = jest.fn().mockImplementation(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), +})); + +jest.mock("next/dynamic", () => ({ + __esModule: true, + default: () => () => null, +})); + +jest.mock("react-redux", () => ({ + useSelector: () => null, +})); + +jest.mock("framer-motion", () => { + const React = require("react"); + return { + __esModule: true, + motion: { + div: React.forwardRef(function Div( + { children, ...props }: { children: React.ReactNode }, + ref: React.Ref + ) { + return React.createElement("div", { ...props, ref }, children); + }), + }, + AnimatePresence: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), + }; +}); + +jest.mock("@/hooks/useDeviceInfo", () => ({ + __esModule: true, + default: () => ({ isApp: false }), +})); + +jest.mock("@/components/waves/CreateDropActions", () => ({ + __esModule: true, + default: (props: any) => ( + + ), +})); + +jest.mock("@/components/waves/CreateDropInput", () => { + const React = require("react"); + return React.forwardRef((_props: any, ref: any) => { + React.useImperativeHandle(ref, () => ({ + clearEditorState: jest.fn(), + insertTextAtCursor: mockInsertTextAtCursor, + insertImagePreviewFromUrl: mockInsertImagePreviewFromUrl, + focus: mockFocus, + })); + return
; + }); +}); + +jest.mock("@/components/waves/CreateDropReplyingWrapper", () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock("@/components/waves/CreateDropMetadata", () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock("@/components/waves/CreateDropContentRequirements", () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock("@/components/waves/CreateDropContentFiles", () => ({ + __esModule: true, + CreateDropContentFiles: () =>
, +})); + +jest.mock("@/components/waves/CreateDropDropModeToggle", () => ({ + __esModule: true, + CreateDropDropModeToggle: () =>
, +})); + +jest.mock("@/components/waves/CreateDropSubmit", () => ({ + __esModule: true, + CreateDropSubmit: () =>
, +})); + +jest.mock("@/contexts/wave/MyStreamContext", () => ({ + __esModule: true, + useMyStream: () => ({ processIncomingDrop: jest.fn() }), +})); + +jest.mock("@/hooks/drops/useDropSignature", () => ({ + __esModule: true, + useDropSignature: () => ({ signDrop: jest.fn() }), +})); + +jest.mock("@/hooks/useWave", () => ({ + __esModule: true, + useWave: () => ({ isMemesWave: false }), +})); + +jest.mock("@/services/websocket", () => ({ + __esModule: true, + useWebSocket: () => ({ send: mockSend }), +})); + +jest.mock("@/components/auth/SeizeConnectContext", () => ({ + __esModule: true, + useSeizeConnectContext: () => ({ + isSafeWallet: false, + address: null, + }), +})); + +jest.mock("@/components/waves/hooks/useDropMetadata", () => ({ + __esModule: true, + generateMetadataId: () => "metadata-id", + useDropMetadata: () => ({ + metadata: [], + setMetadata: jest.fn(), + initialMetadata: [], + }), +})); + +describe("CreateDropContent GIF insert behavior", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("inserts selected GIF URL into editor and does not submit immediately", async () => { + const wave = { + id: "wave-1", + wave: { type: ApiWaveType.Chat }, + chat: { enabled: true }, + participation: { + required_metadata: [], + required_media: [], + signature_required: false, + terms: null, + }, + } as any; + + render( + + ); + + await userEvent.click(screen.getByTestId("select-gif")); + + expect(mockInsertImagePreviewFromUrl).toHaveBeenCalledWith( + "https://media.tenor.com/abc/tenor.gif", + { smartSpacing: true } + ); + expect(mockFocus).toHaveBeenCalled(); + expect(mockSubmitDrop).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/components/waves/CreateDropInput.test.tsx b/__tests__/components/waves/CreateDropInput.test.tsx index 70a962cdf0..8391929636 100644 --- a/__tests__/components/waves/CreateDropInput.test.tsx +++ b/__tests__/components/waves/CreateDropInput.test.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { createRef } from "react"; import { render, screen } from "@testing-library/react"; import CreateDropInput from "@/components/waves/CreateDropInput"; import { ActiveDropAction } from "@/types/dropInteractionTypes"; @@ -89,6 +89,17 @@ jest.mock( "@/components/drops/create/lexical/plugins/DragDropPastePlugin", () => ({ __esModule: true, default: () =>
}) ); +jest.mock("@/components/drops/create/lexical/plugins/InsertTextPlugin", () => ({ + __esModule: true, + default: React.forwardRef(() =>
), +})); +jest.mock( + "@/components/drops/create/lexical/plugins/StandaloneImageUrlPreviewPlugin", + () => ({ + __esModule: true, + default: () =>
, + }) +); jest.mock( "@/components/drops/create/lexical/plugins/enter/EnterKeyPlugin", () => ({ __esModule: true, default: () =>
}) @@ -162,3 +173,27 @@ it("renders validation helper text when provided", () => { "URL must be from superrare.com, manifold.xyz, opensea.io, transient.xyz, or foundation.app." ); }); + +it("exposes insertTextAtCursor on the forwarded ref", () => { + const ref = createRef(); + + render( + + ); + + expect(typeof ref.current?.insertTextAtCursor).toBe("function"); + expect(typeof ref.current?.insertImagePreviewFromUrl).toBe("function"); +}); diff --git a/components/drops/create/lexical/nodes/ImageComponent.tsx b/components/drops/create/lexical/nodes/ImageComponent.tsx index a8230b9a22..7eb362f56e 100644 --- a/components/drops/create/lexical/nodes/ImageComponent.tsx +++ b/components/drops/create/lexical/nodes/ImageComponent.tsx @@ -4,6 +4,11 @@ import React, { useState, type JSX } from "react"; import CircleLoader, { CircleLoaderSize, } from "@/components/distribution-plan-tool/common/CircleLoader"; +import { + CHAT_GIF_PREVIEW_HEIGHT_PX, + isTenorGifUrl, +} from "@/components/waves/drops/gifPreview"; +import { URL_PREVIEW_IMAGE_ALT_TEXT } from "./urlPreviewImage.constants"; interface ImageComponentProps { readonly src: string; @@ -18,6 +23,10 @@ export default function ImageComponent({ width, height, }: ImageComponentProps): JSX.Element { + const isUrlPreviewGif = + altText === URL_PREVIEW_IMAGE_ALT_TEXT && isTenorGifUrl(src); + const displayAltText = + altText === URL_PREVIEW_IMAGE_ALT_TEXT ? "" : (altText ?? ""); const [dimensions, setDimensions] = useState({ width: width ?? 0, height: height ?? 0, @@ -51,11 +60,20 @@ export default function ImageComponent({ return ( {altText} ); } diff --git a/components/drops/create/lexical/nodes/ImageNode.tsx b/components/drops/create/lexical/nodes/ImageNode.tsx index 51dfcede39..7a0878f8de 100644 --- a/components/drops/create/lexical/nodes/ImageNode.tsx +++ b/components/drops/create/lexical/nodes/ImageNode.tsx @@ -20,8 +20,10 @@ import type { import { $applyNodeReplacement, DecoratorNode } from "lexical"; import * as React from "react"; import { Suspense, type JSX } from "react"; +import { CHAT_GIF_PREVIEW_HEIGHT_PX } from "@/components/waves/drops/gifPreview"; const ImageComponent = React.lazy(() => import("./ImageComponent")); +const LOADING_IMAGE_SRC = "loading"; interface ImagePayload { key?: NodeKey | undefined; @@ -152,8 +154,17 @@ export class ImageNode extends DecoratorNode { } override decorate(): JSX.Element { + const fallback = + this.__src === LOADING_IMAGE_SRC ? ( +