diff --git a/src/app/atoms/avatar/Avatar.jsx b/src/app/atoms/avatar/Avatar.jsx index 950c9ba..eefb575 100644 --- a/src/app/atoms/avatar/Avatar.jsx +++ b/src/app/atoms/avatar/Avatar.jsx @@ -2,6 +2,8 @@ import React, { useState, useEffect } from 'react'; import PropTypes from 'prop-types'; import './Avatar.scss'; +import { twemojify } from '../../../util/twemojify'; + import Text from '../text/Text'; import RawIcon from '../system-icons/RawIcon'; @@ -29,7 +31,11 @@ function Avatar({ { iconSrc !== null ? - : text !== null && {[...text][0]} + : text !== null && ( + + {twemojify([...text][0])} + + ) } ) diff --git a/src/app/atoms/text/Text.scss b/src/app/atoms/text/Text.scss index b640353..6f685ea 100644 --- a/src/app/atoms/text/Text.scss +++ b/src/app/atoms/text/Text.scss @@ -4,12 +4,26 @@ 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 { diff --git a/src/app/molecules/dialog/Dialog.jsx b/src/app/molecules/dialog/Dialog.jsx index a039f35..258422d 100644 --- a/src/app/molecules/dialog/Dialog.jsx +++ b/src/app/molecules/dialog/Dialog.jsx @@ -2,6 +2,8 @@ import React from 'react'; 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'; @@ -22,7 +24,7 @@ function Dialog({
- {title} + {twemojify(title)} {contentOptions}
diff --git a/src/app/molecules/message/Message.jsx b/src/app/molecules/message/Message.jsx index 4ab0983..4cf0635 100644 --- a/src/app/molecules/message/Message.jsx +++ b/src/app/molecules/message/Message.jsx @@ -3,10 +3,8 @@ import React, { useState, useRef } from 'react'; 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'; @@ -16,6 +14,7 @@ import { redactEvent, sendReaction } from '../../../client/action/roomTimeline'; 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'; @@ -34,8 +33,6 @@ import PencilIC from '../../../../public/res/ic/outlined/pencil.svg'; 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 (
@@ -61,8 +58,8 @@ function MessageHeader({ return (
- {parse(twemoji.parse(name))} - {userId} + {twemojify(name)} + {twemojify(userId)}
{time} @@ -82,8 +79,9 @@ function MessageReply({ name, color, body }) {
- {parse(twemoji.parse(name))} - <>{` ${body}`} + {twemojify(name)} + {' '} + {twemojify(body)}
); @@ -105,17 +103,21 @@ function MessageBody({ // if body is not string it is a React element. if (typeof body !== 'string') return
{body}
; - 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 (
- { msgType === 'm.emote' && `* ${senderName} ` } - { parsed } + { msgType === 'm.emote' && ( + <> + {'* '} + {twemojify(senderName)} + {' '} + + )} + { content }
{ isEdited && (edited)}
@@ -191,7 +193,7 @@ function genReactionMsg(userIds, reaction) { <> {msg} {genLessContText(' reacted with')} - {parse(twemoji.parse(reaction))} + {twemojify(reaction, { className: 'react-emoji' })} ); } @@ -209,7 +211,7 @@ function MessageReaction({ type="button" className={`msg__reaction${isActive ? ' msg__reaction--active' : ''}`} > - { parse(twemoji.parse(reaction)) } + { twemojify(reaction, { className: 'react-emoji' }) } {users.length} diff --git a/src/app/molecules/message/Message.scss b/src/app/molecules/message/Message.scss index c6472c8..a682418 100644 --- a/src/app/molecules/message/Message.scss +++ b/src/app/molecules/message/Message.scss @@ -1,14 +1,5 @@ @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); @@ -116,10 +107,6 @@ display: flex; align-items: baseline; - & img.emoji { - @extend .custom-emoji; - } - & .message__profile { min-width: 0; color: var(--tc-surface-high); @@ -155,10 +142,6 @@ } } .message__reply { - & img.emoji { - @extend .custom-emoji; - height: 14px; - } .text { color: var(--tc-surface-low); white-space: nowrap; @@ -172,18 +155,10 @@ } .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; @@ -194,6 +169,13 @@ 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] { @@ -257,7 +239,7 @@ border-radius: 4px; cursor: pointer; - & .emoji { + & .react-emoji { width: 14px; height: 14px; margin: 2px; @@ -266,7 +248,7 @@ 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); diff --git a/src/app/molecules/message/sanitize.js b/src/app/molecules/message/sanitize.js deleted file mode 100644 index 38ee2ca..0000000 --- a/src/app/molecules/message/sanitize.js +++ /dev/null @@ -1,153 +0,0 @@ -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); -} diff --git a/src/app/molecules/people-selector/PeopleSelector.jsx b/src/app/molecules/people-selector/PeopleSelector.jsx index 23c71f9..8ea0587 100644 --- a/src/app/molecules/people-selector/PeopleSelector.jsx +++ b/src/app/molecules/people-selector/PeopleSelector.jsx @@ -2,6 +2,8 @@ import React from 'react'; 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'; @@ -19,7 +21,7 @@ function PeopleSelector({ type="button" > - {name} + {twemojify(name)} {peopleRole !== null && {peopleRole}}
diff --git a/src/app/molecules/room-intro/RoomIntro.jsx b/src/app/molecules/room-intro/RoomIntro.jsx index c71e41e..f22f8b1 100644 --- a/src/app/molecules/room-intro/RoomIntro.jsx +++ b/src/app/molecules/room-intro/RoomIntro.jsx @@ -2,16 +2,12 @@ import React from 'react'; 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 {content}; -} - function RoomIntro({ roomId, avatarSrc, name, heading, desc, time, }) { @@ -19,8 +15,8 @@ function RoomIntro({
- {heading} - {linkifyContent(desc)} + {twemojify(heading)} + {twemojify(desc, undefined, true)} { time !== null && {time}}
diff --git a/src/app/molecules/room-selector/RoomSelector.jsx b/src/app/molecules/room-selector/RoomSelector.jsx index 3367746..d5bb1c3 100644 --- a/src/app/molecules/room-selector/RoomSelector.jsx +++ b/src/app/molecules/room-selector/RoomSelector.jsx @@ -2,6 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import './RoomSelector.scss'; +import { twemojify } from '../../../util/twemojify'; import colorMXID from '../../../util/colorMXID'; import Text from '../../atoms/text/Text'; @@ -57,7 +58,7 @@ function RoomSelector({ iconSrc={iconSrc} size="extra-small" /> - {name} + {twemojify(name)} { isUnread && ( {content}; -} - function RoomTile({ avatarSrc, name, id, inviterName, memberCount, desc, options, @@ -26,7 +24,7 @@ function RoomTile({ />
- {name} + {twemojify(name)} { inviterName !== null @@ -36,7 +34,7 @@ function RoomTile({ { desc !== null && (typeof desc === 'string') - ? {linkifyContent(desc)} + ? {twemojify(desc, undefined, true)} : desc }
diff --git a/src/app/organisms/navigation/DrawerBreadcrumb.jsx b/src/app/organisms/navigation/DrawerBreadcrumb.jsx index 7eaae4e..d2f1f9e 100644 --- a/src/app/organisms/navigation/DrawerBreadcrumb.jsx +++ b/src/app/organisms/navigation/DrawerBreadcrumb.jsx @@ -2,6 +2,8 @@ import React, { useState, useEffect, useRef } from 'react'; 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'; @@ -101,7 +103,7 @@ function DrawerBreadcrumb({ spaceId }) { className={index === spacePath.length - 1 ? 'breadcrumb__btn--selected' : ''} onClick={() => selectSpace(id)} > - {id === cons.tabs.HOME ? 'Home' : mx.getRoom(id).name} + {id === cons.tabs.HOME ? 'Home' : twemojify(mx.getRoom(id).name)} { noti !== null && ( - {spaceName || tabName} + {twemojify(spaceName) || tabName} {spaceName && (
- {username} - {userId} + {twemojify(username)} + {twemojify(userId)}
Role diff --git a/src/app/organisms/room/RoomViewHeader.jsx b/src/app/organisms/room/RoomViewHeader.jsx index 22bc07a..a2e0d48 100644 --- a/src/app/organisms/room/RoomViewHeader.jsx +++ b/src/app/organisms/room/RoomViewHeader.jsx @@ -1,6 +1,8 @@ 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'; @@ -27,8 +29,8 @@ function RoomViewHeader({ roomId }) {
- {roomName} - { typeof roomTopic !== 'undefined' &&

{roomTopic}

} + {twemojify(roomName)} + { typeof roomTopic !== 'undefined' &&

{twemojify(roomTopic)}

}
parse(twemoji.parse(sanitizeText(username))); + function getTimelineJSXMessages() { return { join(user) { return ( <> - {user} + {getEmojifiedJsx(user)} {' joined the room'} ); @@ -17,27 +23,27 @@ function getTimelineJSXMessages() { const reasonMsg = (typeof reason === 'string') ? `: ${reason}` : ''; return ( <> - {user} + {getEmojifiedJsx(user)} {' left the room'} - {reasonMsg} + {getEmojifiedJsx(reasonMsg)} ); }, invite(inviter, user) { return ( <> - {inviter} + {getEmojifiedJsx(inviter)} {' invited '} - {user} + {getEmojifiedJsx(user)} ); }, cancelInvite(inviter, user) { return ( <> - {inviter} + {getEmojifiedJsx(inviter)} {' canceled '} - {user} + {getEmojifiedJsx(user)} {'\'s invite'} ); @@ -45,7 +51,7 @@ function getTimelineJSXMessages() { rejectInvite(user) { return ( <> - {user} + {getEmojifiedJsx(user)} {' rejected the invitation'} ); @@ -54,10 +60,10 @@ function getTimelineJSXMessages() { const reasonMsg = (typeof reason === 'string') ? `: ${reason}` : ''; return ( <> - {actor} + {getEmojifiedJsx(actor)} {' kicked '} - {user} - {reasonMsg} + {getEmojifiedJsx(user)} + {getEmojifiedJsx(reasonMsg)} ); }, @@ -65,26 +71,26 @@ function getTimelineJSXMessages() { const reasonMsg = (typeof reason === 'string') ? `: ${reason}` : ''; return ( <> - {actor} + {getEmojifiedJsx(actor)} {' banned '} - {user} - {reasonMsg} + {getEmojifiedJsx(user)} + {getEmojifiedJsx(reasonMsg)} ); }, unban(actor, user) { return ( <> - {actor} + {getEmojifiedJsx(actor)} {' unbanned '} - {user} + {getEmojifiedJsx(user)} ); }, avatarSets(user) { return ( <> - {user} + {getEmojifiedJsx(user)} {' set the avatar'} ); @@ -92,7 +98,7 @@ function getTimelineJSXMessages() { avatarChanged(user) { return ( <> - {user} + {getEmojifiedJsx(user)} {' changed the avatar'} ); @@ -100,7 +106,7 @@ function getTimelineJSXMessages() { avatarRemoved(user) { return ( <> - {user} + {getEmojifiedJsx(user)} {' removed the avatar'} ); @@ -108,27 +114,27 @@ function getTimelineJSXMessages() { nameSets(user, newName) { return ( <> - {user} + {getEmojifiedJsx(user)} {' set the display name to '} - {newName} + {getEmojifiedJsx(newName)} ); }, nameChanged(user, newName) { return ( <> - {user} + {getEmojifiedJsx(user)} {' changed the display name to '} - {newName} + {getEmojifiedJsx(newName)} ); }, nameRemoved(user, lastName) { return ( <> - {user} + {getEmojifiedJsx(user)} {' removed the display name '} - {lastName} + {getEmojifiedJsx(lastName)} ); }, @@ -141,7 +147,7 @@ function getUsersActionJsx(roomId, userIds, actionStr) { if (room?.getMember(userId)) return getUsernameOfRoomMember(room.getMember(userId)); return getUsername(userId); }; - const getUserJSX = (userId) => {getUserDisplayName(userId)}; + const getUserJSX = (userId) => {getEmojifiedJsx(getUserDisplayName(userId))}; if (!Array.isArray(userIds)) return 'Idle'; if (userIds.length === 0) return 'Idle'; const MAX_VISIBLE_COUNT = 3; diff --git a/src/index.scss b/src/index.scss index 583e20f..2c5096d 100644 --- a/src/index.scss +++ b/src/index.scss @@ -33,6 +33,8 @@ --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; @@ -183,6 +185,9 @@ --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%); diff --git a/src/util/sanitize.js b/src/util/sanitize.js new file mode 100644 index 0000000..a308947 --- /dev/null +++ b/src/util/sanitize.js @@ -0,0 +1,128 @@ +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); +} diff --git a/src/util/twemojify.js b/src/util/twemojify.js new file mode 100644 index 0000000..43943ba --- /dev/null +++ b/src/util/twemojify.js @@ -0,0 +1,21 @@ +/* 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); +}