first commit
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import OLNotification from '@/features/ui/components/ol/ol-notification'
|
||||
import OLButton from '@/features/ui/components/ol/ol-button'
|
||||
|
||||
interface ChatFallbackErrorProps {
|
||||
reconnect?: () => void
|
||||
}
|
||||
|
||||
function ChatFallbackError({ reconnect }: ChatFallbackErrorProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<aside className="chat">
|
||||
<div className="chat-error">
|
||||
<OLNotification type="error" content={t('chat_error')} />
|
||||
{reconnect && (
|
||||
<p className="text-center">
|
||||
<OLButton variant="secondary" onClick={reconnect}>
|
||||
{t('reconnect')}
|
||||
</OLButton>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatFallbackError
|
117
services/web/frontend/js/features/chat/components/chat-pane.tsx
Normal file
117
services/web/frontend/js/features/chat/components/chat-pane.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import React, { lazy, Suspense, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import MessageInput from './message-input'
|
||||
import InfiniteScroll from './infinite-scroll'
|
||||
import ChatFallbackError from './chat-fallback-error'
|
||||
import { useLayoutContext } from '../../../shared/context/layout-context'
|
||||
import { useUserContext } from '../../../shared/context/user-context'
|
||||
import withErrorBoundary from '../../../infrastructure/error-boundary'
|
||||
import { FetchError } from '../../../infrastructure/fetch-json'
|
||||
import { useChatContext } from '../context/chat-context'
|
||||
import { FullSizeLoadingSpinner } from '../../../shared/components/loading-spinner'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
|
||||
const MessageList = lazy(() => import('./message-list'))
|
||||
|
||||
const Loading = () => <FullSizeLoadingSpinner delay={500} className="pt-4" />
|
||||
|
||||
const ChatPane = React.memo(function ChatPane() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { chatIsOpen } = useLayoutContext()
|
||||
const user = useUserContext()
|
||||
|
||||
const {
|
||||
status,
|
||||
messages,
|
||||
initialMessagesLoaded,
|
||||
atEnd,
|
||||
loadInitialMessages,
|
||||
loadMoreMessages,
|
||||
reset,
|
||||
sendMessage,
|
||||
markMessagesAsRead,
|
||||
error,
|
||||
} = useChatContext()
|
||||
|
||||
useEffect(() => {
|
||||
if (chatIsOpen && !initialMessagesLoaded) {
|
||||
loadInitialMessages()
|
||||
}
|
||||
}, [chatIsOpen, loadInitialMessages, initialMessagesLoaded])
|
||||
|
||||
const shouldDisplayPlaceholder = status !== 'pending' && messages.length === 0
|
||||
|
||||
const messageContentCount = messages.reduce(
|
||||
(acc, { contents }) => acc + contents.length,
|
||||
0
|
||||
)
|
||||
|
||||
// Keep the chat pane in the DOM to avoid resetting the form input and re-rendering MathJax content.
|
||||
const [chatOpenedOnce, setChatOpenedOnce] = useState(chatIsOpen)
|
||||
useEffect(() => {
|
||||
if (chatIsOpen) {
|
||||
setChatOpenedOnce(true)
|
||||
}
|
||||
}, [chatIsOpen])
|
||||
|
||||
if (error) {
|
||||
// let user try recover from fetch errors
|
||||
if (error instanceof FetchError) {
|
||||
return <ChatFallbackError reconnect={reset} />
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return null
|
||||
}
|
||||
if (!chatOpenedOnce) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="chat">
|
||||
<InfiniteScroll
|
||||
atEnd={atEnd}
|
||||
className="messages"
|
||||
fetchData={loadMoreMessages}
|
||||
isLoading={status === 'pending'}
|
||||
itemCount={messageContentCount}
|
||||
>
|
||||
<div>
|
||||
<h2 className="visually-hidden">{t('chat')}</h2>
|
||||
<Suspense fallback={<Loading />}>
|
||||
{status === 'pending' && <Loading />}
|
||||
{shouldDisplayPlaceholder && <Placeholder />}
|
||||
<MessageList
|
||||
messages={messages}
|
||||
resetUnreadMessages={markMessagesAsRead}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</InfiniteScroll>
|
||||
<MessageInput
|
||||
resetUnreadMessages={markMessagesAsRead}
|
||||
sendMessage={sendMessage}
|
||||
/>
|
||||
</aside>
|
||||
)
|
||||
})
|
||||
|
||||
function Placeholder() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<>
|
||||
<div className="no-messages text-center small">{t('no_messages')}</div>
|
||||
<div className="first-message text-center">
|
||||
{t('send_first_message')}
|
||||
<br />
|
||||
<MaterialIcon type="arrow_downward" />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default withErrorBoundary(ChatPane, ChatFallbackError)
|
@@ -0,0 +1,95 @@
|
||||
import { useRef, useEffect, useLayoutEffect } from 'react'
|
||||
import _ from 'lodash'
|
||||
|
||||
const SCROLL_END_OFFSET = 30
|
||||
|
||||
interface InfiniteScrollProps {
|
||||
atEnd?: boolean
|
||||
children: React.ReactElement
|
||||
className?: string
|
||||
fetchData(): void
|
||||
itemCount: number
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
function InfiniteScroll({
|
||||
atEnd,
|
||||
children,
|
||||
className = '',
|
||||
fetchData,
|
||||
itemCount,
|
||||
isLoading,
|
||||
}: InfiniteScrollProps) {
|
||||
const root = useRef<HTMLDivElement>(null)
|
||||
|
||||
// we keep the value in a Ref instead of state so it can be safely used in effects
|
||||
const scrollBottomRef = useRef(0)
|
||||
function setScrollBottom(value: number) {
|
||||
scrollBottomRef.current = value
|
||||
}
|
||||
|
||||
function updateScrollPosition() {
|
||||
if (root.current) {
|
||||
root.current.scrollTop =
|
||||
root.current.scrollHeight -
|
||||
root.current.clientHeight -
|
||||
scrollBottomRef.current
|
||||
}
|
||||
}
|
||||
|
||||
// Repositions the scroll after new items are loaded
|
||||
useLayoutEffect(updateScrollPosition, [itemCount])
|
||||
|
||||
// Repositions the scroll after a window resize
|
||||
useEffect(() => {
|
||||
const handleResize = _.debounce(updateScrollPosition, 400)
|
||||
window.addEventListener('resize', handleResize)
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
}
|
||||
}, [])
|
||||
|
||||
function onScrollHandler(event: React.UIEvent<HTMLDivElement>) {
|
||||
if (root.current) {
|
||||
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() {
|
||||
if (!root.current) {
|
||||
return false
|
||||
}
|
||||
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 (
|
||||
<div
|
||||
ref={root}
|
||||
onScroll={onScrollHandler}
|
||||
className={`infinite-scroll ${className}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InfiniteScroll
|
@@ -0,0 +1,43 @@
|
||||
import { useRef, useEffect, type FC } from 'react'
|
||||
import Linkify from 'react-linkify'
|
||||
import useIsMounted from '../../../shared/hooks/use-is-mounted'
|
||||
import { loadMathJax } from '../../mathjax/load-mathjax'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
|
||||
const MessageContent: FC<{ content: string }> = ({ content }) => {
|
||||
const root = useRef<HTMLDivElement | null>(null)
|
||||
const mounted = useIsMounted()
|
||||
|
||||
useEffect(() => {
|
||||
if (root.current) {
|
||||
// adds attributes to all the links generated by <Linkify/>, required due to https://github.com/tasti/react-linkify/issues/99
|
||||
for (const a of root.current.getElementsByTagName('a')) {
|
||||
a.setAttribute('target', '_blank')
|
||||
a.setAttribute('rel', 'noreferrer noopener')
|
||||
}
|
||||
|
||||
// MathJax v3 typesetting
|
||||
loadMathJax()
|
||||
.then(async MathJax => {
|
||||
if (mounted.current) {
|
||||
const element = root.current
|
||||
try {
|
||||
await MathJax.typesetPromise([element])
|
||||
MathJax.typesetClear([element])
|
||||
} catch (error) {
|
||||
debugConsole.error(error)
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(debugConsole.error)
|
||||
}
|
||||
}, [content, mounted])
|
||||
|
||||
return (
|
||||
<p ref={root}>
|
||||
<Linkify>{content}</Linkify>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export default MessageContent
|
@@ -0,0 +1,42 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type MessageInputProps = {
|
||||
resetUnreadMessages: () => void
|
||||
sendMessage: (message: string) => void
|
||||
}
|
||||
|
||||
function MessageInput({ resetUnreadMessages, sendMessage }: MessageInputProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
function handleKeyDown(event: React.KeyboardEvent) {
|
||||
const selectingCharacter = event.nativeEvent.isComposing
|
||||
if (event.key === 'Enter' && !selectingCharacter) {
|
||||
event.preventDefault()
|
||||
const target = event.target as HTMLInputElement
|
||||
sendMessage(target.value)
|
||||
// wrap the form reset in setTimeout so input sources have time to finish
|
||||
// https://github.com/overleaf/internal/pull/9206
|
||||
window.setTimeout(() => {
|
||||
target.blur()
|
||||
target.closest('form')?.reset()
|
||||
target.focus()
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="new-message">
|
||||
<label htmlFor="chat-input" className="visually-hidden">
|
||||
{t('your_message_to_collaborators')}
|
||||
</label>
|
||||
<textarea
|
||||
id="chat-input"
|
||||
placeholder={`${t('your_message_to_collaborators')}…`}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={resetUnreadMessages}
|
||||
/>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default MessageInput
|
@@ -0,0 +1,77 @@
|
||||
import moment from 'moment'
|
||||
import Message from './message'
|
||||
import type { Message as MessageType } from '@/features/chat/context/chat-context'
|
||||
import MessageRedesign from '@/features/ide-redesign/components/chat/message'
|
||||
import { useUserContext } from '@/shared/context/user-context'
|
||||
|
||||
const FIVE_MINUTES = 5 * 60 * 1000
|
||||
|
||||
function formatTimestamp(date: moment.MomentInput) {
|
||||
if (!date) {
|
||||
return 'N/A'
|
||||
} else {
|
||||
return `${moment(date).format('h:mm a')} ${moment(date).calendar()}`
|
||||
}
|
||||
}
|
||||
|
||||
interface MessageListProps {
|
||||
messages: MessageType[]
|
||||
resetUnreadMessages(...args: unknown[]): unknown
|
||||
newDesign?: boolean
|
||||
}
|
||||
|
||||
function MessageList({
|
||||
messages,
|
||||
resetUnreadMessages,
|
||||
newDesign,
|
||||
}: MessageListProps) {
|
||||
const user = useUserContext()
|
||||
const MessageComponent = newDesign ? MessageRedesign : Message
|
||||
function shouldRenderDate(messageIndex: number) {
|
||||
if (messageIndex === 0) {
|
||||
return true
|
||||
} else {
|
||||
const message = messages[messageIndex]
|
||||
const previousMessage = messages[messageIndex - 1]
|
||||
return (
|
||||
message.timestamp &&
|
||||
previousMessage.timestamp &&
|
||||
message.timestamp - previousMessage.timestamp > FIVE_MINUTES
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
|
||||
<ul
|
||||
className="list-unstyled"
|
||||
onClick={resetUnreadMessages}
|
||||
onKeyDown={resetUnreadMessages}
|
||||
>
|
||||
{messages.map((message, index) => (
|
||||
// new messages are added to the beginning of the list, so we use a reversed index
|
||||
<li key={message.id} className="message">
|
||||
{shouldRenderDate(index) && (
|
||||
<div className="date">
|
||||
<time
|
||||
dateTime={
|
||||
message.timestamp
|
||||
? moment(message.timestamp).format()
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{formatTimestamp(message.timestamp)}
|
||||
</time>
|
||||
</div>
|
||||
)}
|
||||
<MessageComponent
|
||||
message={message}
|
||||
fromSelf={message.user ? message.user.id === user.id : false}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
export default MessageList
|
@@ -0,0 +1,50 @@
|
||||
import { getHueForUserId } from '@/shared/utils/colors'
|
||||
import MessageContent from './message-content'
|
||||
import type { Message as MessageType } from '@/features/chat/context/chat-context'
|
||||
import { User } from '../../../../../types/user'
|
||||
|
||||
export interface MessageProps {
|
||||
message: MessageType
|
||||
fromSelf: boolean
|
||||
}
|
||||
|
||||
function hue(user?: User) {
|
||||
return user ? getHueForUserId(user.id) : 0
|
||||
}
|
||||
|
||||
function getMessageStyle(user?: User) {
|
||||
return {
|
||||
borderColor: `hsl(${hue(user)}, 85%, 40%)`,
|
||||
backgroundColor: `hsl(${hue(user)}, 85%, 40%`,
|
||||
}
|
||||
}
|
||||
|
||||
function getArrowStyle(user?: User) {
|
||||
return {
|
||||
borderColor: `hsl(${hue(user)}, 85%, 40%)`,
|
||||
}
|
||||
}
|
||||
|
||||
function Message({ message, fromSelf }: MessageProps) {
|
||||
return (
|
||||
<div className="message-wrapper">
|
||||
{!fromSelf && (
|
||||
<div className="name">
|
||||
<span>{message.user.first_name || message.user.email}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="message" style={getMessageStyle(message.user)}>
|
||||
{!fromSelf && (
|
||||
<div className="arrow" style={getArrowStyle(message.user)} />
|
||||
)}
|
||||
<div className="message-content">
|
||||
{message.contents.map((content, index) => (
|
||||
<MessageContent key={index} content={content} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Message
|
Reference in New Issue
Block a user