Refactor how message content highlighting works + change to safe HTML rendering component. Closes #2669
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <img src='/img/emoji/blob/ablobattention.gif' width='30px'/> ."}`);
|
||||
|
||||
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,
|
||||
|
||||
@@ -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<ChatUserMessageProps> = ({
|
||||
|
||||
const color = `var(--theme-color-users-${displayColor})`;
|
||||
const formattedTimestamp = `Sent ${formatTimestamp(timestamp)}`;
|
||||
const [formattedMessage, setFormattedMessage] = useState<string>(body);
|
||||
|
||||
const badgeNodes = [];
|
||||
if (isAuthorModerator) {
|
||||
@@ -81,10 +77,6 @@ export const ChatUserMessage: FC<ChatUserMessageProps> = ({
|
||||
badgeNodes.push(<AuthedUserBadge key="auth" userColor={displayColor} />);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setFormattedMessage(decodeHTML(body));
|
||||
}, [message]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -110,12 +102,14 @@ export const ChatUserMessage: FC<ChatUserMessageProps> = ({
|
||||
</UserTooltip>
|
||||
)}
|
||||
<Tooltip title={formattedTimestamp} mouseEnterDelay={1}>
|
||||
<Highlight search={highlightString}>
|
||||
<div
|
||||
className={styles.message}
|
||||
dangerouslySetInnerHTML={{ __html: linkifyHtml(formattedMessage) }}
|
||||
/>
|
||||
</Highlight>
|
||||
<Interweave
|
||||
className={styles.message}
|
||||
content={body}
|
||||
matchers={[
|
||||
new UrlMatcher('url', { validateTLD: false }),
|
||||
new ChatMessageHighlightMatcher('highlight', { highlightString }),
|
||||
]}
|
||||
/>
|
||||
</Tooltip>
|
||||
{showModeratorMenu && (
|
||||
<div className={styles.modMenuWrapper}>
|
||||
|
||||
44
web/components/chat/ChatUserMessage/customMatcher.ts
Normal file
44
web/components/chat/ChatUserMessage/customMatcher.ts
Normal file
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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, '<p></p>');
|
||||
|
||||
function getInstagramEmbedFromURL(url: string) {
|
||||
const urlObject = new URL(url.replace(/\/$/, ''));
|
||||
urlObject.pathname += '/embed';
|
||||
return `<iframe class="chat-embed instagram-embed" src="${urlObject.href}" frameborder="0" allowfullscreen></iframe>`;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user