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>
This commit is contained in:
Johannes Marbach 2022-06-07 22:20:32 +02:00 committed by GitHub
parent 8036985204
commit abd39c61b1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 298 additions and 19 deletions

View file

@ -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'.
*

View file

@ -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);

View file

@ -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>;

View file

@ -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;
}

View file

@ -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);

View file

@ -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;

View file

@ -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;

View file

@ -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",

View file

@ -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,