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("")
+ ).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 (
);
}
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 ? (
+
+ ) : null;
+
return (
-
+
void;
+ insertImagePreviewFromUrl: (url: string, options?: InsertTextOptions) => void;
+}
+
+const hasNonWhitespace = (value: string | undefined): boolean =>
+ Boolean(value && /\S/.test(value));
+const LOADING_IMAGE_SRC = "loading";
+
+const applySmartSpacing = (
+ selection: RangeSelection,
+ text: string,
+ smartSpacing: boolean
+): string => {
+ if (!smartSpacing || !text.length || !selection.isCollapsed()) {
+ return text;
+ }
+
+ const anchorNode = selection.anchor.getNode();
+ if (!$isTextNode(anchorNode)) {
+ return text;
+ }
+
+ const content = anchorNode.getTextContent();
+ const offset = selection.anchor.offset;
+
+ const beforeChar = offset > 0 ? content[offset - 1] : undefined;
+ const afterChar = offset < content.length ? content[offset] : undefined;
+ const firstChar = text[0];
+ const lastChar = text[text.length - 1];
+
+ const prefix = hasNonWhitespace(beforeChar) && hasNonWhitespace(firstChar);
+ const suffix = hasNonWhitespace(afterChar) && hasNonWhitespace(lastChar);
+
+ return `${prefix ? " " : ""}${text}${suffix ? " " : ""}`;
+};
+
+const getSpacingForSelection = (
+ selection: RangeSelection,
+ smartSpacing: boolean
+): { readonly leading: boolean; readonly trailing: boolean } => {
+ if (!smartSpacing || !selection.isCollapsed()) {
+ return { leading: false, trailing: false };
+ }
+
+ const anchorNode = selection.anchor.getNode();
+ if (!$isTextNode(anchorNode)) {
+ return { leading: false, trailing: false };
+ }
+
+ const content = anchorNode.getTextContent();
+ const offset = selection.anchor.offset;
+ const beforeChar = offset > 0 ? content[offset - 1] : undefined;
+ const afterChar = offset < content.length ? content[offset] : undefined;
+
+ return {
+ leading: hasNonWhitespace(beforeChar),
+ trailing: hasNonWhitespace(afterChar),
+ };
+};
+
+const InsertTextPlugin = forwardRef((_, ref) => {
+ const [editor] = useLexicalComposerContext();
+
+ const insertTextAtCursor = (text: string, options?: InsertTextOptions) => {
+ if (!text.length) {
+ return;
+ }
+
+ editor.update(() => {
+ let selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ $getRoot().selectEnd();
+ selection = $getSelection();
+ }
+
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+
+ const textToInsert = applySmartSpacing(
+ selection,
+ text,
+ Boolean(options?.smartSpacing)
+ );
+
+ selection.insertRawText(textToInsert);
+ });
+ };
+
+ const insertImagePreviewFromUrl = (
+ url: string,
+ options?: InsertTextOptions
+ ) => {
+ const normalizedUrl = url.trim();
+ if (!normalizedUrl.length) {
+ return;
+ }
+ const shouldUseLoadingPlaceholder = isTenorGifUrl(normalizedUrl);
+ let placeholderNodeKey: string | null = null;
+
+ editor.update(() => {
+ let selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ $getRoot().selectEnd();
+ selection = $getSelection();
+ }
+
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+
+ const { leading, trailing } = getSpacingForSelection(
+ selection,
+ Boolean(options?.smartSpacing)
+ );
+ const imageNode = $createImageNode({
+ src: shouldUseLoadingPlaceholder ? LOADING_IMAGE_SRC : normalizedUrl,
+ altText: URL_PREVIEW_IMAGE_ALT_TEXT,
+ });
+ if (shouldUseLoadingPlaceholder) {
+ placeholderNodeKey = imageNode.getKey();
+ }
+
+ const nodes = [
+ ...(leading ? [$createTextNode(" ")] : []),
+ imageNode,
+ ...(trailing ? [$createTextNode(" ")] : []),
+ ];
+
+ $insertNodes(nodes);
+
+ const trailingSpaceNode = trailing ? nodes[nodes.length - 1] : null;
+ if ($isTextNode(trailingSpaceNode)) {
+ trailingSpaceNode.selectEnd();
+ }
+ });
+
+ if (!shouldUseLoadingPlaceholder || !placeholderNodeKey) {
+ return;
+ }
+ const placeholderKey = placeholderNodeKey;
+
+ const replacePlaceholderWithPreview = () => {
+ editor.update(() => {
+ const placeholderNode = $getNodeByKey(placeholderKey);
+ if (!$isImageNode(placeholderNode)) {
+ return;
+ }
+ if (placeholderNode.getSrc() !== LOADING_IMAGE_SRC) {
+ return;
+ }
+ placeholderNode.replace(
+ $createImageNode({
+ src: normalizedUrl,
+ altText: URL_PREVIEW_IMAGE_ALT_TEXT,
+ })
+ );
+ });
+ };
+
+ if (typeof globalThis.Image === "undefined") {
+ replacePlaceholderWithPreview();
+ return;
+ }
+
+ const preloader = new globalThis.Image();
+ const finalize = () => {
+ preloader.onload = null;
+ preloader.onerror = null;
+ replacePlaceholderWithPreview();
+ };
+
+ preloader.onload = finalize;
+ preloader.onerror = finalize;
+ preloader.src = normalizedUrl;
+ };
+
+ useImperativeHandle(ref, () => ({
+ insertTextAtCursor,
+ insertImagePreviewFromUrl,
+ }));
+
+ return <>>;
+});
+
+InsertTextPlugin.displayName = "InsertTextPlugin";
+
+export default InsertTextPlugin;
diff --git a/components/drops/create/lexical/plugins/StandaloneImageUrlPreviewPlugin.tsx b/components/drops/create/lexical/plugins/StandaloneImageUrlPreviewPlugin.tsx
new file mode 100644
index 0000000000..01991bd992
--- /dev/null
+++ b/components/drops/create/lexical/plugins/StandaloneImageUrlPreviewPlugin.tsx
@@ -0,0 +1,233 @@
+"use client";
+
+import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
+import {
+ $createTextNode,
+ $getRoot,
+ $getSelection,
+ $isRangeSelection,
+ $isTextNode,
+ COMMAND_PRIORITY_CRITICAL,
+ COMMAND_PRIORITY_NORMAL,
+ KEY_ENTER_COMMAND,
+ KEY_SPACE_COMMAND,
+ PASTE_COMMAND,
+} from "lexical";
+import { useEffect } from "react";
+import { parseStandaloneMediaUrl } from "@/components/waves/drops/media-utils";
+import { $createImageNode } from "../nodes/ImageNode";
+import { URL_PREVIEW_IMAGE_ALT_TEXT } from "../nodes/urlPreviewImage.constants";
+
+const WHITESPACE_REGEX = /\s/;
+
+const getTokenEndingAtOffset = (
+ text: string,
+ offset: number
+): {
+ readonly start: number;
+ readonly end: number;
+ readonly token: string;
+} | null => {
+ if (offset <= 0) {
+ return null;
+ }
+
+ let start = offset;
+ while (start > 0 && !WHITESPACE_REGEX.test(text[start - 1]!)) {
+ start -= 1;
+ }
+
+ const token = text.slice(start, offset);
+ if (!token.length) {
+ return null;
+ }
+
+ return { start, end: offset, token };
+};
+
+const isPreviewableImageUrl = (token: string): boolean => {
+ const media = parseStandaloneMediaUrl(token);
+ return media?.type === "image";
+};
+
+const replaceTokenWithPreviewNode = ({
+ textNode,
+ start,
+ end,
+ url,
+ appendTrailingSpace,
+}: {
+ readonly textNode: ReturnType;
+ readonly start: number;
+ readonly end: number;
+ readonly url: string;
+ readonly appendTrailingSpace: boolean;
+}): void => {
+ const original = textNode.getTextContent();
+ const beforeText = original.slice(0, start);
+ const afterText = original.slice(end);
+
+ const beforeNode = beforeText.length ? $createTextNode(beforeText) : null;
+ const imageNode = $createImageNode({
+ src: url,
+ altText: URL_PREVIEW_IMAGE_ALT_TEXT,
+ });
+ const trailingSpaceNode = appendTrailingSpace ? $createTextNode(" ") : null;
+ const afterNode = afterText.length ? $createTextNode(afterText) : null;
+
+ const nodes = [
+ ...(beforeNode ? [beforeNode] : []),
+ imageNode,
+ ...(trailingSpaceNode ? [trailingSpaceNode] : []),
+ ...(afterNode ? [afterNode] : []),
+ ];
+
+ const [firstNode, ...restNodes] = nodes;
+ if (!firstNode) {
+ return;
+ }
+
+ textNode.replace(firstNode);
+ let previousNode = firstNode;
+ for (const node of restNodes) {
+ previousNode.insertAfter(node);
+ previousNode = node;
+ }
+
+ if (trailingSpaceNode) {
+ trailingSpaceNode.selectEnd();
+ return;
+ }
+
+ if (afterNode) {
+ afterNode.selectStart();
+ return;
+ }
+
+ imageNode.selectNext();
+};
+
+const convertCurrentTokenToPreviewNode = (
+ appendTrailingSpace: boolean
+): boolean => {
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
+ return false;
+ }
+
+ const anchorNode = selection.anchor.getNode();
+ if (!$isTextNode(anchorNode)) {
+ return false;
+ }
+
+ const tokenRange = getTokenEndingAtOffset(
+ anchorNode.getTextContent(),
+ selection.anchor.offset
+ );
+ if (!tokenRange) {
+ return false;
+ }
+
+ if (!isPreviewableImageUrl(tokenRange.token)) {
+ return false;
+ }
+
+ replaceTokenWithPreviewNode({
+ textNode: anchorNode,
+ start: tokenRange.start,
+ end: tokenRange.end,
+ url: tokenRange.token,
+ appendTrailingSpace,
+ });
+
+ return true;
+};
+
+export default function StandaloneImageUrlPreviewPlugin(): null {
+ const [editor] = useLexicalComposerContext();
+
+ useEffect(() => {
+ const unregisterSpace = editor.registerCommand(
+ KEY_SPACE_COMMAND,
+ (event) => {
+ let converted = false;
+ editor.update(() => {
+ converted = convertCurrentTokenToPreviewNode(true);
+ });
+
+ if (converted) {
+ event?.preventDefault();
+ return true;
+ }
+
+ return false;
+ },
+ COMMAND_PRIORITY_CRITICAL
+ );
+
+ const unregisterEnter = editor.registerCommand(
+ KEY_ENTER_COMMAND,
+ (event) => {
+ if (!event?.shiftKey) {
+ return false;
+ }
+
+ editor.update(() => {
+ convertCurrentTokenToPreviewNode(false);
+ });
+
+ return false;
+ },
+ COMMAND_PRIORITY_CRITICAL
+ );
+
+ const unregisterPaste = editor.registerCommand(
+ PASTE_COMMAND,
+ (event) => {
+ const clipboardData = event.clipboardData;
+ if (!clipboardData) {
+ return false;
+ }
+
+ if (clipboardData.files.length > 0) {
+ return false;
+ }
+
+ const text = clipboardData.getData("text/plain").trim();
+ if (!isPreviewableImageUrl(text)) {
+ return false;
+ }
+
+ event.preventDefault();
+ editor.update(() => {
+ let selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ $getRoot().selectEnd();
+ selection = $getSelection();
+ }
+
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+
+ selection.insertNodes([
+ $createImageNode({
+ src: text,
+ altText: URL_PREVIEW_IMAGE_ALT_TEXT,
+ }),
+ ]);
+ });
+ return true;
+ },
+ COMMAND_PRIORITY_NORMAL
+ );
+
+ return () => {
+ unregisterSpace();
+ unregisterEnter();
+ unregisterPaste();
+ };
+ }, [editor]);
+
+ return null;
+}
diff --git a/components/drops/create/lexical/transformers/UrlPreviewImageTransformer.ts b/components/drops/create/lexical/transformers/UrlPreviewImageTransformer.ts
new file mode 100644
index 0000000000..50022fb4c8
--- /dev/null
+++ b/components/drops/create/lexical/transformers/UrlPreviewImageTransformer.ts
@@ -0,0 +1,28 @@
+import type { Transformer } from "@lexical/markdown";
+import type { ElementNode, LexicalNode } from "lexical";
+import { $isImageNode, ImageNode } from "../nodes/ImageNode";
+import { URL_PREVIEW_IMAGE_ALT_TEXT } from "../nodes/urlPreviewImage.constants";
+
+export const URL_PREVIEW_IMAGE_TRANSFORMER: Transformer = {
+ type: "element",
+ dependencies: [ImageNode],
+ export: (node) => {
+ if (!$isImageNode(node)) {
+ return null;
+ }
+
+ if (node.getAltText() !== URL_PREVIEW_IMAGE_ALT_TEXT) {
+ return null;
+ }
+
+ return node.getSrc();
+ },
+ // Defensive: this transformer is export-only, so import regex should never match.
+ regExp: /$^/,
+ replace: (
+ _parentNode: ElementNode,
+ _children: Array,
+ _match: Array,
+ _isImport: boolean
+ ) => {},
+};
diff --git a/components/drops/view/part/dropPartMarkdown/handlers/gif.tsx b/components/drops/view/part/dropPartMarkdown/handlers/gif.tsx
index 1c4c17ec1e..b92222051f 100644
--- a/components/drops/view/part/dropPartMarkdown/handlers/gif.tsx
+++ b/components/drops/view/part/dropPartMarkdown/handlers/gif.tsx
@@ -1,28 +1,12 @@
import { renderGifEmbed } from "../renderers";
import type { LinkHandler } from "../linkTypes";
import type { LinkPreviewVariant } from "@/components/waves/LinkPreviewContext";
-
-const TENOR_HOST = "media.tenor.com";
-
-const isTenorGif = (href: string): boolean => {
- try {
- const url = new URL(href);
- const hostname = url.hostname.toLowerCase();
- if (hostname !== TENOR_HOST) {
- return false;
- }
-
- const pathname = url.pathname.toLowerCase();
- return pathname.endsWith(".gif");
- } catch {
- return false;
- }
-};
+import { isTenorGifUrl } from "@/components/waves/drops/gifPreview";
export const createGifHandler = (options?: {
readonly linkPreviewVariant?: LinkPreviewVariant;
}): LinkHandler => ({
- match: isTenorGif,
+ match: isTenorGifUrl,
render: (href) =>
renderGifEmbed(href, {
fixedSize: options?.linkPreviewVariant !== "home",
diff --git a/components/drops/view/part/dropPartMarkdown/renderers.tsx b/components/drops/view/part/dropPartMarkdown/renderers.tsx
index 1041a6edd8..64dcad9cb5 100644
--- a/components/drops/view/part/dropPartMarkdown/renderers.tsx
+++ b/components/drops/view/part/dropPartMarkdown/renderers.tsx
@@ -8,6 +8,7 @@ import { getWaveRoute } from "@/helpers/navigation.helpers";
import LinkHandlerFrame from "@/components/waves/LinkHandlerFrame";
import WaveDropQuoteWithDropId from "@/components/waves/drops/WaveDropQuoteWithDropId";
import WaveDropQuoteWithSerialNo from "@/components/waves/drops/WaveDropQuoteWithSerialNo";
+import { CHAT_GIF_PREVIEW_HEIGHT_PX } from "@/components/waves/drops/gifPreview";
import ExpandableTweetPreview from "@/components/tweets/ExpandableTweetPreview";
import type { TweetPreviewMode } from "@/components/tweets/TweetPreviewModeContext";
import { ensureTwitterLink } from "./twitter";
@@ -57,8 +58,6 @@ const renderTweetEmbed = (
);
};
-const CHAT_GIF_HEIGHT = 180;
-
interface GifEmbedOptions {
readonly fixedSize?: boolean | undefined;
}
@@ -74,7 +73,7 @@ const renderGifEmbed = (
src={url}
alt="Embedded GIF"
className="tw-block tw-w-auto tw-max-w-full tw-rounded-xl tw-object-contain"
- style={{ height: `${CHAT_GIF_HEIGHT}px` }}
+ style={{ height: `${CHAT_GIF_PREVIEW_HEIGHT_PX}px` }}
loading="lazy"
/>
);
diff --git a/components/waves/CreateDropContent.tsx b/components/waves/CreateDropContent.tsx
index e7b49fbb31..677c7794a9 100644
--- a/components/waves/CreateDropContent.tsx
+++ b/components/waves/CreateDropContent.tsx
@@ -39,6 +39,7 @@ import { AuthContext } from "../auth/Auth";
import { HASHTAG_TRANSFORMER } from "../drops/create/lexical/transformers/HastagTransformer";
import { IMAGE_TRANSFORMER } from "../drops/create/lexical/transformers/ImageTransformer";
import { MENTION_TRANSFORMER } from "../drops/create/lexical/transformers/MentionTransformer";
+import { URL_PREVIEW_IMAGE_TRANSFORMER } from "../drops/create/lexical/transformers/UrlPreviewImageTransformer";
import { WAVE_MENTION_TRANSFORMER } from "../drops/create/lexical/transformers/WaveMentionTransformer";
import { ReactQueryWrapperContext } from "../react-query-wrapper/ReactQueryWrapper";
import CreateDropActions from "./CreateDropActions";
@@ -425,6 +426,7 @@ const CreateDropContent: React.FC = ({
MENTION_TRANSFORMER,
HASHTAG_TRANSFORMER,
WAVE_MENTION_TRANSFORMER,
+ URL_PREVIEW_IMAGE_TRANSFORMER,
IMAGE_TRANSFORMER,
EMOJI_TRANSFORMER,
])
@@ -555,38 +557,6 @@ const CreateDropContent: React.FC = ({
return null;
};
- const replyTo = getReplyTo();
- const replyToObj = replyTo ? { reply_to: replyTo } : {};
-
- const createGifDrop = (gif: string): CreateDropConfig => {
- return {
- title: null,
- drop_type: isDropMode ? ApiDropType.Participatory : ApiDropType.Chat,
- ...replyToObj,
- parts: [
- ...(drop?.parts ?? []),
- {
- content: gif,
- quoted_drop:
- activeDrop?.action === ActiveDropAction.QUOTE
- ? {
- drop_id: activeDrop.drop.id,
- drop_part_id: activeDrop.partId,
- }
- : null,
- media: files,
- },
- ],
- mentioned_users: [],
- mentioned_waves: [],
- referenced_nfts: [],
- metadata: [],
- signature: null,
- is_safe_signature: isSafeWallet,
- signer_address: address ?? "",
- };
- };
-
const createCurrentDrop = (
markdown: string | null,
allMentions: ApiDropMentionedUser[],
@@ -912,11 +882,16 @@ const CreateDropContent: React.FC = ({
await prepareAndSubmitDrop(getUpdatedDrop());
};
- const onGifDrop = async (gif: string): Promise => {
- if (submitting) {
+ const onGifDrop = (gif: string): void => {
+ const trimmedGif = gif.trim();
+ if (submitting || !trimmedGif.length) {
return;
}
- await prepareAndSubmitDrop(createGifDrop(gif));
+
+ createDropInputRef.current?.insertImagePreviewFromUrl(trimmedGif, {
+ smartSpacing: true,
+ });
+ createDropInputRef.current?.focus();
};
const onSwitchToDropMode = useCallback(() => {
diff --git a/components/waves/CreateDropInput.tsx b/components/waves/CreateDropInput.tsx
index f9c6f541aa..16f73bb1f5 100644
--- a/components/waves/CreateDropInput.tsx
+++ b/components/waves/CreateDropInput.tsx
@@ -59,9 +59,16 @@ import EmojiPlugin from "../drops/create/lexical/plugins/emoji/EmojiPlugin";
import { EmojiNode } from "../drops/create/lexical/nodes/EmojiNode";
import { SAFE_MARKDOWN_TRANSFORMERS } from "@/components/drops/create/lexical/transformers/markdownTransformers";
import PlainTextPastePlugin from "@/components/drops/create/lexical/plugins/PlainTextPastePlugin";
+import InsertTextPlugin, {
+ type InsertTextOptions,
+ type InsertTextPluginHandles,
+} from "../drops/create/lexical/plugins/InsertTextPlugin";
+import StandaloneImageUrlPreviewPlugin from "../drops/create/lexical/plugins/StandaloneImageUrlPreviewPlugin";
export interface CreateDropInputHandles {
clearEditorState: () => void;
+ insertTextAtCursor: (text: string, options?: InsertTextOptions) => void;
+ insertImagePreviewFromUrl: (url: string, options?: InsertTextOptions) => void;
focus: () => void;
}
@@ -192,13 +199,25 @@ const CreateDropInput = forwardRef<
}
const clearEditorRef = useRef(null);
+ const insertTextRef = useRef(null);
const editorRef = useRef(null);
const clearEditorState = () => {
clearEditorRef.current?.clearEditorState();
};
+ const insertTextAtCursor = (text: string, options?: InsertTextOptions) => {
+ insertTextRef.current?.insertTextAtCursor(text, options);
+ };
+ const insertImagePreviewFromUrl = (
+ url: string,
+ options?: InsertTextOptions
+ ) => {
+ insertTextRef.current?.insertImagePreviewFromUrl(url, options);
+ };
useImperativeHandle(ref, () => ({
clearEditorState,
+ insertTextAtCursor,
+ insertImagePreviewFromUrl,
focus: () => {
(
editorRef.current?.querySelector(
@@ -309,12 +328,14 @@ const CreateDropInput = forwardRef<
+
+
{
+ try {
+ const url = new URL(href);
+ const hostname = url.hostname.toLowerCase();
+ if (hostname !== TENOR_HOST) {
+ return false;
+ }
+
+ const pathname = url.pathname.toLowerCase();
+ return pathname.endsWith(".gif");
+ } catch {
+ return false;
+ }
+};