first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

@@ -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

View 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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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