diff --git a/fluxer_app/src/features/channel/components/useChannelComposerPaste.tsx b/fluxer_app/src/features/channel/components/useChannelComposerPaste.tsx index 1f7f56f91..c66e1e2f1 100644 --- a/fluxer_app/src/features/channel/components/useChannelComposerPaste.tsx +++ b/fluxer_app/src/features/channel/components/useChannelComposerPaste.tsx @@ -13,6 +13,7 @@ import { parseComposerClipboardSlice, } from '@app/features/lexical/composer/ComposerClipboard'; import type {ComposerHandle} from '@app/features/lexical/composer/ComposerHandle'; +import {resolvePastedLinkInsertion} from '@app/features/lexical/composer/ComposerLinkPaste'; import {showAttachmentPermissionDeniedModal} from '@app/features/messaging/components/alerts/AttachmentPermissionDeniedModal'; import {FileSizeTooLargeModal} from '@app/features/messaging/components/alerts/FileSizeTooLargeModal'; import {TooManyAttachmentsModal} from '@app/features/messaging/components/alerts/TooManyAttachmentsModal'; @@ -32,7 +33,7 @@ import {modal} from '@app/features/ui/commands/ModalCommands'; import ContextMenuState from '@app/features/ui/state/ContextMenu'; import KeyboardMode from '@app/features/ui/state/KeyboardMode'; import Users from '@app/features/user/state/Users'; -import {COMMAND_PRIORITY_HIGH, PASTE_COMMAND} from 'lexical'; +import {$getSelection, $isRangeSelection, COMMAND_PRIORITY_HIGH, PASTE_COMMAND} from 'lexical'; import type React from 'react'; import {useCallback, useEffect} from 'react'; @@ -68,6 +69,19 @@ function createComposerPasteSlice(pastedText: string, segments: ReadonlyArray void { return mergeRegister( - editor.registerCommand( - PASTE_COMMAND, - (event) => { - if (!state.isEditable() || state.getPlainText()) { - return false; - } - const clipboardEvent = getClipboardEvent(event); - if (clipboardEvent == null || clipboardEvent.clipboardData == null) { - return false; - } - const url = parsePastedUrl(clipboardEvent.clipboardData.getData('text/plain')); - if (url == null) { - return false; - } - const selection = $getSelection(); - if (!$isRangeSelection(selection) || selection.isCollapsed()) { - return false; - } - const selectedText = selection.getTextContent(); - if (!canWrapSelectionAsLink(selectedText)) { - return false; - } - selection.insertText(buildMaskedLink(selectedText, url)); - $addUpdateTag(PASTE_TAG); - clipboardEvent.preventDefault(); - return true; - }, - COMMAND_PRIORITY_NORMAL, - ), editor.registerCommand( COPY_COMMAND, (event) => { diff --git a/fluxer_app/src/features/lexical/composer/ComposerLinkPaste.test.ts b/fluxer_app/src/features/lexical/composer/ComposerLinkPaste.test.ts index 644636a4c..bd4d6ad2e 100644 --- a/fluxer_app/src/features/lexical/composer/ComposerLinkPaste.test.ts +++ b/fluxer_app/src/features/lexical/composer/ComposerLinkPaste.test.ts @@ -4,6 +4,7 @@ import { buildMaskedLink, canWrapSelectionAsLink, parsePastedUrl, + resolvePastedLinkInsertion, } from '@app/features/lexical/composer/ComposerLinkPaste'; import {describe, expect, it} from 'vitest'; @@ -60,3 +61,29 @@ describe('buildMaskedLink', () => { ); }); }); + +describe('resolvePastedLinkInsertion', () => { + it('wraps a selection when a bare url is pasted over it', () => { + expect(resolvePastedLinkInsertion('https://fluxer.app/download', 'the download page')).toBe( + '[the download page]()', + ); + }); + + it('wraps a message link the same way a plain url is wrapped', () => { + const messageLink = 'https://fluxer.app/channels/1234567890/9876543210/1122334455'; + expect(resolvePastedLinkInsertion(messageLink, 'this message')).toBe(`[this message](<${messageLink}>)`); + }); + + it('declines when the pasted text is not a url', () => { + expect(resolvePastedLinkInsertion('just some text', 'selected')).toBeNull(); + }); + + it('declines when the selection cannot be wrapped', () => { + expect(resolvePastedLinkInsertion('https://fluxer.app', ' ')).toBeNull(); + expect(resolvePastedLinkInsertion('https://fluxer.app', 'https://example.test')).toBeNull(); + }); + + it('declines a non-web protocol so pasting cannot forge a link', () => { + expect(resolvePastedLinkInsertion('javascript:alert(1)', 'click me')).toBeNull(); + }); +}); diff --git a/fluxer_app/src/features/lexical/composer/ComposerLinkPaste.ts b/fluxer_app/src/features/lexical/composer/ComposerLinkPaste.ts index 10b2014d1..fd0faf204 100644 --- a/fluxer_app/src/features/lexical/composer/ComposerLinkPaste.ts +++ b/fluxer_app/src/features/lexical/composer/ComposerLinkPaste.ts @@ -43,3 +43,11 @@ export function canWrapSelectionAsLink(selectedText: string): boolean { export function buildMaskedLink(selectedText: string, url: string): string { return `[${selectedText}](<${url}>)`; } + +export function resolvePastedLinkInsertion(pastedText: string, selectedText: string): string | null { + const url = parsePastedUrl(pastedText); + if (url === null || !canWrapSelectionAsLink(selectedText)) { + return null; + } + return buildMaskedLink(selectedText, url); +}