diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js
index 7bc657a558..731ec08a22 100644
--- a/services/web/app/src/Features/Project/ProjectController.js
+++ b/services/web/app/src/Features/Project/ProjectController.js
@@ -842,7 +842,8 @@ const ProjectController = {
gaOptimize: enableOptimize,
customOptimizeEvent: true,
experimentId: Settings.experimentId,
- showNewLogsUI: req.query && req.query.new_logs_ui === 'true'
+ showNewLogsUI: req.query && req.query.new_logs_ui === 'true',
+ showNewChatUI: req.query && req.query.new_chat_ui === 'true'
})
timer.done()
}
diff --git a/services/web/app/views/project/editor.pug b/services/web/app/views/project/editor.pug
index 6ae08442d9..e9a8b8fc07 100644
--- a/services/web/app/views/project/editor.pug
+++ b/services/web/app/views/project/editor.pug
@@ -102,7 +102,10 @@ block content
if !isRestrictedTokenMember
.ui-layout-east
- include ./editor/chat
+ if showNewChatUI
+ include ./editor/chat-react
+ else
+ include ./editor/chat
include ./editor/hotkeys
diff --git a/services/web/app/views/project/editor/chat-react.pug b/services/web/app/views/project/editor/chat-react.pug
new file mode 100644
index 0000000000..ff227c26e2
--- /dev/null
+++ b/services/web/app/views/project/editor/chat-react.pug
@@ -0,0 +1,12 @@
+aside.chat(
+ ng-controller="ReactChatController"
+)
+ chat(
+ at-end="atEnd"
+ loading="loading"
+ load-more-messages="loadMoreMessages"
+ messages="messages"
+ reset-unread-messages="resetUnreadMessages"
+ send-message="sendMessage"
+ user-id="userId"
+ )
diff --git a/services/web/frontend/extracted-translation-keys.json b/services/web/frontend/extracted-translation-keys.json
index 1aa9cccd6b..12aa76d6e8 100644
--- a/services/web/frontend/extracted-translation-keys.json
+++ b/services/web/frontend/extracted-translation-keys.json
@@ -17,5 +17,9 @@
"fast",
"stop_on_validation_error",
"ignore_validation_errors",
- "run_syntax_check_now"
+ "run_syntax_check_now",
+ "loading",
+ "no_messages",
+ "send_first_message",
+ "your_message"
]
diff --git a/services/web/frontend/js/features/chat/components/chat-pane.js b/services/web/frontend/js/features/chat/components/chat-pane.js
new file mode 100644
index 0000000000..ab8e21660f
--- /dev/null
+++ b/services/web/frontend/js/features/chat/components/chat-pane.js
@@ -0,0 +1,90 @@
+import React, { useEffect } from 'react'
+import PropTypes from 'prop-types'
+import MessageList from './message-list'
+import MessageInput from './message-input'
+import InfiniteScroll from './infinite-scroll'
+import Icon from '../../../shared/components/icon'
+import { useTranslation } from 'react-i18next'
+
+function ChatPane({
+ atEnd,
+ loading,
+ loadMoreMessages,
+ messages,
+ resetUnreadMessages,
+ sendMessage,
+ userId
+}) {
+ useEffect(() => {
+ loadMoreMessages()
+ }, [])
+
+ const shouldDisplayPlaceholder = !loading && messages.length === 0
+
+ const messageContentCount = messages.reduce(
+ (acc, { contents }) => acc + contents.length,
+ 0
+ )
+
+ return (
+
+ )
+}
+
+function LoadingSpinner() {
+ const { t } = useTranslation()
+ return (
+
+
+ {` ${t('loading')}...`}
+
+ )
+}
+
+function Placeholder() {
+ const { t } = useTranslation()
+ return (
+ <>
+ {t('no_messages')}
+
+ {t('send_first_message')}
+
+
+
+ >
+ )
+}
+
+ChatPane.propTypes = {
+ atEnd: PropTypes.bool,
+ loading: PropTypes.bool,
+ loadMoreMessages: PropTypes.func.isRequired,
+ messages: PropTypes.array.isRequired,
+ resetUnreadMessages: PropTypes.func.isRequired,
+ sendMessage: PropTypes.func.isRequired,
+ userId: PropTypes.string.isRequired
+}
+
+export default ChatPane
diff --git a/services/web/frontend/js/features/chat/components/infinite-scroll.js b/services/web/frontend/js/features/chat/components/infinite-scroll.js
new file mode 100644
index 0000000000..bc70025e66
--- /dev/null
+++ b/services/web/frontend/js/features/chat/components/infinite-scroll.js
@@ -0,0 +1,117 @@
+import React, { useRef, useEffect } from 'react'
+import PropTypes from 'prop-types'
+
+const SCROLL_END_OFFSET = 30
+
+function usePrevious(value) {
+ const ref = useRef()
+ useEffect(() => {
+ ref.current = value
+ })
+ return ref.current
+}
+
+function InfiniteScroll({
+ atEnd,
+ children,
+ className = '',
+ fetchData,
+ itemCount,
+ isLoading
+}) {
+ const root = useRef(null)
+
+ const prevItemCount = usePrevious(itemCount)
+
+ // we keep the value in a Ref instead of state so it can be safely used in effects
+ const scrollBottomRef = React.useRef(0)
+ function setScrollBottom(value) {
+ scrollBottomRef.current = value
+ }
+
+ // position updates are not immediately applied. The DOM frequently can't calculate
+ // element bounds after react updates, so it needs some throttling
+ function scheduleScrollPositionUpdate(throttle) {
+ const timeoutHandler = setTimeout(
+ () =>
+ (root.current.scrollTop =
+ root.current.scrollHeight -
+ root.current.clientHeight -
+ scrollBottomRef.current),
+ throttle
+ )
+ return () => clearTimeout(timeoutHandler)
+ }
+
+ // Repositions the scroll after new items are loaded
+ useEffect(
+ () => {
+ // the first render requires a longer throttling due to slower DOM updates
+ const scrollThrottle = prevItemCount === 0 ? 150 : 0
+ return scheduleScrollPositionUpdate(scrollThrottle)
+ },
+ [itemCount, prevItemCount]
+ )
+
+ // Repositions the scroll after a window resize
+ useEffect(() => {
+ let clearScrollPositionUpdate
+ const handleResize = () => {
+ clearScrollPositionUpdate = scheduleScrollPositionUpdate(400)
+ }
+ window.addEventListener('resize', handleResize)
+ return () => {
+ window.removeEventListener('resize', handleResize)
+ if (clearScrollPositionUpdate) {
+ clearScrollPositionUpdate()
+ }
+ }
+ }, [])
+
+ function onScrollHandler(event) {
+ setScrollBottom(
+ root.current.scrollHeight -
+ root.current.scrollTop -
+ root.current.clientHeight
+ )
+ if (event.target !== event.currentTarget) {
+ // Ignore scroll events on nested divs
+ // (this check won't be necessary in React 17: https://github.com/facebook/react/issues/15723
+ return
+ }
+ if (shouldFetchData()) {
+ fetchData()
+ }
+ }
+
+ function shouldFetchData() {
+ const containerIsLargerThanContent =
+ root.current.children[0].clientHeight < root.current.clientHeight
+ if (atEnd || isLoading || containerIsLargerThanContent) {
+ return false
+ } else {
+ return root.current.scrollTop < SCROLL_END_OFFSET
+ }
+ }
+
+ return (
+
+ {children}
+
+ )
+}
+
+InfiniteScroll.propTypes = {
+ atEnd: PropTypes.bool,
+ children: PropTypes.element.isRequired,
+ className: PropTypes.string,
+ fetchData: PropTypes.func.isRequired,
+ itemCount: PropTypes.number.isRequired,
+ isLoading: PropTypes.bool
+}
+
+export default InfiniteScroll
diff --git a/services/web/frontend/js/features/chat/components/message-content.js b/services/web/frontend/js/features/chat/components/message-content.js
new file mode 100644
index 0000000000..9829d91bbf
--- /dev/null
+++ b/services/web/frontend/js/features/chat/components/message-content.js
@@ -0,0 +1,52 @@
+import React, { useRef, useEffect } from 'react'
+import PropTypes from 'prop-types'
+import Linkify from 'react-linkify'
+
+function MessageContent({ content }) {
+ const root = useRef(null)
+
+ useEffect(() => {
+ const MJHub = window.MathJax.Hub
+ const inlineMathConfig = MJHub.config && MJHub.config.tex2jax.inlineMath
+ const alreadyConfigured = inlineMathConfig.some(
+ c => c[0] === '$' && c[1] === '$'
+ )
+ if (!alreadyConfigured) {
+ MJHub.Config({
+ tex2jax: {
+ inlineMath: inlineMathConfig.concat([['$', '$']])
+ }
+ })
+ }
+ }, [])
+
+ useEffect(
+ () => {
+ // adds attributes to all the links generated by , required due to https://github.com/tasti/react-linkify/issues/99
+ root.current.getElementsByTagName('a').forEach(a => {
+ a.setAttribute('target', '_blank')
+ a.setAttribute('rel', 'noreferrer noopener')
+ })
+
+ // MathJax typesetting
+ const MJHub = window.MathJax.Hub
+ const timeoutHandler = setTimeout(() => {
+ MJHub.Queue(['Typeset', MJHub, root.current])
+ }, 0)
+ return () => clearTimeout(timeoutHandler)
+ },
+ [content]
+ )
+
+ return (
+
+ {content}
+
+ )
+}
+
+MessageContent.propTypes = {
+ content: PropTypes.string.isRequired
+}
+
+export default MessageContent
diff --git a/services/web/frontend/js/features/chat/components/message-input.js b/services/web/frontend/js/features/chat/components/message-input.js
new file mode 100644
index 0000000000..56ff069674
--- /dev/null
+++ b/services/web/frontend/js/features/chat/components/message-input.js
@@ -0,0 +1,32 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import { useTranslation } from 'react-i18next'
+
+function MessageInput({ resetUnreadMessages, sendMessage }) {
+ const { t } = useTranslation()
+
+ function handleKeyDown(event) {
+ if (event.key === 'Enter') {
+ event.preventDefault()
+ sendMessage(event.target.value)
+ event.target.value = '' // clears the textarea content
+ }
+ }
+
+ return (
+
+
+
+ )
+}
+
+MessageInput.propTypes = {
+ resetUnreadMessages: PropTypes.func.isRequired,
+ sendMessage: PropTypes.func.isRequired
+}
+
+export default MessageInput
diff --git a/services/web/frontend/js/features/chat/components/message-list.js b/services/web/frontend/js/features/chat/components/message-list.js
new file mode 100644
index 0000000000..5de2dd131a
--- /dev/null
+++ b/services/web/frontend/js/features/chat/components/message-list.js
@@ -0,0 +1,69 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import moment from 'moment'
+import Message from './message'
+
+const FIVE_MINUTES = 5 * 60 * 1000
+
+function formatTimestamp(date) {
+ if (!date) {
+ return 'N/A'
+ } else {
+ return `${moment(date).format('h:mm a')} ${moment(date).calendar()}`
+ }
+}
+
+function indexFromEnd(list, index) {
+ return list.length - index - 1
+}
+
+function MessageList({ messages, resetUnreadMessages, userId }) {
+ function shouldRenderDate(messageIndex) {
+ if (messageIndex === 0) {
+ return true
+ } else {
+ const message = messages[messageIndex]
+ const previousMessage = messages[messageIndex - 1]
+ return message.timestamp - previousMessage.timestamp > FIVE_MINUTES
+ }
+ }
+
+ return (
+ // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
+
+ )
+}
+
+MessageList.propTypes = {
+ messages: PropTypes.arrayOf(
+ PropTypes.shape({ timestamp: PropTypes.instanceOf(Date) })
+ ).isRequired,
+ resetUnreadMessages: PropTypes.func.isRequired,
+ userId: PropTypes.string.isRequired
+}
+
+export default MessageList
diff --git a/services/web/frontend/js/features/chat/components/message.js b/services/web/frontend/js/features/chat/components/message.js
new file mode 100644
index 0000000000..c9cd2f2b05
--- /dev/null
+++ b/services/web/frontend/js/features/chat/components/message.js
@@ -0,0 +1,72 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import ColorManager from '../../../ide/colors/ColorManager'
+import MessageContent from './message-content'
+
+function Message({ message, userId }) {
+ const {
+ chatMessageBorderSaturation,
+ chatMessageBorderLightness,
+ chatMessageBgSaturation,
+ chatMessageBgLightness
+ } = window.uiConfig
+
+ function hue(user) {
+ return user ? ColorManager.getHueForUserId(user.id) : 0
+ }
+
+ function getMessageStyle(user) {
+ return {
+ borderColor: `hsl(${hue(
+ user
+ )}, ${chatMessageBorderSaturation}, ${chatMessageBorderLightness})`,
+ backgroundColor: `hsl(${hue(
+ user
+ )}, ${chatMessageBgSaturation}, ${chatMessageBgLightness})`
+ }
+ }
+
+ function getArrowStyle(user) {
+ return {
+ borderColor: `hsl(${hue(
+ user
+ )}, ${chatMessageBorderSaturation}, ${chatMessageBorderLightness})`
+ }
+ }
+
+ const isMessageFromSelf = message.user ? message.user.id === userId : false
+
+ return (
+
+ {!isMessageFromSelf && (
+
+ {message.user.first_name || message.user.email}
+
+ )}
+
+ {!isMessageFromSelf && (
+
+ )}
+
+ {message.contents.map((content, index) => (
+
+ ))}
+
+
+
+ )
+}
+
+Message.propTypes = {
+ message: PropTypes.shape({
+ contents: PropTypes.arrayOf(PropTypes.string).isRequired,
+ user: PropTypes.shape({
+ id: PropTypes.string,
+ email: PropTypes.string,
+ first_name: PropTypes.string
+ })
+ }),
+ userId: PropTypes.string.isRequired
+}
+
+export default Message
diff --git a/services/web/frontend/js/features/chat/controllers/chat-controller.js b/services/web/frontend/js/features/chat/controllers/chat-controller.js
new file mode 100644
index 0000000000..db981a8360
--- /dev/null
+++ b/services/web/frontend/js/features/chat/controllers/chat-controller.js
@@ -0,0 +1,42 @@
+import App from '../../../base'
+import { react2angular } from 'react2angular'
+import ChatPane from '../components/chat-pane'
+
+App.controller('ReactChatController', function($scope, chatMessages, ide) {
+ ide.$scope.$on('chat:more-messages-loaded', onMoreMessagesLoaded)
+ function onMoreMessagesLoaded(e, chat) {
+ ide.$scope.$applyAsync(() => {
+ $scope.atEnd = chatMessages.state.atEnd
+ $scope.loading = chat.state.loading
+ $scope.messages = chat.state.messages.slice(0) // passing a new reference to trigger a prop update on react
+ })
+ }
+
+ ide.$scope.$on('chat:more-messages-loading', onMoreMessagesLoading)
+ function onMoreMessagesLoading(e, chat) {
+ ide.$scope.$applyAsync(() => {
+ $scope.loading = true
+ })
+ }
+
+ function sendMessage(message) {
+ if (message) {
+ chatMessages.sendMessage(message)
+ ide.$scope.$broadcast('chat:newMessage', message)
+ }
+ }
+
+ function resetUnreadMessages() {
+ ide.$scope.$broadcast('chat:resetUnreadMessages')
+ }
+
+ $scope.atEnd = chatMessages.state.atEnd
+ $scope.loading = chatMessages.state.loading
+ $scope.loadMoreMessages = chatMessages.loadMoreMessages
+ $scope.messages = chatMessages.state.messages
+ $scope.resetUnreadMessages = resetUnreadMessages
+ $scope.sendMessage = sendMessage
+ $scope.userId = ide.$scope.user.id
+})
+
+App.component('chat', react2angular(ChatPane))
diff --git a/services/web/frontend/js/ide/chat/index.js b/services/web/frontend/js/ide/chat/index.js
index fa4ac9caaf..7646101f32 100644
--- a/services/web/frontend/js/ide/chat/index.js
+++ b/services/web/frontend/js/ide/chat/index.js
@@ -1,9 +1,6 @@
-/* eslint-disable
-*/
-// TODO: This file was created by bulk-decaffeinate.
-// Fix any style issues and re-enable lint.
import './controllers/ChatButtonController'
import './controllers/ChatController'
import './controllers/ChatMessageController'
import '../../directives/mathjax'
import '../../filters/wrapLongWords'
+import '../../features/chat/controllers/chat-controller'
diff --git a/services/web/frontend/js/ide/chat/services/chatMessages.js b/services/web/frontend/js/ide/chat/services/chatMessages.js
index cbbf8f0ff1..e44a9daa65 100644
--- a/services/web/frontend/js/ide/chat/services/chatMessages.js
+++ b/services/web/frontend/js/ide/chat/services/chatMessages.js
@@ -73,6 +73,7 @@ export default App.factory('chatMessages', function($http, ide) {
url += `&before=${chat.state.nextBeforeTimestamp}`
}
chat.state.loading = true
+ ide.$scope.$broadcast('chat:more-messages-loading', chat)
return $http.get(url).then(function(response) {
const messages = response.data != null ? response.data : []
chat.state.loading = false
@@ -94,15 +95,16 @@ export default App.factory('chatMessages', function($http, ide) {
)
)
}
- return (chat.state.errored = true)
+ chat.state.errored = true
} else {
messages.reverse()
prependMessages(messages)
- return (chat.state.nextBeforeTimestamp =
+ chat.state.nextBeforeTimestamp =
chat.state.messages[0] != null
? chat.state.messages[0].timestamp
- : undefined)
+ : undefined
}
+ ide.$scope.$broadcast('chat:more-messages-loaded', chat)
})
}
@@ -143,14 +145,15 @@ export default App.factory('chatMessages', function($http, ide) {
message.timestamp - lastMessage.timestamp < TIMESTAMP_GROUP_SIZE
if (shouldGroup) {
lastMessage.timestamp = message.timestamp
- return lastMessage.contents.push(message.content)
+ lastMessage.contents.push(message.content)
} else {
- return chat.state.messages.push({
+ chat.state.messages.push({
user: message.user,
timestamp: message.timestamp,
contents: [message.content]
})
}
+ ide.$scope.$broadcast('chat:more-messages-loaded', chat)
}
return chat
diff --git a/services/web/frontend/stylesheets/_style_includes.less b/services/web/frontend/stylesheets/_style_includes.less
index 24b255baf0..b811ec5827 100644
--- a/services/web/frontend/stylesheets/_style_includes.less
+++ b/services/web/frontend/stylesheets/_style_includes.less
@@ -54,6 +54,7 @@
@import 'components/input-suggestions.less';
@import 'components/nvd3.less';
@import 'components/nvd3_override.less';
+@import 'components/infinite-scroll.less';
// Components w/ JavaScript
@import 'components/modals.less';
diff --git a/services/web/frontend/stylesheets/components/infinite-scroll.less b/services/web/frontend/stylesheets/components/infinite-scroll.less
new file mode 100644
index 0000000000..688f8a466d
--- /dev/null
+++ b/services/web/frontend/stylesheets/components/infinite-scroll.less
@@ -0,0 +1,3 @@
+.infinite-scroll {
+ overflow-y: auto;
+}
diff --git a/services/web/package-lock.json b/services/web/package-lock.json
index 606c8e2ab5..4c59b3a7c3 100644
--- a/services/web/package-lock.json
+++ b/services/web/package-lock.json
@@ -9791,7 +9791,7 @@
"append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
- "integrity": "sha1-HjRA6RXwsSA9I3SOeO3XubW0PlY="
+ "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw=="
},
"aproba": {
"version": "1.2.0",
@@ -12252,7 +12252,7 @@
"buffer-shims": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/buffer-shims/-/buffer-shims-1.0.0.tgz",
- "integrity": "sha1-mXjOMXOIxkmth5MCjDR37wRKi1E="
+ "integrity": "sha512-Zy8ZXMyxIT6RMTeY7OP/bDndfj6bwCan7SS98CEndS6deHwWPpseeHlwarNcBim+etXnF9HBc1non5JgDaJU1g=="
},
"buffer-xor": {
"version": "1.0.3",
@@ -12305,7 +12305,7 @@
"busboy": {
"version": "0.2.14",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-0.2.14.tgz",
- "integrity": "sha1-bCpiLvz0fFe7vh4qnDetNseSVFM=",
+ "integrity": "sha512-InWFDomvlkEj+xWLBfU3AvnbVYqeTWmQopiW0tWWEy5yehYm2YkGEc59sUmw/4ty5Zj/b0WHGs1LgecuBSBGrg==",
"requires": {
"dicer": "0.2.5",
"readable-stream": "1.1.x"
@@ -12314,7 +12314,7 @@
"readable-stream": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz",
- "integrity": "sha1-fPTFTvZI44EwhMY23SB54WbAgdk=",
+ "integrity": "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==",
"requires": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.1",
@@ -14969,7 +14969,7 @@
"dicer": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/dicer/-/dicer-0.2.5.tgz",
- "integrity": "sha1-WZbAhrszIYyBLAkL3cCc0S+stw8=",
+ "integrity": "sha512-FDvbtnq7dzlPz0wyYlOExifDEZcu8h+rErEXgfxqmLfRfC/kJidEFh4+effJRO3P0xmfqyPbSMG0LveNRfTKVg==",
"requires": {
"readable-stream": "1.1.x",
"streamsearch": "0.1.2"
@@ -14978,7 +14978,7 @@
"readable-stream": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz",
- "integrity": "sha1-fPTFTvZI44EwhMY23SB54WbAgdk=",
+ "integrity": "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==",
"requires": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.1",
@@ -22659,6 +22659,14 @@
"integrity": "sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=",
"dev": true
},
+ "linkify-it": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-2.2.0.tgz",
+ "integrity": "sha512-GnAl/knGn+i1U/wjBz3akz2stz+HrHLsxMwHQGofCDfPvlf+gDKN58UtfmUquTY4/MXeE2x7k19KQmeoZi94Iw==",
+ "requires": {
+ "uc.micro": "^1.0.1"
+ }
+ },
"load-json-file": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz",
@@ -23523,7 +23531,7 @@
"microtime-nodejs": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/microtime-nodejs/-/microtime-nodejs-1.0.0.tgz",
- "integrity": "sha1-iFlASvLipGKhXJzWvyxORo2r2+g="
+ "integrity": "sha512-SthP/4JW6HUIZfgM0nadNtwKm/WMH0+z1i4RsPDnud+UasjoABzSkCk3eMhIRzipgwPhkdAYpTI69X4II4j1pA=="
},
"miller-rabin": {
"version": "4.0.1",
@@ -23856,7 +23864,7 @@
},
"mkdirp": {
"version": "0.5.1",
- "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
+ "resolved": false,
"integrity": "sha512-SknJC52obPfGQPnjIkXbmA6+5H15E+fR+E4iR2oQ3zzCLbd7/ONua69R/Gw7AgkTLsRG+r5fzksYwWe1AgTyWA==",
"requires": {
"minimist": "0.0.8"
@@ -29780,6 +29788,15 @@
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="
},
+ "react-linkify": {
+ "version": "1.0.0-alpha",
+ "resolved": "https://registry.npmjs.org/react-linkify/-/react-linkify-1.0.0-alpha.tgz",
+ "integrity": "sha512-7gcIUvJkAXXttt1fmBK9cwn+1jTa4hbKLGCZ9J1U6EOkyb2/+LKL1Z28d9rtDLMnpvImlNlLPdTPooorl5cpmg==",
+ "requires": {
+ "linkify-it": "^2.0.3",
+ "tlds": "^1.199.0"
+ }
+ },
"react-overlays": {
"version": "0.9.1",
"resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-0.9.1.tgz",
@@ -31415,7 +31432,7 @@
"resolve-from": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-2.0.0.tgz",
- "integrity": "sha1-lICrIOlP+h2egKgEx+oUdhGWa1c="
+ "integrity": "sha512-qpFcKaXsq8+oRoLilkwyc7zHGF5i9Q2/25NIgLQQ/+VVv9rU4qvr6nXVAw1DsnXJyQkZsR4Ytfbtg5ehfcUssQ=="
},
"resolve-url": {
"version": "0.2.1",
@@ -33315,7 +33332,7 @@
"streamsearch": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-0.1.2.tgz",
- "integrity": "sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo="
+ "integrity": "sha512-jos8u++JKm0ARcSUTAZXOVC0mSox7Bhn6sBgty73P1f3JGf7yG2clTbBNHUdde/kdvP2FESam+vM6l8jBrNxHA=="
},
"strict-uri-encode": {
"version": "1.1.0",
@@ -34924,6 +34941,11 @@
"integrity": "sha1-9PrTM0R7wLB9TcjpIJ2POaisd+g=",
"dev": true
},
+ "tlds": {
+ "version": "1.210.0",
+ "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.210.0.tgz",
+ "integrity": "sha512-5bzt4JE+NlnwiKpVW9yzWxuc44m+t2opmPG+eSKDp5V5qdyGvjMngKgBb5ZK8GiheQMbRTCKpRwFJeIEO6pV7Q=="
+ },
"tmp": {
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
@@ -35316,6 +35338,11 @@
}
}
},
+ "uc.micro": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz",
+ "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA=="
+ },
"uglify-js": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.9.1.tgz",
diff --git a/services/web/package.json b/services/web/package.json
index 9b36b0b58b..39c65a619a 100644
--- a/services/web/package.json
+++ b/services/web/package.json
@@ -125,6 +125,7 @@
"react-dom": "^16.13.1",
"react-error-boundary": "^2.3.1",
"react-i18next": "^11.7.1",
+ "react-linkify": "^1.0.0-alpha",
"react2angular": "^4.0.6",
"redis-sharelatex": "^1.0.13",
"request": "^2.88.2",