Skip to content

Commit 5f62cac

Browse files
authored
Add preventing url preview cards by surrounding a link in anglebrackets (#717)
<!-- Please read https://github.com/SableClient/Sable/blob/dev/CONTRIBUTING.md before submitting your pull request --> ### Description <!-- Please include a summary of the change. Please also include relevant motivation and context. List any dependencies that are required for this change. --> This PR implements preventing previews by surrounding a link in angle brackets, it hides the angle brackets in both the formatted_body and the regular body, and instead adds the links as skeletons to the bundled previews. This PR also serves as scaffolding for implementing the actual bundled previews. (example of the 4 usages) <img width="935" height="861" alt="image" src="https://github.com/user-attachments/assets/6abe5b6a-28ad-4a16-92a1-3c1483c565af" /> <img width="999" height="837" alt="image" src="https://github.com/user-attachments/assets/b4198734-4a3c-4320-ad2c-a1782b40d375" /> This PR matches the features #552 but uses the new standard way instead of an ad hoc solution #### Type of change - [ ] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) ### Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my own code - [x] I have commented my code, particularly in hard-to-understand areas - [x] My changes generate no new warnings ### AI disclosure: - [ ] Partially AI assisted (clarify which code was AI assisted and briefly explain what it does). - [ ] Fully AI generated (explain what all the generated code does in moderate detail). <!-- Write any explanation required here, but do not generate the explanation using AI!! You must prove you understand what the code in this PR does. --> My will be sin eater will clear me of the sin of having implemented this.
2 parents 5251753 + 3d55e96 commit 5f62cac

10 files changed

Lines changed: 224 additions & 31 deletions

File tree

.changeset/add_hide_preview.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
default: minor
3+
---
4+
5+
Add preventing url preview cards by surrounding a link in anglebrackets like <https://app.sable.moe>

src/app/components/RenderMessageContent.test.tsx

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -49,30 +49,47 @@ describe('RenderMessageContent', () => {
4949
expect(screen.queryByTestId('client-preview')).not.toBeInTheDocument();
5050
});
5151

52+
it('still renders url previews for settings links with unknown focus ids', () => {
53+
renderMessage('https://app.example/settings/account?focus=display-name2');
54+
55+
expect(screen.getByTestId('url-preview-holder')).toBeInTheDocument();
56+
expect(screen.getByTestId('url-preview-card')).toHaveTextContent(
57+
'https://app.example/settings/account?focus=display-name2'
58+
);
59+
});
60+
5261
it('still renders url previews for non-settings links', () => {
5362
renderMessage('https://example.com');
5463

5564
expect(screen.getByTestId('url-preview-holder')).toBeInTheDocument();
5665
expect(screen.getByTestId('url-preview-card')).toHaveTextContent('https://example.com');
5766
});
5867

59-
it('still renders url previews for malformed settings-looking links', () => {
60-
renderMessage(
61-
'https://app.example/settings/account?focus=status&moe.sable.client.action=settings">Settings'
62-
);
68+
it('render url previews for text starting with paranthesis', () => {
69+
renderMessage('foo (https://example.com bar');
6370

6471
expect(screen.getByTestId('url-preview-holder')).toBeInTheDocument();
65-
expect(screen.getByTestId('url-preview-card')).toHaveTextContent(
66-
'https://app.example/settings/account?focus=status&moe.sable.client.action=settings">Settings'
67-
);
72+
expect(screen.getByTestId('url-preview-card')).toHaveTextContent('https://example.com');
6873
});
6974

70-
it('still renders url previews for settings links with unknown focus ids', () => {
71-
renderMessage('https://app.example/settings/account?focus=display-name2');
75+
it('include ending paranthesis into the url preview per url spec', () => {
76+
renderMessage('foo https://example.com) bar');
7277

7378
expect(screen.getByTestId('url-preview-holder')).toBeInTheDocument();
74-
expect(screen.getByTestId('url-preview-card')).toHaveTextContent(
75-
'https://app.example/settings/account?focus=display-name2'
76-
);
79+
expect(screen.getByTestId('url-preview-card')).toHaveTextContent('https://example.com)');
80+
});
81+
82+
it('exclude closing paranthesis from the url preview when it marks a []() hyperlink', () => {
83+
renderMessage('[foo](https://example.com) bar');
84+
85+
expect(screen.getByTestId('url-preview-holder')).toBeInTheDocument();
86+
expect(screen.getByTestId('url-preview-card')).toHaveTextContent('https://example.com');
87+
});
88+
89+
it('include inner closing paranthesis from the url preview even within []() hyperlink', () => {
90+
renderMessage('[foo](https://example.com)) bar');
91+
92+
expect(screen.getByTestId('url-preview-holder')).toBeInTheDocument();
93+
expect(screen.getByTestId('url-preview-card')).toHaveTextContent('https://example.com)');
7794
});
7895
});

src/app/components/editor/output.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ const textToCustomHtml = (node: Text, opts: OutputOptions): string => {
3939
if (node.spoiler) string = `<span data-mx-spoiler>${string}</span>`;
4040
}
4141

42-
if (opts.allowInlineMarkdown && string === sanitizeText(node.text)) {
42+
if (opts.allowInlineMarkdown && string === sanitizeText(node.text) && !node.code) {
4343
string = parseInlineMD(string);
4444
}
4545

@@ -198,6 +198,10 @@ const elementToPlainText = (node: CustomElement, children: string): string => {
198198
};
199199

200200
const SPOILERINPUTREGEX = /\|\|.+?\|\|/g;
201+
const LINK_URL = `(https?:\\/\\/.[A-Za-z0-9-._~:/?#[\\]()@!$&'*+,;%=]+)`;
202+
export const LINKINPUTREGEX = new RegExp(`\\(?(${LINK_URL})\\)?`, 'g');
203+
const SPOILEREDLINKINPUTREGEX = new RegExp(`<(${LINK_URL})>`, 'g');
204+
const MASKEDSPOILEREDLINKINPUTREGEX = new RegExp(`\\[.+\\]\\(${LINK_URL}\\)`, 'g');
201205

202206
/**
203207
* convert slate internal representation to a plain text string that can be sent to the server
@@ -217,7 +221,10 @@ export const toPlainText = (
217221
return node.map((n) => toPlainText(n, isMarkdown, stripNickname, nickNameReplacement)).join('');
218222
if (Text.isText(node)) {
219223
let { text } = node;
224+
220225
text = text.replaceAll(SPOILERINPUTREGEX, '[Spoiler]');
226+
text = text.replaceAll(SPOILEREDLINKINPUTREGEX, '$1');
227+
221228
if (stripNickname && nickNameReplacement) {
222229
nickNameReplacement?.keys().forEach((key) => {
223230
const replacement = nickNameReplacement.get(key) ?? '';
@@ -308,3 +315,58 @@ export const getMentions = (mx: MatrixClient, roomId: string, editor: Editor): M
308315

309316
return mentionData;
310317
};
318+
319+
export const getLinks = (serialized: Descendant | Descendant[]): string[] | undefined => {
320+
let finalList: string[] = [];
321+
let isInsideCodeBlock = false;
322+
const parseLinks = (node: Descendant): void => {
323+
if (Text.isText(node)) {
324+
let { text } = node;
325+
if (text.startsWith('```') && !text.includes(' ')) {
326+
isInsideCodeBlock = !isInsideCodeBlock;
327+
return;
328+
}
329+
if (isInsideCodeBlock) return;
330+
// get a list of all the urls and of the ones that are spoilered,
331+
// truncate the spoilered ones of their <> and then remove the items that are present in both lists
332+
const urlsMatch = text.match(LINKINPUTREGEX);
333+
let urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
334+
urls = urls?.map(
335+
(url) =>
336+
(url.startsWith('(') && url.endsWith(')') && url.substring(1, url.length - 1)) ||
337+
(url.startsWith('(') && url.substring(1)) ||
338+
(url.endsWith('/)') && url.substring(0, url.length - 1)) ||
339+
url
340+
);
341+
const spoileredUrlsMatch = text.match(SPOILEREDLINKINPUTREGEX);
342+
let spoileredUrls = spoileredUrlsMatch ? [...new Set(spoileredUrlsMatch)] : undefined;
343+
spoileredUrls = spoileredUrls?.map((spoileredUrl) => spoileredUrl.slice(1, -1));
344+
345+
const maskedSpoileredUrlsMatch = text.match(MASKEDSPOILEREDLINKINPUTREGEX);
346+
let maskedSpoileredUrls = maskedSpoileredUrlsMatch
347+
? [...new Set(maskedSpoileredUrlsMatch)]
348+
: undefined;
349+
maskedSpoileredUrls = maskedSpoileredUrls?.map((maskedSpoileredUrl) =>
350+
maskedSpoileredUrl?.substring(
351+
maskedSpoileredUrl.indexOf('](') + 2,
352+
maskedSpoileredUrl.lastIndexOf(')')
353+
)
354+
);
355+
if (maskedSpoileredUrls)
356+
spoileredUrls = spoileredUrls
357+
? [...spoileredUrls, ...maskedSpoileredUrls]
358+
: maskedSpoileredUrls;
359+
360+
spoileredUrls = spoileredUrls?.filter(
361+
(item, index) => spoileredUrls?.indexOf(item) === index
362+
);
363+
urls = urls?.filter((url) => !spoileredUrls?.includes(url));
364+
finalList = finalList.concat(urls ?? []);
365+
return;
366+
}
367+
node?.children?.forEach(parseLinks);
368+
};
369+
if (Array.isArray(serialized)) serialized.map((n) => parseLinks(n));
370+
else parseLinks(serialized);
371+
return finalList.filter((item, index) => finalList.indexOf(item) === index);
372+
};

src/app/components/message/MsgTypeRenderers.tsx

Lines changed: 53 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { CSSProperties, ReactNode } from 'react';
22
import { useMemo } from 'react';
33
import { Box, Chip, Icon, Icons, Text, toRem } from 'folds';
44
import type { IContent, IPreviewUrlResponse } from '$types/matrix-sdk';
5-
import { JUMBO_EMOJI_REG, URL_REG } from '$utils/regex';
5+
import { JUMBO_EMOJI_REG } from '$utils/regex';
66
import { trimReplyFromBody } from '$utils/room';
77
import type {
88
IAudioContent,
@@ -36,8 +36,9 @@ import {
3636
} from './content';
3737
import { MessageTextBody } from './layout';
3838
import { unwrapForwardedContent } from './modals/MessageForward';
39+
import { LINKINPUTREGEX } from '$components/editor';
3940

40-
interface BundleContent extends IPreviewUrlResponse {
41+
export interface BundleContent extends IPreviewUrlResponse {
4142
matched_url: string;
4243
}
4344

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

148149
let bundleContent: BundleContent[] | undefined;
149-
const urlsMatch = trimmedBody.match(URL_REG);
150+
const urlsMatch = trimmedBody.match(LINKINPUTREGEX);
150151
let urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
152+
urls = urls?.map(
153+
(url) =>
154+
(url.startsWith('(') && url.endsWith(')') && url.substring(1, url.length - 1)) ||
155+
(url.startsWith('(') && url.substring(1)) ||
156+
(url.endsWith('/)') && url.substring(0, url.length - 1)) ||
157+
url
158+
);
151159
bundleContent = content['com.beeper.linkpreviews'] as BundleContent[];
152-
bundleContent = bundleContent?.filter((bundle) => !!urls?.includes(bundle.matched_url));
153-
if (renderUrlsPreview && bundleContent) urls = bundleContent.map((bundle) => bundle.matched_url);
160+
//small "fix" for if someone sends malformed objects (ie not arrays of objects)
161+
try {
162+
bundleContent = bundleContent?.filter((bundle) => !!urls?.includes(bundle.matched_url));
163+
if (renderUrlsPreview && bundleContent)
164+
urls = bundleContent.map((bundle) => bundle.matched_url);
165+
} catch {
166+
urls = [];
167+
}
154168

155169
if ((content['com.beeper.per_message_profile'] as PerMessageProfileBeeperFormat)?.has_fallback) {
156170
// unwrap per-message profile fallback if present
@@ -234,10 +248,24 @@ export function MEmote({
234248
const isJumbo = JUMBO_EMOJI_REG.test(trimmedBody);
235249

236250
let bundleContent: BundleContent[] | undefined;
237-
const urlsMatch = trimmedBody.match(URL_REG);
238-
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
251+
const urlsMatch = trimmedBody.match(LINKINPUTREGEX);
252+
let urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
253+
urls = urls?.map(
254+
(url) =>
255+
(url.startsWith('(') && url.endsWith(')') && url.substring(1, url.length - 1)) ||
256+
(url.startsWith('(') && url.substring(1)) ||
257+
(url.endsWith('/)') && url.substring(0, url.length - 1)) ||
258+
url
259+
);
239260
bundleContent = content['com.beeper.linkpreviews'] as BundleContent[];
240-
bundleContent = bundleContent?.filter((bundle) => !!urls?.includes(bundle.matched_url));
261+
//small "fix" for if someone sends malformed objects (ie not arrays of objects)
262+
try {
263+
bundleContent = bundleContent?.filter((bundle) => !!urls?.includes(bundle.matched_url));
264+
if (renderUrlsPreview && bundleContent)
265+
urls = bundleContent.map((bundle) => bundle.matched_url);
266+
} catch {
267+
urls = [];
268+
}
241269

242270
return (
243271
<>
@@ -286,10 +314,24 @@ export function MNotice({
286314
const isJumbo = JUMBO_EMOJI_REG.test(trimmedBody);
287315

288316
let bundleContent: BundleContent[] | undefined;
289-
const urlsMatch = trimmedBody.match(URL_REG);
290-
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
317+
const urlsMatch = trimmedBody.match(LINKINPUTREGEX);
318+
let urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
319+
urls = urls?.map(
320+
(url) =>
321+
(url.startsWith('(') && url.endsWith(')') && url.substring(1, url.length - 1)) ||
322+
(url.startsWith('(') && url.substring(1)) ||
323+
(url.endsWith('/)') && url.substring(0, url.length - 1)) ||
324+
url
325+
);
291326
bundleContent = content['com.beeper.linkpreviews'] as BundleContent[];
292-
bundleContent = bundleContent?.filter((bundle) => !!urls?.includes(bundle.matched_url));
327+
//small "fix" for if someone sends malformed objects (ie not arrays of objects)
328+
try {
329+
bundleContent = bundleContent?.filter((bundle) => !!urls?.includes(bundle.matched_url));
330+
if (renderUrlsPreview && bundleContent)
331+
urls = bundleContent.map((bundle) => bundle.matched_url);
332+
} catch {
333+
urls = [];
334+
}
293335

294336
return (
295337
<>

src/app/features/room/RoomInput.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ import {
5959
getMentions,
6060
ANYWHERE_AUTOCOMPLETE_PREFIXES,
6161
BEGINNING_AUTOCOMPLETE_PREFIXES,
62+
getLinks,
6263
} from '$components/editor';
6364
import { EmojiBoard, EmojiBoardTab } from '$components/emoji-board';
6465
import { UseStateProvider } from '$components/UseStateProvider';
@@ -729,6 +730,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
729730
});
730731

731732
let plainText = toPlainText(serializedChildren, isMarkdown, true, nicknameReplacement).trim();
733+
732734
/**
733735
* the html we will send
734736
*/
@@ -802,6 +804,10 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
802804

803805
content['m.mentions'] = getMentionContent(Array.from(mentionData.users), mentionData.room);
804806

807+
const links = getLinks(serializedChildren);
808+
content['com.beeper.linkpreviews'] = [];
809+
links?.forEach((link) => content['com.beeper.linkpreviews'].push({ matched_url: link }));
810+
805811
if (replyDraft || !customHtmlEqualsPlainText(formattedBody, body)) {
806812
content.format = 'org.matrix.custom.html';
807813
content.formatted_body = formattedBody;

src/app/features/room/message/MessageEditor.tsx

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ import {
3636
useEditor,
3737
getMentions,
3838
ANYWHERE_AUTOCOMPLETE_PREFIXES,
39+
getLinks,
40+
LINKINPUTREGEX,
3941
} from '$components/editor';
4042
import { useSetting } from '$state/hooks/settings';
4143
import { CaptionPosition, settingsAtom } from '$state/settings';
@@ -56,6 +58,7 @@ import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
5658
import type { Opts as LinkifyOpts } from 'linkifyjs';
5759
import type { GetContentCallback } from '$types/matrix/room';
5860
import { sanitizeText } from '$utils/sanitize';
61+
import type { BundleContent } from '$components/message';
5962

6063
type MessageEditorProps = {
6164
roomId: string;
@@ -116,9 +119,48 @@ export const MessageEditor = as<'div', MessageEditorProps>(
116119
);
117120
}
118121

122+
const bundleContent = content['com.beeper.linkpreviews'] as BundleContent[];
123+
const markHiddenLinks = (original: string, isHTML?: boolean) => {
124+
if (!bundleContent) return original;
125+
/* Split according to the following fule:
126+
- if its not HTML just break it by spaces, newLines, and parans
127+
- if it is HTML
128+
- break it before before any potential opening tag
129+
- break it whenever a <a> tag starts
130+
- break it after a closing </a> tag
131+
- then for every non <a> portion find regular links as though it is plaintext
132+
* this is not recursive but needs flattening
133+
*/
134+
let splitBody = original.split(
135+
isHTML ? /(?=^.+<)|(?=<a.+)|(?<=\/a>)|(?=<code.+)|(?<=\/code>)/gi : /(?=[ \n()])/gi
136+
);
137+
if (isHTML)
138+
splitBody = splitBody
139+
.map((item) => (item.startsWith('<a') ? [item] : item.split(/(?=[ \n()])/g)))
140+
.reduce((acc, current) => acc.concat(current), []);
141+
let newBody = '';
142+
splitBody.map((s) => {
143+
// the length is from the fact that a link is necessarily longer than 6
144+
if (s.length < 6 || s.startsWith('<code') || s.endsWith('code>')) {
145+
newBody += s;
146+
return;
147+
}
148+
// since the way that the match works the key is at the start of the string,
149+
// it needs to be separated such that it can be reintroduced before the < in case of regular text
150+
// or after it in case that it is matching a <a> tag
151+
const strippedS = s.substring(1);
152+
const isHidden =
153+
(bundleContent?.length === 0 ||
154+
bundleContent.filter((b) => s.includes(b.matched_url)).length === 0) &&
155+
strippedS.match(LINKINPUTREGEX) !== null;
156+
newBody += `${isHidden ? (isHTML && ((s.startsWith('<a') && `&lt;${s[0]}`) || `${s[0]}&lt;`)) || `${s[0]}<` : s[0]}${strippedS}${isHidden ? (isHTML && '&gt;') || '>' : ''}`;
157+
});
158+
return newBody;
159+
};
160+
119161
return [
120-
typeof body === 'string' ? body : undefined,
121-
typeof customHtml === 'string' ? customHtml : undefined,
162+
typeof body === 'string' ? markHiddenLinks(body) : undefined,
163+
typeof customHtml === 'string' ? markHiddenLinks(customHtml, true) : undefined,
122164
mMentions,
123165
];
124166
}, [room, mEvent]);
@@ -212,6 +254,8 @@ export const MessageEditor = as<'div', MessageEditorProps>(
212254
newContent['m.mentions'] = mMentions;
213255
contentBody['m.mentions'] = mMentions;
214256

257+
const links = getLinks(editor.children);
258+
215259
if (!customHtmlEqualsPlainText(customHtml, plainText)) {
216260
newContent.format = 'org.matrix.custom.html';
217261
newContent.formatted_body = customHtml;
@@ -246,6 +290,9 @@ export const MessageEditor = as<'div', MessageEditorProps>(
246290
oldContent['page.codeberg.everypizza.msc4193.spoiler'];
247291
}
248292
}
293+
content['com.beeper.linkpreviews'] = [];
294+
links?.forEach((link) => content['com.beeper.linkpreviews'].push({ matched_url: link }));
295+
content['m.new_content']['com.beeper.linkpreviews'] = content['com.beeper.linkpreviews'];
249296

250297
return mx.sendMessage(roomId, content as RoomMessageEventContent);
251298
}, [mx, editor, roomId, mEvent, isMarkdown, getPrevBodyAndFormattedBody, room])

src/app/features/room/settingsLinkMessage.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ describe('settingsLinkMessage', () => {
160160
true
161161
);
162162

163-
expect(toPlainText(rewritten, true).trim()).toBe(`<${settingsUrl}>`);
163+
expect(toPlainText(rewritten, true).trim()).toBe(settingsUrl);
164164
});
165165

166166
it('does not rewrite settings links inside literal html text', () => {

src/app/features/room/settingsLinkMessage.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,6 @@ const getRewritableSettingsLinkMatches = (
9494
if (matches.length === 0) return [];
9595

9696
const codeSpanRanges = isMarkdown ? getMarkdownCodeSpanRanges(text) : [];
97-
9897
return matches.flatMap((match) => {
9998
const href = match.value;
10099
const settingsLink = parseSettingsLink(baseUrl, href);

0 commit comments

Comments
 (0)