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

Commit abd39c6

Browse files
Johennesturt2live
andauthored
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: element-hq/element-web#5180 Signed-off-by: Johannes Marbach <[email protected]> [MSC1767]: matrix-org/matrix-spec-proposals#1767 * Fix build error * Add comment to explain origin of styles Co-authored-by: Travis Ralston <[email protected]> * 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 <[email protected]> * Add blank line Co-authored-by: Travis Ralston <[email protected]> * 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 <[email protected]> Co-authored-by: Travis Ralston <[email protected]>
1 parent 8036985 commit abd39c6

File tree

16 files changed

+298
-19
lines changed

16 files changed

+298
-19
lines changed

res/css/_common.scss

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,73 @@ legend {
304304
overflow-y: auto;
305305
}
306306

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

res/css/views/rooms/_RoomHeader.scss

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,12 @@ limitations under the License.
166166
display: -webkit-box;
167167
}
168168

169+
.mx_RoomHeader_topic .mx_Emoji {
170+
// Undo font size increase to prevent vertical cropping and ensure the same size
171+
// as in plain text emojis
172+
font-size: inherit;
173+
}
174+
169175
.mx_RoomHeader_avatar {
170176
flex: 0;
171177
margin: 0 6px 0 7px;

src/HtmlUtils.tsx

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,18 @@ const composerSanitizeHtmlParams: IExtendedSanitizeOptions = {
323323
},
324324
};
325325

326+
// reduced set of allowed tags to avoid turning topics into Myspace
327+
const topicSanitizeHtmlParams: IExtendedSanitizeOptions = {
328+
...sanitizeHtmlParams,
329+
allowedTags: [
330+
'font', // custom to matrix for IRC-style font coloring
331+
'del', // for markdown
332+
'a', 'sup', 'sub',
333+
'b', 'i', 'u', 'strong', 'em', 'strike', 'br', 'div',
334+
'span',
335+
],
336+
};
337+
326338
abstract class BaseHighlighter<T extends React.ReactNode> {
327339
constructor(public highlightClass: string, public highlightLink: string) {
328340
}
@@ -606,6 +618,57 @@ export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts
606618
</span>;
607619
}
608620

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

src/SlashCommands.tsx

Lines changed: 17 additions & 10 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-org/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,19 @@ 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
480+
? ContentHelpers.parseTopicContent(content)
481+
: { text: _t('This room has no topic.') };
482+
483+
const ref = e => e && linkifyElement(e);
484+
const body = topicToHtml(topic.text, topic.html, ref, true);
478485

479486
Modal.createTrackedDialog('Slash Commands', 'Topic', InfoDialog, {
480487
title: room.name,
481-
description: <div dangerouslySetInnerHTML={{ __html: topicHtml }} />,
488+
description: <div ref={ref}>{ body }</div>,
482489
hasCloseButton: true,
490+
className: "markdown-body",
483491
});
484492
return success();
485493
},
@@ -1333,11 +1341,10 @@ interface ICmd {
13331341
}
13341342

13351343
/**
1336-
* Process the given text for /commands and return a bound method to perform them.
1344+
* Process the given text for /commands and returns a parsed command that can be used for running the operation.
13371345
* @param {string} input The raw text input by the user.
1338-
* @return {null|function(): Object} Function returning an object with the property 'error' if there was an error
1339-
* processing the command, or 'promise' if a request was sent out.
1340-
* Returns null if the input didn't match a command.
1346+
* @return {ICmd} The parsed command object.
1347+
* Returns an empty object if the input didn't match a command.
13411348
*/
13421349
export function getCommand(input: string): ICmd {
13431350
const { cmd, args } = parseCommandString(input);

src/components/views/elements/RoomTopic.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import MatrixClientContext from "../../../contexts/MatrixClientContext";
3131
import AccessibleButton from "./AccessibleButton";
3232
import { Linkify } from "./Linkify";
3333
import TooltipTarget from "./TooltipTarget";
34+
import { topicToHtml } from "../../../HtmlUtils";
3435

3536
interface IProps extends React.HTMLProps<HTMLDivElement> {
3637
room?: Room;
@@ -44,6 +45,7 @@ export default function RoomTopic({
4445
const ref = useRef<HTMLDivElement>();
4546

4647
const topic = useTopic(room);
48+
const body = topicToHtml(topic?.text, topic?.html, ref);
4749

4850
const onClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
4951
props.onClick?.(e);
@@ -62,6 +64,7 @@ export default function RoomTopic({
6264
useDispatcher(dis, (payload) => {
6365
if (payload.action === Action.ShowRoomTopic) {
6466
const canSetTopic = room.currentState.maySendStateEvent(EventType.RoomTopic, client.getUserId());
67+
const body = topicToHtml(topic?.text, topic?.html, ref, true);
6568

6669
const modal = Modal.createDialog(InfoDialog, {
6770
title: room.name,
@@ -74,7 +77,7 @@ export default function RoomTopic({
7477
}
7578
}}
7679
>
77-
{ topic }
80+
{ body }
7881
</Linkify>
7982
{ canSetTopic && <AccessibleButton
8083
kind="primary_outline"
@@ -101,7 +104,7 @@ export default function RoomTopic({
101104
>
102105
<TooltipTarget label={_t("Click to read topic")} alignment={Alignment.Bottom} ignoreHover={ignoreHover}>
103106
<Linkify>
104-
{ topic }
107+
{ body }
105108
</Linkify>
106109
</TooltipTarget>
107110
</div>;

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/spaces/SpaceSettingsGeneralTab.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import AccessibleButton from "../elements/AccessibleButton";
2525
import SpaceBasicSettings from "./SpaceBasicSettings";
2626
import { avatarUrlForRoom } from "../../../Avatar";
2727
import { IDialogProps } from "../dialogs/IDialogProps";
28+
import { htmlSerializeFromMdIfNeeded } from "../../../editor/serialize";
2829
import { leaveSpace } from "../../../utils/leave-behaviour";
2930
import { getTopic } from "../../../hooks/room/useTopic";
3031

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

src/hooks/room/useTopic.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,17 @@ import { EventType } from "matrix-js-sdk/src/@types/event";
1919
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
2020
import { Room } from "matrix-js-sdk/src/models/room";
2121
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
22+
import { parseTopicContent, TopicState } from "matrix-js-sdk/src/content-helpers";
23+
import { MRoomTopicEventContent } from "matrix-js-sdk/src/@types/topic";
2224

2325
import { useTypedEventEmitter } from "../useEventEmitter";
2426

2527
export const getTopic = (room: Room) => {
26-
return room?.currentState?.getStateEvents(EventType.RoomTopic, "")?.getContent()?.topic;
28+
const content: MRoomTopicEventContent = room?.currentState?.getStateEvents(EventType.RoomTopic, "")?.getContent();
29+
return !!content ? parseTopicContent(content) : null;
2730
};
2831

29-
export function useTopic(room: Room): string {
32+
export function useTopic(room: Room): TopicState {
3033
const [topic, setTopic] = useState(getTopic(room));
3134
useTypedEventEmitter(room.currentState, RoomStateEvent.Events, (ev: MatrixEvent) => {
3235
if (ev.getType() !== EventType.RoomTopic) return;

src/i18n/strings/en_EN.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -892,6 +892,7 @@
892892
"Offline encrypted messaging using dehydrated devices": "Offline encrypted messaging using dehydrated devices",
893893
"Show extensible event representation of events": "Show extensible event representation of events",
894894
"Show current avatar and name for users in message history": "Show current avatar and name for users in message history",
895+
"Show HTML representation of room topics": "Show HTML representation of room topics",
895896
"Show info about bridges in room settings": "Show info about bridges in room settings",
896897
"Use new room breadcrumbs": "Use new room breadcrumbs",
897898
"New search experience": "New search experience",

src/settings/Settings.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,13 @@ export const SETTINGS: {[setting: string]: ISetting} = {
330330
supportedLevels: [SettingLevel.ACCOUNT],
331331
default: null,
332332
},
333+
"feature_html_topic": {
334+
isFeature: true,
335+
labsGroup: LabGroup.Rooms,
336+
supportedLevels: LEVELS_FEATURE,
337+
displayName: _td("Show HTML representation of room topics"),
338+
default: false,
339+
},
333340
"feature_bridge_state": {
334341
isFeature: true,
335342
labsGroup: LabGroup.Rooms,

0 commit comments

Comments
 (0)