overleaf/services/web/frontend/js/features/chat/context/chat-context.tsx

368 lines
8.3 KiB
TypeScript
Raw Normal View History

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'
const PAGE_SIZE = 50
export type Message = {
id: string
timestamp: number
contents: string
}
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 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 (state.initialMessagesLoaded) return
dispatch({ type: 'INITIAL_FETCH_MESSAGES' })
fetchMessages()
}
function loadMoreMessages() {
dispatch({ type: 'FETCH_MESSAGES' })
fetchMessages()
}
function reset() {
dispatch({ type: 'CLEAR' })
fetchMessages()
}
return {
loadInitialMessages,
loadMoreMessages,
reset,
}
}, [projectId, state.atEnd, state.initialMessagesLoaded, state.lastTimestamp])
const sendMessage = useCallback(
content => {
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,
})
})
},
[projectId, user]
)
const markMessagesAsRead = useCallback(() => {
dispatch({ type: 'MARK_MESSAGES_AS_READ' })
}, [])
// Handling receiving messages over the socket
const { socket } = useIdeContext()
useEffect(() => {
if (!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)
}
}, [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
}