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: vector-im/element-web#5180 Signed-off-by: Johannes Marbach <johannesm@element.io> [MSC1767]: matrix-org/matrix-spec-proposals#1767 * Fix build error * Add comment to explain origin of styles Co-authored-by: Travis Ralston <travpc@gmail.com> * 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 <travpc@gmail.com> * Add blank line Co-authored-by: Travis Ralston <travpc@gmail.com> * 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 <travpc@gmail.com> Co-authored-by: Travis Ralston <travisr@matrix.org>
This commit is contained in:
parent
8036985204
commit
abd39c61b1
16 changed files with 298 additions and 19 deletions
|
@ -304,6 +304,73 @@ legend {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Styles copied/inspired by GroupLayout, ReplyTile, and EventTile variants.
|
||||||
|
.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 {
|
.mx_Dialog_fixedWidth {
|
||||||
width: 60vw;
|
width: 60vw;
|
||||||
max-width: 704px;
|
max-width: 704px;
|
||||||
|
|
|
@ -166,6 +166,12 @@ limitations under the License.
|
||||||
display: -webkit-box;
|
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 {
|
.mx_RoomHeader_avatar {
|
||||||
flex: 0;
|
flex: 0;
|
||||||
margin: 0 6px 0 7px;
|
margin: 0 6px 0 7px;
|
||||||
|
|
|
@ -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> {
|
abstract class BaseHighlighter<T extends React.ReactNode> {
|
||||||
constructor(public highlightClass: string, public highlightLink: string) {
|
constructor(public highlightClass: string, public highlightLink: string) {
|
||||||
}
|
}
|
||||||
|
@ -606,6 +618,57 @@ export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts
|
||||||
</span>;
|
</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'.
|
* Linkifies the given string. This is a wrapper around 'linkifyjs/string'.
|
||||||
*
|
*
|
||||||
|
|
|
@ -25,6 +25,7 @@ import * as ContentHelpers from 'matrix-js-sdk/src/content-helpers';
|
||||||
import { Element as ChildElement, parseFragment as parseHtml } from "parse5";
|
import { Element as ChildElement, parseFragment as parseHtml } from "parse5";
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
import { IContent } from 'matrix-js-sdk/src/models/event';
|
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 { SlashCommand as SlashCommandEvent } from "@matrix-org/analytics-events/types/typescript/SlashCommand";
|
||||||
|
|
||||||
import { MatrixClientPeg } from './MatrixClientPeg';
|
import { MatrixClientPeg } from './MatrixClientPeg';
|
||||||
|
@ -32,7 +33,7 @@ import dis from './dispatcher/dispatcher';
|
||||||
import { _t, _td, ITranslatableError, newTranslatableError } from './languageHandler';
|
import { _t, _td, ITranslatableError, newTranslatableError } from './languageHandler';
|
||||||
import Modal from './Modal';
|
import Modal from './Modal';
|
||||||
import MultiInviter from './utils/MultiInviter';
|
import MultiInviter from './utils/MultiInviter';
|
||||||
import { linkifyAndSanitizeHtml } from './HtmlUtils';
|
import { linkifyElement, topicToHtml } from './HtmlUtils';
|
||||||
import QuestionDialog from "./components/views/dialogs/QuestionDialog";
|
import QuestionDialog from "./components/views/dialogs/QuestionDialog";
|
||||||
import WidgetUtils from "./utils/WidgetUtils";
|
import WidgetUtils from "./utils/WidgetUtils";
|
||||||
import { textToHtmlRainbow } from "./utils/colour";
|
import { textToHtmlRainbow } from "./utils/colour";
|
||||||
|
@ -66,6 +67,7 @@ import { XOR } from "./@types/common";
|
||||||
import { PosthogAnalytics } from "./PosthogAnalytics";
|
import { PosthogAnalytics } from "./PosthogAnalytics";
|
||||||
import { ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload";
|
import { ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload";
|
||||||
import VoipUserMapper from './VoipUserMapper';
|
import VoipUserMapper from './VoipUserMapper';
|
||||||
|
import { htmlSerializeFromMdIfNeeded } from './editor/serialize';
|
||||||
import { leaveRoomBehaviour } from "./utils/leave-behaviour";
|
import { leaveRoomBehaviour } from "./utils/leave-behaviour";
|
||||||
|
|
||||||
// XXX: workaround for https://github.com/microsoft/TypeScript/issues/31816
|
// XXX: workaround for https://github.com/microsoft/TypeScript/issues/31816
|
||||||
|
@ -463,7 +465,8 @@ export const Commands = [
|
||||||
runFn: function(roomId, args) {
|
runFn: function(roomId, args) {
|
||||||
const cli = MatrixClientPeg.get();
|
const cli = MatrixClientPeg.get();
|
||||||
if (args) {
|
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);
|
const room = cli.getRoom(roomId);
|
||||||
if (!room) {
|
if (!room) {
|
||||||
|
@ -472,14 +475,19 @@ export const Commands = [
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const topicEvents = room.currentState.getStateEvents('m.room.topic', '');
|
const content: MRoomTopicEventContent = room.currentState.getStateEvents('m.room.topic', '')?.getContent();
|
||||||
const topic = topicEvents && topicEvents.getContent().topic;
|
const topic = !!content
|
||||||
const topicHtml = topic ? linkifyAndSanitizeHtml(topic) : _t('This room has no topic.');
|
? 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, {
|
Modal.createTrackedDialog('Slash Commands', 'Topic', InfoDialog, {
|
||||||
title: room.name,
|
title: room.name,
|
||||||
description: <div dangerouslySetInnerHTML={{ __html: topicHtml }} />,
|
description: <div ref={ref}>{ body }</div>,
|
||||||
hasCloseButton: true,
|
hasCloseButton: true,
|
||||||
|
className: "markdown-body",
|
||||||
});
|
});
|
||||||
return success();
|
return success();
|
||||||
},
|
},
|
||||||
|
@ -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.
|
* @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
|
* @return {ICmd} The parsed command object.
|
||||||
* processing the command, or 'promise' if a request was sent out.
|
* Returns an empty object if the input didn't match a command.
|
||||||
* Returns null if the input didn't match a command.
|
|
||||||
*/
|
*/
|
||||||
export function getCommand(input: string): ICmd {
|
export function getCommand(input: string): ICmd {
|
||||||
const { cmd, args } = parseCommandString(input);
|
const { cmd, args } = parseCommandString(input);
|
||||||
|
|
|
@ -31,6 +31,7 @@ import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||||
import AccessibleButton from "./AccessibleButton";
|
import AccessibleButton from "./AccessibleButton";
|
||||||
import { Linkify } from "./Linkify";
|
import { Linkify } from "./Linkify";
|
||||||
import TooltipTarget from "./TooltipTarget";
|
import TooltipTarget from "./TooltipTarget";
|
||||||
|
import { topicToHtml } from "../../../HtmlUtils";
|
||||||
|
|
||||||
interface IProps extends React.HTMLProps<HTMLDivElement> {
|
interface IProps extends React.HTMLProps<HTMLDivElement> {
|
||||||
room?: Room;
|
room?: Room;
|
||||||
|
@ -44,6 +45,7 @@ export default function RoomTopic({
|
||||||
const ref = useRef<HTMLDivElement>();
|
const ref = useRef<HTMLDivElement>();
|
||||||
|
|
||||||
const topic = useTopic(room);
|
const topic = useTopic(room);
|
||||||
|
const body = topicToHtml(topic?.text, topic?.html, ref);
|
||||||
|
|
||||||
const onClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
const onClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
props.onClick?.(e);
|
props.onClick?.(e);
|
||||||
|
@ -62,6 +64,7 @@ export default function RoomTopic({
|
||||||
useDispatcher(dis, (payload) => {
|
useDispatcher(dis, (payload) => {
|
||||||
if (payload.action === Action.ShowRoomTopic) {
|
if (payload.action === Action.ShowRoomTopic) {
|
||||||
const canSetTopic = room.currentState.maySendStateEvent(EventType.RoomTopic, client.getUserId());
|
const canSetTopic = room.currentState.maySendStateEvent(EventType.RoomTopic, client.getUserId());
|
||||||
|
const body = topicToHtml(topic?.text, topic?.html, ref, true);
|
||||||
|
|
||||||
const modal = Modal.createDialog(InfoDialog, {
|
const modal = Modal.createDialog(InfoDialog, {
|
||||||
title: room.name,
|
title: room.name,
|
||||||
|
@ -74,7 +77,7 @@ export default function RoomTopic({
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{ topic }
|
{ body }
|
||||||
</Linkify>
|
</Linkify>
|
||||||
{ canSetTopic && <AccessibleButton
|
{ canSetTopic && <AccessibleButton
|
||||||
kind="primary_outline"
|
kind="primary_outline"
|
||||||
|
@ -101,7 +104,7 @@ export default function RoomTopic({
|
||||||
>
|
>
|
||||||
<TooltipTarget label={_t("Click to read topic")} alignment={Alignment.Bottom} ignoreHover={ignoreHover}>
|
<TooltipTarget label={_t("Click to read topic")} alignment={Alignment.Bottom} ignoreHover={ignoreHover}>
|
||||||
<Linkify>
|
<Linkify>
|
||||||
{ topic }
|
{ body }
|
||||||
</Linkify>
|
</Linkify>
|
||||||
</TooltipTarget>
|
</TooltipTarget>
|
||||||
</div>;
|
</div>;
|
||||||
|
|
|
@ -22,6 +22,7 @@ import Field from "../elements/Field";
|
||||||
import { mediaFromMxc } from "../../../customisations/Media";
|
import { mediaFromMxc } from "../../../customisations/Media";
|
||||||
import AccessibleButton from "../elements/AccessibleButton";
|
import AccessibleButton from "../elements/AccessibleButton";
|
||||||
import AvatarSetting from "../settings/AvatarSetting";
|
import AvatarSetting from "../settings/AvatarSetting";
|
||||||
|
import { htmlSerializeFromMdIfNeeded } from '../../../editor/serialize';
|
||||||
import { chromeFileInputFix } from "../../../utils/BrowserWorkarounds";
|
import { chromeFileInputFix } from "../../../utils/BrowserWorkarounds";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
|
@ -142,7 +143,8 @@ export default class RoomProfileSettings extends React.Component<IProps, IState>
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.state.originalTopic !== this.state.topic) {
|
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;
|
newState.originalTopic = this.state.topic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -25,6 +25,7 @@ import AccessibleButton from "../elements/AccessibleButton";
|
||||||
import SpaceBasicSettings from "./SpaceBasicSettings";
|
import SpaceBasicSettings from "./SpaceBasicSettings";
|
||||||
import { avatarUrlForRoom } from "../../../Avatar";
|
import { avatarUrlForRoom } from "../../../Avatar";
|
||||||
import { IDialogProps } from "../dialogs/IDialogProps";
|
import { IDialogProps } from "../dialogs/IDialogProps";
|
||||||
|
import { htmlSerializeFromMdIfNeeded } from "../../../editor/serialize";
|
||||||
import { leaveSpace } from "../../../utils/leave-behaviour";
|
import { leaveSpace } from "../../../utils/leave-behaviour";
|
||||||
import { getTopic } from "../../../hooks/room/useTopic";
|
import { getTopic } from "../../../hooks/room/useTopic";
|
||||||
|
|
||||||
|
@ -47,7 +48,7 @@ const SpaceSettingsGeneralTab = ({ matrixClient: cli, space, onFinished }: IProp
|
||||||
const canSetName = space.currentState.maySendStateEvent(EventType.RoomName, userId);
|
const canSetName = space.currentState.maySendStateEvent(EventType.RoomName, userId);
|
||||||
const nameChanged = name !== space.name;
|
const nameChanged = name !== space.name;
|
||||||
|
|
||||||
const currentTopic = getTopic(space);
|
const currentTopic = getTopic(space).text;
|
||||||
const [topic, setTopic] = useState<string>(currentTopic);
|
const [topic, setTopic] = useState<string>(currentTopic);
|
||||||
const canSetTopic = space.currentState.maySendStateEvent(EventType.RoomTopic, userId);
|
const canSetTopic = space.currentState.maySendStateEvent(EventType.RoomTopic, userId);
|
||||||
const topicChanged = topic !== currentTopic;
|
const topicChanged = topic !== currentTopic;
|
||||||
|
@ -77,7 +78,8 @@ const SpaceSettingsGeneralTab = ({ matrixClient: cli, space, onFinished }: IProp
|
||||||
}
|
}
|
||||||
|
|
||||||
if (topicChanged) {
|
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);
|
const results = await Promise.allSettled(promises);
|
||||||
|
|
|
@ -62,7 +62,11 @@ export function htmlSerializeIfNeeded(
|
||||||
return escapeHtml(textSerialize(model)).replace(/\n/g, '<br/>');
|
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
|
// copy of raw input to remove unwanted math later
|
||||||
const orig = md;
|
const orig = md;
|
||||||
|
|
||||||
|
|
|
@ -19,14 +19,17 @@ import { EventType } from "matrix-js-sdk/src/@types/event";
|
||||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||||
import { Room } from "matrix-js-sdk/src/models/room";
|
import { Room } from "matrix-js-sdk/src/models/room";
|
||||||
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
|
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";
|
import { useTypedEventEmitter } from "../useEventEmitter";
|
||||||
|
|
||||||
export const getTopic = (room: Room) => {
|
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));
|
const [topic, setTopic] = useState(getTopic(room));
|
||||||
useTypedEventEmitter(room.currentState, RoomStateEvent.Events, (ev: MatrixEvent) => {
|
useTypedEventEmitter(room.currentState, RoomStateEvent.Events, (ev: MatrixEvent) => {
|
||||||
if (ev.getType() !== EventType.RoomTopic) return;
|
if (ev.getType() !== EventType.RoomTopic) return;
|
||||||
|
|
|
@ -892,6 +892,7 @@
|
||||||
"Offline encrypted messaging using dehydrated devices": "Offline encrypted messaging using dehydrated devices",
|
"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 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 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",
|
"Show info about bridges in room settings": "Show info about bridges in room settings",
|
||||||
"Use new room breadcrumbs": "Use new room breadcrumbs",
|
"Use new room breadcrumbs": "Use new room breadcrumbs",
|
||||||
"New search experience": "New search experience",
|
"New search experience": "New search experience",
|
||||||
|
|
|
@ -330,6 +330,13 @@ export const SETTINGS: {[setting: string]: ISetting} = {
|
||||||
supportedLevels: [SettingLevel.ACCOUNT],
|
supportedLevels: [SettingLevel.ACCOUNT],
|
||||||
default: null,
|
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": {
|
"feature_bridge_state": {
|
||||||
isFeature: true,
|
isFeature: true,
|
||||||
labsGroup: LabGroup.Rooms,
|
labsGroup: LabGroup.Rooms,
|
||||||
|
|
66
test/HtmlUtils-test.tsx
Normal file
66
test/HtmlUtils-test.tsx
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
/*
|
||||||
|
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { mount } from 'enzyme';
|
||||||
|
import { mocked } from 'jest-mock';
|
||||||
|
|
||||||
|
import { topicToHtml } from '../src/HtmlUtils';
|
||||||
|
import SettingsStore from '../src/settings/SettingsStore';
|
||||||
|
|
||||||
|
jest.mock("../src/settings/SettingsStore");
|
||||||
|
|
||||||
|
const enableHtmlTopicFeature = () => {
|
||||||
|
mocked(SettingsStore).getValue.mockImplementation((arg) => {
|
||||||
|
return arg === "feature_html_topic";
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('HtmlUtils', () => {
|
||||||
|
it('converts plain text topic to HTML', () => {
|
||||||
|
const component = mount(<div>{ topicToHtml("pizza", null, null, false) }</div>);
|
||||||
|
const wrapper = component.render();
|
||||||
|
expect(wrapper.children().first().html()).toEqual("pizza");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts plain text topic with emoji to HTML', () => {
|
||||||
|
const component = mount(<div>{ topicToHtml("pizza 🍕", null, null, false) }</div>);
|
||||||
|
const wrapper = component.render();
|
||||||
|
expect(wrapper.children().first().html()).toEqual("pizza <span class=\"mx_Emoji\" title=\":pizza:\">🍕</span>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts literal HTML topic to HTML', async () => {
|
||||||
|
enableHtmlTopicFeature();
|
||||||
|
const component = mount(<div>{ topicToHtml("<b>pizza</b>", null, null, false) }</div>);
|
||||||
|
const wrapper = component.render();
|
||||||
|
expect(wrapper.children().first().html()).toEqual("<b>pizza</b>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts true HTML topic to HTML', async () => {
|
||||||
|
enableHtmlTopicFeature();
|
||||||
|
const component = mount(<div>{ topicToHtml("**pizza**", "<b>pizza</b>", null, false) }</div>);
|
||||||
|
const wrapper = component.render();
|
||||||
|
expect(wrapper.children().first().html()).toEqual("<b>pizza</b>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts true HTML topic with emoji to HTML', async () => {
|
||||||
|
enableHtmlTopicFeature();
|
||||||
|
const component = mount(<div>{ topicToHtml("**pizza** 🍕", "<b>pizza</b> 🍕", null, false) }</div>);
|
||||||
|
const wrapper = component.render();
|
||||||
|
expect(wrapper.children().first().html())
|
||||||
|
.toEqual("<b>pizza</b> <span class=\"mx_Emoji\" title=\":pizza:\">🍕</span>");
|
||||||
|
});
|
||||||
|
});
|
40
test/SlashCommands-test.tsx
Normal file
40
test/SlashCommands-test.tsx
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
/*
|
||||||
|
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { MatrixClient } from 'matrix-js-sdk/src/matrix';
|
||||||
|
|
||||||
|
import { getCommand } from '../src/SlashCommands';
|
||||||
|
import { createTestClient } from './test-utils';
|
||||||
|
import { MatrixClientPeg } from '../src/MatrixClientPeg';
|
||||||
|
|
||||||
|
describe('SlashCommands', () => {
|
||||||
|
let client: MatrixClient;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
client = createTestClient();
|
||||||
|
jest.spyOn(MatrixClientPeg, 'get').mockReturnValue(client);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/topic', () => {
|
||||||
|
it('sets topic', async () => {
|
||||||
|
const command = getCommand("/topic pizza");
|
||||||
|
expect(command.cmd).toBeDefined();
|
||||||
|
expect(command.args).toBeDefined();
|
||||||
|
await command.cmd.run("room-id", null, command.args);
|
||||||
|
expect(client.setRoomTopic).toHaveBeenCalledWith("room-id", "pizza", undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -94,5 +94,12 @@ describe('editor/serialize', function() {
|
||||||
const html = htmlSerializeIfNeeded(model, { useMarkdown: false });
|
const html = htmlSerializeIfNeeded(model, { useMarkdown: false });
|
||||||
expect(html).toBe('\\*hello\\* world < hey world!');
|
expect(html).toBe('\\*hello\\* world < hey world!');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('plaintext remains plaintext even when forcing html', function() {
|
||||||
|
const pc = createPartCreator();
|
||||||
|
const model = new EditorModel([pc.plain("hello world")], pc);
|
||||||
|
const html = htmlSerializeIfNeeded(model, { forceHTML: true, useMarkdown: false });
|
||||||
|
expect(html).toBe("hello world");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -116,6 +116,7 @@ export function createTestClient(): MatrixClient {
|
||||||
mxcUrlToHttp: (mxc) => `http://this.is.a.url/${mxc.substring(6)}`,
|
mxcUrlToHttp: (mxc) => `http://this.is.a.url/${mxc.substring(6)}`,
|
||||||
setAccountData: jest.fn(),
|
setAccountData: jest.fn(),
|
||||||
setRoomAccountData: jest.fn(),
|
setRoomAccountData: jest.fn(),
|
||||||
|
setRoomTopic: jest.fn(),
|
||||||
sendTyping: jest.fn().mockResolvedValue({}),
|
sendTyping: jest.fn().mockResolvedValue({}),
|
||||||
sendMessage: () => jest.fn().mockResolvedValue({}),
|
sendMessage: () => jest.fn().mockResolvedValue({}),
|
||||||
sendStateEvent: jest.fn().mockResolvedValue(undefined),
|
sendStateEvent: jest.fn().mockResolvedValue(undefined),
|
||||||
|
|
|
@ -42,7 +42,7 @@ describe("useTopic", () => {
|
||||||
|
|
||||||
function RoomTopic() {
|
function RoomTopic() {
|
||||||
const topic = useTopic(room);
|
const topic = useTopic(room);
|
||||||
return <p>{ topic }</p>;
|
return <p>{ topic.text }</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const wrapper = mount(<RoomTopic />);
|
const wrapper = mount(<RoomTopic />);
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue