overleaf/services/web/frontend/js/features/chat/context/chat-context.js
Alasdair Smith bb4523b7f6 Merge pull request #3735 from overleaf/as-chat-reducer
Refactor chat store to use React state

GitOrigin-RevId: 800a21c3c8a5c3c628c0a13bcb091675d1fb6f25
2021-04-23 02:09:54 +00:00

262 lines
6.8 KiB
JavaScript

import React, {
createContext,
useCallback,
useContext,
useEffect,
useReducer,
useMemo
} from 'react'
import PropTypes from 'prop-types'
import { v4 as uuid } from 'uuid'
import { useApplicationContext } from '../../../shared/context/application-context'
import { useEditorContext } from '../../../shared/context/editor-context'
import { getJSON, postJSON } from '../../../infrastructure/fetch-json'
import { appendMessage, prependMessages } from '../utils/message-list-appender'
import useBrowserWindow from '../../../infrastructure/browser-window-hook'
import { useLayoutContext } from '../../../shared/context/layout-context'
const PAGE_SIZE = 50
export function chatReducer(state, action) {
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',
messages: prependMessages(state.messages, action.messages),
lastTimestamp: action.messages[0] ? action.messages[0].timestamp : null,
atEnd: action.messages.length < PAGE_SIZE
}
case 'SEND_MESSAGE':
return {
...state,
messages: appendMessage(state.messages, {
// Messages are sent optimistically, so don't have an id (used for
// React keys). The uuid is valid for this session, and ensures all
// messages have an id. It will be overwritten by the actual ids on
// refresh
id: uuid(),
user: action.user,
content: action.content,
timestamp: Date.now()
}),
messageWasJustSent: true
}
case 'RECEIVE_MESSAGE':
return {
...state,
messages: appendMessage(state.messages, action.message),
messageWasJustSent: false,
unreadMessageCount: state.unreadMessageCount + 1
}
case 'MARK_MESSAGES_AS_READ':
return {
...state,
unreadMessageCount: 0
}
case 'ERROR':
return {
...state,
status: 'error',
error: action.error
}
default:
throw new Error('Unknown action')
}
}
const initialState = {
status: 'idle',
messages: [],
initialMessagesLoaded: false,
lastTimestamp: null,
atEnd: false,
messageWasJustSent: false,
unreadMessageCount: 0,
error: null
}
export const ChatContext = createContext()
ChatContext.Provider.propTypes = {
value: PropTypes.shape({
status: PropTypes.string.isRequired,
messages: PropTypes.array.isRequired,
initialMessagesLoaded: PropTypes.bool.isRequired,
atEnd: PropTypes.bool.isRequired,
unreadMessageCount: PropTypes.number.isRequired,
loadInitialMessages: PropTypes.func.isRequired,
loadMoreMessages: PropTypes.func.isRequired,
sendMessage: PropTypes.func.isRequired,
markMessagesAsRead: PropTypes.func.isRequired
}).isRequired
}
export function ChatProvider({ children }) {
const { user } = useApplicationContext({
user: PropTypes.shape({ id: PropTypes.string.isRequired }.isRequired)
})
const { projectId } = useEditorContext({
projectId: PropTypes.string.isRequired
})
const { chatIsOpen } = useLayoutContext({ chatIsOpen: PropTypes.bool })
const {
hasFocus: windowHasFocus,
flashTitle,
stopFlashingTitle
} = useBrowserWindow()
const [state, dispatch] = useReducer(chatReducer, initialState)
const { loadInitialMessages, loadMoreMessages } = useMemo(() => {
function fetchMessages() {
if (state.atEnd) return
const query = { limit: PAGE_SIZE }
if (state.lastTimestamp) {
query.before = 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()
})
})
}
function loadInitialMessages() {
if (state.initialMessagesLoaded) return
dispatch({ type: 'INITIAL_FETCH_MESSAGES' })
fetchMessages()
}
function loadMoreMessages() {
dispatch({ type: 'FETCH_MESSAGES' })
fetchMessages()
}
return {
loadInitialMessages,
loadMoreMessages
}
}, [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 }
})
},
[projectId, user]
)
const markMessagesAsRead = useCallback(() => {
dispatch({ type: 'MARK_MESSAGES_AS_READ' })
}, [])
// Handling receiving messages over the socket
const socket = window._ide?.socket
useEffect(() => {
if (!socket) return
function receivedMessage(message) {
// If the message is from the current user and they just sent a message,
// then we are receiving the sent message back from the socket. Ignore it
// to prevent double message
const messageIsFromSelf = message?.user?.id === user.id
if (messageIsFromSelf && state.messageWasJustSent) return
dispatch({ type: 'RECEIVE_MESSAGE', message })
}
socket.on('new-chat-message', receivedMessage)
return () => {
if (!socket) return
socket.removeListener('new-chat-message', receivedMessage)
}
// We're adding and removing the socket listener every time we send a
// message (and messageWasJustSent changes). Not great, but no good way
// around it
}, [socket, state.messageWasJustSent, state.unreadMessageCount, user.id])
// 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 = {
status: state.status,
messages: state.messages,
initialMessagesLoaded: state.initialMessagesLoaded,
atEnd: state.atEnd,
unreadMessageCount: state.unreadMessageCount,
loadInitialMessages,
loadMoreMessages,
sendMessage,
markMessagesAsRead
}
return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>
}
ChatProvider.propTypes = {
children: PropTypes.any
}
export function useChatContext(propTypes) {
const data = useContext(ChatContext)
PropTypes.checkPropTypes(propTypes, data, 'data', 'ChatContext.Provider')
return data
}