From 78f8d382045c0123828fd2c7fd40a9cb1e80128b Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Mon, 2 May 2022 20:07:34 +0200 Subject: [PATCH 01/19] 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 [MSC1767]: matrix-org/matrix-spec-proposals#1767 --- res/css/_common.scss | 66 +++++++++++++++++++ res/css/views/rooms/_RoomHeader.scss | 6 ++ src/HtmlUtils.tsx | 63 ++++++++++++++++++ src/SlashCommands.tsx | 19 ++++-- src/components/structures/SpaceRoomView.tsx | 8 +-- src/components/views/elements/RoomTopic.tsx | 18 +++-- .../room_settings/RoomProfileSettings.tsx | 4 +- src/components/views/rooms/RoomHeader.tsx | 4 +- .../views/spaces/SpaceSettingsGeneralTab.tsx | 6 +- src/editor/serialize.ts | 6 +- src/i18n/strings/en_EN.json | 1 + src/settings/Settings.tsx | 7 ++ 12 files changed, 186 insertions(+), 22 deletions(-) diff --git a/res/css/_common.scss b/res/css/_common.scss index 94ec5ea0115..0bd78c69481 100644 --- a/res/css/_common.scss +++ b/res/css/_common.scss @@ -303,6 +303,72 @@ legend { overflow-y: auto; } +.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; diff --git a/res/css/views/rooms/_RoomHeader.scss b/res/css/views/rooms/_RoomHeader.scss index 85c139402be..2025b16b825 100644 --- a/res/css/views/rooms/_RoomHeader.scss +++ b/res/css/views/rooms/_RoomHeader.scss @@ -161,6 +161,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; diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index ac26eccc718..08af7d1a8a4 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -319,6 +319,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 { constructor(public highlightClass: string, public highlightLink: string) { } @@ -602,6 +614,57 @@ export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts ; } +/** + * 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, + 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; + if (!isFormattedTopic && topicHasEmoji) { + emojiBodyElements = formatEmojis(topic, false); + } + + return isFormattedTopic ? + : + { emojiBodyElements || topic } + ; +} + /** * Linkifies the given string. This is a wrapper around 'linkifyjs/string'. * diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index 4227577d203..4a2f033926d 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -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-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,18 @@ 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:
, + description:
{ body }
, hasCloseButton: true, + className: "markdown-body", }); return success(); }, diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx index 1e9d5caa0cf..6bfb2e57008 100644 --- a/src/components/structures/SpaceRoomView.tsx +++ b/src/components/structures/SpaceRoomView.tsx @@ -292,9 +292,9 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }: ISp - { (topic, ref) => + { (title, body, ref) =>
- { topic } + { body }
}
@@ -460,9 +460,9 @@ const SpaceLanding = ({ space }: { space: Room }) => {
- { (topic, ref) => ( + { (title, body, ref) => (
- { topic } + { body }
) }
diff --git a/src/components/views/elements/RoomTopic.tsx b/src/components/views/elements/RoomTopic.tsx index 4120f6780e7..db7964d952a 100644 --- a/src/components/views/elements/RoomTopic.tsx +++ b/src/components/views/elements/RoomTopic.tsx @@ -14,21 +14,25 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { useEffect, useState } from "react"; +import React, { ReactNode, useEffect, useState } from "react"; import { EventType } from "matrix-js-sdk/src/@types/event"; import { Room } from "matrix-js-sdk/src/models/room"; import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { parseTopicContent } from "matrix-js-sdk/src/content-helpers"; import { useTypedEventEmitter } from "../../../hooks/useEventEmitter"; -import { linkifyElement } from "../../../HtmlUtils"; +import { linkifyElement, topicToHtml } from "../../../HtmlUtils"; interface IProps { room?: Room; - children?(topic: string, ref: (element: HTMLElement) => void): JSX.Element; + children?(title: string, body: ReactNode, ref: (element: HTMLElement) => void): JSX.Element; } -export const getTopic = room => room?.currentState?.getStateEvents(EventType.RoomTopic, "")?.getContent()?.topic; +export const getTopic = room => { + const content = room?.currentState?.getStateEvents(EventType.RoomTopic, "")?.getContent(); + return !!content ? parseTopicContent(content) : null; +}; const RoomTopic = ({ room, children }: IProps): JSX.Element => { const [topic, setTopic] = useState(getTopic(room)); @@ -41,8 +45,10 @@ const RoomTopic = ({ room, children }: IProps): JSX.Element => { }, [room]); const ref = e => e && linkifyElement(e); - if (children) return children(topic, ref); - return { topic }; + const body = topicToHtml(topic?.text, topic?.html, ref); + + if (children) return children(topic?.text, body, ref); + return { body }; }; export default RoomTopic; diff --git a/src/components/views/room_settings/RoomProfileSettings.tsx b/src/components/views/room_settings/RoomProfileSettings.tsx index 85f5ab7600d..6e4900478a5 100644 --- a/src/components/views/room_settings/RoomProfileSettings.tsx +++ b/src/components/views/room_settings/RoomProfileSettings.tsx @@ -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 } 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; } diff --git a/src/components/views/rooms/RoomHeader.tsx b/src/components/views/rooms/RoomHeader.tsx index 9983b6f39c3..c7eb3b0b0d9 100644 --- a/src/components/views/rooms/RoomHeader.tsx +++ b/src/components/views/rooms/RoomHeader.tsx @@ -187,8 +187,8 @@ export default class RoomHeader extends React.Component { ); const topicElement = - { (topic, ref) =>
- { topic } + { (title, body, ref) =>
+ { body }
} ; diff --git a/src/components/views/spaces/SpaceSettingsGeneralTab.tsx b/src/components/views/spaces/SpaceSettingsGeneralTab.tsx index b572122da1b..838e6aea6b9 100644 --- a/src/components/views/spaces/SpaceSettingsGeneralTab.tsx +++ b/src/components/views/spaces/SpaceSettingsGeneralTab.tsx @@ -26,6 +26,7 @@ import SpaceBasicSettings from "./SpaceBasicSettings"; import { avatarUrlForRoom } from "../../../Avatar"; import { IDialogProps } from "../dialogs/IDialogProps"; import { getTopic } from "../elements/RoomTopic"; +import { htmlSerializeFromMdIfNeeded } from "../../../editor/serialize"; import { leaveSpace } from "../../../utils/leave-behaviour"; interface IProps extends IDialogProps { @@ -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(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); diff --git a/src/editor/serialize.ts b/src/editor/serialize.ts index 7c4d62e9ab5..61e24a64ffa 100644 --- a/src/editor/serialize.ts +++ b/src/editor/serialize.ts @@ -62,7 +62,11 @@ export function htmlSerializeIfNeeded( return escapeHtml(textSerialize(model)).replace(/\n/g, '
'); } - 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; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 31c6f3383fd..506f6df37bb 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -894,6 +894,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", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index e4247908ddf..ff52ab14c2e 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -357,6 +357,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, From 36cb244c5ba91ebe443ee58b0c01775272705ffd Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Mon, 9 May 2022 20:22:07 +0200 Subject: [PATCH 02/19] Fix build error --- src/components/views/rooms/RoomPreviewCard.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/views/rooms/RoomPreviewCard.tsx b/src/components/views/rooms/RoomPreviewCard.tsx index e197a3259c5..a7e5abf7179 100644 --- a/src/components/views/rooms/RoomPreviewCard.tsx +++ b/src/components/views/rooms/RoomPreviewCard.tsx @@ -183,9 +183,9 @@ const RoomPreviewCard: FC = ({ room, onJoinButtonClicked, onRejectButton - { (topic, ref) => - topic ?
- { topic } + { (title, body, ref) => + body ?
+ { body }
: null } From e68273a9e855abc4dd11574d0e48200aaa7a4864 Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Wed, 11 May 2022 20:19:10 +0200 Subject: [PATCH 03/19] Add comment to explain origin of styles Co-authored-by: Travis Ralston --- res/css/_common.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/res/css/_common.scss b/res/css/_common.scss index db8a40a9dd8..b378dffffa5 100644 --- a/res/css/_common.scss +++ b/res/css/_common.scss @@ -303,6 +303,7 @@ 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; From ec16b106466646a864d2cc1b9382f969acc2cd5c Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Wed, 11 May 2022 20:58:05 +0200 Subject: [PATCH 04/19] Empty commit to retrigger build From 0894a5bcdb71270ca50716a28b47b22905a6506f Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Tue, 17 May 2022 20:14:26 +0200 Subject: [PATCH 05/19] Fix import grouping --- src/hooks/room/useTopic.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hooks/room/useTopic.ts b/src/hooks/room/useTopic.ts index 03e715a5ec0..3f276a4e645 100644 --- a/src/hooks/room/useTopic.ts +++ b/src/hooks/room/useTopic.ts @@ -19,11 +19,11 @@ 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 { useTypedEventEmitter } from "../useEventEmitter"; 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) => { const content: MRoomTopicEventContent = room?.currentState?.getStateEvents(EventType.RoomTopic, "")?.getContent(); return !!content ? parseTopicContent(content) : null; From a87d9b7f5663fddaecaedda8f70c7235186fc3bc Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Wed, 18 May 2022 09:04:19 +0200 Subject: [PATCH 06/19] Fix useTopic test --- test/useTopic-test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/useTopic-test.tsx b/test/useTopic-test.tsx index 75096b43e48..7052375eb87 100644 --- a/test/useTopic-test.tsx +++ b/test/useTopic-test.tsx @@ -42,7 +42,7 @@ describe("useTopic", () => { function RoomTopic() { const topic = useTopic(room); - return

{ topic }

; + return

{ topic.text }

; } const wrapper = mount(); From e889223f13ae0009d3fa2403b5093ce0331fafca Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Wed, 18 May 2022 20:38:58 +0200 Subject: [PATCH 07/19] Add tests for HtmlUtils --- test/HtmlUtils-test.tsx | 51 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 test/HtmlUtils-test.tsx diff --git a/test/HtmlUtils-test.tsx b/test/HtmlUtils-test.tsx new file mode 100644 index 00000000000..aceec93c46d --- /dev/null +++ b/test/HtmlUtils-test.tsx @@ -0,0 +1,51 @@ +/* +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 { topicToHtml } from '../src/HtmlUtils'; +import SettingsStore from '../src/settings/SettingsStore'; +import { SettingLevel } from '../src/settings/SettingLevel'; + +describe('HtmlUtils', () => { + it('converts plain text topic to HTML', () => { + const component = mount(
{ topicToHtml("pizza", null, null, false) }
); + const wrapper = component.render(); + expect(wrapper.text()).toEqual("pizza"); + }); + + it('converts plain text topic with emoji to HTML', () => { + const component = mount(
{ topicToHtml("🍕", null, null, false) }
); + const wrapper = component.render(); + expect(wrapper.find(".mx_Emoji").text()).toEqual("🍕"); + }); + + it('converts HTML topic to HTML', async () => { + await SettingsStore.setValue("feature_html_topic", null, SettingLevel.DEVICE, true); + const component = mount(
{ topicToHtml("**pizza**", "pizza", null, false) }
); + const wrapper = component.render(); + expect(wrapper.find("b").text()).toEqual("pizza"); + }); + + it('converts HTML topic with emoji to HTML', async () => { + await SettingsStore.setValue("feature_html_topic", null, SettingLevel.DEVICE, true); + const component = mount(
{ topicToHtml("**pizza** 🍕", "pizza 🍕", null, false) }
); + const wrapper = component.render(); + expect(wrapper.find("b").text()).toEqual("pizza"); + expect(wrapper.find(".mx_Emoji").text()).toEqual("🍕"); + }); +}); From 5c2ccf22922439c9430f1a03011b35abdf16d38b Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Thu, 19 May 2022 20:16:33 +0200 Subject: [PATCH 08/19] Add slash command test --- test/SlashCommands-test.tsx | 40 +++++++++++++++++++++++++++++++++++ test/test-utils/test-utils.ts | 1 + 2 files changed, 41 insertions(+) create mode 100644 test/SlashCommands-test.tsx diff --git a/test/SlashCommands-test.tsx b/test/SlashCommands-test.tsx new file mode 100644 index 00000000000..f2b15fb7294 --- /dev/null +++ b/test/SlashCommands-test.tsx @@ -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); + }); + }); +}); diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index 6db49c731d5..14fdb65046d 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -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), From 000c29a3669655cb1279078a5b9e21311780d35e Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Thu, 19 May 2022 20:16:50 +0200 Subject: [PATCH 09/19] Add further serialize test --- test/editor/serialize-test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/editor/serialize-test.ts b/test/editor/serialize-test.ts index d9482859015..06611b175f5 100644 --- a/test/editor/serialize-test.ts +++ b/test/editor/serialize-test.ts @@ -94,5 +94,11 @@ 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"); + }); }); }); From 215ea37ca0fb279cdc194c00ad9f358c2fd97e34 Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Fri, 20 May 2022 18:30:35 +0200 Subject: [PATCH 10/19] Fix ternary formatting Co-authored-by: Travis Ralston --- src/SlashCommands.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index 4a2f033926d..83cff047957 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -476,7 +476,8 @@ export const Commands = [ } const content: MRoomTopicEventContent = room.currentState.getStateEvents('m.room.topic', '')?.getContent(); - const topic = !!content ? ContentHelpers.parseTopicContent(content) + const topic = !!content + ? ContentHelpers.parseTopicContent(content) : { text: _t('This room has no topic.') }; const ref = e => e && linkifyElement(e); From 2f19de81e8545dc849f2efaf8c5d091c017f3d33 Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Fri, 20 May 2022 18:30:53 +0200 Subject: [PATCH 11/19] Add blank line Co-authored-by: Travis Ralston --- test/editor/serialize-test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/editor/serialize-test.ts b/test/editor/serialize-test.ts index 06611b175f5..dcad03c9c84 100644 --- a/test/editor/serialize-test.ts +++ b/test/editor/serialize-test.ts @@ -94,6 +94,7 @@ 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); From f2484725518713e9e252310db21d6f5a6a5c1339 Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Fri, 20 May 2022 18:39:42 +0200 Subject: [PATCH 12/19] Properly mock SettingsStore access --- test/HtmlUtils-test.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/test/HtmlUtils-test.tsx b/test/HtmlUtils-test.tsx index aceec93c46d..724c2583eb1 100644 --- a/test/HtmlUtils-test.tsx +++ b/test/HtmlUtils-test.tsx @@ -16,10 +16,18 @@ 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'; -import { SettingLevel } from '../src/settings/SettingLevel'; + +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', () => { @@ -35,14 +43,14 @@ describe('HtmlUtils', () => { }); it('converts HTML topic to HTML', async () => { - await SettingsStore.setValue("feature_html_topic", null, SettingLevel.DEVICE, true); + enableHtmlTopicFeature(); const component = mount(
{ topicToHtml("**pizza**", "pizza", null, false) }
); const wrapper = component.render(); expect(wrapper.find("b").text()).toEqual("pizza"); }); it('converts HTML topic with emoji to HTML', async () => { - await SettingsStore.setValue("feature_html_topic", null, SettingLevel.DEVICE, true); + enableHtmlTopicFeature(); const component = mount(
{ topicToHtml("**pizza** 🍕", "pizza 🍕", null, false) }
); const wrapper = component.render(); expect(wrapper.find("b").text()).toEqual("pizza"); From d7d74d5f1d37510f0ec3101c55c87d5d1e961f9a Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Fri, 20 May 2022 18:39:57 +0200 Subject: [PATCH 13/19] Remove trailing space --- src/SlashCommands.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index 83cff047957..b71d7b21733 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -476,7 +476,7 @@ export const Commands = [ } const content: MRoomTopicEventContent = room.currentState.getStateEvents('m.room.topic', '')?.getContent(); - const topic = !!content + const topic = !!content ? ContentHelpers.parseTopicContent(content) : { text: _t('This room has no topic.') }; From f1b08ecea4eef5677e257cba47efcb734a7f01a5 Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Fri, 20 May 2022 18:56:34 +0200 Subject: [PATCH 14/19] Assert on HTML content and add test for plain text in HTML parameter --- test/HtmlUtils-test.tsx | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/test/HtmlUtils-test.tsx b/test/HtmlUtils-test.tsx index 724c2583eb1..55f37156141 100644 --- a/test/HtmlUtils-test.tsx +++ b/test/HtmlUtils-test.tsx @@ -33,27 +33,33 @@ describe('HtmlUtils', () => { it('converts plain text topic to HTML', () => { const component = mount(
{ topicToHtml("pizza", null, null, false) }
); const wrapper = component.render(); - expect(wrapper.text()).toEqual("pizza"); + expect(wrapper.children().first().html()).toEqual("pizza"); }); it('converts plain text topic with emoji to HTML', () => { - const component = mount(
{ topicToHtml("🍕", null, null, false) }
); + const component = mount(
{ topicToHtml("pizza 🍕", null, null, false) }
); const wrapper = component.render(); - expect(wrapper.find(".mx_Emoji").text()).toEqual("🍕"); + expect(wrapper.children().first().html()).toEqual("pizza 🍕"); }); - it('converts HTML topic to HTML', async () => { + it('converts plain text HTML topic to HTML', async () => { + enableHtmlTopicFeature(); + const component = mount(
{ topicToHtml("pizza", "pizza", null, false) }
); + const wrapper = component.render(); + expect(wrapper.children().first().html()).toEqual("pizza"); + }); + + it('converts true HTML topic to HTML', async () => { enableHtmlTopicFeature(); const component = mount(
{ topicToHtml("**pizza**", "pizza", null, false) }
); const wrapper = component.render(); - expect(wrapper.find("b").text()).toEqual("pizza"); + expect(wrapper.children().first().html()).toEqual("pizza"); }); - it('converts HTML topic with emoji to HTML', async () => { + it('converts true HTML topic with emoji to HTML', async () => { enableHtmlTopicFeature(); const component = mount(
{ topicToHtml("**pizza** 🍕", "pizza 🍕", null, false) }
); const wrapper = component.render(); - expect(wrapper.find("b").text()).toEqual("pizza"); - expect(wrapper.find(".mx_Emoji").text()).toEqual("🍕"); + expect(wrapper.children().first().html()).toEqual("pizza 🍕"); }); }); From 553a02efe08b9bbbd6e9f8d4ade16024a319ddae Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Fri, 20 May 2022 19:08:28 +0200 Subject: [PATCH 15/19] Appease the linter --- test/HtmlUtils-test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/HtmlUtils-test.tsx b/test/HtmlUtils-test.tsx index 55f37156141..ff4a9a6804c 100644 --- a/test/HtmlUtils-test.tsx +++ b/test/HtmlUtils-test.tsx @@ -60,6 +60,7 @@ describe('HtmlUtils', () => { enableHtmlTopicFeature(); const component = mount(
{ topicToHtml("**pizza** 🍕", "pizza 🍕", null, false) }
); const wrapper = component.render(); - expect(wrapper.children().first().html()).toEqual("pizza 🍕"); + expect(wrapper.children().first().html()).toEqual( + "pizza 🍕"); }); }); From 87f114acbf097662c0aede04328dc2ae4f296da3 Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Wed, 25 May 2022 21:09:53 +0200 Subject: [PATCH 16/19] Fix JSDoc comment --- src/SlashCommands.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index b71d7b21733..167f747d08d 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -1341,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); From 04448ecb712410219b4ca5e68d6ca428c4f8d20e Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Wed, 25 May 2022 21:11:18 +0200 Subject: [PATCH 17/19] Fix toEqual call formatting --- test/HtmlUtils-test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/HtmlUtils-test.tsx b/test/HtmlUtils-test.tsx index ff4a9a6804c..006fe060dca 100644 --- a/test/HtmlUtils-test.tsx +++ b/test/HtmlUtils-test.tsx @@ -60,7 +60,7 @@ describe('HtmlUtils', () => { enableHtmlTopicFeature(); const component = mount(
{ topicToHtml("**pizza** 🍕", "pizza 🍕", null, false) }
); const wrapper = component.render(); - expect(wrapper.children().first().html()).toEqual( - "pizza 🍕"); + expect(wrapper.children().first().html()) + .toEqual("pizza 🍕"); }); }); From 91bf281228a301f03517c4f70b1d8ca99e324816 Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Wed, 25 May 2022 21:21:36 +0200 Subject: [PATCH 18/19] Repurpose test for literal HTML case --- test/HtmlUtils-test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/HtmlUtils-test.tsx b/test/HtmlUtils-test.tsx index 006fe060dca..de862b407a1 100644 --- a/test/HtmlUtils-test.tsx +++ b/test/HtmlUtils-test.tsx @@ -42,11 +42,11 @@ describe('HtmlUtils', () => { expect(wrapper.children().first().html()).toEqual("pizza 🍕"); }); - it('converts plain text HTML topic to HTML', async () => { + it('converts literal HTML topic to HTML', async () => { enableHtmlTopicFeature(); - const component = mount(
{ topicToHtml("pizza", "pizza", null, false) }
); + const component = mount(
{ topicToHtml("pizza", null, null, false) }
); const wrapper = component.render(); - expect(wrapper.children().first().html()).toEqual("pizza"); + expect(wrapper.children().first().html()).toEqual("<b>pizza</b>"); }); it('converts true HTML topic to HTML', async () => { From ee85b7d34b4955cb2ac526397f0cc99d3266a5a9 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 7 Jun 2022 14:03:48 -0600 Subject: [PATCH 19/19] Empty commit to fix CI