Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/add_hide_preview.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: minor
---

Add preventing url preview cards by surrounding a link in anglebrackets like <https://app.sable.moe>
41 changes: 29 additions & 12 deletions src/app/components/RenderMessageContent.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,30 +49,47 @@ 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');

expect(screen.getByTestId('url-preview-holder')).toBeInTheDocument();
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)');
});
});
64 changes: 63 additions & 1 deletion src/app/components/editor/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ const textToCustomHtml = (node: Text, opts: OutputOptions): string => {
if (node.spoiler) string = `<span data-mx-spoiler>${string}</span>`;
}

if (opts.allowInlineMarkdown && string === sanitizeText(node.text)) {
if (opts.allowInlineMarkdown && string === sanitizeText(node.text) && !node.code) {
string = parseInlineMD(string);
}

Expand Down Expand Up @@ -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
Expand All @@ -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) ?? '';
Expand Down Expand Up @@ -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);
};
64 changes: 53 additions & 11 deletions src/app/components/message/MsgTypeRenderers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -146,11 +147,24 @@ export function MText({
if (!body && !customBody) return <BrokenContent body={customBody ?? body} />;

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
Expand Down Expand Up @@ -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 (
<>
Expand Down Expand Up @@ -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 (
<>
Expand Down
6 changes: 6 additions & 0 deletions src/app/features/room/RoomInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -729,6 +730,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
});

let plainText = toPlainText(serializedChildren, isMarkdown, true, nicknameReplacement).trim();

/**
* the html we will send
*/
Expand Down Expand Up @@ -802,6 +804,10 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(

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;
Expand Down
51 changes: 49 additions & 2 deletions src/app/features/room/message/MessageEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -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 <a> tag starts
- break it after a closing </a> tag
- then for every non <a> portion find regular links as though it is plaintext
* this is not recursive but needs flattening
*/
let splitBody = original.split(
isHTML ? /(?=^.+<)|(?=<a.+)|(?<=\/a>)|(?=<code.+)|(?<=\/code>)/gi : /(?=[ \n()])/gi
);
if (isHTML)
splitBody = splitBody
.map((item) => (item.startsWith('<a') ? [item] : item.split(/(?=[ \n()])/g)))
.reduce((acc, current) => 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('<code') || s.endsWith('code>')) {
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 <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('<a') && `&lt;${s[0]}`) || `${s[0]}&lt;`)) || `${s[0]}<` : s[0]}${strippedS}${isHidden ? (isHTML && '&gt;') || '>' : ''}`;
});
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]);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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])
Expand Down
2 changes: 1 addition & 1 deletion src/app/features/room/settingsLinkMessage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
1 change: 0 additions & 1 deletion src/app/features/room/settingsLinkMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading