Twemojified all text
authorAjay Bura <ajbura@gmail.com>
Tue, 23 Nov 2021 06:26:02 +0000 (11:56 +0530)
committerAjay Bura <ajbura@gmail.com>
Tue, 23 Nov 2021 06:26:02 +0000 (11:56 +0530)
Signed-off-by: Ajay Bura <ajbura@gmail.com>
18 files changed:
src/app/atoms/avatar/Avatar.jsx
src/app/atoms/text/Text.scss
src/app/molecules/dialog/Dialog.jsx
src/app/molecules/message/Message.jsx
src/app/molecules/message/Message.scss
src/app/molecules/message/sanitize.js [deleted file]
src/app/molecules/people-selector/PeopleSelector.jsx
src/app/molecules/room-intro/RoomIntro.jsx
src/app/molecules/room-selector/RoomSelector.jsx
src/app/molecules/room-tile/RoomTile.jsx
src/app/organisms/navigation/DrawerBreadcrumb.jsx
src/app/organisms/navigation/DrawerHeader.jsx
src/app/organisms/profile-viewer/ProfileViewer.jsx
src/app/organisms/room/RoomViewHeader.jsx
src/app/organisms/room/common.jsx
src/index.scss
src/util/sanitize.js [new file with mode: 0644]
src/util/twemojify.js [new file with mode: 0644]

index 950c9baef573bc53469dbc81a31638ceac5abca5..eefb5752c1587b29945475e4d95b5110aa4d840a 100644 (file)
@@ -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
                   ? <RawIcon size={size} src={iconSrc} />
-                  : text !== null && <Text variant={textSize}>{[...text][0]}</Text>
+                  : text !== null && (
+                    <Text variant={textSize}>
+                      {twemojify([...text][0])}
+                    </Text>
+                  )
               }
             </span>
           )
index b64035301d7e6afec78fb4f2b8e86bc3fe258c7d..6f685ea4671bec08dea2c443a293b796e9974b81 100644 (file)
@@ -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 {
index a039f358c994c7b3914bfbd75f893402ae6864e9..258422da18c656abc1beb237205f7d895d644859 100644 (file)
@@ -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({
         <div className="dialog__content">
           <Header>
             <TitleWrapper>
-              <Text variant="h2">{title}</Text>
+              <Text variant="h2">{twemojify(title)}</Text>
             </TitleWrapper>
             {contentOptions}
           </Header>
index 4ab0983804a850d3773f22840ff7273385bb3160..4cf06354e564404bc120be51ef839971fd4076d0 100644 (file)
@@ -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 (
     <div className="ph-msg">
@@ -61,8 +58,8 @@ function MessageHeader({
   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>
@@ -82,8 +79,9 @@ function MessageReply({ name, color, body }) {
     <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>
   );
@@ -105,17 +103,21 @@ function MessageBody({
   // 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>
@@ -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' }) }
         <Text variant="b3" className="msg__reaction-count">{users.length}</Text>
       </button>
     </Tooltip>
index c6472c8f19cea17211f15a8349eaf384bb19acae..a682418bcb8d27c5780fa2dd0b29b011b76cf48e 100644 (file)
@@ -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);
   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);
diff --git a/src/app/molecules/message/sanitize.js b/src/app/molecules/message/sanitize.js
deleted file mode 100644 (file)
index 38ee2ca..0000000
+++ /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 = {
-    '&': '&amp;',
-    '<': '&lt;',
-    '>': '&gt;',
-  };
-  return body.replace(/[&<>]/g, (tag) => tagsToReplace[tag] || tag);
-}
index 23c71f9f54688b315aeb93ab847c02b4c9be4884..8ea0587f5639e2ca6c10e110c093f3597a17d00a 100644 (file)
@@ -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"
       >
         <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>
index c71e41e4f314b30fb1b31a5933b603b626b9807c..f22f8b10083dc8b010be9e6f33ec0b9f7fecab38 100644 (file)
@@ -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 <Linkify options={{ target: { url: '_blank' } }}>{content}</Linkify>;
-}
-
 function RoomIntro({
   roomId, avatarSrc, name, heading, desc, time,
 }) {
@@ -19,8 +15,8 @@ function RoomIntro({
     <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>
index 3367746f7267a206ce7e37d26b9e56c827ad7def..d5bb1c30ebd4067a166d89130e3cec7dc2fc5267 100644 (file)
@@ -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"
           />
-          <Text variant="b1">{name}</Text>
+          <Text variant="b1">{twemojify(name)}</Text>
           { isUnread && (
             <NotificationBadge
               alert={isAlert}
index 4e2280602d62003815cbbafd1da9ba0965150fd0..7dd4292b87f50bad8722b5f5a6fb5d9349dfffee 100644 (file)
@@ -2,16 +2,14 @@ import React from 'react';
 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,
@@ -26,7 +24,7 @@ function RoomTile({
         />
       </div>
       <div className="room-tile__content">
-        <Text variant="s1">{name}</Text>
+        <Text variant="s1">{twemojify(name)}</Text>
         <Text variant="b3">
           {
             inviterName !== null
@@ -36,7 +34,7 @@ function RoomTile({
         </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>
index 7eaae4ef38300f83743a986feda48e9fb6b1de4f..d2f1f9e9d623d25d975b99b818e4c0d69711aa18 100644 (file)
@@ -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)}
                   >
-                    <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}
index 5d705252d37e7f7b6b2cee908c2c3b372e8f66bd..072397ef18a8da2c8d625286ce1f2145690aabd8 100644 (file)
@@ -1,6 +1,8 @@
 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 {
@@ -30,7 +32,7 @@ function DrawerHeader({ selectedTab, spaceId }) {
   return (
     <Header>
       <TitleWrapper>
-        <Text variant="s1">{spaceName || tabName}</Text>
+        <Text variant="s1">{twemojify(spaceName) || tabName}</Text>
       </TitleWrapper>
       {spaceName && (
         <IconButton
index 95f18f9b9b67b180b0429d8247c7e8b84b5f1f89..b9fe44837fef968923d253b751d7eb2439c4b653 100644 (file)
@@ -2,6 +2,8 @@ import React, { useState, useEffect, useRef } from 'react';
 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';
@@ -262,8 +264,8 @@ function ProfileViewer() {
             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>
index 22bc07aa9ba6ccaaa735a54a6c76837293b3aa56..a2e0d4850e4ea50f280e51280ce65ca72f7888a5 100644 (file)
@@ -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 }) {
     <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
index 05f5d503ec7a80fba0f87b51843add4c485a44ad..dde94a3f87d1092cd7dd9c57570e8112ffb43a94 100644 (file)
@@ -1,14 +1,20 @@
 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'}
         </>
       );
@@ -17,27 +23,27 @@ function getTimelineJSXMessages() {
       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'}
         </>
       );
@@ -45,7 +51,7 @@ function getTimelineJSXMessages() {
     rejectInvite(user) {
       return (
         <>
-          <b>{user}</b>
+          <b>{getEmojifiedJsx(user)}</b>
           {' rejected the invitation'}
         </>
       );
@@ -54,10 +60,10 @@ function getTimelineJSXMessages() {
       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)}
         </>
       );
     },
@@ -65,26 +71,26 @@ function getTimelineJSXMessages() {
       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'}
         </>
       );
@@ -92,7 +98,7 @@ function getTimelineJSXMessages() {
     avatarChanged(user) {
       return (
         <>
-          <b>{user}</b>
+          <b>{getEmojifiedJsx(user)}</b>
           {' changed the avatar'}
         </>
       );
@@ -100,7 +106,7 @@ function getTimelineJSXMessages() {
     avatarRemoved(user) {
       return (
         <>
-          <b>{user}</b>
+          <b>{getEmojifiedJsx(user)}</b>
           {' removed the avatar'}
         </>
       );
@@ -108,27 +114,27 @@ function getTimelineJSXMessages() {
     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>
         </>
       );
     },
@@ -141,7 +147,7 @@ function getUsersActionJsx(roomId, userIds, actionStr) {
     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;
index 583e20f1d83a888ea0d2807ec13bb57574777bff..2c5096dee12aa31142c5843d702c586d1a09f242 100644 (file)
@@ -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;
 
   --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 (file)
index 0000000..a308947
--- /dev/null
@@ -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 = {
+    '&': '&amp;',
+    '<': '&lt;',
+    '>': '&gt;',
+    '"': '&quot;',
+    "'": '&#39;',
+  };
+  return body.replace(/[&<>]/g, (tag) => tagsToReplace[tag] || tag);
+}
diff --git a/src/util/twemojify.js b/src/util/twemojify.js
new file mode 100644 (file)
index 0000000..43943ba
--- /dev/null
@@ -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);
+}