diff --git a/web/components/chat/ChatUserMessage/ChatUserMessage.module.scss b/web/components/chat/ChatUserMessage/ChatUserMessage.module.scss index ce047c773..f1da93314 100644 --- a/web/components/chat/ChatUserMessage/ChatUserMessage.module.scss +++ b/web/components/chat/ChatUserMessage/ChatUserMessage.module.scss @@ -25,9 +25,18 @@ $p-size: 8px; position: relative; mark { - padding-left: 0.35em; - padding-right: 0.35em; + padding-left: 0.3em; + padding-right: 0.3em; + color: var(--theme-color-palette-4); + border-radius: var(--theme-rounded-corners); + background-color: var(--color-owncast-palette-7); + } + + a { color: var(--theme-color-palette-12); + &:hover { + color: var(--theme-color-palette-4); + } } } diff --git a/web/components/chat/ChatUserMessage/ChatUserMessage.stories.tsx b/web/components/chat/ChatUserMessage/ChatUserMessage.stories.tsx index 91ad0091c..de6580120 100644 --- a/web/components/chat/ChatUserMessage/ChatUserMessage.stories.tsx +++ b/web/components/chat/ChatUserMessage/ChatUserMessage.stories.tsx @@ -43,6 +43,21 @@ const standardMessage: ChatMessage = JSON.parse(`{ }, "body": "Test message from a regular user."}`); +const messageWithLinkAndCustomEmoji: ChatMessage = JSON.parse(`{ + "type": "CHAT", + "id": "wY-MEXwnR", + "timestamp": "2022-04-28T20:30:27.001762726Z", + "user": { + "id": "h_5GQ6E7R", + "displayName": "EliteMooseTaskForce", + "displayColor": 3, + "createdAt": "2022-03-24T03:52:37.966584694Z", + "previousNames": ["gifted-nobel", "EliteMooseTaskForce"], + "nameChangedAt": "2022-04-26T23:56:05.531287897Z", + "scopes": [] + }, + "body": "Test message with a link https://owncast.online and a custom emoji ."}`); + const moderatorMessage: ChatMessage = JSON.parse(`{ "type": "CHAT", "id": "wY-MEXwnR", @@ -80,6 +95,12 @@ WithoutModeratorMenu.args = { showModeratorMenu: false, }; +export const WithLinkAndCustomEmoji = Template.bind({}); +WithLinkAndCustomEmoji.args = { + message: messageWithLinkAndCustomEmoji, + showModeratorMenu: false, +}; + export const WithModeratorMenu = Template.bind({}); WithModeratorMenu.args = { message: standardMessage, diff --git a/web/components/chat/ChatUserMessage/ChatUserMessage.tsx b/web/components/chat/ChatUserMessage/ChatUserMessage.tsx index 9859232d3..8f0255189 100644 --- a/web/components/chat/ChatUserMessage/ChatUserMessage.tsx +++ b/web/components/chat/ChatUserMessage/ChatUserMessage.tsx @@ -1,11 +1,12 @@ /* eslint-disable react/no-danger */ -import { FC, ReactNode, useEffect, useState } from 'react'; +import { FC, ReactNode } from 'react'; import cn from 'classnames'; import { Tooltip } from 'antd'; import { useRecoilValue } from 'recoil'; import dynamic from 'next/dynamic'; -import { decodeHTML } from 'entities'; -import linkifyHtml from 'linkify-html'; +import { Interweave } from 'interweave'; +import { UrlMatcher } from 'interweave-autolink'; +import { ChatMessageHighlightMatcher } from './customMatcher'; import styles from './ChatUserMessage.module.scss'; import { formatTimestamp } from './messageFmt'; import { ChatMessage } from '../../../interfaces/chat-message.model'; @@ -26,10 +27,6 @@ const ChatModerationActionMenu = dynamic( }, ); -const Highlight = dynamic(() => import('react-highlighter-ts').then(mod => mod.Highlight), { - ssr: false, -}); - export type ChatUserMessageProps = { message: ChatMessage; showModeratorMenu: boolean; @@ -71,7 +68,6 @@ export const ChatUserMessage: FC = ({ const color = `var(--theme-color-users-${displayColor})`; const formattedTimestamp = `Sent ${formatTimestamp(timestamp)}`; - const [formattedMessage, setFormattedMessage] = useState(body); const badgeNodes = []; if (isAuthorModerator) { @@ -81,10 +77,6 @@ export const ChatUserMessage: FC = ({ badgeNodes.push(); } - useEffect(() => { - setFormattedMessage(decodeHTML(body)); - }, [message]); - return (
= ({ )} - -
- + {showModeratorMenu && (
diff --git a/web/components/chat/ChatUserMessage/customMatcher.ts b/web/components/chat/ChatUserMessage/customMatcher.ts new file mode 100644 index 000000000..d6136da1a --- /dev/null +++ b/web/components/chat/ChatUserMessage/customMatcher.ts @@ -0,0 +1,44 @@ +/* eslint-disable class-methods-use-this */ +import { ChildrenNode, Matcher, MatchResponse, Node } from 'interweave'; +import React from 'react'; + +export interface CustomProps { + children: React.ReactNode; + key: string; +} + +interface options { + highlightString: string; +} + +export class ChatMessageHighlightMatcher extends Matcher { + match(str: string): MatchResponse<{}> | null { + const { highlightString } = this.options as options; + + if (!highlightString) { + return null; + } + + const result = str.match(highlightString); + + if (!result) { + return null; + } + + return { + index: result.index!, + length: result[0].length, + match: result[0], + valid: true, + }; + } + + replaceWith(children: ChildrenNode, props: CustomProps): Node { + const { key } = props; + return React.createElement('mark', { key }, children); + } + + asTag(): string { + return 'mark'; + } +} diff --git a/web/components/chat/ChatUserMessage/messageFmt.ts b/web/components/chat/ChatUserMessage/messageFmt.ts index 81fb53184..4624e2747 100644 --- a/web/components/chat/ChatUserMessage/messageFmt.ts +++ b/web/components/chat/ChatUserMessage/messageFmt.ts @@ -1,38 +1,8 @@ import { convertToText } from '../chat'; import { getDiffInDaysFromNow } from '../../../utils/helpers'; -const stripTags = (str: string) => str && str.replace(/<\/?[^>]+(>|$)/g, ''); const convertToMarkup = (str = '') => convertToText(str).replace(/\n/g, '

'); -function getInstagramEmbedFromURL(url: string) { - const urlObject = new URL(url.replace(/\/$/, '')); - urlObject.pathname += '/embed'; - return ``; -} - -function isMessageJustAnchor(embedText: string, message: string, anchors: HTMLAnchorElement[]) { - if (embedText !== '' && anchors.length === 1) return false; - return stripTags(message) === stripTags(anchors[0]?.innerHTML); -} - -function getMessageWithEmbeds(message: string) { - let embedText = ''; - // Make a temporary element so we can actually parse the html and pull anchor tags from it. - // This is a better approach than regex. - const container = document.createElement('p'); - container.innerHTML = message; - - const anchors = Array.from(container.querySelectorAll('a')); - anchors.forEach(({ href }) => { - if (href.includes('instagram.com/p/')) embedText += getInstagramEmbedFromURL(href); - }); - - // If this message only consists of a single embeddable link - // then only return the embed and strip the link url from the text. - if (isMessageJustAnchor(embedText, message, anchors)) return embedText; - return message + embedText; -} - export function formatTimestamp(sentAt: Date) { const now = new Date(sentAt); if (Number.isNaN(now)) return ''; @@ -56,8 +26,6 @@ export function formatTimestamp(sentAt: Date) { */ export function formatMessageText(message: string) { - let formattedText = getMessageWithEmbeds(message); - formattedText = convertToMarkup(formattedText); + const formattedText = convertToMarkup(message); return formattedText; - // return await highlightUsername(formattedText, username); } diff --git a/web/package-lock.json b/web/package-lock.json index e5b32383c..52be0c4fe 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -24,8 +24,8 @@ "chart.js": "^4.2.0", "classnames": "2.3.2", "date-fns": "^2.29.3", - "entities": "^4.4.0", - "linkify-html": "^4.1.0", + "interweave": "^13.0.0", + "interweave-autolink": "^5.1.0", "linkifyjs": "^4.1.0", "lodash": "4.17.21", "next": "13.1.6", @@ -38,6 +38,7 @@ "react-chartkick": "^0.5.3", "react-crossfade-img": "1.0.0", "react-dom": "18.2.0", + "react-highlight-words": "^0.20.0", "react-highlighter-ts": "18.0.1", "react-hotkeys-hook": "4.3.4", "react-linkify": "1.0.0-alpha", @@ -18064,6 +18065,7 @@ "version": "4.4.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.4.0.tgz", "integrity": "sha512-oYp7156SP8LkeGD0GF85ad1X9Ai79WtRsZ2gxJqtBuzH+98YUV6jkHEKlZkMbcrjJjIVJNIDP/3WL9wQkoPbWA==", + "dev": true, "engines": { "node": ">=0.12" }, @@ -18265,8 +18267,7 @@ "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "dev": true + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" }, "node_modules/escape-string-regexp": { "version": "1.0.5", @@ -21328,6 +21329,11 @@ "tslib": "^2.0.3" } }, + "node_modules/highlight-words-core": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/highlight-words-core/-/highlight-words-core-1.2.2.tgz", + "integrity": "sha512-BXUKIkUuh6cmmxzi5OIbUJxrG8OAk2MqoL1DtO3Wo9D2faJg2ph5ntyuQeLqaHJmzER6H5tllCDA9ZnNe9BVGg==" + }, "node_modules/hmac-drbg": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", @@ -21769,6 +21775,34 @@ "node": ">= 0.10" } }, + "node_modules/interweave": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/interweave/-/interweave-13.0.0.tgz", + "integrity": "sha512-Mckwj+ix/VtrZu1bRBIIohwrsXj12ZTvJCoYUMZlJmgtvIaQCj0i77eSZ63ckbA1TsPrz2VOvLW9/kTgm5d+mw==", + "dependencies": { + "escape-html": "^1.0.3" + }, + "funding": { + "type": "ko-fi", + "url": "https://ko-fi.com/milesjohnson" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/interweave-autolink": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/interweave-autolink/-/interweave-autolink-5.1.0.tgz", + "integrity": "sha512-WOEakAdwqv/W2H85cLdigkpMM7o6qVg4CWM6iO5cHrFCywwUh+ILVmZgX1tHphEpa55sFdzpKNO2EHhAjbR4GA==", + "funding": { + "type": "ko-fi", + "url": "https://ko-fi.com/milesjohnson" + }, + "peerDependencies": { + "interweave": "^13.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/invariant": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", @@ -23444,14 +23478,6 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "dev": true }, - "node_modules/linkify-html": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/linkify-html/-/linkify-html-4.1.0.tgz", - "integrity": "sha512-cQSNN4i5V1xRjdSUEnXgn855xsl+usD7zBSsNyMSFBf4NlaZFocn7cExJA217azxODeqea79b6fDPXLa7jdkcA==", - "peerDependencies": { - "linkifyjs": "^4.0.0" - } - }, "node_modules/linkify-it": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-2.2.0.tgz", @@ -24614,6 +24640,11 @@ "node": ">= 4.0.0" } }, + "node_modules/memoize-one": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-4.0.3.tgz", + "integrity": "sha512-QmpUu4KqDmX0plH4u+tf0riMc1KHE1+lw95cMrLlXQAFOx/xnBtwhZ52XJxd9X2O6kwKBqX32kmhbhlobD0cuw==" + }, "node_modules/memoizerific": { "version": "1.11.3", "resolved": "https://registry.npmjs.org/memoizerific/-/memoizerific-1.11.3.tgz", @@ -31907,6 +31938,19 @@ "react": "^18.2.0" } }, + "node_modules/react-highlight-words": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/react-highlight-words/-/react-highlight-words-0.20.0.tgz", + "integrity": "sha512-asCxy+jCehDVhusNmCBoxDf2mm1AJ//D+EzDx1m5K7EqsMBIHdZ5G4LdwbSEXqZq1Ros0G0UySWmAtntSph7XA==", + "dependencies": { + "highlight-words-core": "^1.2.0", + "memoize-one": "^4.0.0", + "prop-types": "^15.5.8" + }, + "peerDependencies": { + "react": "^0.14.0 || ^15.0.0 || ^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0" + } + }, "node_modules/react-highlighter-ts": { "version": "18.0.1", "resolved": "https://registry.npmjs.org/react-highlighter-ts/-/react-highlighter-ts-18.0.1.tgz", diff --git a/web/package.json b/web/package.json index 861441b99..f4eb0a847 100644 --- a/web/package.json +++ b/web/package.json @@ -28,8 +28,8 @@ "chart.js": "^4.2.0", "classnames": "2.3.2", "date-fns": "^2.29.3", - "entities": "^4.4.0", - "linkify-html": "^4.1.0", + "interweave": "^13.0.0", + "interweave-autolink": "^5.1.0", "linkifyjs": "^4.1.0", "lodash": "4.17.21", "next": "13.1.6", @@ -42,6 +42,7 @@ "react-chartkick": "^0.5.3", "react-crossfade-img": "1.0.0", "react-dom": "18.2.0", + "react-highlight-words": "^0.20.0", "react-highlighter-ts": "18.0.1", "react-hotkeys-hook": "4.3.4", "react-linkify": "1.0.0-alpha",