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..812126f08 100644 --- a/src/app/components/RenderMessageContent.test.tsx +++ b/src/app/components/RenderMessageContent.test.tsx @@ -56,17 +56,6 @@ 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' - ); - - 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' - ); - }); - it('still renders url previews for settings links with unknown focus ids', () => { renderMessage('https://app.example/settings/account?focus=display-name2'); diff --git a/src/app/components/editor/output.ts b/src/app/components/editor/output.ts index cf65cdcbc..a03e81e68 100644 --- a/src/app/components/editor/output.ts +++ b/src/app/components/editor/output.ts @@ -198,6 +198,9 @@ const elementToPlainText = (node: CustomElement, children: string): string => { }; const SPOILERINPUTREGEX = /\|\|.+?\|\|/g; +//very loose link check with the empty text at the end to make sure it doesnt overextend +export const LINKINPUTREGEX = /\(?(https?:\/\/[A-Za-z0-9-._~:/?#[\]()@!$&'*+,;%=]+)\)?/g; +const SPOILEREDLINKINPUTREGEX = /<(https?:\/\/[A-Za-z0-9-._~:/?#[\]()@!$&'*+,;%=]+)>/g; /** * convert slate internal representation to a plain text string that can be sent to the server @@ -217,7 +220,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 +314,31 @@ export const getMentions = (mx: MatrixClient, roomId: string, editor: Editor): M return mentionData; }; + +export const getLinks = (serialized: Descendant | Descendant[]): string[] | undefined => { + let finalList: string[] = []; + const parseLinks = (node: Descendant): void => { + if (Text.isText(node)) { + let { text } = node; + // 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 + ); + const spoileredUrlsMatch = text.match(SPOILEREDLINKINPUTREGEX); + let spoileredUrls = spoileredUrlsMatch ? [...new Set(spoileredUrlsMatch)] : undefined; + spoileredUrls = spoileredUrls?.map((spoileredUrl) => spoileredUrl.slice(1, -1)); + + urls = urls?.filter((url) => !spoileredUrls?.includes(url)); + finalList = finalList.concat(urls ?? []); + return; + } + if (node.type === BlockType.CodeBlock) return; + node?.children?.forEach(parseLinks); + }; + if (Array.isArray(serialized)) serialized.map((n) => parseLinks(n)); + else parseLinks(serialized); + return finalList; +}; diff --git a/src/app/components/message/MsgTypeRenderers.tsx b/src/app/components/message/MsgTypeRenderers.tsx index 399228cc2..e75dd534b 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,20 @@ 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 + ); 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 +244,20 @@ 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 + ); 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 +306,20 @@ 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 + ); 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..4b1557edd 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,31 @@ export const MessageEditor = as<'div', MessageEditorProps>( ); } + const bundleContent = content['com.beeper.linkpreviews'] as BundleContent[]; + const markHiddenLinks = (original: string, isHTML?: boolean) => { + if (!bundleContent) return original; + const splitBody = original.split(isHTML ? /(?=^.+<)|(?=)/g : /(?=[ \n()])/g); + let newBody = ''; + splitBody.map((s) => { + if (s.length < 5) { + newBody += s; + return; + } + const strippedS = s.substring(1); + const isHidden = + (bundleContent?.length === 0 || + bundleContent.filter((b) => b.matched_url !== strippedS).length > 0) && + strippedS.match(LINKINPUTREGEX) !== null; + newBody += `${isHidden ? (isHTML && `<${s[0]}`) || `${s[0]}<` : s[0]}${strippedS}${isHidden ? (isHTML && '>') || '>' : ''}`; + }); + // oxlint-disable-next-line no-console + console.log(bundleContent, original, newBody, splitBody); + 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 +237,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 +273,8 @@ 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 })); 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..c51a11b1e 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_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; + let [, 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}`;