Refactor how message content highlighting works + change to safe HTML rendering component. Closes #2669
This commit is contained in:
parent
388e4d3d78
commit
e6d3da4f9c
@ -25,9 +25,18 @@ $p-size: 8px;
|
|||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
mark {
|
mark {
|
||||||
padding-left: 0.35em;
|
padding-left: 0.3em;
|
||||||
padding-right: 0.35em;
|
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);
|
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."}`);
|
"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(`{
|
const moderatorMessage: ChatMessage = JSON.parse(`{
|
||||||
"type": "CHAT",
|
"type": "CHAT",
|
||||||
"id": "wY-MEXwnR",
|
"id": "wY-MEXwnR",
|
||||||
@ -80,6 +95,12 @@ WithoutModeratorMenu.args = {
|
|||||||
showModeratorMenu: false,
|
showModeratorMenu: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const WithLinkAndCustomEmoji = Template.bind({});
|
||||||
|
WithLinkAndCustomEmoji.args = {
|
||||||
|
message: messageWithLinkAndCustomEmoji,
|
||||||
|
showModeratorMenu: false,
|
||||||
|
};
|
||||||
|
|
||||||
export const WithModeratorMenu = Template.bind({});
|
export const WithModeratorMenu = Template.bind({});
|
||||||
WithModeratorMenu.args = {
|
WithModeratorMenu.args = {
|
||||||
message: standardMessage,
|
message: standardMessage,
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
/* eslint-disable react/no-danger */
|
/* eslint-disable react/no-danger */
|
||||||
import { FC, ReactNode, useEffect, useState } from 'react';
|
import { FC, ReactNode } from 'react';
|
||||||
import cn from 'classnames';
|
import cn from 'classnames';
|
||||||
import { Tooltip } from 'antd';
|
import { Tooltip } from 'antd';
|
||||||
import { useRecoilValue } from 'recoil';
|
import { useRecoilValue } from 'recoil';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import { decodeHTML } from 'entities';
|
import { Interweave } from 'interweave';
|
||||||
import linkifyHtml from 'linkify-html';
|
import { UrlMatcher } from 'interweave-autolink';
|
||||||
|
import { ChatMessageHighlightMatcher } from './customMatcher';
|
||||||
import styles from './ChatUserMessage.module.scss';
|
import styles from './ChatUserMessage.module.scss';
|
||||||
import { formatTimestamp } from './messageFmt';
|
import { formatTimestamp } from './messageFmt';
|
||||||
import { ChatMessage } from '../../../interfaces/chat-message.model';
|
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 = {
|
export type ChatUserMessageProps = {
|
||||||
message: ChatMessage;
|
message: ChatMessage;
|
||||||
showModeratorMenu: boolean;
|
showModeratorMenu: boolean;
|
||||||
@ -71,7 +68,6 @@ export const ChatUserMessage: FC<ChatUserMessageProps> = ({
|
|||||||
|
|
||||||
const color = `var(--theme-color-users-${displayColor})`;
|
const color = `var(--theme-color-users-${displayColor})`;
|
||||||
const formattedTimestamp = `Sent ${formatTimestamp(timestamp)}`;
|
const formattedTimestamp = `Sent ${formatTimestamp(timestamp)}`;
|
||||||
const [formattedMessage, setFormattedMessage] = useState<string>(body);
|
|
||||||
|
|
||||||
const badgeNodes = [];
|
const badgeNodes = [];
|
||||||
if (isAuthorModerator) {
|
if (isAuthorModerator) {
|
||||||
@ -81,10 +77,6 @@ export const ChatUserMessage: FC<ChatUserMessageProps> = ({
|
|||||||
badgeNodes.push(<AuthedUserBadge key="auth" userColor={displayColor} />);
|
badgeNodes.push(<AuthedUserBadge key="auth" userColor={displayColor} />);
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setFormattedMessage(decodeHTML(body));
|
|
||||||
}, [message]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -110,12 +102,14 @@ export const ChatUserMessage: FC<ChatUserMessageProps> = ({
|
|||||||
</UserTooltip>
|
</UserTooltip>
|
||||||
)}
|
)}
|
||||||
<Tooltip title={formattedTimestamp} mouseEnterDelay={1}>
|
<Tooltip title={formattedTimestamp} mouseEnterDelay={1}>
|
||||||
<Highlight search={highlightString}>
|
<Interweave
|
||||||
<div
|
className={styles.message}
|
||||||
className={styles.message}
|
content={body}
|
||||||
dangerouslySetInnerHTML={{ __html: linkifyHtml(formattedMessage) }}
|
matchers={[
|
||||||
/>
|
new UrlMatcher('url', { validateTLD: false }),
|
||||||
</Highlight>
|
new ChatMessageHighlightMatcher('highlight', { highlightString }),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{showModeratorMenu && (
|
{showModeratorMenu && (
|
||||||
<div className={styles.modMenuWrapper}>
|
<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 { convertToText } from '../chat';
|
||||||
import { getDiffInDaysFromNow } from '../../../utils/helpers';
|
import { getDiffInDaysFromNow } from '../../../utils/helpers';
|
||||||
|
|
||||||
const stripTags = (str: string) => str && str.replace(/<\/?[^>]+(>|$)/g, '');
|
|
||||||
const convertToMarkup = (str = '') => convertToText(str).replace(/\n/g, '<p></p>');
|
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) {
|
export function formatTimestamp(sentAt: Date) {
|
||||||
const now = new Date(sentAt);
|
const now = new Date(sentAt);
|
||||||
if (Number.isNaN(now)) return '';
|
if (Number.isNaN(now)) return '';
|
||||||
@ -56,8 +26,6 @@ export function formatTimestamp(sentAt: Date) {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export function formatMessageText(message: string) {
|
export function formatMessageText(message: string) {
|
||||||
let formattedText = getMessageWithEmbeds(message);
|
const formattedText = convertToMarkup(message);
|
||||||
formattedText = convertToMarkup(formattedText);
|
|
||||||
return formattedText;
|
return formattedText;
|
||||||
// return await highlightUsername(formattedText, username);
|
|
||||||
}
|
}
|
||||||
|
68
web/package-lock.json
generated
68
web/package-lock.json
generated
@ -24,8 +24,8 @@
|
|||||||
"chart.js": "^4.2.0",
|
"chart.js": "^4.2.0",
|
||||||
"classnames": "2.3.2",
|
"classnames": "2.3.2",
|
||||||
"date-fns": "^2.29.3",
|
"date-fns": "^2.29.3",
|
||||||
"entities": "^4.4.0",
|
"interweave": "^13.0.0",
|
||||||
"linkify-html": "^4.1.0",
|
"interweave-autolink": "^5.1.0",
|
||||||
"linkifyjs": "^4.1.0",
|
"linkifyjs": "^4.1.0",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"next": "13.1.6",
|
"next": "13.1.6",
|
||||||
@ -38,6 +38,7 @@
|
|||||||
"react-chartkick": "^0.5.3",
|
"react-chartkick": "^0.5.3",
|
||||||
"react-crossfade-img": "1.0.0",
|
"react-crossfade-img": "1.0.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
|
"react-highlight-words": "^0.20.0",
|
||||||
"react-highlighter-ts": "18.0.1",
|
"react-highlighter-ts": "18.0.1",
|
||||||
"react-hotkeys-hook": "4.3.4",
|
"react-hotkeys-hook": "4.3.4",
|
||||||
"react-linkify": "1.0.0-alpha",
|
"react-linkify": "1.0.0-alpha",
|
||||||
@ -18064,6 +18065,7 @@
|
|||||||
"version": "4.4.0",
|
"version": "4.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/entities/-/entities-4.4.0.tgz",
|
||||||
"integrity": "sha512-oYp7156SP8LkeGD0GF85ad1X9Ai79WtRsZ2gxJqtBuzH+98YUV6jkHEKlZkMbcrjJjIVJNIDP/3WL9wQkoPbWA==",
|
"integrity": "sha512-oYp7156SP8LkeGD0GF85ad1X9Ai79WtRsZ2gxJqtBuzH+98YUV6jkHEKlZkMbcrjJjIVJNIDP/3WL9wQkoPbWA==",
|
||||||
|
"dev": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.12"
|
"node": ">=0.12"
|
||||||
},
|
},
|
||||||
@ -18265,8 +18267,7 @@
|
|||||||
"node_modules/escape-html": {
|
"node_modules/escape-html": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/escape-string-regexp": {
|
"node_modules/escape-string-regexp": {
|
||||||
"version": "1.0.5",
|
"version": "1.0.5",
|
||||||
@ -21328,6 +21329,11 @@
|
|||||||
"tslib": "^2.0.3"
|
"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": {
|
"node_modules/hmac-drbg": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz",
|
||||||
@ -21769,6 +21775,34 @@
|
|||||||
"node": ">= 0.10"
|
"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": {
|
"node_modules/invariant": {
|
||||||
"version": "2.2.4",
|
"version": "2.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
|
||||||
@ -23444,14 +23478,6 @@
|
|||||||
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/linkify-it": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-2.2.0.tgz",
|
||||||
@ -24614,6 +24640,11 @@
|
|||||||
"node": ">= 4.0.0"
|
"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": {
|
"node_modules/memoizerific": {
|
||||||
"version": "1.11.3",
|
"version": "1.11.3",
|
||||||
"resolved": "https://registry.npmjs.org/memoizerific/-/memoizerific-1.11.3.tgz",
|
"resolved": "https://registry.npmjs.org/memoizerific/-/memoizerific-1.11.3.tgz",
|
||||||
@ -31907,6 +31938,19 @@
|
|||||||
"react": "^18.2.0"
|
"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": {
|
"node_modules/react-highlighter-ts": {
|
||||||
"version": "18.0.1",
|
"version": "18.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-highlighter-ts/-/react-highlighter-ts-18.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-highlighter-ts/-/react-highlighter-ts-18.0.1.tgz",
|
||||||
|
@ -28,8 +28,8 @@
|
|||||||
"chart.js": "^4.2.0",
|
"chart.js": "^4.2.0",
|
||||||
"classnames": "2.3.2",
|
"classnames": "2.3.2",
|
||||||
"date-fns": "^2.29.3",
|
"date-fns": "^2.29.3",
|
||||||
"entities": "^4.4.0",
|
"interweave": "^13.0.0",
|
||||||
"linkify-html": "^4.1.0",
|
"interweave-autolink": "^5.1.0",
|
||||||
"linkifyjs": "^4.1.0",
|
"linkifyjs": "^4.1.0",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"next": "13.1.6",
|
"next": "13.1.6",
|
||||||
@ -42,6 +42,7 @@
|
|||||||
"react-chartkick": "^0.5.3",
|
"react-chartkick": "^0.5.3",
|
||||||
"react-crossfade-img": "1.0.0",
|
"react-crossfade-img": "1.0.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
|
"react-highlight-words": "^0.20.0",
|
||||||
"react-highlighter-ts": "18.0.1",
|
"react-highlighter-ts": "18.0.1",
|
||||||
"react-hotkeys-hook": "4.3.4",
|
"react-hotkeys-hook": "4.3.4",
|
||||||
"react-linkify": "1.0.0-alpha",
|
"react-linkify": "1.0.0-alpha",
|
||||||
|
Loading…
x
Reference in New Issue
Block a user