Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit 78f8d38

Browse files
committed
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: element-hq/element-web#5180 Signed-off-by: Johannes Marbach <[email protected]> [MSC1767]: matrix-org/matrix-spec-proposals#1767
1 parent 3e31fdb commit 78f8d38

File tree

12 files changed

+186
-22
lines changed

12 files changed

+186
-22
lines changed

res/css/_common.scss

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,72 @@ legend {
303303
overflow-y: auto;
304304
}
305305

306+
.mx_Dialog .markdown-body {
307+
font-family: inherit !important;
308+
white-space: normal !important;
309+
line-height: inherit !important;
310+
color: inherit; // inherit the colour from the dark or light theme by default (but not for code blocks)
311+
font-size: $font-14px;
312+
313+
pre,
314+
code {
315+
font-family: $monospace-font-family !important;
316+
background-color: $codeblock-background-color;
317+
}
318+
319+
// this selector wrongly applies to code blocks too but we will unset it in the next one
320+
code {
321+
white-space: pre-wrap; // don't collapse spaces in inline code blocks
322+
}
323+
324+
pre code {
325+
white-space: pre; // we want code blocks to be scrollable and not wrap
326+
327+
>* {
328+
display: inline;
329+
}
330+
}
331+
332+
pre {
333+
// have to use overlay rather than auto otherwise Linux and Windows
334+
// Chrome gets very confused about vertical spacing:
335+
// https://github.com/vector-im/vector-web/issues/754
336+
overflow-x: overlay;
337+
overflow-y: visible;
338+
339+
&::-webkit-scrollbar-corner {
340+
background: transparent;
341+
}
342+
}
343+
}
344+
345+
.mx_Dialog .markdown-body h1,
346+
.mx_Dialog .markdown-body h2,
347+
.mx_Dialog .markdown-body h3,
348+
.mx_Dialog .markdown-body h4,
349+
.mx_Dialog .markdown-body h5,
350+
.mx_Dialog .markdown-body h6 {
351+
font-family: inherit !important;
352+
color: inherit;
353+
}
354+
355+
/* Make h1 and h2 the same size as h3. */
356+
.mx_Dialog .markdown-body h1,
357+
.mx_Dialog .markdown-body h2 {
358+
font-size: 1.5em;
359+
border-bottom: none !important; // override GFM
360+
}
361+
362+
.mx_Dialog .markdown-body a {
363+
color: $accent-alt;
364+
}
365+
366+
.mx_Dialog .markdown-body blockquote {
367+
border-left: 2px solid $blockquote-bar-color;
368+
border-radius: 2px;
369+
padding: 0 10px;
370+
}
371+
306372
.mx_Dialog_fixedWidth {
307373
width: 60vw;
308374
max-width: 704px;

res/css/views/rooms/_RoomHeader.scss

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,12 @@ limitations under the License.
161161
display: -webkit-box;
162162
}
163163

164+
.mx_RoomHeader_topic .mx_Emoji {
165+
// Undo font size increase to prevent vertical cropping and ensure the same size
166+
// as in plain text emojis
167+
font-size: inherit;
168+
}
169+
164170
.mx_RoomHeader_avatar {
165171
flex: 0;
166172
margin: 0 6px 0 7px;

src/HtmlUtils.tsx

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,18 @@ const composerSanitizeHtmlParams: IExtendedSanitizeOptions = {
319319
},
320320
};
321321

322+
// reduced set of allowed tags to avoid turning topics into Myspace
323+
const topicSanitizeHtmlParams: IExtendedSanitizeOptions = {
324+
...sanitizeHtmlParams,
325+
allowedTags: [
326+
'font', // custom to matrix for IRC-style font coloring
327+
'del', // for markdown
328+
'a', 'sup', 'sub',
329+
'b', 'i', 'u', 'strong', 'em', 'strike', 'br', 'div',
330+
'span',
331+
],
332+
};
333+
322334
abstract class BaseHighlighter<T extends React.ReactNode> {
323335
constructor(public highlightClass: string, public highlightLink: string) {
324336
}
@@ -602,6 +614,57 @@ export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts
602614
</span>;
603615
}
604616

617+
/**
618+
* Turn a room topic into html
619+
* @param topic plain text topic
620+
* @param htmlTopic optional html topic
621+
* @param ref React ref to attach to any React components returned
622+
* @param allowExtendedHtml whether to allow extended HTML tags such as headings and lists
623+
* @return The HTML-ified node.
624+
*/
625+
export function topicToHtml(
626+
topic: string,
627+
htmlTopic?: string,
628+
ref?: React.Ref<HTMLSpanElement>,
629+
allowExtendedHtml = false,
630+
): ReactNode {
631+
if (!SettingsStore.getValue("feature_html_topic")) {
632+
htmlTopic = null;
633+
}
634+
635+
let isFormattedTopic = !!htmlTopic;
636+
let topicHasEmoji = false;
637+
let safeTopic = "";
638+
639+
try {
640+
topicHasEmoji = mightContainEmoji(isFormattedTopic ? htmlTopic : topic);
641+
642+
if (isFormattedTopic) {
643+
safeTopic = sanitizeHtml(htmlTopic, allowExtendedHtml ? sanitizeHtmlParams : topicSanitizeHtmlParams);
644+
if (topicHasEmoji) {
645+
safeTopic = formatEmojis(safeTopic, true).join('');
646+
}
647+
}
648+
} catch {
649+
isFormattedTopic = false; // Fall back to plain-text topic
650+
}
651+
652+
let emojiBodyElements: ReturnType<typeof formatEmojis>;
653+
if (!isFormattedTopic && topicHasEmoji) {
654+
emojiBodyElements = formatEmojis(topic, false);
655+
}
656+
657+
return isFormattedTopic ?
658+
<span
659+
key="body"
660+
ref={ref}
661+
dangerouslySetInnerHTML={{ __html: safeTopic }}
662+
dir="auto"
663+
/> : <span key="body" ref={ref} dir="auto">
664+
{ emojiBodyElements || topic }
665+
</span>;
666+
}
667+
605668
/**
606669
* Linkifies the given string. This is a wrapper around 'linkifyjs/string'.
607670
*

src/SlashCommands.tsx

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,15 @@ import * as ContentHelpers from 'matrix-js-sdk/src/content-helpers';
2525
import { Element as ChildElement, parseFragment as parseHtml } from "parse5";
2626
import { logger } from "matrix-js-sdk/src/logger";
2727
import { IContent } from 'matrix-js-sdk/src/models/event';
28+
import { MRoomTopicEventContent } from 'matrix-js-sdk/src/@types/topic';
2829
import { SlashCommand as SlashCommandEvent } from "matrix-analytics-events/types/typescript/SlashCommand";
2930

3031
import { MatrixClientPeg } from './MatrixClientPeg';
3132
import dis from './dispatcher/dispatcher';
3233
import { _t, _td, ITranslatableError, newTranslatableError } from './languageHandler';
3334
import Modal from './Modal';
3435
import MultiInviter from './utils/MultiInviter';
35-
import { linkifyAndSanitizeHtml } from './HtmlUtils';
36+
import { linkifyElement, topicToHtml } from './HtmlUtils';
3637
import QuestionDialog from "./components/views/dialogs/QuestionDialog";
3738
import WidgetUtils from "./utils/WidgetUtils";
3839
import { textToHtmlRainbow } from "./utils/colour";
@@ -66,6 +67,7 @@ import { XOR } from "./@types/common";
6667
import { PosthogAnalytics } from "./PosthogAnalytics";
6768
import { ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload";
6869
import VoipUserMapper from './VoipUserMapper';
70+
import { htmlSerializeFromMdIfNeeded } from './editor/serialize';
6971
import { leaveRoomBehaviour } from "./utils/leave-behaviour";
7072

7173
// XXX: workaround for https://github.com/microsoft/TypeScript/issues/31816
@@ -463,7 +465,8 @@ export const Commands = [
463465
runFn: function(roomId, args) {
464466
const cli = MatrixClientPeg.get();
465467
if (args) {
466-
return success(cli.setRoomTopic(roomId, args));
468+
const html = htmlSerializeFromMdIfNeeded(args, { forceHTML: false });
469+
return success(cli.setRoomTopic(roomId, args, html));
467470
}
468471
const room = cli.getRoom(roomId);
469472
if (!room) {
@@ -472,14 +475,18 @@ export const Commands = [
472475
);
473476
}
474477

475-
const topicEvents = room.currentState.getStateEvents('m.room.topic', '');
476-
const topic = topicEvents && topicEvents.getContent().topic;
477-
const topicHtml = topic ? linkifyAndSanitizeHtml(topic) : _t('This room has no topic.');
478+
const content: MRoomTopicEventContent = room.currentState.getStateEvents('m.room.topic', '')?.getContent();
479+
const topic = !!content ? ContentHelpers.parseTopicContent(content)
480+
: { text: _t('This room has no topic.') };
481+
482+
const ref = e => e && linkifyElement(e);
483+
const body = topicToHtml(topic.text, topic.html, ref, true);
478484

479485
Modal.createTrackedDialog('Slash Commands', 'Topic', InfoDialog, {
480486
title: room.name,
481-
description: <div dangerouslySetInnerHTML={{ __html: topicHtml }} />,
487+
description: <div ref={ref}>{ body }</div>,
482488
hasCloseButton: true,
489+
className: "markdown-body",
483490
});
484491
return success();
485492
},

src/components/structures/SpaceRoomView.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -292,9 +292,9 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }: ISp
292292
</h1>
293293
<SpaceInfo space={space} />
294294
<RoomTopic room={space}>
295-
{ (topic, ref) =>
295+
{ (title, body, ref) =>
296296
<div className="mx_SpaceRoomView_preview_topic" ref={ref}>
297-
{ topic }
297+
{ body }
298298
</div>
299299
}
300300
</RoomTopic>
@@ -460,9 +460,9 @@ const SpaceLanding = ({ space }: { space: Room }) => {
460460
</div>
461461
</div>
462462
<RoomTopic room={space}>
463-
{ (topic, ref) => (
463+
{ (title, body, ref) => (
464464
<div className="mx_SpaceRoomView_landing_topic" ref={ref}>
465-
{ topic }
465+
{ body }
466466
</div>
467467
) }
468468
</RoomTopic>

src/components/views/elements/RoomTopic.tsx

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,21 +14,25 @@ See the License for the specific language governing permissions and
1414
limitations under the License.
1515
*/
1616

17-
import React, { useEffect, useState } from "react";
17+
import React, { ReactNode, useEffect, useState } from "react";
1818
import { EventType } from "matrix-js-sdk/src/@types/event";
1919
import { Room } from "matrix-js-sdk/src/models/room";
2020
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
2121
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
22+
import { parseTopicContent } from "matrix-js-sdk/src/content-helpers";
2223

2324
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter";
24-
import { linkifyElement } from "../../../HtmlUtils";
25+
import { linkifyElement, topicToHtml } from "../../../HtmlUtils";
2526

2627
interface IProps {
2728
room?: Room;
28-
children?(topic: string, ref: (element: HTMLElement) => void): JSX.Element;
29+
children?(title: string, body: ReactNode, ref: (element: HTMLElement) => void): JSX.Element;
2930
}
3031

31-
export const getTopic = room => room?.currentState?.getStateEvents(EventType.RoomTopic, "")?.getContent()?.topic;
32+
export const getTopic = room => {
33+
const content = room?.currentState?.getStateEvents(EventType.RoomTopic, "")?.getContent();
34+
return !!content ? parseTopicContent(content) : null;
35+
};
3236

3337
const RoomTopic = ({ room, children }: IProps): JSX.Element => {
3438
const [topic, setTopic] = useState(getTopic(room));
@@ -41,8 +45,10 @@ const RoomTopic = ({ room, children }: IProps): JSX.Element => {
4145
}, [room]);
4246

4347
const ref = e => e && linkifyElement(e);
44-
if (children) return children(topic, ref);
45-
return <span ref={ref}>{ topic }</span>;
48+
const body = topicToHtml(topic?.text, topic?.html, ref);
49+
50+
if (children) return children(topic?.text, body, ref);
51+
return <span ref={ref}>{ body }</span>;
4652
};
4753

4854
export default RoomTopic;

src/components/views/room_settings/RoomProfileSettings.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import Field from "../elements/Field";
2222
import { mediaFromMxc } from "../../../customisations/Media";
2323
import AccessibleButton from "../elements/AccessibleButton";
2424
import AvatarSetting from "../settings/AvatarSetting";
25+
import { htmlSerializeFromMdIfNeeded } from '../../../editor/serialize';
2526
import { chromeFileInputFix } from "../../../utils/BrowserWorkarounds";
2627

2728
interface IProps {
@@ -142,7 +143,8 @@ export default class RoomProfileSettings extends React.Component<IProps, IState>
142143
}
143144

144145
if (this.state.originalTopic !== this.state.topic) {
145-
await client.setRoomTopic(this.props.roomId, this.state.topic);
146+
const html = htmlSerializeFromMdIfNeeded(this.state.topic, { forceHTML: false });
147+
await client.setRoomTopic(this.props.roomId, this.state.topic, html);
146148
newState.originalTopic = this.state.topic;
147149
}
148150

src/components/views/rooms/RoomHeader.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,8 +187,8 @@ export default class RoomHeader extends React.Component<IProps, IState> {
187187
);
188188

189189
const topicElement = <RoomTopic room={this.props.room}>
190-
{ (topic, ref) => <div className="mx_RoomHeader_topic" ref={ref} title={topic} dir="auto">
191-
{ topic }
190+
{ (title, body, ref) => <div className="mx_RoomHeader_topic" ref={ref} title={title} dir="auto">
191+
{ body }
192192
</div> }
193193
</RoomTopic>;
194194

src/components/views/spaces/SpaceSettingsGeneralTab.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import SpaceBasicSettings from "./SpaceBasicSettings";
2626
import { avatarUrlForRoom } from "../../../Avatar";
2727
import { IDialogProps } from "../dialogs/IDialogProps";
2828
import { getTopic } from "../elements/RoomTopic";
29+
import { htmlSerializeFromMdIfNeeded } from "../../../editor/serialize";
2930
import { leaveSpace } from "../../../utils/leave-behaviour";
3031

3132
interface IProps extends IDialogProps {
@@ -47,7 +48,7 @@ const SpaceSettingsGeneralTab = ({ matrixClient: cli, space, onFinished }: IProp
4748
const canSetName = space.currentState.maySendStateEvent(EventType.RoomName, userId);
4849
const nameChanged = name !== space.name;
4950

50-
const currentTopic = getTopic(space);
51+
const currentTopic = getTopic(space).text;
5152
const [topic, setTopic] = useState<string>(currentTopic);
5253
const canSetTopic = space.currentState.maySendStateEvent(EventType.RoomTopic, userId);
5354
const topicChanged = topic !== currentTopic;
@@ -77,7 +78,8 @@ const SpaceSettingsGeneralTab = ({ matrixClient: cli, space, onFinished }: IProp
7778
}
7879

7980
if (topicChanged) {
80-
promises.push(cli.setRoomTopic(space.roomId, topic));
81+
const htmlTopic = htmlSerializeFromMdIfNeeded(topic, { forceHTML: false });
82+
promises.push(cli.setRoomTopic(space.roomId, topic, htmlTopic));
8183
}
8284

8385
const results = await Promise.allSettled(promises);

src/editor/serialize.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,11 @@ export function htmlSerializeIfNeeded(
6262
return escapeHtml(textSerialize(model)).replace(/\n/g, '<br/>');
6363
}
6464

65-
let md = mdSerialize(model);
65+
const md = mdSerialize(model);
66+
return htmlSerializeFromMdIfNeeded(md, { forceHTML });
67+
}
68+
69+
export function htmlSerializeFromMdIfNeeded(md: string, { forceHTML = false } = {}): string {
6670
// copy of raw input to remove unwanted math later
6771
const orig = md;
6872

0 commit comments

Comments
 (0)