From c29901eb1049d79d7ae022a3bd27167e34fa1f24 Mon Sep 17 00:00:00 2001 From: npub14ndfusear8wdpe4kss8h7juc7wjk78atnqzf63zvppcpneknv4sq6x9370 Date: Tue, 4 Aug 2026 16:01:54 -0700 Subject: [PATCH] fix(desktop): intercept plain links before clipboard parsing Co-authored-by: npub14ndfusear8wdpe4kss8h7juc7wjk78atnqzf63zvppcpneknv4sq6x9370 Signed-off-by: npub14ndfusear8wdpe4kss8h7juc7wjk78atnqzf63zvppcpneknv4sq6x9370 --- .../messages/lib/useRichTextEditor.ts | 52 ++++++++----------- .../features/messages/ui/MessageComposer.tsx | 16 +++--- desktop/tests/e2e/messaging.spec.ts | 7 +-- 3 files changed, 32 insertions(+), 43 deletions(-) diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts index 3d526fb825..f0e40e4065 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -486,6 +486,28 @@ export function useRichTextEditor({ }), ], editorProps: { + handleDOMEvents: { + paste: (view, event) => { + const text = (event as ClipboardEvent).clipboardData?.getData( + "text/plain", + ); + if (!text || !/^https?:\/\/\S+$/i.test(text)) return false; + const { from, to } = view.state.selection; + const link = view.state.schema.marks.link; + if (!link) return false; + let tr = view.state.tr.replaceRangeWith( + from, + to, + view.state.schema.text(text, [link.create({ href: text })]), + ); + const end = tr.mapping.map(to); + tr = tr.insertText(" ", end).removeMark(end, end + 1, link); + tr = tr.setSelection(TextSelection.create(tr.doc, end + 1)); + view.dispatch(tr.setStoredMarks([]).scrollIntoView()); + event.preventDefault(); + return true; + }, + }, attributes: { autocapitalize: "none", autocorrect: "off", @@ -812,35 +834,6 @@ export function useRichTextEditor({ [editor, customEmojiWiring.resolveUrl], ); - const insertPlainHttpLink = React.useCallback( - (text: string): boolean => { - // A plain URL is the common link-preview path. Insert it directly as a - // ProseMirror link instead of sending it through tiptap-markdown's full - // clipboard parser; that parser blocks the paste event long enough to - // drop visible frames before the preview can render. - if (!editor || !/^https?:\/\/\S+$/i.test(text)) return false; - const { state, view } = editor; - const { from, to } = state.selection; - const linkMark = state.schema.marks.link; - if (!linkMark) return false; - - const linkedText = state.schema.text(text, [ - linkMark.create({ href: text }), - ]); - let transaction = state.tr.replaceRangeWith(from, to, linkedText); - const linkEnd = transaction.mapping.map(to); - transaction = transaction.insertText(" ", linkEnd); - transaction = transaction.removeMark(linkEnd, linkEnd + 1, linkMark); - transaction = transaction.setSelection( - TextSelection.create(transaction.doc, linkEnd + 1), - ); - transaction.setStoredMarks([]); - view.dispatch(transaction.scrollIntoView()); - return true; - }, - [editor], - ); - /** * Link mark info for the current selection — its href and the covered * text, expanded to the full link range when the caret merely sits inside @@ -928,7 +921,6 @@ export function useRichTextEditor({ focusPreserve, getPlainTextAndCursor, replacePlainTextRange, - insertPlainHttpLink, getLinkSelectionInfo, applyLink, removeLink, diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 728f2c985a..2f6925ceea 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -100,10 +100,11 @@ function MessageComposerImpl({ syncContentRefFromEditorRef, } = useComposerContentState(); const [previewContent, setPreviewContent] = React.useState(""); + const deferredPreviewContent = React.useDeferredValue(previewContent); const { previewList: composerLinkPreviews, getReadyTags: getReadyLinkPreviewTags, - } = useComposerLinkPreviews(previewContent); + } = useComposerLinkPreviews(deferredPreviewContent); const [isEmojiPickerOpen, setIsEmojiPickerOpen] = React.useState(false); const [isFormattingOpen, setIsFormattingOpen] = React.useState(false); const [spoileredAttachmentUrls, setSpoileredAttachmentUrls] = React.useState< @@ -263,7 +264,7 @@ function MessageComposerImpl({ onLinkShortcut: () => onLinkShortcutRef.current?.() ?? false, onUpdate: ({ cursor, text }) => { setComposerContentFromText(text); - React.startTransition(() => setPreviewContent(text)); + setPreviewContent(text); mentions.updateMentionQuery(text, cursor); channelLinks.updateChannelQuery(text, cursor); emojiAutocomplete.updateEmojiQuery(text, cursor); @@ -734,8 +735,6 @@ function MessageComposerImpl({ // ── Media paste + ⌘K link shortcut via Tiptap editorProps ────────── const uploadFileRef = React.useRef(media.uploadFile); uploadFileRef.current = media.uploadFile; - const insertPlainHttpLinkRef = React.useRef(richText.insertPlainHttpLink); - insertPlainHttpLinkRef.current = richText.insertPlainHttpLink; React.useEffect(() => { if (!richText.editor) return; richText.editor.setOptions({ @@ -793,10 +792,6 @@ function MessageComposerImpl({ return true; } const plainText = event.clipboardData?.getData("text/plain") ?? ""; - if (insertPlainHttpLinkRef.current(plainText)) { - event.preventDefault(); - return true; - } if (plainText.includes("\n")) { scrollComposerToBottom(); } @@ -825,9 +820,11 @@ function MessageComposerImpl({ ], ); const handleCaptureSelection = React.useCallback(() => {}, []); + const handlePaperclipClick = React.useCallback(() => { void media.handlePaperclip(); }, [media.handlePaperclip]); + const handleRemoveAttachment = React.useCallback( (url: string) => { setSpoileredAttachmentUrls((current) => { @@ -840,12 +837,14 @@ function MessageComposerImpl({ }, [media.removeAttachment], ); + const { handleAttachmentEditSave, handleAttachmentRevert } = useAttachmentEditing({ revertAttachment: media.revertAttachment, setSpoileredAttachmentUrls, uploadEditedAttachment: media.uploadEditedAttachment, }); + const handleToggleAttachmentSpoiler = React.useCallback((url: string) => { setSpoileredAttachmentUrls((current) => { const next = new Set(current); @@ -950,6 +949,7 @@ function MessageComposerImpl({ ) : null} + {composerLinkPreviews} {(media.pendingImeta.length > 0 || media.queuedAttachments.length > 0 || diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index c807d55c87..af0608e2ca 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -217,7 +217,7 @@ test.beforeEach(async ({ page }, testInfo) => { imageDomain: null, }, linkPreviewMetadataDelayMs: testInfo.title.includes( - "avoids a long task", + "raw paste bypasses clipboard parsing", ) ? 10_000 : testInfo.title.includes("style defaults") || @@ -475,7 +475,7 @@ test("link preview style defaults to compact and Rich unfurls descriptions", asy await page.getByTestId("link-preview-style-compact").click(); }); -test("link preview plain URL paste avoids a long task before the loading card", async ({ +test("link preview raw paste bypasses clipboard parsing before loading", async ({ page, }) => { const previewUrl = "https://github.com/block/buzz/pull/3246?paste=async"; @@ -512,9 +512,6 @@ test("link preview plain URL paste avoids a long task before the loading card", }; }, previewUrl); expect(firstPaint.text).toContain(previewUrl); - // A 50ms task is the browser's standard long-task boundary. Guard the - // synchronous paste event itself and require three advancing frames so a - // later multi-frame stall cannot hide behind one permissive rAF assertion. expect(firstPaint.pasteEventMs).toBeLessThan(50); expect(firstPaint.frameTimes).toHaveLength(3); expect(firstPaint.frameTimes[2]).toBeGreaterThan(firstPaint.frameTimes[0]);