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 all commits
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
67 changes: 67 additions & 0 deletions res/css/_common.scss
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,73 @@ legend {
overflow-y: auto;
}

// Styles copied/inspired by GroupLayout, ReplyTile, and EventTile variants.
.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 @@ -166,6 +166,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 @@ -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) {
}
Expand Down Expand Up @@ -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'.
*
Expand Down
27 changes: 17 additions & 10 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-org/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,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();
},
Expand Down Expand Up @@ -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);
Expand Down
7 changes: 5 additions & 2 deletions src/components/views/elements/RoomTopic.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -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,
Expand All @@ -74,7 +77,7 @@ export default function RoomTopic({
}
}}
>
{ topic }
{ body }
</Linkify>
{ canSetTopic && <AccessibleButton
kind="primary_outline"
Expand All @@ -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>;
Expand Down
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
6 changes: 4 additions & 2 deletions src/components/views/spaces/SpaceSettingsGeneralTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

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
7 changes: 5 additions & 2 deletions src/hooks/room/useTopic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
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 @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions src/settings/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,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
Loading