Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for MD / HTML in room topics #8215

Merged
merged 26 commits into from
Jun 7, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
78f8d38
Add support for MD / HTML in room topics
Johennes May 2, 2022
93c7fcf
Merge branch 'develop' into feature/html-topic
Johennes May 9, 2022
36cb244
Fix build error
Johennes May 9, 2022
e68273a
Add comment to explain origin of styles
Johennes May 11, 2022
080a485
Merge branch 'develop' into feature/html-topic
Johennes May 11, 2022
ec16b10
Empty commit to retrigger build
Johennes May 11, 2022
21a67f6
Merge branch 'develop' into feature/html-topic
Johennes May 17, 2022
0894a5b
Fix import grouping
Johennes May 17, 2022
29a1597
Merge branch 'develop' into feature/html-topic
Johennes May 18, 2022
a87d9b7
Fix useTopic test
Johennes May 18, 2022
e889223
Add tests for HtmlUtils
Johennes May 18, 2022
5c2ccf2
Add slash command test
Johennes May 19, 2022
000c29a
Add further serialize test
Johennes May 19, 2022
215ea37
Fix ternary formatting
Johennes May 20, 2022
2f19de8
Add blank line
Johennes May 20, 2022
f248472
Properly mock SettingsStore access
Johennes May 20, 2022
d7d74d5
Remove trailing space
Johennes May 20, 2022
f1b08ec
Assert on HTML content and add test for plain text in HTML parameter
Johennes May 20, 2022
553a02e
Appease the linter
Johennes May 20, 2022
87f114a
Fix JSDoc comment
Johennes May 25, 2022
04448ec
Fix toEqual call formatting
Johennes May 25, 2022
91bf281
Repurpose test for literal HTML case
Johennes May 25, 2022
d8bf47b
Merge branch 'develop' into feature/html-topic
Johennes May 25, 2022
ee089c5
Merge branch 'develop' into feature/html-topic
turt2live Jun 7, 2022
eac9691
Merge branch 'develop' into feature/html-topic
turt2live Jun 7, 2022
ee85b7d
Empty commit to fix CI
turt2live Jun 7, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 {
Johennes marked this conversation as resolved.
Show resolved Hide resolved
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;
Johennes marked this conversation as resolved.
Show resolved Hide resolved
}

.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.') };
Johennes marked this conversation as resolved.
Show resolved Hide resolved

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;
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": {
Johennes marked this conversation as resolved.
Show resolved Hide resolved
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