Skip to content

Commit 1763b56

Browse files
authored
Fix matrixTo link rendering (#751)
<!-- 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. --> Should prevent links from being displayed as the full link. Instead renders room name plus a message preview if possible, not well tested but core functionality is better than the existing broken one #### Type of change - [x] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] This change requires a documentation update ### Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my own code - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings ### AI disclosure: - [x] 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. --> Tests were AI generated
2 parents 2e08ac3 + 7ef0f61 commit 1763b56

5 files changed

Lines changed: 163 additions & 3 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
default: patch
3+
---
4+
5+
Fixed message links being rendered as full links.

src/app/plugins/matrix-to.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
getMatrixToRoom,
44
getMatrixToRoomEvent,
55
getMatrixToUser,
6+
isRedundantMatrixToAnchorText,
67
parseMatrixToRoom,
78
parseMatrixToRoomEvent,
89
parseMatrixToUser,
@@ -229,3 +230,43 @@ describe('parseMatrixToRoomEvent', () => {
229230
});
230231
});
231232
});
233+
234+
describe('isRedundantMatrixToAnchorText', () => {
235+
it('treats empty anchor text as redundant', () => {
236+
expect(isRedundantMatrixToAnchorText('https://matrix.to/#/!r:example.org', undefined)).toBe(
237+
true
238+
);
239+
expect(isRedundantMatrixToAnchorText('https://matrix.to/#/!r:example.org', '')).toBe(true);
240+
expect(isRedundantMatrixToAnchorText('https://matrix.to/#/!r:example.org', ' ')).toBe(true);
241+
});
242+
243+
it('treats anchor text that repeats the same permalink as redundant', () => {
244+
const url =
245+
'https://matrix.to/#/!a6sXbRuOyyc7MKutmy:sable.moe/$6C-iT549tGKwcQy3Vmb-GgwVZPXiyQ4paJY8-IN2ohs?via=matrix.org&via=unredacted.org&via=4d2.org';
246+
expect(isRedundantMatrixToAnchorText(url, url)).toBe(true);
247+
});
248+
249+
it('treats http vs https with the same fragment as redundant', () => {
250+
const httpsUrl = 'https://matrix.to/#/!room:example.com/$event123';
251+
const httpUrl = 'http://matrix.to/#/!room:example.com/$event123';
252+
expect(isRedundantMatrixToAnchorText(httpsUrl, httpUrl)).toBe(true);
253+
});
254+
255+
it('does not treat different permalinks as redundant', () => {
256+
expect(
257+
isRedundantMatrixToAnchorText(
258+
'https://matrix.to/#/!a:example.com/$e1',
259+
'https://matrix.to/#/!b:example.com/$e2'
260+
)
261+
).toBe(false);
262+
});
263+
264+
it('does not treat plain-language anchor text as redundant', () => {
265+
expect(
266+
isRedundantMatrixToAnchorText(
267+
'https://matrix.to/#/!room:example.com/$event123',
268+
'read this post'
269+
)
270+
).toBe(false);
271+
});
272+
});

src/app/plugins/matrix-to.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,3 +118,46 @@ export const parseMatrixToRoomEvent = (href: string): MatrixToRoomEvent | undefi
118118
viaServers: viaServers.length === 0 ? undefined : viaServers,
119119
};
120120
};
121+
122+
const tryDecodeUriComponent = (s: string): string => {
123+
try {
124+
return decodeURIComponent(s);
125+
} catch {
126+
return s;
127+
}
128+
};
129+
130+
/**
131+
* Normalized Matrix permalink fragment (path after `#/`, lowercased), for comparing href vs anchor text.
132+
*/
133+
export const matrixPermalinkFragmentKey = (url: string): string | undefined => {
134+
const trimmed = url.trim();
135+
const decoded = tryDecodeUriComponent(trimmed);
136+
const candidate = testMatrixTo(trimmed) ? trimmed : testMatrixTo(decoded) ? decoded : undefined;
137+
if (!candidate) return undefined;
138+
try {
139+
const u = new URL(candidate);
140+
const frag = u.hash.startsWith('#') ? u.hash.slice(1) : '';
141+
const key = frag.replace(/^\/+/u, '').replace(/\/+$/u, '').toLowerCase();
142+
return key || undefined;
143+
} catch {
144+
return undefined;
145+
}
146+
};
147+
148+
/**
149+
* True when anchor text is empty or only repeats the same permalink as `href` (e.g. pasted full URL).
150+
*/
151+
export const isRedundantMatrixToAnchorText = (
152+
href: string,
153+
anchorText: string | undefined
154+
): boolean => {
155+
if (anchorText == null || anchorText.trim() === '') return true;
156+
const keyHref = matrixPermalinkFragmentKey(href);
157+
if (!keyHref) return false;
158+
const t = anchorText.trim();
159+
const keyText =
160+
matrixPermalinkFragmentKey(t) ?? matrixPermalinkFragmentKey(tryDecodeUriComponent(t));
161+
if (!keyText) return false;
162+
return keyHref === keyText;
163+
};

src/app/plugins/react-custom-html-parser.test.tsx

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,50 @@ describe('react custom html parser', () => {
255255
expect(link.querySelector('[aria-hidden="true"]')).not.toBeNull();
256256
});
257257

258+
it('uses room name when formatted body uses the full matrix.to URL as link text', () => {
259+
const url = 'https://matrix.to/#/!room:example.org';
260+
const mx = createMatrixClient({
261+
getRoom: (id: string) =>
262+
id === '!room:example.org' ? { roomId: '!room:example.org', name: 'Lobby' } : undefined,
263+
});
264+
renderParsedHtml(`<a href="${url}">${url}</a>`, { sanitize: false, mx });
265+
266+
expect(screen.getByRole('link', { name: '#Lobby' })).toHaveAttribute('href', url);
267+
});
268+
269+
it('uses message snippet for event permalinks when the event is in the store', () => {
270+
const url = 'https://matrix.to/#/!room:example.org/$eventABC';
271+
const mx = createMatrixClient({
272+
getRoom: () => ({
273+
roomId: '!room:example.org',
274+
name: 'Lobby',
275+
findEventById: (id: string) =>
276+
id === '$eventABC'
277+
? {
278+
getContent: () => ({
279+
body: `${'Hello world '.repeat(12)}tail`,
280+
}),
281+
}
282+
: null,
283+
}),
284+
});
285+
renderParsedHtml(`<a href="${url}">${url}</a>`, { sanitize: false, mx });
286+
287+
const link = screen.getByRole('link', { name: /#Lobby: Hello world/ });
288+
expect(link).toHaveAttribute('data-mention-event-id', '$eventABC');
289+
expect(link.textContent).toMatch(//);
290+
});
291+
292+
it('keeps custom link text when it is not just the permalink URL', () => {
293+
const url = 'https://matrix.to/#/!room:example.org/$event123';
294+
const mx = createMatrixClient({
295+
getRoom: () => ({ roomId: '!room:example.org', name: 'Lobby' }),
296+
});
297+
renderParsedHtml(`<a href="${url}">see this thread</a>`, { sanitize: false, mx });
298+
299+
expect(screen.getByRole('link', { name: 'see this thread' })).toBeInTheDocument();
300+
});
301+
258302
it('translates Matrix color data attributes into rendered styles', () => {
259303
renderParsedHtml('<span data-mx-color="#ff0000" data-mx-bg-color="#00ff00">colored</span>');
260304

src/app/plugins/react-custom-html-parser.tsx

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { getSettingsLinkChipLabel, parseSettingsLink } from '$features/settings/
2929
import { ClientSideHoverFreeze } from '$components/ClientSideHoverFreeze';
3030
import { CodeHighlightRenderer } from '$components/code-highlight';
3131
import {
32+
isRedundantMatrixToAnchorText,
3233
parseMatrixToRoom,
3334
parseMatrixToRoomEvent,
3435
parseMatrixToUser,
@@ -148,6 +149,18 @@ export const makeMentionCustomProps = (
148149
children: content,
149150
});
150151

152+
const matrixPermalinkDisplayLabel = (
153+
href: string,
154+
customChildren: ReactNode | undefined,
155+
fallback: ReactNode
156+
): ReactNode => {
157+
if (customChildren === undefined || customChildren === null) return fallback;
158+
if (typeof customChildren === 'string') {
159+
return isRedundantMatrixToAnchorText(href, customChildren) ? fallback : customChildren;
160+
}
161+
return customChildren;
162+
};
163+
151164
export const renderMatrixMention = (
152165
mx: MatrixClient,
153166
currentRoomId: string | undefined,
@@ -182,6 +195,7 @@ export const renderMatrixMention = (
182195
);
183196

184197
const fallbackContent = mentionRoom ? `#${mentionRoom.name}` : roomIdOrAlias;
198+
const label = matrixPermalinkDisplayLabel(href, customProps.children, fallbackContent);
185199

186200
return (
187201
<a
@@ -193,7 +207,7 @@ export const renderMatrixMention = (
193207
data-mention-id={mentionRoom?.roomId ?? roomIdOrAlias}
194208
data-mention-via={viaServers?.join(',')}
195209
>
196-
{customProps.children ? customProps.children : fallbackContent}
210+
{label}
197211
</a>
198212
);
199213
}
@@ -204,7 +218,20 @@ export const renderMatrixMention = (
204218
const mentionRoom = mx.getRoom(
205219
isRoomAlias(roomIdOrAlias) ? getCanonicalAliasRoomId(mx, roomIdOrAlias) : roomIdOrAlias
206220
);
207-
const fallbackContent = mentionRoom ? `#${mentionRoom.name}` : roomIdOrAlias;
221+
let fallbackContent = mentionRoom ? `#${mentionRoom.name}` : roomIdOrAlias;
222+
if (mentionRoom) {
223+
const linkedEvent = mentionRoom.findEventById?.(eventId);
224+
if (linkedEvent) {
225+
const raw = linkedEvent.getContent() as { body?: unknown };
226+
const body = typeof raw.body === 'string' ? raw.body.trim() : '';
227+
if (body) {
228+
const singleLine = body.replace(/\s+/g, ' ');
229+
const short = singleLine.length > 72 ? `${singleLine.slice(0, 69)}…` : singleLine;
230+
fallbackContent = `#${mentionRoom.name}: ${short}`;
231+
}
232+
}
233+
}
234+
const label = matrixPermalinkDisplayLabel(href, customProps.children, fallbackContent);
208235

209236
return (
210237
<a
@@ -223,7 +250,7 @@ export const renderMatrixMention = (
223250
<span aria-hidden="true" className={css.MentionIcon}>
224251
<Icon size="50" src={Icons.Message} />
225252
</span>
226-
{customProps.children ? customProps.children : fallbackContent}
253+
{label}
227254
</a>
228255
);
229256
}

0 commit comments

Comments
 (0)