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
|
399
services/web/frontend/js/features/chat/context/chat-context.tsx
Normal file
399
services/web/frontend/js/features/chat/context/chat-context.tsx
Normal file
@@ -0,0 +1,399 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useReducer,
|
||||
useMemo,
|
||||
useRef,
|
||||
FC,
|
||||
} from 'react'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
|
||||
import { useUserContext } from '../../../shared/context/user-context'
|
||||
import { useProjectContext } from '../../../shared/context/project-context'
|
||||
import { getJSON, postJSON } from '../../../infrastructure/fetch-json'
|
||||
import { appendMessage, prependMessages } from '../utils/message-list-appender'
|
||||
import useBrowserWindow from '../../../shared/hooks/use-browser-window'
|
||||
import { useLayoutContext } from '../../../shared/context/layout-context'
|
||||
import { useIdeContext } from '@/shared/context/ide-context'
|
||||
import getMeta from '@/utils/meta'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import { User } from '../../../../../types/user'
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
export type Message = {
|
||||
id: string
|
||||
timestamp: number
|
||||
contents: string[]
|
||||
user: User
|
||||
}
|
||||
|
||||
type State = {
|
||||
status: 'idle' | 'pending' | 'error'
|
||||
messages: Message[]
|
||||
initialMessagesLoaded: boolean
|
||||
lastTimestamp: number | null
|
||||
atEnd: boolean
|
||||
unreadMessageCount: number
|
||||
error?: Error | null
|
||||
uniqueMessageIds: string[]
|
||||
}
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: 'INITIAL_FETCH_MESSAGES'
|
||||
}
|
||||
| {
|
||||
type: 'FETCH_MESSAGES'
|
||||
}
|
||||
| {
|
||||
type: 'FETCH_MESSAGES_SUCCESS'
|
||||
messages: Message[]
|
||||
}
|
||||
| {
|
||||
type: 'SEND_MESSAGE'
|
||||
user: any
|
||||
content: any
|
||||
}
|
||||
| {
|
||||
type: 'RECEIVE_MESSAGE'
|
||||
message: any
|
||||
}
|
||||
| {
|
||||
type: 'MARK_MESSAGES_AS_READ'
|
||||
}
|
||||
| {
|
||||
type: 'CLEAR'
|
||||
}
|
||||
| {
|
||||
type: 'ERROR'
|
||||
error: any
|
||||
}
|
||||
|
||||
// Wrap uuid in an object method so that it can be stubbed
|
||||
export const chatClientIdGenerator = {
|
||||
generate: () => uuid(),
|
||||
}
|
||||
|
||||
let nextChatMessageId = 1
|
||||
|
||||
function generateChatMessageId() {
|
||||
return '' + nextChatMessageId++
|
||||
}
|
||||
|
||||
function chatReducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case 'INITIAL_FETCH_MESSAGES':
|
||||
return {
|
||||
...state,
|
||||
status: 'pending',
|
||||
initialMessagesLoaded: true,
|
||||
}
|
||||
|
||||
case 'FETCH_MESSAGES':
|
||||
return {
|
||||
...state,
|
||||
status: 'pending',
|
||||
}
|
||||
|
||||
case 'FETCH_MESSAGES_SUCCESS':
|
||||
return {
|
||||
...state,
|
||||
status: 'idle',
|
||||
...prependMessages(
|
||||
state.messages,
|
||||
action.messages,
|
||||
state.uniqueMessageIds
|
||||
),
|
||||
lastTimestamp: action.messages[0] ? action.messages[0].timestamp : null,
|
||||
atEnd: action.messages.length < PAGE_SIZE,
|
||||
}
|
||||
|
||||
case 'SEND_MESSAGE':
|
||||
return {
|
||||
...state,
|
||||
...appendMessage(
|
||||
state.messages,
|
||||
{
|
||||
// Messages are sent optimistically, so don't have an id (used for
|
||||
// React keys). The id is valid for this session, and ensures all
|
||||
// messages have an id. It will be overwritten by the actual ids on
|
||||
// refresh
|
||||
id: generateChatMessageId(),
|
||||
user: action.user,
|
||||
content: action.content,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
state.uniqueMessageIds
|
||||
),
|
||||
}
|
||||
|
||||
case 'RECEIVE_MESSAGE':
|
||||
return {
|
||||
...state,
|
||||
...appendMessage(
|
||||
state.messages,
|
||||
action.message,
|
||||
state.uniqueMessageIds
|
||||
),
|
||||
unreadMessageCount: state.unreadMessageCount + 1,
|
||||
}
|
||||
|
||||
case 'MARK_MESSAGES_AS_READ':
|
||||
return {
|
||||
...state,
|
||||
unreadMessageCount: 0,
|
||||
}
|
||||
|
||||
case 'CLEAR':
|
||||
return { ...initialState }
|
||||
|
||||
case 'ERROR':
|
||||
return {
|
||||
...state,
|
||||
status: 'error',
|
||||
error: action.error,
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error('Unknown action')
|
||||
}
|
||||
}
|
||||
|
||||
const initialState: State = {
|
||||
status: 'idle',
|
||||
messages: [],
|
||||
initialMessagesLoaded: false,
|
||||
lastTimestamp: null,
|
||||
atEnd: false,
|
||||
unreadMessageCount: 0,
|
||||
error: null,
|
||||
uniqueMessageIds: [],
|
||||
}
|
||||
|
||||
export const ChatContext = createContext<
|
||||
| {
|
||||
status: 'idle' | 'pending' | 'error'
|
||||
messages: Message[]
|
||||
initialMessagesLoaded: boolean
|
||||
atEnd: boolean
|
||||
unreadMessageCount: number
|
||||
loadInitialMessages: () => void
|
||||
loadMoreMessages: () => void
|
||||
sendMessage: (message: any) => void
|
||||
markMessagesAsRead: () => void
|
||||
reset: () => void
|
||||
error?: Error | null
|
||||
}
|
||||
| undefined
|
||||
>(undefined)
|
||||
|
||||
export const ChatProvider: FC = ({ children }) => {
|
||||
const chatEnabled = getMeta('ol-chatEnabled')
|
||||
|
||||
const clientId = useRef<string>()
|
||||
if (clientId.current === undefined) {
|
||||
clientId.current = chatClientIdGenerator.generate()
|
||||
}
|
||||
const user = useUserContext()
|
||||
const { _id: projectId } = useProjectContext()
|
||||
|
||||
const { chatIsOpen } = useLayoutContext()
|
||||
|
||||
const {
|
||||
hasFocus: windowHasFocus,
|
||||
flashTitle,
|
||||
stopFlashingTitle,
|
||||
} = useBrowserWindow()
|
||||
|
||||
const [state, dispatch] = useReducer(chatReducer, initialState)
|
||||
|
||||
const { loadInitialMessages, loadMoreMessages, reset } = useMemo(() => {
|
||||
function fetchMessages() {
|
||||
if (state.atEnd) return
|
||||
|
||||
const query: Record<string, string> = {
|
||||
limit: String(PAGE_SIZE),
|
||||
}
|
||||
|
||||
if (state.lastTimestamp) {
|
||||
query.before = String(state.lastTimestamp)
|
||||
}
|
||||
|
||||
const queryString = new URLSearchParams(query)
|
||||
const url = `/project/${projectId}/messages?${queryString.toString()}`
|
||||
|
||||
getJSON(url)
|
||||
.then((messages = []) => {
|
||||
dispatch({
|
||||
type: 'FETCH_MESSAGES_SUCCESS',
|
||||
messages: messages.reverse(),
|
||||
})
|
||||
})
|
||||
.catch(error => {
|
||||
dispatch({
|
||||
type: 'ERROR',
|
||||
error,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function loadInitialMessages() {
|
||||
if (!chatEnabled) {
|
||||
debugConsole.warn(`chat is disabled, won't load initial messages`)
|
||||
return
|
||||
}
|
||||
if (state.initialMessagesLoaded) return
|
||||
|
||||
dispatch({ type: 'INITIAL_FETCH_MESSAGES' })
|
||||
fetchMessages()
|
||||
}
|
||||
|
||||
function loadMoreMessages() {
|
||||
if (!chatEnabled) {
|
||||
debugConsole.warn(`chat is disabled, won't load messages`)
|
||||
return
|
||||
}
|
||||
dispatch({ type: 'FETCH_MESSAGES' })
|
||||
fetchMessages()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (!chatEnabled) {
|
||||
debugConsole.warn(`chat is disabled, won't reset chat`)
|
||||
return
|
||||
}
|
||||
dispatch({ type: 'CLEAR' })
|
||||
fetchMessages()
|
||||
}
|
||||
|
||||
return {
|
||||
loadInitialMessages,
|
||||
loadMoreMessages,
|
||||
reset,
|
||||
}
|
||||
}, [
|
||||
chatEnabled,
|
||||
projectId,
|
||||
state.atEnd,
|
||||
state.initialMessagesLoaded,
|
||||
state.lastTimestamp,
|
||||
])
|
||||
|
||||
const sendMessage = useCallback(
|
||||
content => {
|
||||
if (!chatEnabled) {
|
||||
debugConsole.warn(`chat is disabled, won't send message`)
|
||||
return
|
||||
}
|
||||
if (!content) return
|
||||
|
||||
dispatch({
|
||||
type: 'SEND_MESSAGE',
|
||||
user,
|
||||
content,
|
||||
})
|
||||
|
||||
const url = `/project/${projectId}/messages`
|
||||
postJSON(url, {
|
||||
body: { content, client_id: clientId.current },
|
||||
}).catch(error => {
|
||||
dispatch({
|
||||
type: 'ERROR',
|
||||
error,
|
||||
})
|
||||
})
|
||||
},
|
||||
[chatEnabled, projectId, user]
|
||||
)
|
||||
|
||||
const markMessagesAsRead = useCallback(() => {
|
||||
if (!chatEnabled) {
|
||||
debugConsole.warn(`chat is disabled, won't mark messages as read`)
|
||||
return
|
||||
}
|
||||
dispatch({ type: 'MARK_MESSAGES_AS_READ' })
|
||||
}, [chatEnabled])
|
||||
|
||||
// Handling receiving messages over the socket
|
||||
const { socket } = useIdeContext()
|
||||
useEffect(() => {
|
||||
if (!chatEnabled || !socket) return
|
||||
|
||||
function receivedMessage(message: any) {
|
||||
// If the message is from the current client id, then we are receiving the sent message back from the socket.
|
||||
// Ignore it to prevent double message.
|
||||
if (message.clientId === clientId.current) return
|
||||
|
||||
dispatch({ type: 'RECEIVE_MESSAGE', message })
|
||||
}
|
||||
|
||||
socket.on('new-chat-message', receivedMessage)
|
||||
return () => {
|
||||
if (!socket) return
|
||||
|
||||
socket.removeListener('new-chat-message', receivedMessage)
|
||||
}
|
||||
}, [chatEnabled, socket])
|
||||
|
||||
// Handle unread messages
|
||||
useEffect(() => {
|
||||
if (windowHasFocus) {
|
||||
stopFlashingTitle()
|
||||
if (chatIsOpen) {
|
||||
markMessagesAsRead()
|
||||
}
|
||||
}
|
||||
if (!windowHasFocus && state.unreadMessageCount > 0) {
|
||||
flashTitle('New Message')
|
||||
}
|
||||
}, [
|
||||
windowHasFocus,
|
||||
chatIsOpen,
|
||||
state.unreadMessageCount,
|
||||
flashTitle,
|
||||
stopFlashingTitle,
|
||||
markMessagesAsRead,
|
||||
])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
status: state.status,
|
||||
messages: state.messages,
|
||||
initialMessagesLoaded: state.initialMessagesLoaded,
|
||||
atEnd: state.atEnd,
|
||||
unreadMessageCount: state.unreadMessageCount,
|
||||
loadInitialMessages,
|
||||
loadMoreMessages,
|
||||
reset,
|
||||
sendMessage,
|
||||
markMessagesAsRead,
|
||||
error: state.error,
|
||||
}),
|
||||
[
|
||||
loadInitialMessages,
|
||||
loadMoreMessages,
|
||||
markMessagesAsRead,
|
||||
reset,
|
||||
sendMessage,
|
||||
state.atEnd,
|
||||
state.error,
|
||||
state.initialMessagesLoaded,
|
||||
state.messages,
|
||||
state.status,
|
||||
state.unreadMessageCount,
|
||||
]
|
||||
)
|
||||
|
||||
return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>
|
||||
}
|
||||
|
||||
export function useChatContext() {
|
||||
const context = useContext(ChatContext)
|
||||
if (!context) {
|
||||
throw new Error('useChatContext is only available inside ChatProvider')
|
||||
}
|
||||
return context
|
||||
}
|
@@ -0,0 +1,79 @@
|
||||
const TIMESTAMP_GROUP_SIZE = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
export function appendMessage(messageList, message, uniqueMessageIds) {
|
||||
if (uniqueMessageIds.includes(message.id)) {
|
||||
return { messages: messageList, uniqueMessageIds }
|
||||
}
|
||||
|
||||
uniqueMessageIds = uniqueMessageIds.slice(0)
|
||||
|
||||
uniqueMessageIds.push(message.id)
|
||||
|
||||
const lastMessage = messageList[messageList.length - 1]
|
||||
|
||||
const shouldGroup =
|
||||
lastMessage &&
|
||||
message &&
|
||||
message.user &&
|
||||
message.user.id &&
|
||||
message.user.id === lastMessage.user.id &&
|
||||
message.timestamp - lastMessage.timestamp < TIMESTAMP_GROUP_SIZE
|
||||
|
||||
if (shouldGroup) {
|
||||
messageList = messageList.slice(0, messageList.length - 1).concat({
|
||||
...lastMessage,
|
||||
// the `id` is updated to the latest received content when a new
|
||||
// message is appended or prepended
|
||||
id: message.id,
|
||||
timestamp: message.timestamp,
|
||||
contents: lastMessage.contents.concat(message.content),
|
||||
})
|
||||
} else {
|
||||
messageList = messageList.slice(0).concat({
|
||||
id: message.id,
|
||||
user: message.user,
|
||||
timestamp: message.timestamp,
|
||||
contents: [message.content],
|
||||
})
|
||||
}
|
||||
|
||||
return { messages: messageList, uniqueMessageIds }
|
||||
}
|
||||
|
||||
export function prependMessages(messageList, messages, uniqueMessageIds) {
|
||||
const listCopy = messageList.slice(0)
|
||||
|
||||
uniqueMessageIds = uniqueMessageIds.slice(0)
|
||||
|
||||
messages
|
||||
.slice(0)
|
||||
.reverse()
|
||||
.forEach(message => {
|
||||
if (uniqueMessageIds.includes(message.id)) {
|
||||
return
|
||||
}
|
||||
uniqueMessageIds.push(message.id)
|
||||
const firstMessage = listCopy[0]
|
||||
const shouldGroup =
|
||||
firstMessage &&
|
||||
message &&
|
||||
message.user &&
|
||||
message.user.id === firstMessage.user.id &&
|
||||
firstMessage.timestamp - message.timestamp < TIMESTAMP_GROUP_SIZE
|
||||
|
||||
if (shouldGroup) {
|
||||
firstMessage.id = message.id
|
||||
firstMessage.timestamp = message.timestamp
|
||||
firstMessage.contents = [message.content].concat(firstMessage.contents)
|
||||
} else {
|
||||
listCopy.unshift({
|
||||
id: message.id,
|
||||
user: message.user,
|
||||
timestamp: message.timestamp,
|
||||
contents: [message.content],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return { messages: listCopy, uniqueMessageIds }
|
||||
}
|
Reference in New Issue
Block a user