diff --git a/.changeset/add_hide_preview.md b/.changeset/add_hide_preview.md new file mode 100644 index 000000000..c8ab919ca --- /dev/null +++ b/.changeset/add_hide_preview.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Add preventing url preview cards by surrounding a link in anglebrackets like diff --git a/src/app/components/RenderMessageContent.test.tsx b/src/app/components/RenderMessageContent.test.tsx index 191fa23b4..666beab50 100644 --- a/src/app/components/RenderMessageContent.test.tsx +++ b/src/app/components/RenderMessageContent.test.tsx @@ -49,6 +49,15 @@ describe('RenderMessageContent', () => { expect(screen.queryByTestId('client-preview')).not.toBeInTheDocument(); }); + it('still renders url previews for settings links with unknown focus ids', () => { + renderMessage('https://app.example/settings/account?focus=display-name2'); + + expect(screen.getByTestId('url-preview-holder')).toBeInTheDocument(); + expect(screen.getByTestId('url-preview-card')).toHaveTextContent( + 'https://app.example/settings/account?focus=display-name2' + ); + }); + it('still renders url previews for non-settings links', () => { renderMessage('https://example.com'); @@ -56,23 +65,31 @@ describe('RenderMessageContent', () => { expect(screen.getByTestId('url-preview-card')).toHaveTextContent('https://example.com'); }); - it('still renders url previews for malformed settings-looking links', () => { - renderMessage( - 'https://app.example/settings/account?focus=status&moe.sable.client.action=settings">Settings' - ); + it('render url previews for text starting with paranthesis', () => { + renderMessage('foo (https://example.com bar'); expect(screen.getByTestId('url-preview-holder')).toBeInTheDocument(); - expect(screen.getByTestId('url-preview-card')).toHaveTextContent( - 'https://app.example/settings/account?focus=status&moe.sable.client.action=settings">Settings' - ); + expect(screen.getByTestId('url-preview-card')).toHaveTextContent('https://example.com'); }); - it('still renders url previews for settings links with unknown focus ids', () => { - renderMessage('https://app.example/settings/account?focus=display-name2'); + it('include ending paranthesis into the url preview per url spec', () => { + renderMessage('foo https://example.com) bar'); expect(screen.getByTestId('url-preview-holder')).toBeInTheDocument(); - expect(screen.getByTestId('url-preview-card')).toHaveTextContent( - 'https://app.example/settings/account?focus=display-name2' - ); + expect(screen.getByTestId('url-preview-card')).toHaveTextContent('https://example.com)'); + }); + + it('exclude closing paranthesis from the url preview when it marks a []() hyperlink', () => { + renderMessage('[foo](https://example.com) bar'); + + expect(screen.getByTestId('url-preview-holder')).toBeInTheDocument(); + expect(screen.getByTestId('url-preview-card')).toHaveTextContent('https://example.com'); + }); + + it('include inner closing paranthesis from the url preview even within []() hyperlink', () => { + renderMessage('[foo](https://example.com)) bar'); + + expect(screen.getByTestId('url-preview-holder')).toBeInTheDocument(); + expect(screen.getByTestId('url-preview-card')).toHaveTextContent('https://example.com)'); }); }); diff --git a/src/app/components/editor/output.ts b/src/app/components/editor/output.ts index cf65cdcbc..d3efcd8dd 100644 --- a/src/app/components/editor/output.ts +++ b/src/app/components/editor/output.ts @@ -39,7 +39,7 @@ const textToCustomHtml = (node: Text, opts: OutputOptions): string => { if (node.spoiler) string = `${string}`; } - if (opts.allowInlineMarkdown && string === sanitizeText(node.text)) { + if (opts.allowInlineMarkdown && string === sanitizeText(node.text) && !node.code) { string = parseInlineMD(string); } @@ -198,6 +198,10 @@ const elementToPlainText = (node: CustomElement, children: string): string => { }; const SPOILERINPUTREGEX = /\|\|.+?\|\|/g; +const LINK_URL = `(https?:\\/\\/.[A-Za-z0-9-._~:/?#[\\]()@!$&'*+,;%=]+)`; +export const LINKINPUTREGEX = new RegExp(`\\(?(${LINK_URL})\\)?`, 'g'); +const SPOILEREDLINKINPUTREGEX = new RegExp(`<(${LINK_URL})>`, 'g'); +const MASKEDSPOILEREDLINKINPUTREGEX = new RegExp(`\\[.+\\]\\(${LINK_URL}\\)`, 'g'); /** * convert slate internal representation to a plain text string that can be sent to the server @@ -217,7 +221,10 @@ export const toPlainText = ( return node.map((n) => toPlainText(n, isMarkdown, stripNickname, nickNameReplacement)).join(''); if (Text.isText(node)) { let { text } = node; + text = text.replaceAll(SPOILERINPUTREGEX, '[Spoiler]'); + text = text.replaceAll(SPOILEREDLINKINPUTREGEX, '$1'); + if (stripNickname && nickNameReplacement) { nickNameReplacement?.keys().forEach((key) => { const replacement = nickNameReplacement.get(key) ?? ''; @@ -308,3 +315,58 @@ export const getMentions = (mx: MatrixClient, roomId: string, editor: Editor): M return mentionData; }; + +export const getLinks = (serialized: Descendant | Descendant[]): string[] | undefined => { + let finalList: string[] = []; + let isInsideCodeBlock = false; + const parseLinks = (node: Descendant): void => { + if (Text.isText(node)) { + let { text } = node; + if (text.startsWith('```') && !text.includes(' ')) { + isInsideCodeBlock = !isInsideCodeBlock; + return; + } + if (isInsideCodeBlock) return; + // get a list of all the urls and of the ones that are spoilered, + // truncate the spoilered ones of their <> and then remove the items that are present in both lists + const urlsMatch = text.match(LINKINPUTREGEX); + let urls = urlsMatch ? [...new Set(urlsMatch)] : undefined; + urls = urls?.map( + (url) => + (url.startsWith('(') && url.endsWith(')') && url.substring(1, url.length - 1)) || + (url.startsWith('(') && url.substring(1)) || + (url.endsWith('/)') && url.substring(0, url.length - 1)) || + url + ); + const spoileredUrlsMatch = text.match(SPOILEREDLINKINPUTREGEX); + let spoileredUrls = spoileredUrlsMatch ? [...new Set(spoileredUrlsMatch)] : undefined; + spoileredUrls = spoileredUrls?.map((spoileredUrl) => spoileredUrl.slice(1, -1)); + + const maskedSpoileredUrlsMatch = text.match(MASKEDSPOILEREDLINKINPUTREGEX); + let maskedSpoileredUrls = maskedSpoileredUrlsMatch + ? [...new Set(maskedSpoileredUrlsMatch)] + : undefined; + maskedSpoileredUrls = maskedSpoileredUrls?.map((maskedSpoileredUrl) => + maskedSpoileredUrl?.substring( + maskedSpoileredUrl.indexOf('](') + 2, + maskedSpoileredUrl.lastIndexOf(')') + ) + ); + if (maskedSpoileredUrls) + spoileredUrls = spoileredUrls + ? [...spoileredUrls, ...maskedSpoileredUrls] + : maskedSpoileredUrls; + + spoileredUrls = spoileredUrls?.filter( + (item, index) => spoileredUrls?.indexOf(item) === index + ); + urls = urls?.filter((url) => !spoileredUrls?.includes(url)); + finalList = finalList.concat(urls ?? []); + return; + } + node?.children?.forEach(parseLinks); + }; + if (Array.isArray(serialized)) serialized.map((n) => parseLinks(n)); + else parseLinks(serialized); + return finalList.filter((item, index) => finalList.indexOf(item) === index); +}; diff --git a/src/app/components/message/MsgTypeRenderers.tsx b/src/app/components/message/MsgTypeRenderers.tsx index 399228cc2..d94c92564 100644 --- a/src/app/components/message/MsgTypeRenderers.tsx +++ b/src/app/components/message/MsgTypeRenderers.tsx @@ -2,7 +2,7 @@ import type { CSSProperties, ReactNode } from 'react'; import { useMemo } from 'react'; import { Box, Chip, Icon, Icons, Text, toRem } from 'folds'; import type { IContent, IPreviewUrlResponse } from '$types/matrix-sdk'; -import { JUMBO_EMOJI_REG, URL_REG } from '$utils/regex'; +import { JUMBO_EMOJI_REG } from '$utils/regex'; import { trimReplyFromBody } from '$utils/room'; import type { IAudioContent, @@ -36,8 +36,9 @@ import { } from './content'; import { MessageTextBody } from './layout'; import { unwrapForwardedContent } from './modals/MessageForward'; +import { LINKINPUTREGEX } from '$components/editor'; -interface BundleContent extends IPreviewUrlResponse { +export interface BundleContent extends IPreviewUrlResponse { matched_url: string; } @@ -146,11 +147,24 @@ export function MText({ if (!body && !customBody) return ; let bundleContent: BundleContent[] | undefined; - const urlsMatch = trimmedBody.match(URL_REG); + const urlsMatch = trimmedBody.match(LINKINPUTREGEX); let urls = urlsMatch ? [...new Set(urlsMatch)] : undefined; + urls = urls?.map( + (url) => + (url.startsWith('(') && url.endsWith(')') && url.substring(1, url.length - 1)) || + (url.startsWith('(') && url.substring(1)) || + (url.endsWith('/)') && url.substring(0, url.length - 1)) || + url + ); bundleContent = content['com.beeper.linkpreviews'] as BundleContent[]; - bundleContent = bundleContent?.filter((bundle) => !!urls?.includes(bundle.matched_url)); - if (renderUrlsPreview && bundleContent) urls = bundleContent.map((bundle) => bundle.matched_url); + //small "fix" for if someone sends malformed objects (ie not arrays of objects) + try { + bundleContent = bundleContent?.filter((bundle) => !!urls?.includes(bundle.matched_url)); + if (renderUrlsPreview && bundleContent) + urls = bundleContent.map((bundle) => bundle.matched_url); + } catch { + urls = []; + } if ((content['com.beeper.per_message_profile'] as PerMessageProfileBeeperFormat)?.has_fallback) { // unwrap per-message profile fallback if present @@ -234,10 +248,24 @@ export function MEmote({ const isJumbo = JUMBO_EMOJI_REG.test(trimmedBody); let bundleContent: BundleContent[] | undefined; - const urlsMatch = trimmedBody.match(URL_REG); - const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined; + const urlsMatch = trimmedBody.match(LINKINPUTREGEX); + let urls = urlsMatch ? [...new Set(urlsMatch)] : undefined; + urls = urls?.map( + (url) => + (url.startsWith('(') && url.endsWith(')') && url.substring(1, url.length - 1)) || + (url.startsWith('(') && url.substring(1)) || + (url.endsWith('/)') && url.substring(0, url.length - 1)) || + url + ); bundleContent = content['com.beeper.linkpreviews'] as BundleContent[]; - bundleContent = bundleContent?.filter((bundle) => !!urls?.includes(bundle.matched_url)); + //small "fix" for if someone sends malformed objects (ie not arrays of objects) + try { + bundleContent = bundleContent?.filter((bundle) => !!urls?.includes(bundle.matched_url)); + if (renderUrlsPreview && bundleContent) + urls = bundleContent.map((bundle) => bundle.matched_url); + } catch { + urls = []; + } return ( <> @@ -286,10 +314,24 @@ export function MNotice({ const isJumbo = JUMBO_EMOJI_REG.test(trimmedBody); let bundleContent: BundleContent[] | undefined; - const urlsMatch = trimmedBody.match(URL_REG); - const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined; + const urlsMatch = trimmedBody.match(LINKINPUTREGEX); + let urls = urlsMatch ? [...new Set(urlsMatch)] : undefined; + urls = urls?.map( + (url) => + (url.startsWith('(') && url.endsWith(')') && url.substring(1, url.length - 1)) || + (url.startsWith('(') && url.substring(1)) || + (url.endsWith('/)') && url.substring(0, url.length - 1)) || + url + ); bundleContent = content['com.beeper.linkpreviews'] as BundleContent[]; - bundleContent = bundleContent?.filter((bundle) => !!urls?.includes(bundle.matched_url)); + //small "fix" for if someone sends malformed objects (ie not arrays of objects) + try { + bundleContent = bundleContent?.filter((bundle) => !!urls?.includes(bundle.matched_url)); + if (renderUrlsPreview && bundleContent) + urls = bundleContent.map((bundle) => bundle.matched_url); + } catch { + urls = []; + } return ( <> diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index ed2fdb695..db21b3544 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -59,6 +59,7 @@ import { getMentions, ANYWHERE_AUTOCOMPLETE_PREFIXES, BEGINNING_AUTOCOMPLETE_PREFIXES, + getLinks, } from '$components/editor'; import { EmojiBoard, EmojiBoardTab } from '$components/emoji-board'; import { UseStateProvider } from '$components/UseStateProvider'; @@ -729,6 +730,7 @@ export const RoomInput = forwardRef( }); let plainText = toPlainText(serializedChildren, isMarkdown, true, nicknameReplacement).trim(); + /** * the html we will send */ @@ -802,6 +804,10 @@ export const RoomInput = forwardRef( content['m.mentions'] = getMentionContent(Array.from(mentionData.users), mentionData.room); + const links = getLinks(serializedChildren); + content['com.beeper.linkpreviews'] = []; + links?.forEach((link) => content['com.beeper.linkpreviews'].push({ matched_url: link })); + if (replyDraft || !customHtmlEqualsPlainText(formattedBody, body)) { content.format = 'org.matrix.custom.html'; content.formatted_body = formattedBody; diff --git a/src/app/features/room/message/MessageEditor.tsx b/src/app/features/room/message/MessageEditor.tsx index edcb4c4c3..e3ea4874a 100644 --- a/src/app/features/room/message/MessageEditor.tsx +++ b/src/app/features/room/message/MessageEditor.tsx @@ -36,6 +36,8 @@ import { useEditor, getMentions, ANYWHERE_AUTOCOMPLETE_PREFIXES, + getLinks, + LINKINPUTREGEX, } from '$components/editor'; import { useSetting } from '$state/hooks/settings'; import { CaptionPosition, settingsAtom } from '$state/settings'; @@ -56,6 +58,7 @@ import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; import type { Opts as LinkifyOpts } from 'linkifyjs'; import type { GetContentCallback } from '$types/matrix/room'; import { sanitizeText } from '$utils/sanitize'; +import type { BundleContent } from '$components/message'; type MessageEditorProps = { roomId: string; @@ -116,9 +119,48 @@ export const MessageEditor = as<'div', MessageEditorProps>( ); } + const bundleContent = content['com.beeper.linkpreviews'] as BundleContent[]; + const markHiddenLinks = (original: string, isHTML?: boolean) => { + if (!bundleContent) return original; + /* Split according to the following fule: + - if its not HTML just break it by spaces, newLines, and parans + - if it is HTML + - break it before before any potential opening tag + - break it whenever a tag starts + - break it after a closing tag + - then for every non portion find regular links as though it is plaintext + * this is not recursive but needs flattening + */ + let splitBody = original.split( + isHTML ? /(?=^.+<)|(?=)|(?=)/gi : /(?=[ \n()])/gi + ); + if (isHTML) + splitBody = splitBody + .map((item) => (item.startsWith(' acc.concat(current), []); + let newBody = ''; + splitBody.map((s) => { + // the length is from the fact that a link is necessarily longer than 6 + if (s.length < 6 || s.startsWith('')) { + newBody += s; + return; + } + // since the way that the match works the key is at the start of the string, + // it needs to be separated such that it can be reintroduced before the < in case of regular text + // or after it in case that it is matching a tag + const strippedS = s.substring(1); + const isHidden = + (bundleContent?.length === 0 || + bundleContent.filter((b) => s.includes(b.matched_url)).length === 0) && + strippedS.match(LINKINPUTREGEX) !== null; + newBody += `${isHidden ? (isHTML && ((s.startsWith('' : ''}`; + }); + return newBody; + }; + return [ - typeof body === 'string' ? body : undefined, - typeof customHtml === 'string' ? customHtml : undefined, + typeof body === 'string' ? markHiddenLinks(body) : undefined, + typeof customHtml === 'string' ? markHiddenLinks(customHtml, true) : undefined, mMentions, ]; }, [room, mEvent]); @@ -212,6 +254,8 @@ export const MessageEditor = as<'div', MessageEditorProps>( newContent['m.mentions'] = mMentions; contentBody['m.mentions'] = mMentions; + const links = getLinks(editor.children); + if (!customHtmlEqualsPlainText(customHtml, plainText)) { newContent.format = 'org.matrix.custom.html'; newContent.formatted_body = customHtml; @@ -246,6 +290,9 @@ export const MessageEditor = as<'div', MessageEditorProps>( oldContent['page.codeberg.everypizza.msc4193.spoiler']; } } + content['com.beeper.linkpreviews'] = []; + links?.forEach((link) => content['com.beeper.linkpreviews'].push({ matched_url: link })); + content['m.new_content']['com.beeper.linkpreviews'] = content['com.beeper.linkpreviews']; return mx.sendMessage(roomId, content as RoomMessageEventContent); }, [mx, editor, roomId, mEvent, isMarkdown, getPrevBodyAndFormattedBody, room]) diff --git a/src/app/features/room/settingsLinkMessage.test.ts b/src/app/features/room/settingsLinkMessage.test.ts index c769357f7..44f2b3157 100644 --- a/src/app/features/room/settingsLinkMessage.test.ts +++ b/src/app/features/room/settingsLinkMessage.test.ts @@ -160,7 +160,7 @@ describe('settingsLinkMessage', () => { true ); - expect(toPlainText(rewritten, true).trim()).toBe(`<${settingsUrl}>`); + expect(toPlainText(rewritten, true).trim()).toBe(settingsUrl); }); it('does not rewrite settings links inside literal html text', () => { diff --git a/src/app/features/room/settingsLinkMessage.ts b/src/app/features/room/settingsLinkMessage.ts index 84621c474..90fa4e1d6 100644 --- a/src/app/features/room/settingsLinkMessage.ts +++ b/src/app/features/room/settingsLinkMessage.ts @@ -94,7 +94,6 @@ const getRewritableSettingsLinkMatches = ( if (matches.length === 0) return []; const codeSpanRanges = isMarkdown ? getMarkdownCodeSpanRanges(text) : []; - return matches.flatMap((match) => { const href = match.value; const settingsLink = parseSettingsLink(baseUrl, href); diff --git a/src/app/plugins/markdown/inline/parser.ts b/src/app/plugins/markdown/inline/parser.ts index 19c032bbd..95dbeafb8 100644 --- a/src/app/plugins/markdown/inline/parser.ts +++ b/src/app/plugins/markdown/inline/parser.ts @@ -2,6 +2,7 @@ import { BoldRule, CodeRule, EscapeRule, + HiddenLinkRule, ItalicRule1, ItalicRule2, LinkRule, @@ -13,6 +14,7 @@ import { runInlineRule, runInlineRules } from './runner'; import type { InlineMDParser } from './type'; const LeveledRules = [ + HiddenLinkRule, BoldRule, ItalicRule1, UnderlineRule, diff --git a/src/app/plugins/markdown/inline/rules.ts b/src/app/plugins/markdown/inline/rules.ts index 499a1a8d6..11f76d3f0 100644 --- a/src/app/plugins/markdown/inline/rules.ts +++ b/src/app/plugins/markdown/inline/rules.ts @@ -108,16 +108,29 @@ export const SpoilerRule: InlineMDRule = { }; const LINK_ALT = `\\[${MIN_ANY}\\]`; -const LINK_URL = `\\((https?:\\/\\/.+?)\\)`; -const LINK_REG_1 = new RegExp(`${LINK_ALT}${LINK_URL}`); +const LINK_URL = `\\(((<)?https?:\\/\\/.[A-Za-z0-9-._~:/?#[\\]()@!$&'*+,;%=]+)(>)?\\)`; +const LINK_REG_1 = new RegExp(`(<)?${LINK_ALT}${LINK_URL}(>)?`); export const LinkRule: InlineMDRule = { match: (text) => text.match(LINK_REG_1), html: (parse, match) => { - const [, g1, g2] = match; + const [, , g1, g2] = match; if (!g1 || !g2) return ''; + if (g2.startsWith('<') && g2.endsWith('>')) + return `${parse(g1)}`; + return `${parse(g1)}`; }, }; +const HIDDEN_LINK_URL = `<(https?:\\/\\/.[A-Za-z0-9-._~:/?#[\\]()@!$&'*+,;%=]+)>`; +const HIDDEN_LINK_REG_1 = new RegExp(HIDDEN_LINK_URL); +export const HiddenLinkRule: InlineMDRule = { + match: (text) => text.match(HIDDEN_LINK_REG_1), + html: (parse, match) => { + const [, g1] = match; + if (!g1) return ''; + return g1; + }, +}; export const INLINE_SEQUENCE_SET = '[*_~`|]'; export const CAP_INLINE_SEQ = `${URL_NEG_LB}${INLINE_SEQUENCE_SET}`;