Skip to content

Commit

Permalink
Add support for MD / HTML in room topics
Browse files Browse the repository at this point in the history
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: element-hq/element-web#5180
Signed-off-by: Johannes Marbach <johannesm@element.io>

[MSC1767]: matrix-org/matrix-spec-proposals#1767
  • Loading branch information
Johennes committed May 2, 2022
1 parent 3e31fdb commit 78f8d38
Show file tree
Hide file tree
Showing 12 changed files with 186 additions and 22 deletions.
66 changes: 66 additions & 0 deletions res/css/_common.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions res/css/views/rooms/_RoomHeader.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
63 changes: 63 additions & 0 deletions src/HtmlUtils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends React.ReactNode> {
constructor(public highlightClass: string, public highlightLink: string) {
}
Expand Down Expand Up @@ -602,6 +614,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'.
*
Expand Down
19 changes: 13 additions & 6 deletions src/SlashCommands.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,15 @@ 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';
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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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: <div dangerouslySetInnerHTML={{ __html: topicHtml }} />,
description: <div ref={ref}>{ body }</div>,
hasCloseButton: true,
className: "markdown-body",
});
return success();
},
Expand Down
8 changes: 4 additions & 4 deletions src/components/structures/SpaceRoomView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -292,9 +292,9 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }: ISp
</h1>
<SpaceInfo space={space} />
<RoomTopic room={space}>
{ (topic, ref) =>
{ (title, body, ref) =>
<div className="mx_SpaceRoomView_preview_topic" ref={ref}>
{ topic }
{ body }
</div>
}
</RoomTopic>
Expand Down Expand Up @@ -460,9 +460,9 @@ const SpaceLanding = ({ space }: { space: Room }) => {
</div>
</div>
<RoomTopic room={space}>
{ (topic, ref) => (
{ (title, body, ref) => (
<div className="mx_SpaceRoomView_landing_topic" ref={ref}>
{ topic }
{ body }
</div>
) }
</RoomTopic>
Expand Down
18 changes: 12 additions & 6 deletions src/components/views/elements/RoomTopic.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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 <span ref={ref}>{ topic }</span>;
const body = topicToHtml(topic?.text, topic?.html, ref);

if (children) return children(topic?.text, body, ref);
return <span ref={ref}>{ body }</span>;
};

export default RoomTopic;
4 changes: 3 additions & 1 deletion src/components/views/room_settings/RoomProfileSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}

Expand Down
4 changes: 2 additions & 2 deletions src/components/views/rooms/RoomHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -187,8 +187,8 @@ export default class RoomHeader extends React.Component<IProps, IState> {
);

const topicElement = <RoomTopic room={this.props.room}>
{ (topic, ref) => <div className="mx_RoomHeader_topic" ref={ref} title={topic} dir="auto">
{ topic }
{ (title, body, ref) => <div className="mx_RoomHeader_topic" ref={ref} title={title} dir="auto">
{ body }
</div> }
</RoomTopic>;

Expand Down
6 changes: 4 additions & 2 deletions src/components/views/spaces/SpaceSettingsGeneralTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 5 additions & 1 deletion src/editor/serialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
1 change: 1 addition & 0 deletions src/i18n/strings/en_EN.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions src/settings/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down

0 comments on commit 78f8d38

Please sign in to comment.