mirror of https://github.com/vector-im/riot-web
				
				
				
			Add support for MD / HTML in room topics (#8215)
* Add support for MD / HTML in room topics Setting MD / HTML supported: - /topic command - Room settings overlay - Space settings overlay Display of MD / HTML supported: - /topic command - Room header - Space home Based on extensible events as defined in [MSC1767] Fixes: vector-im/element-web#5180 Signed-off-by: Johannes Marbach <johannesm@element.io> [MSC1767]: matrix-org/matrix-spec-proposals#1767 * Fix build error * Add comment to explain origin of styles Co-authored-by: Travis Ralston <travpc@gmail.com> * Empty commit to retrigger build * Fix import grouping * Fix useTopic test * Add tests for HtmlUtils * Add slash command test * Add further serialize test * Fix ternary formatting Co-authored-by: Travis Ralston <travpc@gmail.com> * Add blank line Co-authored-by: Travis Ralston <travpc@gmail.com> * Properly mock SettingsStore access * Remove trailing space * Assert on HTML content and add test for plain text in HTML parameter * Appease the linter * Fix JSDoc comment * Fix toEqual call formatting * Repurpose test for literal HTML case * Empty commit to fix CI Co-authored-by: Travis Ralston <travpc@gmail.com> Co-authored-by: Travis Ralston <travisr@matrix.org>pull/28788/head^2
							parent
							
								
									8036985204
								
							
						
					
					
						commit
						abd39c61b1
					
				|  | @ -304,6 +304,73 @@ legend { | |||
|     overflow-y: auto; | ||||
| } | ||||
| 
 | ||||
| // Styles copied/inspired by GroupLayout, ReplyTile, and EventTile variants. | ||||
| .mx_Dialog .markdown-body { | ||||
|     font-family: inherit !important; | ||||
|     white-space: normal !important; | ||||
|     line-height: inherit !important; | ||||
|     color: inherit; // inherit the colour from the dark or light theme by default (but not for code blocks) | ||||
|     font-size: $font-14px; | ||||
| 
 | ||||
|     pre, | ||||
|     code { | ||||
|         font-family: $monospace-font-family !important; | ||||
|         background-color: $codeblock-background-color; | ||||
|     } | ||||
| 
 | ||||
|     // this selector wrongly applies to code blocks too but we will unset it in the next one | ||||
|     code { | ||||
|         white-space: pre-wrap; // don't collapse spaces in inline code blocks | ||||
|     } | ||||
| 
 | ||||
|     pre code { | ||||
|         white-space: pre; // we want code blocks to be scrollable and not wrap | ||||
| 
 | ||||
|         >* { | ||||
|             display: inline; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     pre { | ||||
|         // have to use overlay rather than auto otherwise Linux and Windows | ||||
|         // Chrome gets very confused about vertical spacing: | ||||
|         // https://github.com/vector-im/vector-web/issues/754 | ||||
|         overflow-x: overlay; | ||||
|         overflow-y: visible; | ||||
| 
 | ||||
|         &::-webkit-scrollbar-corner { | ||||
|             background: transparent; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| .mx_Dialog .markdown-body h1, | ||||
| .mx_Dialog .markdown-body h2, | ||||
| .mx_Dialog .markdown-body h3, | ||||
| .mx_Dialog .markdown-body h4, | ||||
| .mx_Dialog .markdown-body h5, | ||||
| .mx_Dialog .markdown-body h6 { | ||||
|     font-family: inherit !important; | ||||
|     color: inherit; | ||||
| } | ||||
| 
 | ||||
| /* Make h1 and h2 the same size as h3. */ | ||||
| .mx_Dialog .markdown-body h1, | ||||
| .mx_Dialog .markdown-body h2 { | ||||
|     font-size: 1.5em; | ||||
|     border-bottom: none !important; // override GFM | ||||
| } | ||||
| 
 | ||||
| .mx_Dialog .markdown-body a { | ||||
|     color: $accent-alt; | ||||
| } | ||||
| 
 | ||||
| .mx_Dialog .markdown-body blockquote { | ||||
|     border-left: 2px solid $blockquote-bar-color; | ||||
|     border-radius: 2px; | ||||
|     padding: 0 10px; | ||||
| } | ||||
| 
 | ||||
| .mx_Dialog_fixedWidth { | ||||
|     width: 60vw; | ||||
|     max-width: 704px; | ||||
|  |  | |||
|  | @ -166,6 +166,12 @@ limitations under the License. | |||
|     display: -webkit-box; | ||||
| } | ||||
| 
 | ||||
| .mx_RoomHeader_topic .mx_Emoji { | ||||
|     // Undo font size increase to prevent vertical cropping and ensure the same size | ||||
|     // as in plain text emojis | ||||
|     font-size: inherit; | ||||
| } | ||||
| 
 | ||||
| .mx_RoomHeader_avatar { | ||||
|     flex: 0; | ||||
|     margin: 0 6px 0 7px; | ||||
|  |  | |||
|  | @ -323,6 +323,18 @@ const composerSanitizeHtmlParams: IExtendedSanitizeOptions = { | |||
|     }, | ||||
| }; | ||||
| 
 | ||||
| // reduced set of allowed tags to avoid turning topics into Myspace
 | ||||
| const topicSanitizeHtmlParams: IExtendedSanitizeOptions = { | ||||
|     ...sanitizeHtmlParams, | ||||
|     allowedTags: [ | ||||
|         'font', // custom to matrix for IRC-style font coloring
 | ||||
|         'del', // for markdown
 | ||||
|         'a', 'sup', 'sub', | ||||
|         'b', 'i', 'u', 'strong', 'em', 'strike', 'br', 'div', | ||||
|         'span', | ||||
|     ], | ||||
| }; | ||||
| 
 | ||||
| abstract class BaseHighlighter<T extends React.ReactNode> { | ||||
|     constructor(public highlightClass: string, public highlightLink: string) { | ||||
|     } | ||||
|  | @ -606,6 +618,57 @@ export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts | |||
|         </span>; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Turn a room topic into html | ||||
|  * @param topic plain text topic | ||||
|  * @param htmlTopic optional html topic | ||||
|  * @param ref React ref to attach to any React components returned | ||||
|  * @param allowExtendedHtml whether to allow extended HTML tags such as headings and lists | ||||
|  * @return The HTML-ified node. | ||||
|  */ | ||||
| export function topicToHtml( | ||||
|     topic: string, | ||||
|     htmlTopic?: string, | ||||
|     ref?: React.Ref<HTMLSpanElement>, | ||||
|     allowExtendedHtml = false, | ||||
| ): ReactNode { | ||||
|     if (!SettingsStore.getValue("feature_html_topic")) { | ||||
|         htmlTopic = null; | ||||
|     } | ||||
| 
 | ||||
|     let isFormattedTopic = !!htmlTopic; | ||||
|     let topicHasEmoji = false; | ||||
|     let safeTopic = ""; | ||||
| 
 | ||||
|     try { | ||||
|         topicHasEmoji = mightContainEmoji(isFormattedTopic ? htmlTopic : topic); | ||||
| 
 | ||||
|         if (isFormattedTopic) { | ||||
|             safeTopic = sanitizeHtml(htmlTopic, allowExtendedHtml ? sanitizeHtmlParams : topicSanitizeHtmlParams); | ||||
|             if (topicHasEmoji) { | ||||
|                 safeTopic = formatEmojis(safeTopic, true).join(''); | ||||
|             } | ||||
|         } | ||||
|     } catch { | ||||
|         isFormattedTopic = false; // Fall back to plain-text topic
 | ||||
|     } | ||||
| 
 | ||||
|     let emojiBodyElements: ReturnType<typeof formatEmojis>; | ||||
|     if (!isFormattedTopic && topicHasEmoji) { | ||||
|         emojiBodyElements = formatEmojis(topic, false); | ||||
|     } | ||||
| 
 | ||||
|     return isFormattedTopic ? | ||||
|         <span | ||||
|             key="body" | ||||
|             ref={ref} | ||||
|             dangerouslySetInnerHTML={{ __html: safeTopic }} | ||||
|             dir="auto" | ||||
|         /> : <span key="body" ref={ref} dir="auto"> | ||||
|             { emojiBodyElements || topic } | ||||
|         </span>; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Linkifies the given string. This is a wrapper around 'linkifyjs/string'. | ||||
|  * | ||||
|  |  | |||
|  | @ -25,6 +25,7 @@ import * as ContentHelpers from 'matrix-js-sdk/src/content-helpers'; | |||
| import { Element as ChildElement, parseFragment as parseHtml } from "parse5"; | ||||
| import { logger } from "matrix-js-sdk/src/logger"; | ||||
| import { IContent } from 'matrix-js-sdk/src/models/event'; | ||||
| import { MRoomTopicEventContent } from 'matrix-js-sdk/src/@types/topic'; | ||||
| import { SlashCommand as SlashCommandEvent } from "@matrix-org/analytics-events/types/typescript/SlashCommand"; | ||||
| 
 | ||||
| import { MatrixClientPeg } from './MatrixClientPeg'; | ||||
|  | @ -32,7 +33,7 @@ import dis from './dispatcher/dispatcher'; | |||
| import { _t, _td, ITranslatableError, newTranslatableError } from './languageHandler'; | ||||
| import Modal from './Modal'; | ||||
| import MultiInviter from './utils/MultiInviter'; | ||||
| import { linkifyAndSanitizeHtml } from './HtmlUtils'; | ||||
| import { linkifyElement, topicToHtml } from './HtmlUtils'; | ||||
| import QuestionDialog from "./components/views/dialogs/QuestionDialog"; | ||||
| import WidgetUtils from "./utils/WidgetUtils"; | ||||
| import { textToHtmlRainbow } from "./utils/colour"; | ||||
|  | @ -66,6 +67,7 @@ import { XOR } from "./@types/common"; | |||
| import { PosthogAnalytics } from "./PosthogAnalytics"; | ||||
| import { ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload"; | ||||
| import VoipUserMapper from './VoipUserMapper'; | ||||
| import { htmlSerializeFromMdIfNeeded } from './editor/serialize'; | ||||
| import { leaveRoomBehaviour } from "./utils/leave-behaviour"; | ||||
| 
 | ||||
| // XXX: workaround for https://github.com/microsoft/TypeScript/issues/31816
 | ||||
|  | @ -463,7 +465,8 @@ export const Commands = [ | |||
|         runFn: function(roomId, args) { | ||||
|             const cli = MatrixClientPeg.get(); | ||||
|             if (args) { | ||||
|                 return success(cli.setRoomTopic(roomId, args)); | ||||
|                 const html = htmlSerializeFromMdIfNeeded(args, { forceHTML: false }); | ||||
|                 return success(cli.setRoomTopic(roomId, args, html)); | ||||
|             } | ||||
|             const room = cli.getRoom(roomId); | ||||
|             if (!room) { | ||||
|  | @ -472,14 +475,19 @@ export const Commands = [ | |||
|                 ); | ||||
|             } | ||||
| 
 | ||||
|             const topicEvents = room.currentState.getStateEvents('m.room.topic', ''); | ||||
|             const topic = topicEvents && topicEvents.getContent().topic; | ||||
|             const topicHtml = topic ? linkifyAndSanitizeHtml(topic) : _t('This room has no topic.'); | ||||
|             const content: MRoomTopicEventContent = room.currentState.getStateEvents('m.room.topic', '')?.getContent(); | ||||
|             const topic = !!content | ||||
|                 ? ContentHelpers.parseTopicContent(content) | ||||
|                 : { text: _t('This room has no topic.') }; | ||||
| 
 | ||||
|             const ref = e => e && linkifyElement(e); | ||||
|             const body = topicToHtml(topic.text, topic.html, ref, true); | ||||
| 
 | ||||
|             Modal.createTrackedDialog('Slash Commands', 'Topic', InfoDialog, { | ||||
|                 title: room.name, | ||||
|                 description: <div dangerouslySetInnerHTML={{ __html: topicHtml }} />, | ||||
|                 description: <div ref={ref}>{ body }</div>, | ||||
|                 hasCloseButton: true, | ||||
|                 className: "markdown-body", | ||||
|             }); | ||||
|             return success(); | ||||
|         }, | ||||
|  | @ -1333,11 +1341,10 @@ interface ICmd { | |||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Process the given text for /commands and return a bound method to perform them. | ||||
|  * Process the given text for /commands and returns a parsed command that can be used for running the operation. | ||||
|  * @param {string} input The raw text input by the user. | ||||
|  * @return {null|function(): Object} Function returning an object with the property 'error' if there was an error | ||||
|  * processing the command, or 'promise' if a request was sent out. | ||||
|  * Returns null if the input didn't match a command. | ||||
|  * @return {ICmd} The parsed command object. | ||||
|  * Returns an empty object if the input didn't match a command. | ||||
|  */ | ||||
| export function getCommand(input: string): ICmd { | ||||
|     const { cmd, args } = parseCommandString(input); | ||||
|  |  | |||
|  | @ -31,6 +31,7 @@ import MatrixClientContext from "../../../contexts/MatrixClientContext"; | |||
| import AccessibleButton from "./AccessibleButton"; | ||||
| import { Linkify } from "./Linkify"; | ||||
| import TooltipTarget from "./TooltipTarget"; | ||||
| import { topicToHtml } from "../../../HtmlUtils"; | ||||
| 
 | ||||
| interface IProps extends React.HTMLProps<HTMLDivElement> { | ||||
|     room?: Room; | ||||
|  | @ -44,6 +45,7 @@ export default function RoomTopic({ | |||
|     const ref = useRef<HTMLDivElement>(); | ||||
| 
 | ||||
|     const topic = useTopic(room); | ||||
|     const body = topicToHtml(topic?.text, topic?.html, ref); | ||||
| 
 | ||||
|     const onClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => { | ||||
|         props.onClick?.(e); | ||||
|  | @ -62,6 +64,7 @@ export default function RoomTopic({ | |||
|     useDispatcher(dis, (payload) => { | ||||
|         if (payload.action === Action.ShowRoomTopic) { | ||||
|             const canSetTopic = room.currentState.maySendStateEvent(EventType.RoomTopic, client.getUserId()); | ||||
|             const body = topicToHtml(topic?.text, topic?.html, ref, true); | ||||
| 
 | ||||
|             const modal = Modal.createDialog(InfoDialog, { | ||||
|                 title: room.name, | ||||
|  | @ -74,7 +77,7 @@ export default function RoomTopic({ | |||
|                             } | ||||
|                         }} | ||||
|                     > | ||||
|                         { topic } | ||||
|                         { body } | ||||
|                     </Linkify> | ||||
|                     { canSetTopic && <AccessibleButton | ||||
|                         kind="primary_outline" | ||||
|  | @ -101,7 +104,7 @@ export default function RoomTopic({ | |||
|     > | ||||
|         <TooltipTarget label={_t("Click to read topic")} alignment={Alignment.Bottom} ignoreHover={ignoreHover}> | ||||
|             <Linkify> | ||||
|                 { topic } | ||||
|                 { body } | ||||
|             </Linkify> | ||||
|         </TooltipTarget> | ||||
|     </div>; | ||||
|  |  | |||
|  | @ -22,6 +22,7 @@ import Field from "../elements/Field"; | |||
| import { mediaFromMxc } from "../../../customisations/Media"; | ||||
| import AccessibleButton from "../elements/AccessibleButton"; | ||||
| import AvatarSetting from "../settings/AvatarSetting"; | ||||
| import { htmlSerializeFromMdIfNeeded } from '../../../editor/serialize'; | ||||
| import { chromeFileInputFix } from "../../../utils/BrowserWorkarounds"; | ||||
| 
 | ||||
| interface IProps { | ||||
|  | @ -142,7 +143,8 @@ export default class RoomProfileSettings extends React.Component<IProps, IState> | |||
|         } | ||||
| 
 | ||||
|         if (this.state.originalTopic !== this.state.topic) { | ||||
|             await client.setRoomTopic(this.props.roomId, this.state.topic); | ||||
|             const html = htmlSerializeFromMdIfNeeded(this.state.topic, { forceHTML: false }); | ||||
|             await client.setRoomTopic(this.props.roomId, this.state.topic, html); | ||||
|             newState.originalTopic = this.state.topic; | ||||
|         } | ||||
| 
 | ||||
|  |  | |||
|  | @ -25,6 +25,7 @@ import AccessibleButton from "../elements/AccessibleButton"; | |||
| import SpaceBasicSettings from "./SpaceBasicSettings"; | ||||
| import { avatarUrlForRoom } from "../../../Avatar"; | ||||
| import { IDialogProps } from "../dialogs/IDialogProps"; | ||||
| import { htmlSerializeFromMdIfNeeded } from "../../../editor/serialize"; | ||||
| import { leaveSpace } from "../../../utils/leave-behaviour"; | ||||
| import { getTopic } from "../../../hooks/room/useTopic"; | ||||
| 
 | ||||
|  | @ -47,7 +48,7 @@ const SpaceSettingsGeneralTab = ({ matrixClient: cli, space, onFinished }: IProp | |||
|     const canSetName = space.currentState.maySendStateEvent(EventType.RoomName, userId); | ||||
|     const nameChanged = name !== space.name; | ||||
| 
 | ||||
|     const currentTopic = getTopic(space); | ||||
|     const currentTopic = getTopic(space).text; | ||||
|     const [topic, setTopic] = useState<string>(currentTopic); | ||||
|     const canSetTopic = space.currentState.maySendStateEvent(EventType.RoomTopic, userId); | ||||
|     const topicChanged = topic !== currentTopic; | ||||
|  | @ -77,7 +78,8 @@ const SpaceSettingsGeneralTab = ({ matrixClient: cli, space, onFinished }: IProp | |||
|         } | ||||
| 
 | ||||
|         if (topicChanged) { | ||||
|             promises.push(cli.setRoomTopic(space.roomId, topic)); | ||||
|             const htmlTopic = htmlSerializeFromMdIfNeeded(topic, { forceHTML: false }); | ||||
|             promises.push(cli.setRoomTopic(space.roomId, topic, htmlTopic)); | ||||
|         } | ||||
| 
 | ||||
|         const results = await Promise.allSettled(promises); | ||||
|  |  | |||
|  | @ -62,7 +62,11 @@ export function htmlSerializeIfNeeded( | |||
|         return escapeHtml(textSerialize(model)).replace(/\n/g, '<br/>'); | ||||
|     } | ||||
| 
 | ||||
|     let md = mdSerialize(model); | ||||
|     const md = mdSerialize(model); | ||||
|     return htmlSerializeFromMdIfNeeded(md, { forceHTML }); | ||||
| } | ||||
| 
 | ||||
| export function htmlSerializeFromMdIfNeeded(md: string, { forceHTML = false } = {}): string { | ||||
|     // copy of raw input to remove unwanted math later
 | ||||
|     const orig = md; | ||||
| 
 | ||||
|  |  | |||
|  | @ -19,14 +19,17 @@ import { EventType } from "matrix-js-sdk/src/@types/event"; | |||
| import { MatrixEvent } from "matrix-js-sdk/src/models/event"; | ||||
| import { Room } from "matrix-js-sdk/src/models/room"; | ||||
| import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; | ||||
| import { parseTopicContent, TopicState } from "matrix-js-sdk/src/content-helpers"; | ||||
| import { MRoomTopicEventContent } from "matrix-js-sdk/src/@types/topic"; | ||||
| 
 | ||||
| import { useTypedEventEmitter } from "../useEventEmitter"; | ||||
| 
 | ||||
| export const getTopic = (room: Room) => { | ||||
|     return room?.currentState?.getStateEvents(EventType.RoomTopic, "")?.getContent()?.topic; | ||||
|     const content: MRoomTopicEventContent = room?.currentState?.getStateEvents(EventType.RoomTopic, "")?.getContent(); | ||||
|     return !!content ? parseTopicContent(content) : null; | ||||
| }; | ||||
| 
 | ||||
| export function useTopic(room: Room): string { | ||||
| export function useTopic(room: Room): TopicState { | ||||
|     const [topic, setTopic] = useState(getTopic(room)); | ||||
|     useTypedEventEmitter(room.currentState, RoomStateEvent.Events, (ev: MatrixEvent) => { | ||||
|         if (ev.getType() !== EventType.RoomTopic) return; | ||||
|  |  | |||
|  | @ -892,6 +892,7 @@ | |||
|     "Offline encrypted messaging using dehydrated devices": "Offline encrypted messaging using dehydrated devices", | ||||
|     "Show extensible event representation of events": "Show extensible event representation of events", | ||||
|     "Show current avatar and name for users in message history": "Show current avatar and name for users in message history", | ||||
|     "Show HTML representation of room topics": "Show HTML representation of room topics", | ||||
|     "Show info about bridges in room settings": "Show info about bridges in room settings", | ||||
|     "Use new room breadcrumbs": "Use new room breadcrumbs", | ||||
|     "New search experience": "New search experience", | ||||
|  |  | |||
|  | @ -330,6 +330,13 @@ export const SETTINGS: {[setting: string]: ISetting} = { | |||
|         supportedLevels: [SettingLevel.ACCOUNT], | ||||
|         default: null, | ||||
|     }, | ||||
|     "feature_html_topic": { | ||||
|         isFeature: true, | ||||
|         labsGroup: LabGroup.Rooms, | ||||
|         supportedLevels: LEVELS_FEATURE, | ||||
|         displayName: _td("Show HTML representation of room topics"), | ||||
|         default: false, | ||||
|     }, | ||||
|     "feature_bridge_state": { | ||||
|         isFeature: true, | ||||
|         labsGroup: LabGroup.Rooms, | ||||
|  |  | |||
|  | @ -0,0 +1,66 @@ | |||
| /* | ||||
| Copyright 2022 The Matrix.org Foundation C.I.C. | ||||
| 
 | ||||
| Licensed under the Apache License, Version 2.0 (the "License"); | ||||
| you may not use this file except in compliance with the License. | ||||
| You may obtain a copy of the License at | ||||
| 
 | ||||
|     http://www.apache.org/licenses/LICENSE-2.0
 | ||||
| 
 | ||||
| Unless required by applicable law or agreed to in writing, software | ||||
| distributed under the License is distributed on an "AS IS" BASIS, | ||||
| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||
| See the License for the specific language governing permissions and | ||||
| limitations under the License. | ||||
| */ | ||||
| 
 | ||||
| import React from 'react'; | ||||
| import { mount } from 'enzyme'; | ||||
| import { mocked } from 'jest-mock'; | ||||
| 
 | ||||
| import { topicToHtml } from '../src/HtmlUtils'; | ||||
| import SettingsStore from '../src/settings/SettingsStore'; | ||||
| 
 | ||||
| jest.mock("../src/settings/SettingsStore"); | ||||
| 
 | ||||
| const enableHtmlTopicFeature = () => { | ||||
|     mocked(SettingsStore).getValue.mockImplementation((arg) => { | ||||
|         return arg === "feature_html_topic"; | ||||
|     }); | ||||
| }; | ||||
| 
 | ||||
| describe('HtmlUtils', () => { | ||||
|     it('converts plain text topic to HTML', () => { | ||||
|         const component = mount(<div>{ topicToHtml("pizza", null, null, false) }</div>); | ||||
|         const wrapper = component.render(); | ||||
|         expect(wrapper.children().first().html()).toEqual("pizza"); | ||||
|     }); | ||||
| 
 | ||||
|     it('converts plain text topic with emoji to HTML', () => { | ||||
|         const component = mount(<div>{ topicToHtml("pizza 🍕", null, null, false) }</div>); | ||||
|         const wrapper = component.render(); | ||||
|         expect(wrapper.children().first().html()).toEqual("pizza <span class=\"mx_Emoji\" title=\":pizza:\">🍕</span>"); | ||||
|     }); | ||||
| 
 | ||||
|     it('converts literal HTML topic to HTML', async () => { | ||||
|         enableHtmlTopicFeature(); | ||||
|         const component = mount(<div>{ topicToHtml("<b>pizza</b>", null, null, false) }</div>); | ||||
|         const wrapper = component.render(); | ||||
|         expect(wrapper.children().first().html()).toEqual("<b>pizza</b>"); | ||||
|     }); | ||||
| 
 | ||||
|     it('converts true HTML topic to HTML', async () => { | ||||
|         enableHtmlTopicFeature(); | ||||
|         const component = mount(<div>{ topicToHtml("**pizza**", "<b>pizza</b>", null, false) }</div>); | ||||
|         const wrapper = component.render(); | ||||
|         expect(wrapper.children().first().html()).toEqual("<b>pizza</b>"); | ||||
|     }); | ||||
| 
 | ||||
|     it('converts true HTML topic with emoji to HTML', async () => { | ||||
|         enableHtmlTopicFeature(); | ||||
|         const component = mount(<div>{ topicToHtml("**pizza** 🍕", "<b>pizza</b> 🍕", null, false) }</div>); | ||||
|         const wrapper = component.render(); | ||||
|         expect(wrapper.children().first().html()) | ||||
|             .toEqual("<b>pizza</b> <span class=\"mx_Emoji\" title=\":pizza:\">🍕</span>"); | ||||
|     }); | ||||
| }); | ||||
|  | @ -0,0 +1,40 @@ | |||
| /* | ||||
| Copyright 2022 The Matrix.org Foundation C.I.C. | ||||
| 
 | ||||
| Licensed under the Apache License, Version 2.0 (the "License"); | ||||
| you may not use this file except in compliance with the License. | ||||
| You may obtain a copy of the License at | ||||
| 
 | ||||
|     http://www.apache.org/licenses/LICENSE-2.0
 | ||||
| 
 | ||||
| Unless required by applicable law or agreed to in writing, software | ||||
| distributed under the License is distributed on an "AS IS" BASIS, | ||||
| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||
| See the License for the specific language governing permissions and | ||||
| limitations under the License. | ||||
| */ | ||||
| 
 | ||||
| import { MatrixClient } from 'matrix-js-sdk/src/matrix'; | ||||
| 
 | ||||
| import { getCommand } from '../src/SlashCommands'; | ||||
| import { createTestClient } from './test-utils'; | ||||
| import { MatrixClientPeg } from '../src/MatrixClientPeg'; | ||||
| 
 | ||||
| describe('SlashCommands', () => { | ||||
|     let client: MatrixClient; | ||||
| 
 | ||||
|     beforeEach(() => { | ||||
|         client = createTestClient(); | ||||
|         jest.spyOn(MatrixClientPeg, 'get').mockReturnValue(client); | ||||
|     }); | ||||
| 
 | ||||
|     describe('/topic', () => { | ||||
|         it('sets topic', async () => { | ||||
|             const command = getCommand("/topic pizza"); | ||||
|             expect(command.cmd).toBeDefined(); | ||||
|             expect(command.args).toBeDefined(); | ||||
|             await command.cmd.run("room-id", null, command.args); | ||||
|             expect(client.setRoomTopic).toHaveBeenCalledWith("room-id", "pizza", undefined); | ||||
|         }); | ||||
|     }); | ||||
| }); | ||||
|  | @ -94,5 +94,12 @@ describe('editor/serialize', function() { | |||
|             const html = htmlSerializeIfNeeded(model, { useMarkdown: false }); | ||||
|             expect(html).toBe('\\*hello\\* world < hey world!'); | ||||
|         }); | ||||
| 
 | ||||
|         it('plaintext remains plaintext even when forcing html', function() { | ||||
|             const pc = createPartCreator(); | ||||
|             const model = new EditorModel([pc.plain("hello world")], pc); | ||||
|             const html = htmlSerializeIfNeeded(model, { forceHTML: true, useMarkdown: false }); | ||||
|             expect(html).toBe("hello world"); | ||||
|         }); | ||||
|     }); | ||||
| }); | ||||
|  |  | |||
|  | @ -116,6 +116,7 @@ export function createTestClient(): MatrixClient { | |||
|         mxcUrlToHttp: (mxc) => `http://this.is.a.url/${mxc.substring(6)}`, | ||||
|         setAccountData: jest.fn(), | ||||
|         setRoomAccountData: jest.fn(), | ||||
|         setRoomTopic: jest.fn(), | ||||
|         sendTyping: jest.fn().mockResolvedValue({}), | ||||
|         sendMessage: () => jest.fn().mockResolvedValue({}), | ||||
|         sendStateEvent: jest.fn().mockResolvedValue(undefined), | ||||
|  |  | |||
|  | @ -42,7 +42,7 @@ describe("useTopic", () => { | |||
| 
 | ||||
|         function RoomTopic() { | ||||
|             const topic = useTopic(room); | ||||
|             return <p>{ topic }</p>; | ||||
|             return <p>{ topic.text }</p>; | ||||
|         } | ||||
| 
 | ||||
|         const wrapper = mount(<RoomTopic />); | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue
	
	 Johannes Marbach
						Johannes Marbach