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() 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 = { 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 {children} } export function useChatContext() { const context = useContext(ChatContext) if (!context) { throw new Error('useChatContext is only available inside ChatProvider') } return context }