Skip to content
Draft
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>
11 changes: 0 additions & 11 deletions src/app/components/RenderMessageContent.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
34 changes: 34 additions & 0 deletions src/app/components/editor/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) ?? '';
Expand Down Expand Up @@ -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;
};
52 changes: 41 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,20 @@ 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
);
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 +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 (
<>
Expand Down Expand Up @@ -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 (
<>
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
33 changes: 31 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,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 ? /(?=^.+<)|(?=<a.+)|(?<=\/a>)/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 && `&lt;${s[0]}`) || `${s[0]}<` : s[0]}${strippedS}${isHidden ? (isHTML && '&gt;') || '>' : ''}`;
});
// 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]);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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])
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
2 changes: 2 additions & 0 deletions src/app/plugins/markdown/inline/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
BoldRule,
CodeRule,
EscapeRule,
HiddenLinkRule,
ItalicRule1,
ItalicRule2,
LinkRule,
Expand All @@ -13,6 +14,7 @@ import { runInlineRule, runInlineRules } from './runner';
import type { InlineMDParser } from './type';

const LeveledRules = [
HiddenLinkRule,
BoldRule,
ItalicRule1,
UnderlineRule,
Expand Down
17 changes: 15 additions & 2 deletions src/app/plugins/markdown/inline/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,16 +108,29 @@ export const SpoilerRule: InlineMDRule = {
};

const LINK_ALT = `\\[${MIN_ANY}\\]`;
const LINK_URL = `\\((https?:\\/\\/.+?)\\)`;
const LINK_URL = `\\(((&lt;)?https?:\\/\\/.[A-Za-z0-9-._~:/?#[\\]()@!$&'*+,;%=]+)(&gt;)?\\)`;
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('&lt;') && g2.endsWith('&gt;'))
return `<a data-md href="${g2.substring(4, g2.lastIndexOf('&gt;'))}">${parse(g1)}</a>`;

return `<a data-md href="${g2}">${parse(g1)}</a>`;
},
};
const HIDDEN_LINK_URL = `&lt;(https?:\\/\\/.[A-Za-z0-9-._~:/?#[\\]()@!$&'*+,;%=]+)&gt;`;
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}`;
Expand Down
Loading