mirror of
https://github.com/overleaf/overleaf.git
synced 2024-10-24 21:12:38 -04:00
837b5a4b9b
[web] Allow Viewers to access chat and view comments/tracked changes GitOrigin-RevId: 72be0444afdf5e0ad5937bf25718a7566bad001a
367 lines
8.3 KiB
TypeScript
367 lines
8.3 KiB
TypeScript
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
|
|
}
|