fix(app): wrap pasted links from any source, not just Fluxer's own clipboard (#1752)

This commit is contained in:
Hampus
2026-08-19 14:57:13 +02:00
committed by GitHub
parent 5aa283e8e0
commit 59887ad404
4 changed files with 54 additions and 35 deletions
@@ -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<Me
return {display: displayParts.join(''), segments: projectedSegments};
}
function $insertLinkAroundSelection(pastedText: string): boolean {
const selection = $getSelection();
if (!$isRangeSelection(selection) || selection.isCollapsed()) {
return false;
}
const replacement = resolvePastedLinkInsertion(pastedText, selection.getTextContent());
if (replacement === null) {
return false;
}
selection.insertText(replacement);
return true;
}
export function useChannelComposerPaste({
channel,
handleRef,
@@ -230,6 +244,10 @@ export function useChannelComposerPaste({
void handlePasteExceedsLimit(pastedText);
return true;
}
if ($insertLinkAroundSelection(pastedText)) {
event.preventDefault();
return true;
}
if (!insertPastedText(pastedText)) {
return false;
}
@@ -1,10 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
buildMaskedLink,
canWrapSelectionAsLink,
parsePastedUrl,
} from '@app/features/lexical/composer/ComposerLinkPaste';
import {
$createComposerSegmentNodes,
$projectComposer,
@@ -465,35 +460,6 @@ export function registerComposerClipboardCommands(
state: ComposerClipboardCommandState,
): () => 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) => {
@@ -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](<https://fluxer.app/download>)',
);
});
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();
});
});
@@ -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);
}