import PropTypes from 'prop-types';
import './Avatar.scss';
+import { twemojify } from '../../../util/twemojify';
+
import Text from '../text/Text';
import RawIcon from '../system-icons/RawIcon';
{
iconSrc !== null
? <RawIcon size={size} src={iconSrc} />
- : text !== null && <Text variant={textSize}>{[...text][0]}</Text>
+ : text !== null && (
+ <Text variant={textSize}>
+ {twemojify([...text][0])}
+ </Text>
+ )
}
</span>
)
font-weight: $weight;
letter-spacing: var(--ls-#{$type});
line-height: var(--lh-#{$type});
+
+ & img.emoji,
+ & img[data-mx-emoticon] {
+ height: var(--fs-#{$type});
+ }
}
%text {
margin: 0;
padding: 0;
color: var(--tc-surface-high);
+
+ & img.emoji,
+ & img[data-mx-emoticon] {
+ margin: 0 !important;
+ margin-right: 2px !important;
+ padding: 0 !important;
+ position: relative;
+ top: 2px;
+ }
}
.text-h1 {
import PropTypes from 'prop-types';
import './Dialog.scss';
+import { twemojify } from '../../../util/twemojify';
+
import Text from '../../atoms/text/Text';
import Header, { TitleWrapper } from '../../atoms/header/Header';
import ScrollView from '../../atoms/scroll/ScrollView';
<div className="dialog__content">
<Header>
<TitleWrapper>
- <Text variant="h2">{title}</Text>
+ <Text variant="h2">{twemojify(title)}</Text>
</TitleWrapper>
{contentOptions}
</Header>
import PropTypes from 'prop-types';
import './Message.scss';
-import linkifyHtml from 'linkifyjs/html';
-import parse from 'html-react-parser';
-import twemoji from 'twemoji';
import dateFormat from 'dateformat';
+import { twemojify } from '../../../util/twemojify';
import initMatrix from '../../../client/initMatrix';
import { getUsername, getUsernameOfRoomMember } from '../../../util/matrixUtil';
import {
openEmojiBoard, openProfileViewer, openReadReceipts, replyTo,
} from '../../../client/action/navigation';
+import { sanitizeCustomHtml } from '../../../util/sanitize';
import Text from '../../atoms/text/Text';
import RawIcon from '../../atoms/system-icons/RawIcon';
import TickMarkIC from '../../../../public/res/ic/outlined/tick-mark.svg';
import BinIC from '../../../../public/res/ic/outlined/bin.svg';
-import { sanitizeCustomHtml, sanitizeText } from './sanitize';
-
function PlaceholderMessage() {
return (
<div className="ph-msg">
return (
<div className="message__header">
<div style={{ color }} className="message__profile">
- <Text variant="b1">{parse(twemoji.parse(name))}</Text>
- <Text variant="b1">{userId}</Text>
+ <Text variant="b1">{twemojify(name)}</Text>
+ <Text variant="b1">{twemojify(userId)}</Text>
</div>
<div className="message__time">
<Text variant="b3">{time}</Text>
<div className="message__reply">
<Text variant="b2">
<RawIcon color={color} size="extra-small" src={ReplyArrowIC} />
- <span style={{ color }}>{parse(twemoji.parse(name))}</span>
- <>{` ${body}`}</>
+ <span style={{ color }}>{twemojify(name)}</span>
+ {' '}
+ {twemojify(body)}
</Text>
</div>
);
// if body is not string it is a React element.
if (typeof body !== 'string') return <div className="message__body">{body}</div>;
- let content = isCustomHTML ? sanitizeCustomHtml(body) : body;
- if (!isCustomHTML) content = sanitizeText(body);
- content = linkifyHtml(content, { target: '_blank', rel: 'noreferrer noopener' });
- content = twemoji.parse(content);
+ const content = isCustomHTML
+ ? twemojify(sanitizeCustomHtml(body), undefined, true, false)
+ : twemojify(body, undefined, true);
- const parsed = parse(content);
return (
<div className="message__body">
<div className="text text-b1">
- { msgType === 'm.emote' && `* ${senderName} ` }
- { parsed }
+ { msgType === 'm.emote' && (
+ <>
+ {'* '}
+ {twemojify(senderName)}
+ {' '}
+ </>
+ )}
+ { content }
</div>
{ isEdited && <Text className="message__body-edited" variant="b3">(edited)</Text>}
</div>
<>
{msg}
{genLessContText(' reacted with')}
- {parse(twemoji.parse(reaction))}
+ {twemojify(reaction, { className: 'react-emoji' })}
</>
);
}
type="button"
className={`msg__reaction${isActive ? ' msg__reaction--active' : ''}`}
>
- { parse(twemoji.parse(reaction)) }
+ { twemojify(reaction, { className: 'react-emoji' }) }
<Text variant="b3" className="msg__reaction-count">{users.length}</Text>
</button>
</Tooltip>
@use '../../atoms/scroll/scrollbar';
-.custom-emoji {
- height: var(--fs-b1);
- margin: 0 !important;
- margin-right: 2px !important;
- padding: 0 !important;
- position: relative;
- top: 2px;
-}
-
.message,
.ph-msg {
padding: var(--sp-ultra-tight) var(--sp-normal);
display: flex;
align-items: baseline;
- & img.emoji {
- @extend .custom-emoji;
- }
-
& .message__profile {
min-width: 0;
color: var(--tc-surface-high);
}
}
.message__reply {
- & img.emoji {
- @extend .custom-emoji;
- height: 14px;
- }
.text {
color: var(--tc-surface-low);
white-space: nowrap;
}
.message__body {
word-break: break-word;
-
- & > .text > * {
- white-space: pre-wrap;
- }
& a {
word-break: break-word;
}
- & img.emoji,
- & img[data-mx-emoticon] {
- @extend .custom-emoji;
- }
& span[data-mx-pill] {
background-color: hsla(0, 0%, 64%, 0.15);
padding: 0 2px;
background-color: hsla(0, 0%, 64%, 0.3);
color: var(--tc-surface-high);
}
+
+ &[data-mx-ping] {
+ background-color: var(--bg-ping);
+ &:hover {
+ background-color: var(--bg-ping-hover);
+ }
+ }
}
& span[data-mx-spoiler] {
border-radius: 4px;
cursor: pointer;
- & .emoji {
+ & .react-emoji {
width: 14px;
height: 14px;
margin: 2px;
margin: 0 var(--sp-ultra-tight);
color: var(--tc-surface-normal)
}
- &-tooltip .emoji {
+ &-tooltip .react-emoji {
width: 14px;
height: 14px;
margin: 0 var(--sp-ultra-tight);
+++ /dev/null
-import sanitizeHtml from 'sanitize-html';
-import initMatrix from '../../../client/initMatrix';
-
-function sanitizeColorizedTag(tagName, attributes) {
- const attribs = { ...attributes };
- const styles = [];
- if (attributes['data-mx-color']) {
- styles.push(`color: ${attributes['data-mx-color']};`);
- }
- if (attributes['data-mx-bg-color']) {
- styles.push(`background-color: ${attributes['data-mx-bg-color']};`);
- }
- attribs.style = styles.join(' ');
-
- return { tagName, attribs };
-}
-
-function sanitizeLinkTag(tagName, attribs) {
- const userLink = attribs.href.match(/^https?:\/\/matrix.to\/#\/(@.+:.+)/);
- if (userLink !== null) {
- // convert user link to pill
- const userId = userLink[1];
- return {
- tagName: 'span',
- attribs: {
- 'data-mx-pill': userId,
- },
- };
- }
-
- return {
- tagName,
- attribs: {
- ...attribs,
- target: '_blank',
- rel: 'noreferrer noopener',
- },
- };
-}
-
-function sanitizeCodeTag(tagName, attributes) {
- const attribs = { ...attributes };
- let classes = [];
- if (attributes.class) {
- classes = attributes.class.split(/\s+/).filter((className) => className.match(/^language-(\w+)/));
- }
-
- return {
- tagName,
- attribs: {
- ...attribs,
- class: classes.join(' '),
- },
- };
-}
-
-function sanitizeImgTag(tagName, attributes) {
- const mx = initMatrix.matrixClient;
- const { src } = attributes;
- const attribs = { ...attributes };
- delete attribs.src;
-
- if (src.match(/^mxc:\/\//)) {
- attribs.src = mx.mxcUrlToHttp(src);
- }
-
- return { tagName, attribs };
-}
-
-export function sanitizeCustomHtml(body) {
- return sanitizeHtml(body, {
- allowedTags: [
- 'font',
- 'del',
- 'h1',
- 'h2',
- 'h3',
- 'h4',
- 'h5',
- 'h6',
- 'blockquote',
- 'p',
- 'a',
- 'ul',
- 'ol',
- 'sup',
- 'sub',
- 'li',
- 'b',
- 'i',
- 'u',
- 'strong',
- 'em',
- 'strike',
- 'code',
- 'hr',
- 'br',
- 'div',
- 'table',
- 'thead',
- 'tbody',
- 'tr',
- 'th',
- 'td',
- 'caption',
- 'pre',
- 'span',
- 'img',
- 'details',
- 'summary',
- ],
- allowedClasses: {},
- allowedAttributes: {
- ol: ['start'],
- img: ['width', 'height', 'alt', 'title', 'src', 'data-mx-emoticon'],
- a: ['name', 'target', 'href', 'rel'],
- code: ['class'],
- font: ['data-mx-bg-color', 'data-mx-color', 'color', 'style'],
- span: ['data-mx-bg-color', 'data-mx-color', 'data-mx-spoiler', 'style', 'data-mx-pill'],
- },
- allowProtocolRelative: false,
- allowedSchemesByTag: {
- a: ['https', 'http', 'ftp', 'mailto', 'magnet'],
- img: ['https', 'http'],
- },
- allowedStyles: {
- '*': {
- color: [/^#(0x)?[0-9a-f]+$/i],
- 'background-color': [/^#(0x)?[0-9a-f]+$/i],
- },
- },
- nestingLimit: 100,
- nonTextTags: [
- 'style', 'script', 'textarea', 'option', 'mx-reply',
- ],
- transformTags: {
- a: sanitizeLinkTag,
- img: sanitizeImgTag,
- code: sanitizeCodeTag,
- font: sanitizeColorizedTag,
- span: sanitizeColorizedTag,
- },
- });
-}
-
-export function sanitizeText(body) {
- const tagsToReplace = {
- '&': '&',
- '<': '<',
- '>': '>',
- };
- return body.replace(/[&<>]/g, (tag) => tagsToReplace[tag] || tag);
-}
import PropTypes from 'prop-types';
import './PeopleSelector.scss';
+import { twemojify } from '../../../util/twemojify';
+
import { blurOnBubbling } from '../../atoms/button/script';
import Text from '../../atoms/text/Text';
type="button"
>
<Avatar imageSrc={avatarSrc} text={name} bgColor={color} size="extra-small" />
- <Text className="people-selector__name" variant="b1">{name}</Text>
+ <Text className="people-selector__name" variant="b1">{twemojify(name)}</Text>
{peopleRole !== null && <Text className="people-selector__role" variant="b3">{peopleRole}</Text>}
</button>
</div>
import PropTypes from 'prop-types';
import './RoomIntro.scss';
-import Linkify from 'linkifyjs/react';
+import { twemojify } from '../../../util/twemojify';
import colorMXID from '../../../util/colorMXID';
import Text from '../../atoms/text/Text';
import Avatar from '../../atoms/avatar/Avatar';
-function linkifyContent(content) {
- return <Linkify options={{ target: { url: '_blank' } }}>{content}</Linkify>;
-}
-
function RoomIntro({
roomId, avatarSrc, name, heading, desc, time,
}) {
<div className="room-intro">
<Avatar imageSrc={avatarSrc} text={name} bgColor={colorMXID(roomId)} size="large" />
<div className="room-intro__content">
- <Text className="room-intro__name" variant="h1">{heading}</Text>
- <Text className="room-intro__desc" variant="b1">{linkifyContent(desc)}</Text>
+ <Text className="room-intro__name" variant="h1">{twemojify(heading)}</Text>
+ <Text className="room-intro__desc" variant="b1">{twemojify(desc, undefined, true)}</Text>
{ time !== null && <Text className="room-intro__time" variant="b3">{time}</Text>}
</div>
</div>
import PropTypes from 'prop-types';
import './RoomSelector.scss';
+import { twemojify } from '../../../util/twemojify';
import colorMXID from '../../../util/colorMXID';
import Text from '../../atoms/text/Text';
iconSrc={iconSrc}
size="extra-small"
/>
- <Text variant="b1">{name}</Text>
+ <Text variant="b1">{twemojify(name)}</Text>
{ isUnread && (
<NotificationBadge
alert={isAlert}
import PropTypes from 'prop-types';
import './RoomTile.scss';
-import Linkify from 'linkifyjs/react';
+import { twemojify } from '../../../util/twemojify';
+import { sanitizeText } from '../../../util/sanitize';
+
import colorMXID from '../../../util/colorMXID';
import Text from '../../atoms/text/Text';
import Avatar from '../../atoms/avatar/Avatar';
-function linkifyContent(content) {
- return <Linkify options={{ target: { url: '_blank' } }}>{content}</Linkify>;
-}
-
function RoomTile({
avatarSrc, name, id,
inviterName, memberCount, desc, options,
/>
</div>
<div className="room-tile__content">
- <Text variant="s1">{name}</Text>
+ <Text variant="s1">{twemojify(name)}</Text>
<Text variant="b3">
{
inviterName !== null
</Text>
{
desc !== null && (typeof desc === 'string')
- ? <Text className="room-tile__content__desc" variant="b2">{linkifyContent(desc)}</Text>
+ ? <Text className="room-tile__content__desc" variant="b2">{twemojify(desc, undefined, true)}</Text>
: desc
}
</div>
import PropTypes from 'prop-types';
import './DrawerBreadcrumb.scss';
+import { twemojify } from '../../../util/twemojify';
+
import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
import { selectSpace } from '../../../client/action/navigation';
className={index === spacePath.length - 1 ? 'breadcrumb__btn--selected' : ''}
onClick={() => selectSpace(id)}
>
- <Text variant="b2">{id === cons.tabs.HOME ? 'Home' : mx.getRoom(id).name}</Text>
+ <Text variant="b2">{id === cons.tabs.HOME ? 'Home' : twemojify(mx.getRoom(id).name)}</Text>
{ noti !== null && (
<NotificationBadge
alert={noti.highlight !== 0}
import React, { useState } from 'react';
import PropTypes from 'prop-types';
+import { twemojify } from '../../../util/twemojify';
+
import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
import {
return (
<Header>
<TitleWrapper>
- <Text variant="s1">{spaceName || tabName}</Text>
+ <Text variant="s1">{twemojify(spaceName) || tabName}</Text>
</TitleWrapper>
{spaceName && (
<IconButton
import PropTypes from 'prop-types';
import './ProfileViewer.scss';
+import { twemojify } from '../../../util/twemojify';
+
import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
import navigation from '../../../client/state/navigation';
size="large"
/>
<div className="profile-viewer__user__info">
- <Text variant="s1">{username}</Text>
- <Text variant="b2">{userId}</Text>
+ <Text variant="s1">{twemojify(username)}</Text>
+ <Text variant="b2">{twemojify(userId)}</Text>
</div>
<div className="profile-viewer__user__role">
<Text variant="b3">Role</Text>
import React from 'react';
import PropTypes from 'prop-types';
+import { twemojify } from '../../../util/twemojify';
+
import initMatrix from '../../../client/initMatrix';
import { openRoomOptions } from '../../../client/action/navigation';
import { togglePeopleDrawer } from '../../../client/action/settings';
<Header>
<Avatar imageSrc={avatarSrc} text={roomName} bgColor={colorMXID(roomId)} size="small" />
<TitleWrapper>
- <Text variant="h2">{roomName}</Text>
- { typeof roomTopic !== 'undefined' && <p title={roomTopic} className="text text-b3">{roomTopic}</p>}
+ <Text variant="h2">{twemojify(roomName)}</Text>
+ { typeof roomTopic !== 'undefined' && <p title={roomTopic} className="text text-b3">{twemojify(roomTopic)}</p>}
</TitleWrapper>
<IconButton onClick={togglePeopleDrawer} tooltip="People" src={UserIC} />
<IconButton
import React from 'react';
+import parse from 'html-react-parser';
+import twemoji from 'twemoji';
+import { sanitizeText } from '../../../util/sanitize';
+
import initMatrix from '../../../client/initMatrix';
import { getUsername, getUsernameOfRoomMember } from '../../../util/matrixUtil';
+const getEmojifiedJsx = (username) => parse(twemoji.parse(sanitizeText(username)));
+
function getTimelineJSXMessages() {
return {
join(user) {
return (
<>
- <b>{user}</b>
+ <b>{getEmojifiedJsx(user)}</b>
{' joined the room'}
</>
);
const reasonMsg = (typeof reason === 'string') ? `: ${reason}` : '';
return (
<>
- <b>{user}</b>
+ <b>{getEmojifiedJsx(user)}</b>
{' left the room'}
- {reasonMsg}
+ {getEmojifiedJsx(reasonMsg)}
</>
);
},
invite(inviter, user) {
return (
<>
- <b>{inviter}</b>
+ <b>{getEmojifiedJsx(inviter)}</b>
{' invited '}
- <b>{user}</b>
+ <b>{getEmojifiedJsx(user)}</b>
</>
);
},
cancelInvite(inviter, user) {
return (
<>
- <b>{inviter}</b>
+ <b>{getEmojifiedJsx(inviter)}</b>
{' canceled '}
- <b>{user}</b>
+ <b>{getEmojifiedJsx(user)}</b>
{'\'s invite'}
</>
);
rejectInvite(user) {
return (
<>
- <b>{user}</b>
+ <b>{getEmojifiedJsx(user)}</b>
{' rejected the invitation'}
</>
);
const reasonMsg = (typeof reason === 'string') ? `: ${reason}` : '';
return (
<>
- <b>{actor}</b>
+ <b>{getEmojifiedJsx(actor)}</b>
{' kicked '}
- <b>{user}</b>
- {reasonMsg}
+ <b>{getEmojifiedJsx(user)}</b>
+ {getEmojifiedJsx(reasonMsg)}
</>
);
},
const reasonMsg = (typeof reason === 'string') ? `: ${reason}` : '';
return (
<>
- <b>{actor}</b>
+ <b>{getEmojifiedJsx(actor)}</b>
{' banned '}
- <b>{user}</b>
- {reasonMsg}
+ <b>{getEmojifiedJsx(user)}</b>
+ {getEmojifiedJsx(reasonMsg)}
</>
);
},
unban(actor, user) {
return (
<>
- <b>{actor}</b>
+ <b>{getEmojifiedJsx(actor)}</b>
{' unbanned '}
- <b>{user}</b>
+ <b>{getEmojifiedJsx(user)}</b>
</>
);
},
avatarSets(user) {
return (
<>
- <b>{user}</b>
+ <b>{getEmojifiedJsx(user)}</b>
{' set the avatar'}
</>
);
avatarChanged(user) {
return (
<>
- <b>{user}</b>
+ <b>{getEmojifiedJsx(user)}</b>
{' changed the avatar'}
</>
);
avatarRemoved(user) {
return (
<>
- <b>{user}</b>
+ <b>{getEmojifiedJsx(user)}</b>
{' removed the avatar'}
</>
);
nameSets(user, newName) {
return (
<>
- <b>{user}</b>
+ <b>{getEmojifiedJsx(user)}</b>
{' set the display name to '}
- <b>{newName}</b>
+ <b>{getEmojifiedJsx(newName)}</b>
</>
);
},
nameChanged(user, newName) {
return (
<>
- <b>{user}</b>
+ <b>{getEmojifiedJsx(user)}</b>
{' changed the display name to '}
- <b>{newName}</b>
+ <b>{getEmojifiedJsx(newName)}</b>
</>
);
},
nameRemoved(user, lastName) {
return (
<>
- <b>{user}</b>
+ <b>{getEmojifiedJsx(user)}</b>
{' removed the display name '}
- <b>{lastName}</b>
+ <b>{getEmojifiedJsx(lastName)}</b>
</>
);
},
if (room?.getMember(userId)) return getUsernameOfRoomMember(room.getMember(userId));
return getUsername(userId);
};
- const getUserJSX = (userId) => <b>{getUserDisplayName(userId)}</b>;
+ const getUserJSX = (userId) => <b>{getEmojifiedJsx(getUserDisplayName(userId))}</b>;
if (!Array.isArray(userIds)) return 'Idle';
if (userIds.length === 0) return 'Idle';
const MAX_VISIBLE_COUNT = 3;
--bg-tooltip: #353535;
--bg-badge: #989898;
+ --bg-ping: hsla(137deg, 100%, 68%, 40%);
+ --bg-ping-hover: hsla(137deg, 100%, 68%, 50%);
/* text color | --tc-[background type]-[priority]: value */
--tc-surface-high: #000000;
--bg-tooltip: #000;
--bg-badge: hsl(0, 0%, 75%);
+ --bg-ping: hsla(137deg, 100%, 38%, 40%);
+ --bg-ping-hover: hsla(137deg, 100%, 38%, 50%);
+
/* text color | --tc-[background type]-[priority]: value */
--tc-surface-high: rgba(255, 255, 255, 98%);
--- /dev/null
+import sanitizeHtml from 'sanitize-html';
+import initMatrix from '../client/initMatrix';
+
+const MAX_TAG_NESTING = 100;
+
+const permittedHtmlTags = [
+ 'font', 'del', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
+ 'blockquote', 'p', 'a', 'ul', 'ol', 'sup', 'sub',
+ 'li', 'b', 'i', 'u', 'strong', 'em', 'strike', 'code',
+ 'hr', 'br', 'div', 'table', 'thead', 'tbody', 'tr', 'th',
+ 'td', 'caption', 'pre', 'span', 'img', 'details', 'summary',
+];
+
+const urlSchemes = ['https', 'http', 'ftp', 'mailto', 'magnet'];
+
+const permittedTagToAttributes = {
+ font: ['style', 'data-mx-bg-color', 'data-mx-color', 'color'],
+ span: ['style', 'data-mx-bg-color', 'data-mx-color', 'data-mx-spoiler', 'data-mx-pill', 'data-mx-ping'],
+ a: ['name', 'target', 'href', 'rel'],
+ img: ['width', 'height', 'alt', 'title', 'src', 'data-mx-emoticon'],
+ o: ['start'],
+ code: ['class'],
+};
+
+function transformFontTag(tagName, attribs) {
+ return {
+ tagName,
+ attribs: {
+ ...attribs,
+ style: `background-color: ${attribs['data-mx-bg-color']}; color: ${attribs['data-mx-color']}`,
+ },
+ };
+}
+
+function transformSpanTag(tagName, attribs) {
+ return {
+ tagName,
+ attribs: {
+ ...attribs,
+ style: `background-color: ${attribs['data-mx-bg-color']}; color: ${attribs['data-mx-color']}`,
+ },
+ };
+}
+
+function transformATag(tagName, attribs) {
+ const userLink = attribs.href.match(/^https?:\/\/matrix.to\/#\/(@.+:.+)/);
+ if (userLink !== null) {
+ // convert user link to pill
+ const userId = userLink[1];
+ const pill = {
+ tagName: 'span',
+ attribs: {
+ 'data-mx-pill': userId,
+ },
+ };
+ if (userId === initMatrix.matrixClient.getUserId()) {
+ pill.attribs['data-mx-ping'] = undefined;
+ }
+ return pill;
+ }
+
+ const rex = /[\u{1f300}-\u{1f5ff}\u{1f900}-\u{1f9ff}\u{1f600}-\u{1f64f}\u{1f680}-\u{1f6ff}\u{2600}-\u{26ff}\u{2700}-\u{27bf}\u{1f1e6}-\u{1f1ff}\u{1f191}-\u{1f251}\u{1f004}\u{1f0cf}\u{1f170}-\u{1f171}\u{1f17e}-\u{1f17f}\u{1f18e}\u{3030}\u{2b50}\u{2b55}\u{2934}-\u{2935}\u{2b05}-\u{2b07}\u{2b1b}-\u{2b1c}\u{3297}\u{3299}\u{303d}\u{00a9}\u{00ae}\u{2122}\u{23f3}\u{24c2}\u{23e9}-\u{23ef}\u{25b6}\u{23f8}-\u{23fa}]/ug;
+ const newHref = attribs.href.replace(rex, (match) => `[e-${match.codePointAt(0).toString(16)}]`);
+
+ return {
+ tagName,
+ attribs: {
+ ...attribs,
+ href: newHref,
+ rel: 'noopener',
+ target: '_blank',
+ },
+ };
+}
+
+function transformImgTag(tagName, attribs) {
+ const { src } = attribs;
+ const mx = initMatrix.matrixClient;
+ return {
+ tagName,
+ attribs: {
+ ...attribs,
+ src: src.startsWith('mxc://') ? mx.mxcUrlToHttp(src) : src,
+ },
+ };
+}
+
+export function sanitizeCustomHtml(body) {
+ return sanitizeHtml(body, {
+ allowedTags: permittedHtmlTags,
+ allowedAttributes: permittedTagToAttributes,
+ disallowedTagsMode: 'discard',
+ allowedSchemes: urlSchemes,
+ allowedSchemesByTag: {
+ a: urlSchemes,
+ },
+ allowedSchemesAppliedToAttributes: ['href'],
+ allowProtocolRelative: false,
+ allowedClasses: {
+ code: ['language-*'],
+ },
+ allowedStyles: {
+ '*': {
+ color: [/^#(?:[0-9a-fA-F]{3}){1,2}$/],
+ 'background-color': [/^#(?:[0-9a-fA-F]{3}){1,2}$/],
+ },
+ },
+ transformTags: {
+ font: transformFontTag,
+ span: transformSpanTag,
+ a: transformATag,
+ img: transformImgTag,
+ },
+ nonTextTags: ['style', 'script', 'textarea', 'option', 'noscript', 'mx-reply'],
+ nestingLimit: MAX_TAG_NESTING,
+ });
+}
+
+export function sanitizeText(body) {
+ const tagsToReplace = {
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ "'": ''',
+ };
+ return body.replace(/[&<>]/g, (tag) => tagsToReplace[tag] || tag);
+}
--- /dev/null
+/* eslint-disable import/prefer-default-export */
+import linkifyHtml from 'linkifyjs/html';
+import parse from 'html-react-parser';
+import twemoji from 'twemoji';
+import { sanitizeText } from './sanitize';
+
+/**
+ * @param {string} text - text to twemojify
+ * @param {object|undefined} opts - options for tweomoji.parse
+ * @param {boolean} [linkify=false] - convert links to html tags (default: false)
+ * @param {boolean} [sanitize=true] - sanitize html text (default: true)
+ * @returns React component
+ */
+export function twemojify(text, opts, linkify = false, sanitize = true) {
+ if (typeof text !== 'string') return text;
+ let content = sanitize ? twemoji.parse(sanitizeText(text), opts) : twemoji.parse(text, opts);
+ if (linkify) {
+ content = linkifyHtml(content, { target: '_blank', rel: 'noreferrer noopener' });
+ }
+ return parse(content);
+}