Skip to content
Merged
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/fix-markdown-header-stuff.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Made markdown headers also function properly with single new lines instead of only two new lines.
5 changes: 5 additions & 0 deletions .changeset/fix-mentions-not-linked.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Fix mentions not being linkfied.
5 changes: 5 additions & 0 deletions .changeset/fix-room-ping-label.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Fixed room pings looking like normal message links instead of pings.
127 changes: 125 additions & 2 deletions src/app/components/editor/output.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import { describe, expect, it } from 'vitest';
import { toMatrixCustomHTML, trimCustomHtml } from '$components/editor/output';
import type { Room } from '$types/matrix-sdk';
import { toMatrixCustomHTML, toPlainText, trimCustomHtml } from '$components/editor/output';
import { BlockType } from '$components/editor/types';

const roomWithMember = (userId: string, rawDisplayName: string): Room =>
({
getMember: (id: string) =>
id === userId ? ({ userId: id, rawDisplayName } as never) : undefined,
}) as unknown as Room;

describe('toMatrixCustomHTML emoticons', () => {
it('always serializes custom emoji images with height=32', () => {
const html = trimCustomHtml(
Expand Down Expand Up @@ -30,7 +37,31 @@ describe('toMatrixCustomHTML emoticons', () => {
});

describe('toMatrixCustomHTML matrix.to', () => {
it('serializes room mentions as raw matrix.to URL text, not an anchor', () => {
it('serializes @room pings as a markdown link so the label is @room, not a bare permalink', () => {
const html = trimCustomHtml(
toMatrixCustomHTML(
[
{
type: BlockType.Paragraph,
children: [
{
type: BlockType.Mention,
id: '!room:example.org',
name: '@room',
children: [{ text: '' }],
} as never,
],
} as never,
],
{}
)
);

expect(html).toMatch(/<a\b[^>]*href="https:\/\/matrix\.to\/#\/!room:example\.org"/i);
expect(html).toContain('@room');
});

it('serializes non–@room room mentions as bare matrix.to URL text', () => {
const html = trimCustomHtml(
toMatrixCustomHTML(
[
Expand All @@ -54,6 +85,82 @@ describe('toMatrixCustomHTML matrix.to', () => {
expect(html).not.toMatch(/<a\b[^>]*matrix\.to/i);
});

it('serializes user mentions using room membership display name, not private Slate node.name', () => {
const room = roomWithMember('@alice:example.org', 'Alice');
const html = trimCustomHtml(
toMatrixCustomHTML(
[
{
type: BlockType.Paragraph,
children: [
{
type: BlockType.Mention,
id: '@alice:example.org',
name: 'Secret local only nickname',
highlight: true,
children: [{ text: '' }],
} as never,
],
} as never,
],
{ room }
)
);

expect(html).toMatch(/<a\b[^>]*href="https:\/\/matrix\.to\/#\/@alice:example\.org"/i);
expect(html).toContain('Alice');
expect(html).not.toContain('Secret local only nickname');
});

it('serializes user mentions without room using MXID localpart as link label', () => {
const html = trimCustomHtml(
toMatrixCustomHTML(
[
{
type: BlockType.Paragraph,
children: [
{
type: BlockType.Mention,
id: '@alice:example.org',
name: 'Secret local only nickname',
highlight: true,
children: [{ text: '' }],
} as never,
],
} as never,
],
{}
)
);

expect(html).toMatch(/<a\b[^>]*href="https:\/\/matrix\.to\/#\/@alice:example\.org"/i);
expect(html).toMatch(/>alice<\/a>/i);
expect(html).not.toContain('Secret local only nickname');
});

it('uses @room in plain body for room pings, not the room id', () => {
const plain = toPlainText(
[
{
type: BlockType.Paragraph,
children: [
{
type: BlockType.Mention,
id: '!room:example.org',
name: '@room',
highlight: true,
children: [{ text: '' }],
} as never,
],
} as never,
],
false,
undefined
).trim();

expect(plain).toBe('@room');
});

it('serializes matrix.to links as raw URL text, not an anchor', () => {
const html = trimCustomHtml(
toMatrixCustomHTML(
Expand All @@ -77,3 +184,19 @@ describe('toMatrixCustomHTML matrix.to', () => {
expect(html).not.toMatch(/<a\b[^>]*matrix\.to/i);
});
});

describe('toMatrixCustomHTML single-newline markdown blocks', () => {
it('parses -# on a second Slate paragraph joined with a single newline', () => {
const html = trimCustomHtml(
toMatrixCustomHTML(
[
{ type: BlockType.Paragraph, children: [{ text: 'test' }] } as never,
{ type: BlockType.Paragraph, children: [{ text: '-# caption' }] } as never,
],
{}
)
);
expect(html).toContain('<sub');
expect(html).toContain('data-md="-#"');
});
});
42 changes: 36 additions & 6 deletions src/app/components/editor/output.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type { Descendant, Editor } from 'slate';
import { Text } from 'slate';
import type { MatrixClient } from '$types/matrix-sdk';
import type { MatrixClient, Room } from '$types/matrix-sdk';
import { sanitizeText } from '$utils/sanitize';
import { markdownToHtml, injectDataMd } from '$plugins/markdown';
import { sanitizeForRegex } from '$utils/regex';
import { isUserId } from '$utils/matrix';
import { getMxIdLocalPart, isUserId } from '$utils/matrix';
import { getMemberDisplayName } from '$utils/room';
import type { CustomElement } from './slate';
import { BlockType } from './types';
import { getMarkdownCodeSpanRanges, isInsideMarkdownCodeSpan } from './utils';
Expand All @@ -21,11 +22,33 @@ export type OutputOptions = {
nickNameReplacement?: Map<RegExp, string>;
/** When true, markdown HTML omits the leading `<p>` 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}<br/>`;
Expand All @@ -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:
Expand Down Expand Up @@ -105,15 +135,15 @@ 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 => {
switch (node.type) {
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:
Expand Down Expand Up @@ -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:
Expand Down
10 changes: 6 additions & 4 deletions src/app/components/message/content/ImageContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -144,15 +144,15 @@ export const ImageContent = as<'div', ImageContentProps>(
useEffect(() => {
if (!viewer) {
setViewerFullSrc(null);
return;
return undefined;
}
if (
typeof matrixThumbnailMaxEdge !== 'number' ||
matrixThumbnailMaxEdge <= 0 ||
encInfo ||
url.startsWith('http')
) {
return;
return undefined;
}
let cancelled = false;
void (async () => {
Expand Down Expand Up @@ -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 =
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/app/features/room/RoomInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -778,6 +778,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
stripNickname: true,
nickNameReplacement: nicknameReplacement,
forEmote: commandName === Command.Me,
room,
})
);

Expand Down Expand Up @@ -853,6 +854,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
stripNickname: true,
nickNameReplacement: nicknameReplacement,
forEmote: commandName === Command.Me,
room,
})
);
}
Expand Down
5 changes: 4 additions & 1 deletion src/app/features/room/message/MessageEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,10 @@ export const MessageEditor = as<'div', MessageEditorProps>(
const msgtype = mEvent.getContent().msgtype as RoomMessageTextEventContent['msgtype'];
let plainText = toPlainText(editor.children).trim();
let customHtml = trimCustomHtml(
toMatrixCustomHTML(editor.children, { forEmote: msgtype === MsgType.Emote })
toMatrixCustomHTML(editor.children, {
forEmote: msgtype === MsgType.Emote,
room,
})
);

const [prevBody, prevCustomHtml, prevMentions] = getPrevBodyAndFormattedBody();
Expand Down
Loading
Loading