` wrapper (for `m.emote` / `/me`). */
forEmote?: boolean;
+ room?: Room;
};
const textToCustomHtml = (node: Text): string => sanitizeText(node.text);
-const elementToCustomHtml = (node: CustomElement, children: string): string => {
+const markdownInlineLinkLabel = (label: string, fallback: string): string => {
+ const t = label.trim();
+ if (!t) return fallback;
+ if (t.includes(']')) return fallback;
+ for (let i = 0; i < t.length; i++) {
+ if (t.charCodeAt(i) <= 0x1f) return fallback;
+ }
+ return t;
+};
+
+const userMentionMarkdownLinkLabel = (userId: string, room: Room | undefined): string => {
+ const fallback = getMxIdLocalPart(userId) ?? userId;
+ if (!room) return fallback;
+ const fromMembership = getMemberDisplayName(room, userId);
+ return markdownInlineLinkLabel(fromMembership ?? '', fallback);
+};
+
+const elementToCustomHtml = (
+ node: CustomElement,
+ children: string,
+ opts: OutputOptions
+): string => {
switch (node.type) {
case BlockType.Paragraph:
return `${children}
`;
@@ -41,6 +64,13 @@ const elementToCustomHtml = (node: CustomElement, children: string): string => {
}
const matrixTo = `${MATRIX_TO_BASE}#/${fragment}`;
+ if (node.name === '@room') {
+ return `[@room](${encodeURI(matrixTo)})`;
+ }
+ if (isUserId(node.id)) {
+ const label = userMentionMarkdownLinkLabel(node.id, opts.room);
+ return `[${label}](${encodeURI(matrixTo)})`;
+ }
return sanitizeText(matrixTo);
}
case BlockType.Emoticon:
@@ -105,7 +135,7 @@ export const toMatrixCustomHTML = (
const children = node.children
.map((element, index, array) => parseNode(element, index, array))
.join('');
- return elementToCustomHtml(node, children);
+ return elementToCustomHtml(node, children, opts);
};
const elementToPlainText = (node: CustomElement, children: string): string => {
@@ -113,7 +143,7 @@ const elementToPlainText = (node: CustomElement, children: string): string => {
case BlockType.Paragraph:
return `${children}\n`;
case BlockType.Mention:
- return node.id;
+ return node.name === '@room' ? node.name : node.id;
case BlockType.Emoticon:
return node.key.startsWith('mxc://') ? `:${node.shortcode}:` : node.key;
case BlockType.Link:
@@ -184,7 +214,7 @@ export const toRawText = (node: Descendant | Descendant[]): string => {
case BlockType.Emoticon:
return node.key.startsWith('mxc://') ? `:${node.shortcode}:` : node.key;
case BlockType.Mention:
- return node.id;
+ return node.name === '@room' ? node.name : node.id;
case BlockType.Command:
return `/${node.command}`;
default:
diff --git a/src/app/components/message/content/ImageContent.tsx b/src/app/components/message/content/ImageContent.tsx
index 13868c4bf..9904fa9f3 100644
--- a/src/app/components/message/content/ImageContent.tsx
+++ b/src/app/components/message/content/ImageContent.tsx
@@ -144,7 +144,7 @@ export const ImageContent = as<'div', ImageContentProps>(
useEffect(() => {
if (!viewer) {
setViewerFullSrc(null);
- return;
+ return undefined;
}
if (
typeof matrixThumbnailMaxEdge !== 'number' ||
@@ -152,7 +152,7 @@ export const ImageContent = as<'div', ImageContentProps>(
encInfo ||
url.startsWith('http')
) {
- return;
+ return undefined;
}
let cancelled = false;
void (async () => {
@@ -182,7 +182,9 @@ export const ImageContent = as<'div', ImageContentProps>(
if (autoPlay) loadSrc();
}, [autoPlay, loadSrc]);
- const hasDimensions = typeof info?.w === 'number' && typeof info?.h === 'number';
+ const imageW = info?.w;
+ const imageH = info?.h;
+ const hasDimensions = typeof imageW === 'number' && typeof imageH === 'number';
const isContained = mediaLayout === 'contained';
const fillsSlot = Boolean(fillsPreviewSlot && isContained);
const containedReserveStrip =
@@ -200,7 +202,7 @@ export const ImageContent = as<'div', ImageContentProps>(
: isContained
? { minHeight: containedReserveStrip ? toRem(stripMin) : undefined }
: hasDimensions
- ? { aspectRatio: `${info!.w} / ${info!.h}` }
+ ? { aspectRatio: `${imageW} / ${imageH}` }
: { minHeight: '150px' };
const fillPreviewSlotStyle = fillsSlot
diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx
index 69f67248b..adaae6490 100644
--- a/src/app/features/room/RoomInput.tsx
+++ b/src/app/features/room/RoomInput.tsx
@@ -778,6 +778,7 @@ export const RoomInput = forwardRef {
+ const html = markdownToHtml('intro\n- item');
+ expect(html).toContain('
{
+ expect(markdownToHtml('intro\n> quote')).toContain('
');
+ });
+
+ it('does not promote -# inside fenced code when the fence follows a single newline', () => {
+ const html = markdownToHtml('test\n```\n-# not sub\n```');
+ expect(html).not.toContain(' {
+ const html = markdownToHtml('test\n```\ncode\n```\n-# cap');
+ expect(html).toContain('
');
+ expect(html).toContain(' {
+ const result = markdownToHtml('test\n\n-# caption');
+ expect(result).toContain(' {
+ const html = markdownToHtml('k. Hello world\nHello again');
+ expect(html).not.toContain('');
+ });
+
+ it('does not promote escaped line-start -# after a single newline', () => {
+ expect(markdownToHtml('x\n\\-# literal')).not.toContain('data-md="-#"');
+ });
+ });
+
it('does not parse escaped \\-# as small/sub', () => {
const result = markdownToHtml('\\-# literal caption');
expect(result).not.toContain('): string => {
let result = html;
- const keys = [...placeholders.keys()].sort((a, b) => b.length - a.length);
+ const keys = [...placeholders.keys()].toSorted((a, b) => b.length - a.length);
for (const key of keys) {
const url = placeholders.get(key);
if (url) result = result.split(key).join(escapeHtml(url));
@@ -131,8 +132,10 @@ export function markdownToHtml(markdown: string, options?: MarkdownToHtmlOptions
const preprocessed = preprocessEmoticon(blockquotePrefixed);
+ const boundaryExpanded = expandBlockBoundariesAfterSingleNewlines(preprocessed);
+
const { shielded: matrixToShielded, placeholders: matrixToPlaceholders } =
- shieldBareMatrixToLinks(preprocessed);
+ shieldBareMatrixToLinks(boundaryExpanded);
const mathInput = shieldDollarRunsForMarked(maskDollarSignsInsideMarkdownCode(matrixToShielded));