diff --git a/.changeset/add-misskey-flavored-markdown.md b/.changeset/add-misskey-flavored-markdown.md new file mode 100644 index 000000000..3ac6c8fb6 --- /dev/null +++ b/.changeset/add-misskey-flavored-markdown.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Add support for misskey-flavored markdown color definitions. E.g. `$[fg.color=f00 bg.color=00ff00 red on green]`. diff --git a/.changeset/fix-arbitrary-list-starts.md b/.changeset/fix-arbitrary-list-starts.md new file mode 100644 index 000000000..950c19070 --- /dev/null +++ b/.changeset/fix-arbitrary-list-starts.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fixed starting lists at arbitrary numbers and list markers extending off screen with long numbers. diff --git a/.changeset/fix-blockquote-newlines.md b/.changeset/fix-blockquote-newlines.md new file mode 100644 index 000000000..3e963a5cc --- /dev/null +++ b/.changeset/fix-blockquote-newlines.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fix single new lines after block quotes being block-quoted. diff --git a/.changeset/fix-emojis-not-rendering-in-reply.md b/.changeset/fix-emojis-not-rendering-in-reply.md new file mode 100644 index 000000000..f584bb22a --- /dev/null +++ b/.changeset/fix-emojis-not-rendering-in-reply.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fix emojis not rendering in reply chips. diff --git a/.changeset/fix-hardened-html-input.md b/.changeset/fix-hardened-html-input.md new file mode 100644 index 000000000..5ac50bec2 --- /dev/null +++ b/.changeset/fix-hardened-html-input.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Hardened html parsing in standard input box, should no longer randomly delete text in arrow brackets (unless valid, properly closed, legal html). diff --git a/.changeset/fix-matrix-to-links-arrow.md b/.changeset/fix-matrix-to-links-arrow.md new file mode 100644 index 000000000..d1cef5d18 --- /dev/null +++ b/.changeset/fix-matrix-to-links-arrow.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fix matrix.to links getting arrow brackets inserted when editing messages. diff --git a/.changeset/fix-matrix-to-mentions.md b/.changeset/fix-matrix-to-mentions.md new file mode 100644 index 000000000..2c3201d17 --- /dev/null +++ b/.changeset/fix-matrix-to-mentions.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fix mentions breaking after editing messages with mentions. diff --git a/src/app/components/editor/Elements.tsx b/src/app/components/editor/Elements.tsx index 410d9aa98..6d8cbb297 100644 --- a/src/app/components/editor/Elements.tsx +++ b/src/app/components/editor/Elements.tsx @@ -9,7 +9,7 @@ import { mxcUrlToHttp } from '$utils/matrix'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; import { nicknamesAtom } from '$state/nicknames'; import { BlockType } from './types'; -import { getBeginCommand } from './utils'; +import { formatMentionElementDisplayName, getBeginCommand } from './utils'; import type { CommandElement, EmoticonElement, LinkElement, MentionElement } from './slate'; // Put this at the start and end of an inline component to work around this Chromium bug: @@ -32,7 +32,7 @@ function RenderMentionElement({ const nicknames = useAtomValue(nicknamesAtom); const nickname = nicknames[element.id]; - const displayName = nickname ? `@${nickname}` : element.name; + const displayName = nickname ? `@${nickname}` : formatMentionElementDisplayName(element); return ( { + const handleAutocomplete: MentionAutoCompleteHandler = (uId) => { const mentionEl = createMentionElement( uId, - name.startsWith('@') ? name : `@${name}`, + resolveUserMentionName(uId, { room, nicknames }), mx.getUserId() === uId || roomAliasOrId === uId ); replaceWithElement(editor, query.range, mentionEl); diff --git a/src/app/components/editor/input.ts b/src/app/components/editor/input.ts index 471a0e5f5..f2553b1af 100644 --- a/src/app/components/editor/input.ts +++ b/src/app/components/editor/input.ts @@ -1,5 +1,6 @@ import type { Descendant } from 'slate'; +import type { MentionResolveOptions } from './utils'; import { MX_EMOTICON_MD_END, MX_EMOTICON_MD_SEP, @@ -9,6 +10,7 @@ import { import { BlockType } from './types'; import type { ParagraphElement } from './slate'; import { createEmoticonElement } from './utils'; +import { expandMatrixMentionMarkdownInText } from './matrixMentionMarkdown'; /** Matches placeholders emitted by htmlToMarkdown for <img data-mx-emoticon>. */ const MX_EMOTICON_MD_TOKEN = new RegExp( @@ -35,14 +37,19 @@ function mergeAdjacentTextNodes( return out.length > 0 ? out : [{ text: '' }]; } -function lineToParagraphChildren(line: string): ParagraphElement['children'] { +function lineToParagraphChildren( + line: string, + mentionOptions?: MentionResolveOptions +): ParagraphElement['children'] { MX_EMOTICON_MD_TOKEN.lastIndex = 0; const parts: ParagraphElement['children'] = []; let last = 0; let match: RegExpExecArray | null; while ((match = MX_EMOTICON_MD_TOKEN.exec(line)) !== null) { if (match.index > last) { - parts.push({ text: line.slice(last, match.index) }); + parts.push( + ...expandMatrixMentionMarkdownInText(line.slice(last, match.index), mentionOptions) + ); } const [, src, shortcode] = match; if (src && shortcode && validateMxcUrl(src)) { @@ -53,15 +60,18 @@ function lineToParagraphChildren(line: string): ParagraphElement['children'] { last = MX_EMOTICON_MD_TOKEN.lastIndex; } if (last < line.length) { - parts.push({ text: line.slice(last) }); + parts.push(...expandMatrixMentionMarkdownInText(line.slice(last), mentionOptions)); } return mergeAdjacentTextNodes(parts); } -export const plainToEditorInput = (text: string): Descendant[] => { +export const plainToEditorInput = ( + text: string, + mentionOptions?: MentionResolveOptions +): Descendant[] => { const lines = text.split('\n'); return lines.map((lineText) => ({ type: BlockType.Paragraph, - children: lineToParagraphChildren(lineText), + children: lineToParagraphChildren(lineText, mentionOptions), })); }; diff --git a/src/app/components/editor/matrixMentionMarkdown.test.ts b/src/app/components/editor/matrixMentionMarkdown.test.ts new file mode 100644 index 000000000..45472282f --- /dev/null +++ b/src/app/components/editor/matrixMentionMarkdown.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; +import type { Room } from '$types/matrix-sdk'; +import { plainToEditorInput } from './input'; +import { BlockType } from './types'; +import { htmlToMarkdown } from '$plugins/markdown'; +import { + expandMatrixMentionMarkdownInText, + mentionFromMatrixToMarkdownLink, +} from './matrixMentionMarkdown'; + +const roomWithMember = (userId: string, rawDisplayName: string): Room => + ({ + getMember: (id: string) => + id === userId ? ({ userId: id, rawDisplayName } as never) : undefined, + }) as unknown as Room; + +describe('matrixMentionMarkdown', () => { + it('recognizes matrix.to user permalinks as mentions with @ display name', () => { + const room = roomWithMember('@alice:example.org', 'Alice'); + const el = mentionFromMatrixToMarkdownLink('Alice', 'https://matrix.to/#/@alice:example.org', { + room, + }); + expect(el).toMatchObject({ + type: BlockType.Mention, + id: '@alice:example.org', + name: '@Alice', + }); + }); + + it('expands markdown matrix.to user links into Mention elements', () => { + const room = roomWithMember('@alice:example.org', 'Alice'); + const parts = expandMatrixMentionMarkdownInText( + 'hi [Alice](https://matrix.to/#/@alice:example.org)!', + { room } + ); + expect(parts).toEqual([ + { text: 'hi ' }, + expect.objectContaining({ + type: BlockType.Mention, + id: '@alice:example.org', + name: '@Alice', + }), + { text: '!' }, + ]); + }); + + it('expands preview-suppressed matrix.to mention links from corrupted edits', () => { + const parts = expandMatrixMentionMarkdownInText( + 'hi [Alice]()!' + ); + expect(parts[1]).toMatchObject({ + type: BlockType.Mention, + id: '@alice:example.org', + }); + }); + + it('plainToEditorInput round-trips formatted_body user mentions', () => { + const room = roomWithMember('@alice:example.org', 'Alice'); + const md = htmlToMarkdown( + '

Hello Alice!

' + ); + const doc = plainToEditorInput(md, { room }); + const paragraph = doc[0] as { children: unknown[] }; + expect(paragraph.children).toEqual([ + { text: 'Hello ' }, + expect.objectContaining({ + type: BlockType.Mention, + id: '@alice:example.org', + name: '@Alice', + }), + { text: '!' }, + ]); + }); + + it('does not treat regular https links as mentions', () => { + const parts = expandMatrixMentionMarkdownInText('[site](https://example.org/)'); + expect(parts).toEqual([{ text: '[site](https://example.org/)' }]); + }); +}); diff --git a/src/app/components/editor/matrixMentionMarkdown.ts b/src/app/components/editor/matrixMentionMarkdown.ts new file mode 100644 index 000000000..619da1880 --- /dev/null +++ b/src/app/components/editor/matrixMentionMarkdown.ts @@ -0,0 +1,100 @@ +import type { InlineElement } from './slate'; +import { + parseMatrixToRoom, + parseMatrixToRoomEvent, + parseMatrixToUser, + isMatrixToMentionHref, +} from '$plugins/matrix-to'; +import type { MentionResolveOptions } from './utils'; +import { + createMentionElement, + getMarkdownCodeSpanRanges, + isInsideMarkdownCodeSpan, + resolveRoomMentionHighlight, + resolveRoomMentionName, + resolveUserMentionHighlight, + resolveUserMentionName, +} from './utils'; + +/** [label](href) or [label]() */ +const MD_INLINE_LINK = /\[((?:[^\]\]\\]|\\.)*)\]\((?:<([^>]+)>|([^)]+))\)/g; + +export const mentionFromMatrixToMarkdownLink = ( + label: string, + href: string, + options?: MentionResolveOptions +): InlineElement | null => { + const trimmedHref = href.trim(); + if (!isMatrixToMentionHref(trimmedHref)) return null; + + const userId = parseMatrixToUser(trimmedHref); + if (userId) { + return createMentionElement( + userId, + resolveUserMentionName(userId, options), + resolveUserMentionHighlight(userId, options) + ); + } + + const roomEvent = parseMatrixToRoomEvent(trimmedHref); + if (roomEvent) { + return createMentionElement( + roomEvent.roomIdOrAlias, + resolveRoomMentionName(roomEvent.roomIdOrAlias, label, options), + resolveRoomMentionHighlight(roomEvent.roomIdOrAlias, options), + roomEvent.eventId, + roomEvent.viaServers + ); + } + + const room = parseMatrixToRoom(trimmedHref); + if (room) { + return createMentionElement( + room.roomIdOrAlias, + resolveRoomMentionName(room.roomIdOrAlias, label, options), + resolveRoomMentionHighlight(room.roomIdOrAlias, options), + undefined, + room.viaServers + ); + } + + return null; +}; + +export const expandMatrixMentionMarkdownInText = ( + text: string, + options?: MentionResolveOptions +): InlineElement[] => { + const codeSpanRanges = getMarkdownCodeSpanRanges(text); + const parts: InlineElement[] = []; + let last = 0; + + MD_INLINE_LINK.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = MD_INLINE_LINK.exec(text)) !== null) { + const start = match.index; + const end = start + match[0].length; + if (isInsideMarkdownCodeSpan(start, end, codeSpanRanges)) continue; + + const label = match[1] ?? ''; + const href = (match[2] ?? match[3] ?? '').trim(); + + if (start > last) { + parts.push({ text: text.slice(last, start) }); + } + + const mention = mentionFromMatrixToMarkdownLink(label, href, options); + if (mention) { + parts.push(mention); + } else { + parts.push({ text: match[0] }); + } + last = end; + } + + if (last < text.length) { + parts.push({ text: text.slice(last) }); + } + + return parts.length > 0 ? parts : [{ text: '' }]; +}; diff --git a/src/app/components/editor/mentionResolve.test.ts b/src/app/components/editor/mentionResolve.test.ts new file mode 100644 index 000000000..925e835f7 --- /dev/null +++ b/src/app/components/editor/mentionResolve.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import type { Room } from '$types/matrix-sdk'; +import { + formatMentionElementDisplayName, + formatUserMentionDisplayName, + resolveUserMentionName, +} from './utils'; +import { BlockType } from './types'; + +const roomWithMember = (userId: string, rawDisplayName: string): Room => + ({ + getMember: (id: string) => + id === userId ? ({ userId: id, rawDisplayName } as never) : undefined, + }) as unknown as Room; + +describe('mention resolve', () => { + it('resolveUserMentionName uses room membership and adds @', () => { + const room = roomWithMember('@alice:example.org', 'Alice'); + expect(resolveUserMentionName('@alice:example.org', { room })).toBe('@Alice'); + }); + + it('formatMentionElementDisplayName adds @ to legacy mention nodes without prefix', () => { + expect( + formatMentionElementDisplayName({ + type: BlockType.Mention, + id: '@alice:example.org', + name: 'Alice', + highlight: true, + children: [{ text: '' }], + }) + ).toBe('@Alice'); + }); + + it('formatUserMentionDisplayName is idempotent for names that already include @', () => { + expect(formatUserMentionDisplayName('@Alice')).toBe('@Alice'); + }); +}); diff --git a/src/app/components/editor/output.test.ts b/src/app/components/editor/output.test.ts index cd8199c52..6798cc6e5 100644 --- a/src/app/components/editor/output.test.ts +++ b/src/app/components/editor/output.test.ts @@ -185,6 +185,52 @@ describe('toMatrixCustomHTML matrix.to', () => { }); }); +describe('toMatrixCustomHTML angle bracket escapes', () => { + it('renders backslash-escaped angle brackets as literal characters in formatted output', () => { + const html = trimCustomHtml( + toMatrixCustomHTML( + [ + { + type: BlockType.Paragraph, + children: [{ text: String.raw`\` }], + } as never, + ], + {} + ) + ); + + expect(html).toContain('<test>'); + expect(html).not.toMatch(/]*>/); + }); + + it('does not double-encode when the editor already contains entity text', () => { + const html = trimCustomHtml( + toMatrixCustomHTML( + [ + { + type: BlockType.Paragraph, + children: [{ text: '<test>' }], + } as never, + ], + {} + ) + ); + + expect(html).toContain('<test>'); + expect(html).not.toContain('&lt;'); + }); + + it('keeps backslash escapes in plain body for round-trip editing', () => { + const children = [ + { + type: BlockType.Paragraph, + children: [{ text: String.raw`\` }], + } as never, + ]; + expect(toPlainText(children).trim()).toBe(String.raw`\`); + }); +}); + describe('toMatrixCustomHTML single-newline markdown blocks', () => { it('parses -# on a second Slate paragraph joined with a single newline', () => { const html = trimCustomHtml( diff --git a/src/app/components/editor/output.ts b/src/app/components/editor/output.ts index 665da06a9..0a06a2330 100644 --- a/src/app/components/editor/output.ts +++ b/src/app/components/editor/output.ts @@ -160,7 +160,6 @@ const LINK_URL = `(https?:\\/\\/.[A-Za-z0-9-._~:/?#[\\()@!$&'*+,;%=]+)`; export const LINKINPUTREGEX = new RegExp(`\\(?(${LINK_URL})\\)?`, 'g'); const SPOILEREDLINKINPUTREGEX = new RegExp(`<(${LINK_URL})>`, 'g'); const SPOILEREDLINKDIRECTREGEX = new RegExp(`\\|\\|(${LINK_URL})\\|\\|`, 'g'); - /** * convert slate internal representation to a plain text string that can be sent to the server * @param node the slate node diff --git a/src/app/components/editor/utils.ts b/src/app/components/editor/utils.ts index 03adabd90..bc228dc2d 100644 --- a/src/app/components/editor/utils.ts +++ b/src/app/components/editor/utils.ts @@ -1,5 +1,9 @@ import type { BasePoint, BaseRange } from 'slate'; import { Editor, Element, Point, Range, Text, Transforms } from 'slate'; +import type { Room } from '$types/matrix-sdk'; +import type { Nicknames } from '$state/nicknames'; +import { getMxIdLocalPart, isUserId } from '$utils/matrix'; +import { getMemberDisplayName } from '$utils/room'; import { BlockType } from './types'; import type { CommandElement, @@ -9,6 +13,70 @@ import type { MentionElement, } from './slate'; +export type MentionResolveOptions = { + room?: Room; + nicknames?: Nicknames; + mxUserId?: string; +}; + +/** Same @-prefix rule as {@link UserMentionAutocomplete} and timeline mention insertion. */ +export const formatUserMentionDisplayName = (name: string): string => + name.startsWith('@') ? name : `@${name}`; + +export const resolveUserMentionName = (userId: string, options?: MentionResolveOptions): string => { + const base = + (options?.room && getMemberDisplayName(options.room, userId, options.nicknames)) ?? + getMxIdLocalPart(userId) ?? + userId; + return formatUserMentionDisplayName(base); +}; + +/** Same #-prefix rule as {@link RoomMentionAutocomplete}. */ +export const formatRoomMentionDisplayName = (name: string): string => { + if (name === '@room') return '@room'; + return name.startsWith('#') ? name : `#${name}`; +}; + +export const resolveRoomMentionName = ( + roomIdOrAlias: string, + label: string, + options?: MentionResolveOptions +): string => { + const trimmed = label.trim(); + if (trimmed === '@room') return '@room'; + if (trimmed) return formatRoomMentionDisplayName(trimmed); + if ( + options?.room && + (options.room.roomId === roomIdOrAlias || options.room.getCanonicalAlias() === roomIdOrAlias) + ) { + return formatRoomMentionDisplayName(options.room.name || roomIdOrAlias); + } + return formatRoomMentionDisplayName(roomIdOrAlias); +}; + +export const resolveUserMentionHighlight = ( + userId: string, + options?: MentionResolveOptions +): boolean => options?.mxUserId === userId; + +export const resolveRoomMentionHighlight = ( + roomIdOrAlias: string, + options?: MentionResolveOptions +): boolean => { + if (!options?.room) return true; + const { roomId } = options.room; + const alias = options.room.getCanonicalAlias(); + return roomId === roomIdOrAlias || alias === roomIdOrAlias; +}; + +export const formatMentionElementDisplayName = (element: MentionElement): string => { + if (isUserId(element.id)) { + return formatUserMentionDisplayName(element.name); + } + if (element.name === '@room') return '@room'; + return formatRoomMentionDisplayName(element.name); +}; + export const resetEditor = (editor: Editor) => { Transforms.delete(editor, { at: { diff --git a/src/app/components/message/Reply.test.tsx b/src/app/components/message/Reply.test.tsx index f837f84aa..def400b46 100644 --- a/src/app/components/message/Reply.test.tsx +++ b/src/app/components/message/Reply.test.tsx @@ -1,7 +1,12 @@ import { render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { EventType, MsgType } from '$types/matrix-sdk'; -import { Reply, replyPreviewBodyForTimelineEvent } from './Reply'; +import { + Reply, + replyFormattedPreviewTextOnly, + replyPreviewBodyForTimelineEvent, + shouldParseReplyFormattedPreview, +} from './Reply'; /* oxlint-disable typescript/no-explicit-any */ @@ -47,12 +52,14 @@ vi.mock('$hooks/useIgnoredUsers', () => ({ useIgnoredUsers: () => [], })); +const mockMxcUrlToHttp = vi.fn<() => string | null>(() => null); + vi.mock('$hooks/useMatrixClient', () => ({ useMatrixClient: () => ({ getRoom: () => undefined, getUserId: () => '@me:example.com', - mxcUrlToHttp: () => null, + mxcUrlToHttp: () => mockMxcUrlToHttp(), }) as any, })); @@ -92,6 +99,36 @@ const createReplyEvent = (formattedBody: string) => getClearContent: () => ({}), }) as any; +describe('shouldParseReplyFormattedPreview', () => { + it('returns false for empty html', () => { + expect(shouldParseReplyFormattedPreview('')).toBe(false); + }); + + it('returns true when html has visible text', () => { + expect(shouldParseReplyFormattedPreview('

hello

')).toBe(true); + }); + + it('returns true for custom emoji img tags without surrounding text', () => { + expect( + shouldParseReplyFormattedPreview( + 'blobcat' + ) + ).toBe(true); + }); + + it('returns false for non-emoticon image-only html', () => { + expect( + shouldParseReplyFormattedPreview('photo') + ).toBe(false); + }); +}); + +describe('replyFormattedPreviewTextOnly', () => { + it('strips tags and collapses whitespace', () => { + expect(replyFormattedPreviewTextOnly('

hi there

')).toBe('hi there'); + }); +}); + describe('replyPreviewBodyForTimelineEvent', () => { it('uses filename for image messages with an empty body', () => { const { container } = render( @@ -202,6 +239,53 @@ describe('Reply', () => { expect(screen.queryByAltText('blocked image')).not.toBeInTheDocument(); }); + it('renders custom emoji in the reply chip when formatted_body is image-only', () => { + mockMxcUrlToHttp.mockReturnValue('https://cdn.example/emote.png'); + + mockUseRoomEvent.mockReturnValue( + createReplyEvent( + 'blobcat' + ) + ); + + const { container } = render( + undefined } as any} + replyEventId="$reply:example.com" + /> + ); + + expect(screen.queryByText(/Failed to load message/i)).not.toBeInTheDocument(); + expect(container.querySelector('img[src="https://cdn.example/emote.png"]')).not.toBeNull(); + }); + + it('falls back to plain body text when formatted_body is a non-emoticon image only', () => { + mockUseRoomEvent.mockReturnValue({ + getContent: () => ({ + body: '😀', + format: 'org.matrix.custom.html', + formatted_body: '😀', + msgtype: 'm.text', + }), + getSender: () => '@alice:example.com', + getType: () => 'm.room.message', + isRedacted: () => false, + isEncrypted: () => false, + isDecryptionFailure: () => false, + getClearContent: () => ({}), + } as any); + + render( + undefined } as any} + replyEventId="$reply:example.com" + /> + ); + + expect(screen.getByText('😀')).toBeInTheDocument(); + expect(screen.queryByText(/Failed to load message/i)).not.toBeInTheDocument(); + }); + it('does not render unresolved mxc images as raw browser img tags in reply previews', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(vi.fn<() => void>()); diff --git a/src/app/components/message/Reply.tsx b/src/app/components/message/Reply.tsx index 6672e5641..725b5089b 100644 --- a/src/app/components/message/Reply.tsx +++ b/src/app/components/message/Reply.tsx @@ -57,6 +57,20 @@ const nonEmptyTrimmed = (v: unknown): string | undefined => { return t.length > 0 ? t : undefined; }; +const FORMATTED_EMOTICON_IMG_RE = /]*\bdata-mx-emoticon\b/i; + +export const replyFormattedPreviewTextOnly = (sanitizedHtml: string): string => + sanitizedHtml + .replaceAll(//gi, ' ') + .replaceAll(/<[^>]+>/g, '') + .replaceAll(/\s+/g, ' ') + .trim(); + +export const shouldParseReplyFormattedPreview = (sanitizedHtml: string): boolean => { + const textOnly = replyFormattedPreviewTextOnly(sanitizedHtml); + return textOnly !== '' || FORMATTED_EMOTICON_IMG_RE.test(sanitizedHtml); +}; + export const replyPreviewBodyForTimelineEvent = ( eventType: string | undefined, content: Record, @@ -256,12 +270,7 @@ export const Reply = as<'div', ReplyProps>( if (isFormattedReply && formattedBody !== '') { const sanitizedHtml = sanitizeReplyFormattedPreview(formattedBody); - const textOnly = sanitizedHtml - .replaceAll(//gi, ' ') - .replaceAll(/<[^>]+>/g, '') - .replaceAll(/\s+/g, ' ') - .trim(); - if (textOnly !== '') { + if (shouldParseReplyFormattedPreview(sanitizedHtml)) { const parserOpts = getReactCustomHtmlParser(mx, room.roomId, { settingsLinkBaseUrl, linkifyOpts: replyLinkifyOpts, @@ -272,6 +281,9 @@ export const Reply = as<'div', ReplyProps>( incomingInlineImagesMaxHeight, }); bodyJSX = parse(sanitizedHtml, parserOpts) as JSX.Element; + } else if (hasPlainTextReply) { + const strippedBody = trimReplyFromBody(body).replaceAll(/(?:\r\n|\r|\n)/g, ' '); + bodyJSX = scaleSystemEmoji(strippedBody); } } else if (hasPlainTextReply) { const strippedBody = trimReplyFromBody(body).replaceAll(/(?:\r\n|\r|\n)/g, ' '); @@ -394,7 +406,7 @@ export const Reply = as<'div', ReplyProps>( before={} onClick={(evt) => { evt.stopPropagation(); - queryClient.invalidateQueries({ + void queryClient.invalidateQueries({ queryKey: [room.roomId, replyEventId], }); }} diff --git a/src/app/features/room/message/MessageEditor.tsx b/src/app/features/room/message/MessageEditor.tsx index 341a227a0..10dbca6d7 100644 --- a/src/app/features/room/message/MessageEditor.tsx +++ b/src/app/features/room/message/MessageEditor.tsx @@ -1,5 +1,6 @@ import type { KeyboardEventHandler, MouseEventHandler } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useAtomValue } from 'jotai'; import type { RectCords } from 'folds'; import { Box, Chip, Icon, IconButton, Icons, PopOut, Spinner, Text, as, config } from 'folds'; import { Editor, Transforms } from 'slate'; @@ -46,6 +47,7 @@ import { UseStateProvider } from '$components/UseStateProvider'; import { EmojiBoard } from '$components/emoji-board'; import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; import { useMatrixClient } from '$hooks/useMatrixClient'; +import { nicknamesAtom } from '$state/nicknames'; import { getEditedEvent, getMentionContent, trimReplyFromFormattedBody } from '$utils/room'; import { mobileOrTablet } from '$utils/user-agent'; import { useComposingCheck } from '$hooks/useComposingCheck'; @@ -53,6 +55,7 @@ import { floatingEditor } from '$styles/overrides/Composer.css'; import { RenderMessageContent } from '$components/RenderMessageContent'; import { useSettingsLinkBaseUrl } from '$features/settings/useSettingsLinkBaseUrl'; import { getReactCustomHtmlParser, LINKIFY_OPTS } from '$plugins/react-custom-html-parser'; +import { testMatrixTo } from '$plugins/matrix-to'; import { useSpoilerClickHandler } from '$hooks/useSpoilerClickHandler'; import type { HTMLReactParserOptions } from 'html-react-parser'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; @@ -75,6 +78,7 @@ type MessageEditorProps = { export const MessageEditor = as<'div', MessageEditorProps>( ({ room, roomId, mEvent, imagePackRooms, onCancel, ...props }, ref) => { const mx = useMatrixClient(); + const nicknames = useAtomValue(nicknamesAtom); const editor = useEditor(); const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline'); const isComposing = useComposingCheck(); @@ -151,7 +155,14 @@ export const MessageEditor = as<'div', MessageEditorProps>( // it needs to be separated such that it can be reintroduced before the < in case of regular text // or after it in case that it is matching a tag const strippedS = s.substring(1); + const matrixToAnchorHref = + isHTML && s.toLowerCase().startsWith(' s.includes(b.matched_url)).length === 0) && strippedS.match(LINKINPUTREGEX) !== null; @@ -364,12 +375,18 @@ export const MessageEditor = as<'div', MessageEditorProps>( useEffect(() => { const [body, customHtml] = getPrevBodyAndFormattedBody(); + const mentionOptions = { + room, + nicknames, + mxUserId: mx.getUserId() ?? undefined, + }; const initialValue = plainToEditorInput( customHtml ? stripMarkdownEscapesForHiddenPreviews(htmlToMarkdown(customHtml)) : typeof body === 'string' - ? body - : '' + ? stripMarkdownEscapesForHiddenPreviews(body) + : '', + mentionOptions ); Transforms.select(editor, { @@ -379,7 +396,7 @@ export const MessageEditor = as<'div', MessageEditorProps>( editor.insertFragment(initialValue); if (!mobileOrTablet()) ReactEditor.focus(editor); - }, [editor, getPrevBodyAndFormattedBody]); + }, [editor, getPrevBodyAndFormattedBody, room, nicknames, mx]); useEffect(() => { if (saveState.status === AsyncStatus.Success) { diff --git a/src/app/features/room/message/hiddenLinkPreviews.test.ts b/src/app/features/room/message/hiddenLinkPreviews.test.ts index 86b959eb7..d34cddd86 100644 --- a/src/app/features/room/message/hiddenLinkPreviews.test.ts +++ b/src/app/features/room/message/hiddenLinkPreviews.test.ts @@ -64,6 +64,21 @@ describe('readdAngleBracketsForHiddenPreviews', () => { ); }); + it('does not wrap matrix.to mention destinations in markdown links', () => { + expect( + readdAngleBracketsForHiddenPreviews( + 'Hello [Alice](https://matrix.to/#/@alice:example.org)!', + [] + ) + ).toBe('Hello [Alice](https://matrix.to/#/@alice:example.org)!'); + }); + + it('does not wrap bare matrix.to event permalinks', () => { + const url = + 'https://matrix.to/#/!ILh1573wEoGMeBRdt_lvfuF-6UZntdLebiZq7uV4cS0/$WqIkTq0ZUF1_8hCamWNf4wDj7oKrK4VeNDWLAtgVXjg?via=sable.moe&via=matrix.org'; + expect(readdAngleBracketsForHiddenPreviews(`see ${url} thanks`, [])).toBe(`see ${url} thanks`); + }); + it('does not corrupt markdown suppressed links [url]()', () => { expect( readdAngleBracketsForHiddenPreviews( diff --git a/src/app/features/room/message/hiddenLinkPreviews.ts b/src/app/features/room/message/hiddenLinkPreviews.ts index 7c8ed7e54..6a2fd4a48 100644 --- a/src/app/features/room/message/hiddenLinkPreviews.ts +++ b/src/app/features/room/message/hiddenLinkPreviews.ts @@ -1,4 +1,5 @@ import type { BundleContent } from '$components/message'; +import { testMatrixTo } from '$plugins/matrix-to'; const LINK_URL = `(https?:\\/\\/.[A-Za-z0-9-._~:/?#[\\()@!$&'*+,;%=]+)`; const LINKINPUTREGEX = new RegExp(`\\(?(${LINK_URL})\\)?`, 'g'); @@ -49,6 +50,11 @@ export function readdAngleBracketsForHiddenPreviews( const offset = args[args.length - 2] as number; if (!url || previewed.has(url)) return full; + // matrix.to permalinks are never preview-suppressed (mentions, event links, room links). + if (testMatrixTo(url)) return full; + + const urlIndex = body.indexOf(url, offset); + // URL is the label of a markdown link [url](...) — do not insert "<" into the label. const after = body.slice(offset + full.length, offset + full.length + 2); if (after === '](') { @@ -61,7 +67,6 @@ export function readdAngleBracketsForHiddenPreviews( } // If the URL is already wrapped as , leave it alone. - const urlIndex = body.indexOf(url, offset); if (urlIndex !== -1 && body.slice(urlIndex - 1, urlIndex + url.length + 1) === `<${url}>`) { return full; } diff --git a/src/app/features/room/pmpProxyOutgoingPipeline.test.ts b/src/app/features/room/pmpProxyOutgoingPipeline.test.ts index f5815891c..7759989f8 100644 --- a/src/app/features/room/pmpProxyOutgoingPipeline.test.ts +++ b/src/app/features/room/pmpProxyOutgoingPipeline.test.ts @@ -47,9 +47,16 @@ describe('PMP proxy outgoing pipeline parity', () => { it('escapes raw html so it is not treated as markup', () => { const { plain, html } = runOutgoingPipeline('nope'); expect(plain).toBe('nope'); - // markdownToHtml sanitizes/strips raw tags; ensure it does not render as actual . expect(html).toContain('nope'); - expect(html).not.toContain('nope'); + expect(html).toContain('<b>'); + expect(html).not.toMatch(/nope<\/b>/); + }); + + it('renders backslash-escaped angle brackets as literal characters, not entity text', () => { + const { plain, html } = runOutgoingPipeline(String.raw`\`); + expect(plain).toBe(String.raw`\`); + expect(html).toContain('<test>'); + expect(html).not.toMatch(/]*>/); }); it('applies outgoing transforms (settings link rewrite) like normal messages', () => { diff --git a/src/app/plugins/markdown/allowedHtmlTags.test.ts b/src/app/plugins/markdown/allowedHtmlTags.test.ts new file mode 100644 index 000000000..a6f7acc4a --- /dev/null +++ b/src/app/plugins/markdown/allowedHtmlTags.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { escapeNonAllowlistedHtmlTags } from './allowedHtmlTags'; + +describe('escapeNonAllowlistedHtmlTags', () => { + it('entity-escapes unknown tags', () => { + expect(escapeNonAllowlistedHtmlTags('')).toBe('<test>'); + expect(escapeNonAllowlistedHtmlTags(' ')).toBe('<test> </test>'); + }); + + it('leaves well-formed allowlisted tags unchanged', () => { + expect(escapeNonAllowlistedHtmlTags('bold')).toBe('bold'); + expect(escapeNonAllowlistedHtmlTags('
')).toBe('
'); + expect(escapeNonAllowlistedHtmlTags('e')).toBe( + 'e' + ); + }); + + it('entity-escapes allowlisted tags missing a required closing tag', () => { + expect(escapeNonAllowlistedHtmlTags('bold')).toBe('<strong>bold'); + expect(escapeNonAllowlistedHtmlTags('

hello')).toBe('<p>hello'); + }); + + it('entity-escapes mismatched closing tags', () => { + expect(escapeNonAllowlistedHtmlTags('foo')).toBe('<strong>foo</em>'); + }); + + it('entity-escapes orphan closing tags', () => { + expect(escapeNonAllowlistedHtmlTags('')).toBe('</strong>'); + }); + + it('does not treat angle-bracket URLs as tags', () => { + const url = ''; + expect(escapeNonAllowlistedHtmlTags(url)).toBe(url); + }); + + it('does not escape tags inside markdown code spans', () => { + expect(escapeNonAllowlistedHtmlTags('``')).toBe('``'); + expect(escapeNonAllowlistedHtmlTags('```\n\n```')).toBe('```\n\n```'); + }); + + it('does not entity-escape markdown-backslash-escaped tags', () => { + expect(escapeNonAllowlistedHtmlTags(String.raw`\`)).toBe(String.raw`\`); + expect(escapeNonAllowlistedHtmlTags(String.raw`\`)).toBe(String.raw`\`); + expect(escapeNonAllowlistedHtmlTags(String.raw`before \ after`)).toBe( + String.raw`before \ after` + ); + }); +}); diff --git a/src/app/plugins/markdown/allowedHtmlTags.ts b/src/app/plugins/markdown/allowedHtmlTags.ts new file mode 100644 index 000000000..be6d446b7 --- /dev/null +++ b/src/app/plugins/markdown/allowedHtmlTags.ts @@ -0,0 +1,202 @@ +import { getMarkdownCodeSpanRanges } from '$components/editor/utils'; + +export const MARKDOWN_ALLOWED_HTML_TAGS = new Set([ + 'a', + 'blockquote', + 'br', + 'code', + 'del', + 'details', + 'div', + 'em', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'hr', + 'img', + 'li', + 'mx-reply', + 'ol', + 'p', + 'pre', + 's', + 'span', + 'strong', + 'sub', + 'summary', + 'table', + 'tbody', + 'td', + 'th', + 'thead', + 'tr', + 'u', + 'ul', +]); + +/** Void elements that do not require a separate closing tag. */ +export const VOID_HTML_TAGS = new Set(['br', 'hr', 'img']); + +export const isAllowedHtmlTag = (tagName: string): boolean => + MARKDOWN_ALLOWED_HTML_TAGS.has(tagName.toLowerCase()); + +/** Matches HTML open/close/self-closing tags (not `` preview suppressors). */ +const RAW_HTML_TAG = /<\/?([a-zA-Z][\da-zA-Z-]*)(?:\s(?:[^>"']|"[^"]*"|'[^']*')*)?\s*\/?>/g; + +const CODE_PLACEHOLDER_START = '\uE000'; +const CODE_PLACEHOLDER_END = '\uE001'; + +type HtmlTagToken = { + index: number; + length: number; + raw: string; + tagName: string; + kind: 'open' | 'close' | 'void'; +}; + +const entityEscapeTag = (raw: string): string => + raw.replaceAll('<', '<').replaceAll('>', '>'); + +/** True when an odd number of `\` immediately precedes `index` (CommonMark escape). */ +const isMarkdownEscapedAt = (input: string, index: number): boolean => { + let slashes = 0; + for (let i = index - 1; i >= 0 && input[i] === '\\'; i -= 1) { + slashes += 1; + } + return slashes % 2 === 1; +}; + +const isVoidHtmlTag = (tagName: string, raw: string): boolean => + VOID_HTML_TAGS.has(tagName.toLowerCase()) || /\/>\s*$/.test(raw); + +const scanHtmlTags = (input: string): HtmlTagToken[] => { + const tokens: HtmlTagToken[] = []; + const re = new RegExp(RAW_HTML_TAG.source, 'g'); + let match: RegExpExecArray | null; + + while ((match = re.exec(input)) !== null) { + const raw = match[0]; + const tagName = match[1]!.toLowerCase(); + const isClose = raw.startsWith(' => { + const escapeAt = new Set(); + const openStack: { tagName: string; index: number }[] = []; + + for (const token of tokens) { + if (isMarkdownEscapedAt(input, token.index)) { + continue; + } + + if (!isAllowedHtmlTag(token.tagName)) { + escapeAt.add(token.index); + continue; + } + + if (token.kind === 'void') { + continue; + } + + if (token.kind === 'open') { + openStack.push({ tagName: token.tagName, index: token.index }); + continue; + } + + const top = openStack[openStack.length - 1]; + if (top && top.tagName === token.tagName) { + openStack.pop(); + } else { + escapeAt.add(token.index); + } + } + + for (const { index } of openStack) { + escapeAt.add(index); + } + + return escapeAt; +}; + +const applyTagEscapes = (input: string, tokens: HtmlTagToken[], escapeAt: Set): string => { + if (escapeAt.size === 0) return input; + + let out = ''; + let cursor = 0; + + for (const token of tokens) { + out += input.slice(cursor, token.index); + out += escapeAt.has(token.index) ? entityEscapeTag(token.raw) : token.raw; + cursor = token.index + token.length; + } + + return out + input.slice(cursor); +}; + +const maskMarkdownVerbatimRegions = (text: string): { masked: string; chunks: string[] } => { + const chunks: string[] = []; + const placeholder = (index: number) => + `${CODE_PLACEHOLDER_START}CODE${index}${CODE_PLACEHOLDER_END}`; + + let masked = text.replace(/```[^\n`]*\n[\s\S]*?```/g, (chunk) => { + chunks.push(chunk); + return placeholder(chunks.length - 1); + }); + masked = masked.replace(/```[\s\S]*?```/g, (chunk) => { + chunks.push(chunk); + return placeholder(chunks.length - 1); + }); + + const inlineRanges = getMarkdownCodeSpanRanges(masked); + for (let i = inlineRanges.length - 1; i >= 0; i -= 1) { + const [start, end] = inlineRanges[i]!; + const chunk = masked.slice(start, end); + chunks.push(chunk); + masked = `${masked.slice(0, start)}${placeholder(chunks.length - 1)}${masked.slice(end)}`; + } + + return { masked, chunks }; +}; + +const unmaskMarkdownVerbatimRegions = (text: string, chunks: string[]): string => + text.replace( + new RegExp(`${CODE_PLACEHOLDER_START}CODE(\\d+)${CODE_PLACEHOLDER_END}`, 'g'), + (_, index) => chunks[parseInt(index, 10)] ?? '' + ); + +const escapeHtmlTagsInMarkdown = (input: string): string => { + const tokens = scanHtmlTags(input); + if (tokens.length === 0) return input; + const escapeAt = collectTagsToEscape(input, tokens); + return applyTagEscapes(input, tokens, escapeAt); +}; + +/** + * Entity-escapes HTML tags that are not on the allowlist or are structurally invalid + * (e.g. missing a required closing tag, or a mismatched close) so marked/DOMPurify never + * interpret them as HTML. + */ +export const escapeNonAllowlistedHtmlTags = (input: string): string => { + const { masked, chunks } = maskMarkdownVerbatimRegions(input); + const escaped = escapeHtmlTagsInMarkdown(masked); + return unmaskMarkdownVerbatimRegions(escaped, chunks); +}; diff --git a/src/app/plugins/markdown/bidirectional.test.ts b/src/app/plugins/markdown/bidirectional.test.ts index 56c036121..a987ab15e 100644 --- a/src/app/plugins/markdown/bidirectional.test.ts +++ b/src/app/plugins/markdown/bidirectional.test.ts @@ -94,7 +94,6 @@ describe('bidirectional round-trip', () => { const html = markdownToHtml(markdown); const injected = injectDataMd(html); const result = htmlToMarkdown(injected); - // Note: marked normalizes ordered lists to start at 1, but we increment for output expect(result).toContain('1. First'); expect(result).toContain('2. Second'); }); diff --git a/src/app/plugins/markdown/expandBlockNewlines.ts b/src/app/plugins/markdown/expandBlockNewlines.ts index 6c860744e..efcbf7257 100644 --- a/src/app/plugins/markdown/expandBlockNewlines.ts +++ b/src/app/plugins/markdown/expandBlockNewlines.ts @@ -104,16 +104,46 @@ function looksLikeBlockStart(effective: string): boolean { return false; } -function nextLineIsBlockStarter(md: string, newlineIdx: number): boolean { +function lineAtNewline(md: string, newlineIdx: number): string { + let start = newlineIdx - 1; + while (start >= 0 && md[start] !== '\n') start--; + return md.slice(start + 1, newlineIdx); +} + +function lineAfterNewline(md: string, newlineIdx: number): string { const start = newlineIdx + 1; - if (start >= md.length) return false; + if (start >= md.length) return ''; const nextNl = md.indexOf('\n', start); - const line = nextNl === -1 ? md.slice(start) : md.slice(start, nextNl); - const effective = effectiveContentAfterEscapes(line); + return nextNl === -1 ? md.slice(start) : md.slice(start, nextNl); +} + +function prevLineIsBlockquote(md: string, newlineIdx: number): boolean { + const effective = effectiveContentAfterEscapes(lineAtNewline(md, newlineIdx)); + if (effective === null) return false; + return /^>\s/.test(effective); +} + +function nextLineContinuesBlockquote(md: string, newlineIdx: number): boolean { + const effective = effectiveContentAfterEscapes(lineAfterNewline(md, newlineIdx)); + if (effective === null) return false; + return effective.startsWith('>'); +} + +function nextLineIsBlockStarter(md: string, newlineIdx: number): boolean { + const effective = effectiveContentAfterEscapes(lineAfterNewline(md, newlineIdx)); if (effective === null) return false; return looksLikeBlockStart(effective); } +function shouldExpandSingleNewline(md: string, newlineIdx: number): boolean { + if (nextLineIsBlockStarter(md, newlineIdx)) return true; + // CommonMark lazy continuation keeps non-`>` lines inside blockquotes, close on single `\n`. + if (prevLineIsBlockquote(md, newlineIdx) && !nextLineContinuesBlockquote(md, newlineIdx)) { + return true; + } + return false; +} + /** * After a single newline (not part of `\n\n`), inserts one more `\n` when the following line * opens a block the marked stack understands. Fenced code is copied verbatim without changes. @@ -139,7 +169,7 @@ export function expandBlockBoundariesAfterSingleNewlines(markdown: string): stri md[i] === '\n' && (i === 0 || md[i - 1] !== '\n') && (i + 1 >= n || md[i + 1] !== '\n') && - nextLineIsBlockStarter(md, i) + shouldExpandSingleNewline(md, i) ) { out += '\n\n'; i += 1; diff --git a/src/app/plugins/markdown/extensions/matrix-math.ts b/src/app/plugins/markdown/extensions/matrix-math.ts index da94783fa..21265f70f 100644 --- a/src/app/plugins/markdown/extensions/matrix-math.ts +++ b/src/app/plugins/markdown/extensions/matrix-math.ts @@ -256,6 +256,7 @@ export const matrixMathExtension = { name: 'math', level: 'inline', start(src: string) { + if (/^\$\[(?:fg|bg)\.color=/.test(src)) return -1; return src.indexOf('$'); }, tokenizer(src: string) { diff --git a/src/app/plugins/markdown/extensions/matrix-mfm-color.test.ts b/src/app/plugins/markdown/extensions/matrix-mfm-color.test.ts new file mode 100644 index 000000000..037553aeb --- /dev/null +++ b/src/app/plugins/markdown/extensions/matrix-mfm-color.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { parseMfmColorFnArgs, tryParseMfmColor } from './matrix-mfm-color'; +import { markdownToHtml } from '../markdownToHtml'; +import { htmlToMarkdown } from '../htmlToMarkdown'; + +describe('tryParseMfmColor', () => { + it('parses a color function block', () => { + const src = '$[fg.color=ff0000 red text]'; + expect(tryParseMfmColor(src)).toMatchObject({ + text: 'red text', + dataMd: '$[fg.color=ff0000', + }); + }); +}); + +describe('parseMfmColorFnArgs', () => { + it('parses fg only', () => { + expect(parseMfmColorFnArgs('fg.color=ff0000')).toEqual({ fg: '#ff0000' }); + }); + + it('parses fg and bg', () => { + expect(parseMfmColorFnArgs('fg.color=ff0000 bg.color=00ff00')).toEqual({ + fg: '#ff0000', + bg: '#00ff00', + }); + }); + + it('expands 3-digit hex', () => { + expect(parseMfmColorFnArgs('fg.color=fff')).toEqual({ fg: '#ffffff' }); + }); +}); + +describe('matrixMfmColorExtension', () => { + it('converts fg.color to data-mx-color', () => { + const html = markdownToHtml('$[fg.color=ff0000 red text]'); + expect(html).toContain('data-mx-color="#ff0000"'); + expect(html).toContain('red text'); + }); + + it('converts bg.color with 3-digit hex', () => { + const html = markdownToHtml('$[bg.color=0f0 highlighted]'); + expect(html).toContain('data-mx-bg-color="#00ff00"'); + }); + + it('does not parse 4- or 8-digit hex (no alpha)', () => { + expect(markdownToHtml('$[fg.color=ff00 no color]')).not.toContain('data-mx-color'); + expect(markdownToHtml('$[fg.color=ff0000ff no color]')).not.toContain('data-mx-color'); + }); + + it('round-trips via htmlToMarkdown', () => { + const md = '$[fg.color=f00 hello]'; + const html = markdownToHtml(md); + expect(htmlToMarkdown(html)).toBe(md); + }); +}); diff --git a/src/app/plugins/markdown/extensions/matrix-mfm-color.ts b/src/app/plugins/markdown/extensions/matrix-mfm-color.ts new file mode 100644 index 000000000..a7d40e8cf --- /dev/null +++ b/src/app/plugins/markdown/extensions/matrix-mfm-color.ts @@ -0,0 +1,115 @@ +import type { TokenizerExtension, RendererExtension, Tokens } from 'marked'; +import { MFM_HEX_COLOR_VALUE_PATTERN, normalizeMfmHexToMatrixColor } from '$utils/matrixHtml'; + +export type MfmColorArgs = { + fg?: string; + bg?: string; +}; + +const MFM_COLOR_FN_START = /^\$\[(?=(?:fg\.color|bg\.color)=)/; +const MFM_COLOR_ARGS_PREFIX = new RegExp( + `^((?:(?:fg|bg)\\.color=${MFM_HEX_COLOR_VALUE_PATTERN}\\s*)+)` +); + +/** Parses `fg.color=…` / `bg.color=…` tokens from the inside of `$[…]`. */ +export function parseMfmColorFnArgs(argsPart: string): MfmColorArgs | undefined { + const trimmed = argsPart.trim(); + if (!trimmed) return undefined; + + const result: MfmColorArgs = {}; + const tokens = trimmed.split(/\s+/); + + for (const token of tokens) { + const m = new RegExp(`^(fg|bg)\\.color=(${MFM_HEX_COLOR_VALUE_PATTERN})$`).exec(token); + if (!m) return undefined; + const normalized = normalizeMfmHexToMatrixColor(m[2]!); + if (!normalized) return undefined; + if (m[1] === 'fg') result.fg = normalized; + else result.bg = normalized; + } + + return result.fg !== undefined || result.bg !== undefined ? result : undefined; +} + +/** Opening args for `data-md` round-trip, e.g. `$[fg.color=ff0000 bg.color=00ff00`. */ +export function formatMfmColorDataMd(args: MfmColorArgs): string { + const parts: string[] = []; + if (args.fg) { + parts.push(`fg.color=${args.fg.replace(/^#/, '').toLowerCase()}`); + } + if (args.bg) { + parts.push(`bg.color=${args.bg.replace(/^#/, '').toLowerCase()}`); + } + return `$[${parts.join(' ')}`; +} + +export function tryParseMfmColor( + src: string +): { raw: string; args: MfmColorArgs; text: string; dataMd: string } | undefined { + if (!MFM_COLOR_FN_START.test(src)) return undefined; + + const close = src.indexOf(']'); + if (close <= 2) return undefined; + + const inner = src.slice(2, close); + const argsMatch = MFM_COLOR_ARGS_PREFIX.exec(inner); + if (!argsMatch) return undefined; + + const argsPart = argsMatch[1]!.trimEnd(); + const text = inner.slice(argsMatch[0].length).trimStart(); + if (!text) return undefined; + const args = parseMfmColorFnArgs(argsPart); + if (!args) return undefined; + + return { + raw: src.slice(0, close + 1), + args, + text, + dataMd: `$[${argsPart}`, + }; +} + +export const matrixMfmColorExtension = { + name: 'mfmColor', + level: 'inline', + start(src: string) { + const fg = src.indexOf('$[fg.color='); + const bg = src.indexOf('$[bg.color='); + if (fg === -1) return bg; + if (bg === -1) return fg; + return Math.min(fg, bg); + }, + tokenizer( + this: { lexer: { inlineTokens: (t: string, tokens: Tokens.Generic[]) => void } }, + src: string + ) { + const parsed = tryParseMfmColor(src); + if (!parsed) return undefined; + + const token = { + type: 'mfmColor', + raw: parsed.raw, + text: parsed.text, + mfmArgs: parsed.args, + dataMd: parsed.dataMd, + tokens: [] as Tokens.Generic[], + }; + this.lexer.inlineTokens(parsed.text, token.tokens); + return token; + }, + renderer( + this: { parser: { parseInline: (tokens: Tokens.Generic[]) => string } }, + token: Tokens.Generic + ) { + const t = token as unknown as { + tokens?: Tokens.Generic[]; + mfmArgs: MfmColorArgs; + dataMd: string; + }; + const inner = this.parser.parseInline(t.tokens ?? []); + const attrs: string[] = [`data-md="${t.dataMd}"`]; + if (t.mfmArgs.fg) attrs.push(`data-mx-color="${t.mfmArgs.fg}"`); + if (t.mfmArgs.bg) attrs.push(`data-mx-bg-color="${t.mfmArgs.bg}"`); + return `${inner}`; + }, +} satisfies TokenizerExtension & RendererExtension; diff --git a/src/app/plugins/markdown/extensions/matrix.test.ts b/src/app/plugins/markdown/extensions/matrix.test.ts index 707a3f003..4dcaa5d23 100644 --- a/src/app/plugins/markdown/extensions/matrix.test.ts +++ b/src/app/plugins/markdown/extensions/matrix.test.ts @@ -7,10 +7,11 @@ import { shieldDollarRunsForMarked, } from './matrix-math'; import { matrixSubscriptExtension } from './matrix-subscript'; - +import { matrixMfmColorExtension } from './matrix-mfm-color'; function parse(input: string): string { const processor = marked.use({ extensions: [ + matrixMfmColorExtension, matrixSpoilerExtension, matrixMathExtension, matrixMathBlockExtension, @@ -20,6 +21,14 @@ function parse(input: string): string { return processor.parse(shieldDollarRunsForMarked(input)) as string; } +describe('matrixMfmColorExtension', () => { + it('parses fg.color syntax', () => { + const result = parse('$[fg.color=ff0000 red text]'); + expect(result).toContain('data-mx-color="#ff0000"'); + expect(result).toContain('red text'); + }); +}); + describe('matrixSpoilerExtension', () => { it('parses ||spoiler|| syntax', () => { expect(parse('Hello ||spoiler|| world')).toContain('data-mx-spoiler'); diff --git a/src/app/plugins/markdown/htmlToMarkdown.test.ts b/src/app/plugins/markdown/htmlToMarkdown.test.ts index 8ca43b6ad..f98a50637 100644 --- a/src/app/plugins/markdown/htmlToMarkdown.test.ts +++ b/src/app/plugins/markdown/htmlToMarkdown.test.ts @@ -67,6 +67,17 @@ describe('htmlToMarkdown', () => { expect(htmlToMarkdown(html)).toBe('[https://example.org/]()'); }); + it('does not use preview-suppressed destinations for matrix.to user mentions', () => { + const html = '

'; + expect(htmlToMarkdown(html)).toBe('[Alice](https://matrix.to/#/@alice:example.org)'); + }); + + it('does not use preview-suppressed destinations for matrix.to event permalinks', () => { + const url = 'https://matrix.to/#/!room:example.org/$event123?via=sable.moe&via=matrix.org'; + const html = `

<${url}>

`; + expect(htmlToMarkdown(html)).toBe(`[${url}](${url})`); + }); + it('converts hidden-preview wrapped links when angle brackets are decimal entities', () => { const html = '

<https://example.org/>

'; expect(htmlToMarkdown(html)).toBe('[https://example.org/]()'); @@ -130,6 +141,11 @@ describe('htmlToMarkdown', () => { ); }); + it('preserves unknown tags as escaped markdown literals', () => { + expect(htmlToMarkdown('

hi

')).toContain('\\'); + expect(htmlToMarkdown('

')).toContain('\\'); + }); + it('plainToEditorInput expands emoticon placeholders into Slate emoticon elements', () => { const src = 'mxc://matrix.org/emote'; const md = `before${MX_EMOTICON_MD_START}${src}${MX_EMOTICON_MD_SEP}blobcat${MX_EMOTICON_MD_END}after`; diff --git a/src/app/plugins/markdown/htmlToMarkdown.ts b/src/app/plugins/markdown/htmlToMarkdown.ts index 967fb99b7..2ceb6d87e 100644 --- a/src/app/plugins/markdown/htmlToMarkdown.ts +++ b/src/app/plugins/markdown/htmlToMarkdown.ts @@ -6,6 +6,10 @@ import { validateMxcUrl, } from './extensions/matrix-emoticon'; import { escapeMarkdownInlineSequences } from './utils'; +import { testMatrixTo } from '$plugins/matrix-to'; +import { isAllowedHtmlTag } from './allowedHtmlTags'; +import { formatMfmColorDataMd } from './extensions/matrix-mfm-color'; +import { isMatrixHexColor } from '$utils/matrixHtml'; /** * Converts Matrix-compatible HTML back to markdown for round-trip editing. @@ -97,13 +101,17 @@ function processNode(node: ChildNode, listDepth: number = 0, insideCode: boolean return processMath(node, 'inline'); } if (node.attribs['data-md'] !== undefined) { + const dataMd = node.attribs['data-md']; + if (typeof dataMd === 'string' && dataMd.startsWith('$[')) { + return processMfmColorSpan(node, listDepth, insideCode); + } return processInlineMarkdown(node, listDepth, insideCode); } if ( node.attribs['data-mx-color'] !== undefined || node.attribs['data-mx-bg-color'] !== undefined ) { - return reconstructTag(node, listDepth, insideCode); + return processMfmColorSpan(node, listDepth, insideCode); } } @@ -177,9 +185,29 @@ function processNode(node: ChildNode, listDepth: number = 0, insideCode: boolean return processImage(node); default: + if (!isAllowedHtmlTag(tag)) { + return processUnknownHtmlTag(node, listDepth, insideCode); + } return processInlineElements(node, listDepth, insideCode); } } + +function formatHtmlTagAttributes(attribs: Element['attribs']): string { + return Object.entries(attribs) + .map(([key, value]) => ` ${key}="${value}"`) + .join(''); +} + +function processUnknownHtmlTag( + node: Element, + listDepth: number = 0, + insideCode: boolean = false +): string { + const content = processChildren(node.children, listDepth, insideCode); + const attrs = formatHtmlTagAttributes(node.attribs); + const raw = `<${node.name}${attrs}>${content}`; + return escapeMarkdownInlineSequences(raw); +} function reconstructTag(node: Element, listDepth: number = 0, insideCode: boolean = false): string { const content = processInlineElements(node, listDepth, insideCode); const attributes = Object.entries(node.attribs) @@ -232,8 +260,11 @@ function processChildren( ) { const href = next.attribs.href ?? ''; const content = next.children.map((c) => processNode(c, listDepth, insideCode)).join(''); - // Suppressed autolink: [label]() so bracket text is not run through escapeMarkdown as "\<". - out.push(`[${content}](<${href}>)`); + if (testMatrixTo(href)) { + out.push(`[${content}](${href})`); + } else { + out.push(`[${content}](<${href}>)`); + } i += 2; continue; } @@ -411,6 +442,30 @@ function processLink(node: Element, listDepth = 0, insideCode = false): string { return `[${content}](${href})`; } +function processMfmColorSpan( + node: Element, + listDepth: number = 0, + insideCode: boolean = false +): string { + const content = processChildren(node.children, listDepth, insideCode); + const dataMd = node.attribs['data-md']; + if (typeof dataMd === 'string' && dataMd.startsWith('$[')) { + return `${dataMd} ${content}]`; + } + + const args: { fg?: string; bg?: string } = {}; + const fg = node.attribs['data-mx-color']; + const bg = node.attribs['data-mx-bg-color']; + if (typeof fg === 'string' && isMatrixHexColor(fg)) args.fg = fg; + if (typeof bg === 'string' && isMatrixHexColor(bg)) args.bg = bg; + + if (args.fg !== undefined || args.bg !== undefined) { + return `${formatMfmColorDataMd(args)} ${content}]`; + } + + return reconstructTag(node, listDepth, insideCode); +} + function processSpoiler(node: Element, listDepth = 0, insideCode = false): string { const content = node.children.map((c) => processNode(c, listDepth, insideCode)).join(''); return `||${content}||`; diff --git a/src/app/plugins/markdown/injectDataMd.ts b/src/app/plugins/markdown/injectDataMd.ts index ca1403650..059b054bf 100644 --- a/src/app/plugins/markdown/injectDataMd.ts +++ b/src/app/plugins/markdown/injectDataMd.ts @@ -88,6 +88,27 @@ export function injectDataMd(html: string): string { return `${content}`; }); + // Inject MFM color data-md on colored spans from other clients + html = html.replace( + /]*data-mx-color="(#[0-9a-fA-F]{6})"[^>]*)>([^<]*)<\/span>/g, + (match, attrs, _color, content) => { + if (attrs.includes('data-md')) return match; + const bgMatch = /data-mx-bg-color="(#[0-9a-fA-F]{6})"/.exec(attrs); + const fg = _color as string; + const bg = bgMatch?.[1]; + const parts: string[] = [`fg.color=${fg.replace(/^#/, '').toLowerCase()}`]; + if (bg) parts.push(`bg.color=${bg.replace(/^#/, '').toLowerCase()}`); + return `${content}`; + } + ); + html = html.replace( + /]*data-mx-bg-color="(#[0-9a-fA-F]{6})"[^>]*)>([^<]*)<\/span>/g, + (match, attrs, bg, content) => { + if (attrs.includes('data-md') || attrs.includes('data-mx-color')) return match; + return `${content}`; + } + ); + // Inject inline code marker html = html.replace(/]*)>([^<]*)<\/code>/g, (_, attrs, content) => { if (attrs.includes('data-md')) return `${content}`; diff --git a/src/app/plugins/markdown/markdownToHtml.test.ts b/src/app/plugins/markdown/markdownToHtml.test.ts index d6e2b2461..f6599622a 100644 --- a/src/app/plugins/markdown/markdownToHtml.test.ts +++ b/src/app/plugins/markdown/markdownToHtml.test.ts @@ -43,6 +43,17 @@ describe('markdownToHtml', () => { expect(result).toContain('spoiler'); }); + it('converts MFM fg.color syntax', () => { + const result = markdownToHtml('$[fg.color=f00 red]'); + expect(result).toContain('data-mx-color="#ff0000"'); + expect(result).toContain('red'); + }); + + it('converts MFM bg.color with 6-digit hex', () => { + const result = markdownToHtml('$[bg.color=00ff00 green]'); + expect(result).toContain('data-mx-bg-color="#00ff00"'); + }); + it('converts inline math syntax', () => { const result = markdownToHtml('$E = mc^2$'); expect(result).toContain('data-mx-maths'); @@ -137,6 +148,21 @@ describe('markdownToHtml', () => { expect(markdownToHtml('intro\n> quote')).toContain('
'); }); + it('ends blockquote when the next line does not start with >', () => { + const html = markdownToHtml('> test\ntest 2'); + expect(html).toContain('
'); + expect(html).toContain('test'); + expect(html).not.toMatch(/
[\s\S]*test 2[\s\S]*<\/blockquote>/); + expect(html).toContain('test 2'); + }); + + it('keeps consecutive blockquote lines with > markers', () => { + const html = markdownToHtml('> line one\n> line two'); + expect(html).toContain('
'); + expect(html).toContain('line one'); + expect(html).toContain('line two'); + }); + 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(' { expect(result).not.toContain('
    '); }); + it('preserves arbitrary ordered list start numbers', () => { + const result = markdownToHtml('23423. hello'); + expect(result).toContain('start="23423"'); + }); + + it('strips invalid ol start values', () => { + const result = markdownToHtml('
    1. x
    '); + expect(result).toContain('
      '); + expect(result).not.toContain('start='); + }); + it('handles text without markdown', () => { const result = markdownToHtml('Plain text without any formatting'); expect(result).toContain('Plain text'); @@ -325,4 +362,39 @@ describe('markdownToHtml', () => { expect(result).toContain('https://matrix.to/#/#room:example.org'); expect(result).not.toMatch(/]*matrix\.to/); }); + + it('renders backslash-escaped angle brackets as literal < and > in HTML output', () => { + const html = markdownToHtml(String.raw`\`); + expect(html).toContain('<test>'); + expect(html).not.toMatch(/]*>/); + }); + + it('does not double-encode when only the opening bracket is backslash-escaped', () => { + const html = markdownToHtml(String.raw`\`); + expect(html).toContain('<test>'); + expect(html).not.toContain('&lt;'); + expect(html).not.toMatch(/]*>/); + }); + + it('entity-escapes unknown html-like tags instead of stripping them', () => { + expect(markdownToHtml('')).toContain('<test>'); + expect(markdownToHtml('')).not.toMatch(/]*>/); + expect(markdownToHtml(' <\\test>')).toContain('<test>'); + expect(markdownToHtml('nope')).toContain('<b>'); + expect(markdownToHtml('nope')).toContain('nope'); + }); + + it('entity-escapes allowlisted tags without a proper closing tag', () => { + const result = markdownToHtml('bold'); + expect(result).toContain('<strong>'); + expect(result).toContain('bold'); + expect(result).not.toMatch(/]*>bold/); + }); + + it('preserves preview-suppressed angle-bracket URLs', () => { + const url = 'https://example.com/doc'; + const result = markdownToHtml(`see <${url}> there`); + expect(result).toContain(url); + expect(result).not.toContain('<https'); + }); }); diff --git a/src/app/plugins/markdown/markdownToHtml.ts b/src/app/plugins/markdown/markdownToHtml.ts index 74b680e11..c7761859e 100644 --- a/src/app/plugins/markdown/markdownToHtml.ts +++ b/src/app/plugins/markdown/markdownToHtml.ts @@ -12,16 +12,19 @@ import { import { matrixSubscriptExtension } from './extensions/matrix-subscript'; import { matrixEmoticonExtension, preprocessEmoticon } from './extensions/matrix-emoticon'; import { matrixUnderlineExtension } from './extensions/matrix-underline'; +import { matrixMfmColorExtension } from './extensions/matrix-mfm-color'; import { escapeLineStartBlockquoteWithoutFollowingSpace, unescapeMarkdownInlineSequencesExceptInCodeHtml, } from './utils'; import { expandBlockBoundariesAfterSingleNewlines } from './expandBlockNewlines'; +import { escapeNonAllowlistedHtmlTags, MARKDOWN_ALLOWED_HTML_TAGS } from './allowedHtmlTags'; // Configure marked with Matrix extensions const processor = marked.use({ breaks: true, extensions: [ + matrixMfmColorExtension, matrixUnderlineExtension, matrixSpoilerExtension, matrixMathExtension, @@ -53,6 +56,8 @@ const decodeHtmlEntities = (text: string): string => { const MATRIX_TO_PLACEHOLDER_PREFIX = 'MATRIXTORAWLINKTOKEN'; +const ORDERED_LIST_START_REGEX = /^-?\d+$/; + const escapeHtml = (text: string): string => text .replace(/&/g, '&') @@ -130,7 +135,9 @@ export function markdownToHtml(markdown: string, options?: MarkdownToHtmlOptions // Only treat `> ` as block quote, escape bare `>` at line start (e.g. `>:3`) const blockquotePrefixed = escapeLineStartBlockquoteWithoutFollowingSpace(decoded); - const preprocessed = preprocessEmoticon(blockquotePrefixed); + const allowlistedOnly = escapeNonAllowlistedHtmlTags(blockquotePrefixed); + + const preprocessed = preprocessEmoticon(allowlistedOnly); const boundaryExpanded = expandBlockBoundariesAfterSingleNewlines(preprocessed); @@ -145,51 +152,24 @@ export function markdownToHtml(markdown: string, options?: MarkdownToHtmlOptions // Unescape inline sequences (e.g., \*, \_) after parsing, but not inside
      /
         const unescapedInline = unescapeMarkdownInlineSequencesExceptInCodeHtml(html);
       
      -  // Force all links to open in a new tab
      +  const allowlistedHtml = escapeNonAllowlistedHtmlTags(unescapedInline);
      +
      +  // Force all links to open in a new tab, validate 
        . DOMPurify.addHook('afterSanitizeAttributes', (node) => { if (node.tagName === 'A' && node.getAttribute('href')) { node.setAttribute('target', '_blank'); node.setAttribute('rel', 'noreferrer noopener'); } + if (node.tagName === 'OL') { + const start = node.getAttribute('start'); + if (start !== null && !ORDERED_LIST_START_REGEX.test(start)) { + node.removeAttribute('start'); + } + } }); - const sanitized = DOMPurify.sanitize(unescapedInline, { - ALLOWED_TAGS: [ - 'h1', - 'h2', - 'h3', - 'h4', - 'h5', - 'h6', - 'p', - 'br', - 'hr', - 'blockquote', - 'ul', - 'ol', - 'li', - 'pre', - 'code', - 'strong', - 'em', - 'u', - 's', - 'del', - 'a', - 'img', - 'span', - 'div', - 'sub', - 'details', - 'summary', - 'table', - 'thead', - 'tbody', - 'tr', - 'th', - 'td', - 'mx-reply', - ], + const sanitized = DOMPurify.sanitize(allowlistedHtml, { + ALLOWED_TAGS: [...MARKDOWN_ALLOWED_HTML_TAGS], ALLOWED_ATTR: [ 'href', 'src', @@ -213,7 +193,9 @@ export function markdownToHtml(markdown: string, options?: MarkdownToHtmlOptions ], // Ensure these safe attrs survive sanitization even when the input HTML // originates from markdown-embedded tags (e.g. custom emoji ). + // `start` must be URI-safe or DOMPurify drops it when ALLOWED_URI_REGEXP is set. ADD_ATTR: ['target', 'rel', 'height', 'width'], + ADD_URI_SAFE_ATTR: ['start'], // Force all links to have safe rel attribute FORCE_BODY: false, ALLOWED_URI_REGEXP: /^(?:https?|ftp|mailto|magnet|mxc):/i, diff --git a/src/app/plugins/matrix-to.test.ts b/src/app/plugins/matrix-to.test.ts index 7c0287c43..2b5b66740 100644 --- a/src/app/plugins/matrix-to.test.ts +++ b/src/app/plugins/matrix-to.test.ts @@ -4,6 +4,7 @@ import { getMatrixToRoomEvent, getMatrixToUser, isRedundantMatrixToAnchorText, + isMatrixToMentionHref, parseMatrixToRoom, parseMatrixToRoomEvent, parseMatrixToUser, @@ -92,6 +93,13 @@ describe('testMatrixTo', () => { expect(testMatrixTo('http://matrix.to/#/@alice:example.com')).toBe(true); }); + it('isMatrixToMentionHref matches user, room, and event permalinks only', () => { + expect(isMatrixToMentionHref('https://matrix.to/#/@alice:example.com')).toBe(true); + expect(isMatrixToMentionHref('https://matrix.to/#/!room:example.com')).toBe(true); + expect(isMatrixToMentionHref('https://matrix.to/#/!room:example.com/$event123')).toBe(true); + expect(isMatrixToMentionHref('https://example.com')).toBe(false); + }); + it('rejects non-matrix.to URLs', () => { expect(testMatrixTo('https://example.com')).toBe(false); expect(testMatrixTo('https://notmatrix.to/#/@alice:example.com')).toBe(false); diff --git a/src/app/plugins/matrix-to.ts b/src/app/plugins/matrix-to.ts index 3c6712e53..428100d84 100644 --- a/src/app/plugins/matrix-to.ts +++ b/src/app/plugins/matrix-to.ts @@ -81,6 +81,17 @@ const getMatchRegexes = () => { export const testMatrixTo = (href: string): boolean => getMatchRegexes().any.test(href); +/** True when the URL is a matrix.to user, room, or event permalink (a mention), not an arbitrary link. */ +export const isMatrixToMentionHref = (href: string): boolean => { + const trimmed = href.trim(); + if (!testMatrixTo(trimmed)) return false; + return !!( + parseMatrixToUser(trimmed) ?? + parseMatrixToRoom(trimmed) ?? + parseMatrixToRoomEvent(trimmed) + ); +}; + export const parseMatrixToUser = (href: string): string | undefined => { const match = href.match(getMatchRegexes().user); if (!match) return undefined; diff --git a/src/app/plugins/react-custom-html-parser.test.tsx b/src/app/plugins/react-custom-html-parser.test.tsx index 13e8c3088..5f0cb8fb3 100644 --- a/src/app/plugins/react-custom-html-parser.test.tsx +++ b/src/app/plugins/react-custom-html-parser.test.tsx @@ -11,6 +11,7 @@ import { makeMentionCustomProps, renderMatrixMention, } from './react-custom-html-parser'; +import { markdownToHtml } from './markdown/markdownToHtml'; const settingsLinkBaseUrl = 'https://app.example'; @@ -435,4 +436,14 @@ describe('react custom html parser', () => { }) ).toHaveAttribute('href', 'https://github.com/SableClient/Sable/pull/PR/626'); }); + + it.each([String.raw`\`, String.raw`\`])( + 'renders %s as literal angle brackets, not entity text', + (md) => { + renderParsedHtml(markdownToHtml(md)); + + expect(screen.getByText('')).toBeInTheDocument(); + expect(screen.queryByText('<test>')).not.toBeInTheDocument(); + } + ); }); diff --git a/src/app/plugins/react-custom-html-parser.tsx b/src/app/plugins/react-custom-html-parser.tsx index 951f9d033..8a15df7ea 100644 --- a/src/app/plugins/react-custom-html-parser.tsx +++ b/src/app/plugins/react-custom-html-parser.tsx @@ -675,7 +675,7 @@ export const getReactCustomHtmlParser = ( } if (name === 'ol') { return ( -
          +
            {renderChildren()}
          ); diff --git a/src/app/styles/CustomHtml.css.ts b/src/app/styles/CustomHtml.css.ts index 592a63aa3..dfae8c942 100644 --- a/src/app/styles/CustomHtml.css.ts +++ b/src/app/styles/CustomHtml.css.ts @@ -138,6 +138,17 @@ export const List = style([ }, ]); +/** Outside markers right-align digits so periods line up, pushing long numbers off-screen left. */ +export const OrderedList = style([ + DefaultReset, + MarginSpaced, + { + padding: `0 ${config.space.S100}`, + paddingInlineStart: config.space.S200, + listStylePosition: 'inside', + }, +]); + export const Img = style([ DefaultReset, MarginSpaced, diff --git a/src/app/utils/matrixHtml.test.ts b/src/app/utils/matrixHtml.test.ts new file mode 100644 index 000000000..00dd5704c --- /dev/null +++ b/src/app/utils/matrixHtml.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeMfmHexToMatrixColor } from './matrixHtml'; + +describe('normalizeMfmHexToMatrixColor', () => { + it('expands 3-digit hex', () => { + expect(normalizeMfmHexToMatrixColor('f00')).toBe('#ff0000'); + expect(normalizeMfmHexToMatrixColor('#f00')).toBe('#ff0000'); + }); + + it('accepts 6-digit hex', () => { + expect(normalizeMfmHexToMatrixColor('ff0000')).toBe('#ff0000'); + expect(normalizeMfmHexToMatrixColor('#00ff00')).toBe('#00ff00'); + }); + + it('rejects invalid values', () => { + expect(normalizeMfmHexToMatrixColor('red')).toBeUndefined(); + expect(normalizeMfmHexToMatrixColor('ffff')).toBeUndefined(); + }); + + it('rejects 4- and 8-digit hex (alpha channels)', () => { + expect(normalizeMfmHexToMatrixColor('ff00')).toBeUndefined(); + expect(normalizeMfmHexToMatrixColor('#ff00')).toBeUndefined(); + expect(normalizeMfmHexToMatrixColor('ff0000ff')).toBeUndefined(); + expect(normalizeMfmHexToMatrixColor('#ff0000ff')).toBeUndefined(); + }); +}); diff --git a/src/app/utils/matrixHtml.ts b/src/app/utils/matrixHtml.ts index 0176c804d..953496eea 100644 --- a/src/app/utils/matrixHtml.ts +++ b/src/app/utils/matrixHtml.ts @@ -3,3 +3,33 @@ export const MATRIX_HEX_COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; export function isMatrixHexColor(value: string): boolean { return MATRIX_HEX_COLOR_REGEX.test(value); } + +/** MFM / composer input: optional `#` then exactly 3 or 6 hex digits (no alpha). */ +export const MFM_HEX_INPUT_REGEX = /^#?(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/; + +/** One `fg.color=` / `bg.color=` value in a `$[…]` block (no alpha). */ +export const MFM_HEX_COLOR_VALUE_PATTERN = '[#]?(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{3})(?![0-9a-fA-F])'; + +/** + * Normalizes MFM-style hex (3 or 6 digits, optional `#`) to Matrix `#RRGGBB`. + */ +export function normalizeMfmHexToMatrixColor(hex: string): string | undefined { + const trimmed = hex.trim(); + if (!MFM_HEX_INPUT_REGEX.test(trimmed)) return undefined; + + const digits = trimmed.replace(/^#/, '').toLowerCase(); + if (digits.length === 3) { + const expanded = `${digits[0]}${digits[0]}${digits[1]}${digits[1]}${digits[2]}${digits[2]}`; + const color = `#${expanded}`; + return isMatrixHexColor(color) ? color : undefined; + } + + const color = `#${digits}`; + return isMatrixHexColor(color) ? color : undefined; +} + +/** Strips `#` for MFM `fg.color=` / `bg.color=` round-trip. */ +export function matrixColorToMfmHex(value: string): string | undefined { + if (!isMatrixHexColor(value)) return undefined; + return value.slice(1).toLowerCase(); +} diff --git a/src/app/utils/sanitize.test.ts b/src/app/utils/sanitize.test.ts index c1332cfc7..b4839f926 100644 --- a/src/app/utils/sanitize.test.ts +++ b/src/app/utils/sanitize.test.ts @@ -122,6 +122,14 @@ describe('sanitizeCustomHtml', () => { expect(result.match(/\ssrc=/g)).toHaveLength(1); }); + it('drops invalid ol start values', () => { + expect(sanitizeCustomHtml('
          1. ok
          ')).toContain('start="2"'); + expect(sanitizeCustomHtml('
          1. x
          ')).not.toContain( + 'start=' + ); + expect(sanitizeCustomHtml('
          1. x
          ')).not.toContain('start='); + }); + it('drops invalid Matrix color attributes instead of translating them to style', () => { const result = sanitizeCustomHtml( 'text'

<Alice>